@feralfile/cli 1.1.4 → 1.3.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 (36) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +4 -2
  3. package/dist/package.json +84 -0
  4. package/dist/src/ai-orchestrator/index.js +14 -2
  5. package/dist/src/commands/build.js +3 -3
  6. package/dist/src/commands/chat.js +3 -3
  7. package/dist/src/commands/config.js +2 -2
  8. package/dist/src/commands/device.js +6 -6
  9. package/dist/src/commands/find.js +502 -0
  10. package/dist/src/commands/helpers/device-discovery.js +1 -1
  11. package/dist/src/commands/play.js +52 -11
  12. package/dist/src/commands/setup.js +1 -1
  13. package/dist/src/commands/status.js +2 -2
  14. package/dist/src/intent-parser/index.js +16 -5
  15. package/dist/src/intent-parser/utils.js +7 -2
  16. package/dist/src/main.js +61 -17
  17. package/dist/src/utilities/ab-marketplace.js +95 -0
  18. package/dist/src/utilities/feral-file-artwork.js +89 -0
  19. package/dist/src/utilities/ff-marketplace.js +115 -0
  20. package/dist/src/utilities/ff1-device.js +4 -4
  21. package/dist/src/utilities/functions.js +1 -1
  22. package/dist/src/utilities/fxhash-marketplace.js +146 -0
  23. package/dist/src/utilities/index.js +80 -1
  24. package/dist/src/utilities/marketplace-url.js +373 -0
  25. package/dist/src/utilities/neort-marketplace.js +102 -0
  26. package/dist/src/utilities/nft-indexer.js +22 -14
  27. package/dist/src/utilities/objkt-marketplace.js +77 -0
  28. package/dist/src/utilities/playlist-publisher.js +9 -42
  29. package/dist/src/utilities/raster-client.js +252 -0
  30. package/dist/src/utilities/user-agent.js +20 -0
  31. package/docs/CONFIGURATION.md +3 -1
  32. package/docs/EXAMPLES.md +23 -0
  33. package/docs/FUNCTION_CALLING.md +3 -1
  34. package/docs/README.md +11 -10
  35. package/docs/RELEASING.md +92 -22
  36. package/package.json +1 -1
@@ -0,0 +1,502 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.findCommand = void 0;
40
+ exports.parseLimitOption = parseLimitOption;
41
+ exports.decideActions = decideActions;
42
+ const commander_1 = require("commander");
43
+ const chalk_1 = __importDefault(require("chalk"));
44
+ const fs_1 = require("fs");
45
+ const prompt_1 = require("./helpers/prompt");
46
+ const marketplace_url_1 = require("../utilities/marketplace-url");
47
+ const ff_marketplace_1 = require("../utilities/ff-marketplace");
48
+ const objkt_marketplace_1 = require("../utilities/objkt-marketplace");
49
+ const ab_marketplace_1 = require("../utilities/ab-marketplace");
50
+ const fxhash_marketplace_1 = require("../utilities/fxhash-marketplace");
51
+ const neort_marketplace_1 = require("../utilities/neort-marketplace");
52
+ const raster_client_1 = require("../utilities/raster-client");
53
+ const ff1_device_1 = require("../utilities/ff1-device");
54
+ // nft-indexer + playlist-builder are still CommonJS; require keeps interop simple.
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const { buildDP1Playlist, buildUrlItem } = require('../utilities/playlist-builder');
59
+ /**
60
+ * Maximum item count for a single DP-1 playlist (v1.1 spec). Build paths
61
+ * that exceed this would silently produce a playlist that `validateDP1Playlist`
62
+ * rejects — so cap implicitly here and surface an actionable warning, and
63
+ * reject explicit `--limit > MAX` outright.
64
+ */
65
+ const DP1_MAX_ITEMS = 1024;
66
+ const RASTER_PAGE_SIZE = 100;
67
+ exports.findCommand = new commander_1.Command('find')
68
+ .description('Find an artwork on the web and build a DP-1 playlist')
69
+ .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
70
+ .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
71
+ .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
72
+ .option('-p, --play', 'Play the playlist on an FF1 device after building')
73
+ .option('-d, --device <name>', 'Device to play on (used with --play; default: first configured)')
74
+ .option('--publish', 'Publish the playlist to a configured feed server')
75
+ .option('-s, --server <index>', 'Feed server index (used with --publish)')
76
+ .option('-y, --yes', 'Skip interactive prompts; defaults to Play unless --output or --publish is set')
77
+ .option('--skip-verify', 'Skip signature verification before --play (mirrors `ff-cli play --skip-verify`)')
78
+ .action(async (input, options) => {
79
+ try {
80
+ console.log(chalk_1.default.blue('\nFind on Feral File CLI\n'));
81
+ const parsed = (0, marketplace_url_1.parseFindInput)(input);
82
+ if (!parsed) {
83
+ console.error(chalk_1.default.red('Could not understand input.'));
84
+ console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort. ' +
85
+ 'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
86
+ 'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
87
+ process.exit(1);
88
+ }
89
+ if (parsed.kind === 'unsupported') {
90
+ console.error(chalk_1.default.red(parsed.reason));
91
+ process.exit(1);
92
+ }
93
+ // Neort is off-chain; its items skip Raster + FF indexer entirely and
94
+ // build a DP-1 entry directly from Neort's API response.
95
+ if (parsed.kind === 'neort-art') {
96
+ await runNeortFind(parsed.id, options);
97
+ return;
98
+ }
99
+ const target = await resolveTarget(parsed, !!options.yes);
100
+ if (target.kind === 'series') {
101
+ console.log(chalk_1.default.cyan((0, raster_client_1.formatSummaryLine)(target.summary)));
102
+ }
103
+ else {
104
+ const { chain, contract, tokenId } = target.coords;
105
+ console.log(chalk_1.default.cyan(`Single token — ${chain} ${contract}:${tokenId}`));
106
+ console.log(chalk_1.default.dim(" (Raster doesn't index this series — building a one-item playlist.)"));
107
+ }
108
+ console.log();
109
+ const userLimit = parseLimitOption(options.limit);
110
+ const seriesTotal = target.kind === 'series' ? target.summary.tokenCount : 1;
111
+ // Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
112
+ // clear warning. Explicit `--limit > 1024` was already rejected by
113
+ // parseLimitOption, so reaching here means either capped-by-default or
114
+ // the user picked a value within spec.
115
+ const limit = Math.min(userLimit, DP1_MAX_ITEMS);
116
+ if (userLimit === Number.POSITIVE_INFINITY && seriesTotal > DP1_MAX_ITEMS) {
117
+ console.log(chalk_1.default.yellow(`Series has ${seriesTotal} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
118
+ `Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
119
+ console.log();
120
+ }
121
+ const willInclude = Math.min(seriesTotal, limit);
122
+ const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(willInclude, seriesTotal));
123
+ if (!shouldBuild) {
124
+ console.log(chalk_1.default.dim('Cancelled.'));
125
+ return;
126
+ }
127
+ const tokens = target.kind === 'series' ? await fetchTokens(target.summary, limit) : [target.coords];
128
+ console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
129
+ // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
130
+ // display seconds), not concurrency — concurrency is hardcoded inside.
131
+ // Omit it so the default (10s) applies; no misleading constant on this side.
132
+ const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
133
+ chain: t.chain,
134
+ contractAddress: t.contract,
135
+ tokenId: t.tokenId,
136
+ })));
137
+ if (!Array.isArray(items) || items.length === 0) {
138
+ console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
139
+ console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
140
+ console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
141
+ process.exit(1);
142
+ }
143
+ const title = playlistTitleFor(target, items);
144
+ const playlist = await buildDP1Playlist({ items, title });
145
+ const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
146
+ await fs_1.promises.writeFile(outputPath, JSON.stringify(playlist, null, 2));
147
+ console.log(chalk_1.default.green(`✓ Playlist saved to ${outputPath}`));
148
+ console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
149
+ const dropped = tokens.length - items.length;
150
+ if (dropped > 0) {
151
+ console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} dropped during indexing`));
152
+ }
153
+ console.log();
154
+ const actions = await decideActions(options);
155
+ for (const action of actions) {
156
+ if (action === 'play') {
157
+ await doPlay(playlist, options.device, !!options.skipVerify);
158
+ }
159
+ else if (action === 'publish') {
160
+ await doPublish(outputPath, options.server, !!options.yes);
161
+ }
162
+ }
163
+ }
164
+ catch (error) {
165
+ console.error(chalk_1.default.red('\nError:'), error.message);
166
+ process.exit(1);
167
+ }
168
+ });
169
+ async function resolveTarget(parsed, skipPrompt) {
170
+ if (parsed.kind === 'token') {
171
+ return resolveCoords(parsed.coords);
172
+ }
173
+ if (parsed.kind === 'ff-url') {
174
+ const coords = await (0, ff_marketplace_1.resolveFeralFileToken)(parsed);
175
+ return resolveCoords(coords);
176
+ }
177
+ if (parsed.kind === 'objkt-alias') {
178
+ const contract = await (0, objkt_marketplace_1.resolveObjktAlias)(parsed.alias);
179
+ return resolveCoords({ chain: 'tezos', contract, tokenId: parsed.tokenId });
180
+ }
181
+ if (parsed.kind === 'ab-collection') {
182
+ const coords = await (0, ab_marketplace_1.resolveArtBlocksCollection)(parsed.slug);
183
+ return resolveCoords(coords);
184
+ }
185
+ if (parsed.kind === 'fxhash-iteration') {
186
+ const coords = await (0, fxhash_marketplace_1.resolveFxhashIteration)(parsed.slug);
187
+ return resolveCoords(coords);
188
+ }
189
+ if (parsed.kind === 'fxhash-project') {
190
+ const coords = await (0, fxhash_marketplace_1.resolveFxhashProject)(parsed.slug);
191
+ return resolveCoords(coords);
192
+ }
193
+ if (parsed.kind === 'address') {
194
+ const artistId = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
195
+ if (artistId === null) {
196
+ throw new Error(`No artist found on Raster for address ${parsed.address}. ` +
197
+ 'Raster indexes artists by their on-chain addresses; this address may not yet be claimed.');
198
+ }
199
+ const catalog = await (0, raster_client_1.listArtistArtworks)(artistId);
200
+ if (catalog.length === 0) {
201
+ throw new Error('Artist has no artworks indexed on Raster.');
202
+ }
203
+ const picked = await pickFromCatalog(catalog, skipPrompt);
204
+ const summary = await (0, raster_client_1.getArtworkSummary)(picked.artworkId);
205
+ return { kind: 'series', summary };
206
+ }
207
+ // `unsupported` is handled upstream; this branch keeps the type narrowing honest.
208
+ throw new Error(`Unsupported parse result: ${parsed.kind}`);
209
+ }
210
+ /**
211
+ * Resolve on-chain coords to a target. If Raster doesn't index this token
212
+ * (404), fall back to single-token mode so we still build something playable.
213
+ */
214
+ async function resolveCoords(coords) {
215
+ const lookup = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
216
+ if (lookup === null) {
217
+ return { kind: 'single', coords };
218
+ }
219
+ const summary = await (0, raster_client_1.getArtworkSummary)(lookup.artworkId);
220
+ return { kind: 'series', summary };
221
+ }
222
+ /**
223
+ * Build the playlist title. For series, "Artist — Series Title". For a
224
+ * one-item fallback, use the indexer's token name (already in the item)
225
+ * with a fall-through to coords if name is missing.
226
+ */
227
+ function playlistTitleFor(target, items) {
228
+ if (target.kind === 'series') {
229
+ const artistLabel = target.summary.artists.map((a) => a.name).join(', ') || 'Unknown artist';
230
+ return `${artistLabel} — ${target.summary.title}`;
231
+ }
232
+ return items[0]?.title ?? `Token ${target.coords.tokenId}`;
233
+ }
234
+ function parseLimitOption(limitStr) {
235
+ if (limitStr === undefined) {
236
+ return Number.POSITIVE_INFINITY;
237
+ }
238
+ // Strict integer parse: `Number('5abc')` is NaN, while `parseInt('5abc', 10)`
239
+ // returns 5 and silently truncates user input. Reject anything that isn't a
240
+ // clean positive integer.
241
+ const n = Number(limitStr);
242
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
243
+ throw new Error(`Invalid --limit value: ${limitStr} (expected a positive integer)`);
244
+ }
245
+ if (n > DP1_MAX_ITEMS) {
246
+ throw new Error(`Invalid --limit value: ${n} exceeds DP-1 playlist max of ${DP1_MAX_ITEMS} items.`);
247
+ }
248
+ return n;
249
+ }
250
+ async function fetchTokens(summary, limit) {
251
+ const collected = [];
252
+ let cursor;
253
+ let skipped = 0;
254
+ while (collected.length < limit) {
255
+ const page = await (0, raster_client_1.listArtworkTokens)(summary.artworkId, {
256
+ cursor,
257
+ pageSize: RASTER_PAGE_SIZE,
258
+ });
259
+ skipped += page.skippedUnsupported;
260
+ for (const t of page.tokens) {
261
+ if (collected.length >= limit) {
262
+ break;
263
+ }
264
+ collected.push({
265
+ chain: t.chain,
266
+ contract: t.contractAddress,
267
+ tokenId: t.tokenId,
268
+ });
269
+ }
270
+ if (page.nextCursor === null) {
271
+ break;
272
+ }
273
+ cursor = page.nextCursor;
274
+ }
275
+ if (skipped > 0) {
276
+ console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
277
+ }
278
+ return collected;
279
+ }
280
+ async function pickFromCatalog(catalog, skipPrompt) {
281
+ if (catalog.length === 1 || skipPrompt) {
282
+ return catalog[0];
283
+ }
284
+ console.log(chalk_1.default.cyan(`Found ${catalog.length} artworks for this artist:`));
285
+ catalog.forEach((row, i) => {
286
+ const count = `${row.tokenCount} token${row.tokenCount === 1 ? '' : 's'}`;
287
+ console.log(chalk_1.default.dim(` ${i}: ${row.title} (${count})`));
288
+ });
289
+ console.log();
290
+ const prompt = (0, prompt_1.createPrompt)();
291
+ const answer = await prompt.ask(`Pick one (0-${catalog.length - 1}, default 0): `);
292
+ prompt.close();
293
+ console.log();
294
+ // Strict integer parse: Number.parseInt('5abc') silently returns 5; here we
295
+ // want '5abc' to be rejected outright. Same anti-pattern parseLimitOption fixes.
296
+ const index = answer ? Number(answer) : 0;
297
+ if (!Number.isInteger(index) || index < 0 || index >= catalog.length) {
298
+ throw new Error(`Invalid selection: "${answer}"`);
299
+ }
300
+ return catalog[index];
301
+ }
302
+ async function confirmMakePlaylist(count, total) {
303
+ const noun = total === 1 ? 'token' : 'tokens';
304
+ const label = count < total ? `${count} of ${total} ${noun}` : `${count} ${noun}`;
305
+ const prompt = (0, prompt_1.createPrompt)();
306
+ const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
307
+ prompt.close();
308
+ console.log();
309
+ return yes;
310
+ }
311
+ /**
312
+ * Decide which post-build actions to run.
313
+ *
314
+ * Precedence: explicit flags (--play / --publish) win and may combine. If no
315
+ * flag is set, fall back to --yes default (Play) or an interactive 3-way
316
+ * prompt. Save is unconditional and already done before this is called.
317
+ */
318
+ async function decideActions(options) {
319
+ const flagActions = [];
320
+ if (options.play) {
321
+ flagActions.push('play');
322
+ }
323
+ if (options.publish) {
324
+ flagActions.push('publish');
325
+ }
326
+ if (flagActions.length > 0) {
327
+ return flagActions;
328
+ }
329
+ // --output alone (with or without --yes) means "save mode" — the save was the action.
330
+ if (options.output) {
331
+ return [];
332
+ }
333
+ // --yes without --output defaults to Play (max-satisfaction default for scripted use).
334
+ if (options.yes) {
335
+ return ['play'];
336
+ }
337
+ return [await promptNextAction()];
338
+ }
339
+ async function promptNextAction() {
340
+ const prompt = (0, prompt_1.createPrompt)();
341
+ const answer = (await prompt.ask('Next? [P]lay on FF1 / just [S]ave / pub[L]ish to feed (default: Play): '))
342
+ .trim()
343
+ .toLowerCase();
344
+ prompt.close();
345
+ console.log();
346
+ if (!answer || answer === 'p' || answer === 'play' || answer === 'y' || answer === 'yes') {
347
+ return 'play';
348
+ }
349
+ if (answer === 's' || answer === 'save' || answer === 'n' || answer === 'no') {
350
+ return 'skip';
351
+ }
352
+ if (answer === 'l' || answer === 'pub' || answer === 'publish') {
353
+ return 'publish';
354
+ }
355
+ throw new Error(`Unrecognized choice: "${answer}". Use P, S, or L.`);
356
+ }
357
+ async function doPlay(playlist, deviceName, skipVerify) {
358
+ // Match `ff-cli play`'s verification gate: signed playlists are verified
359
+ // before device delivery; the same surface that play surfaces failures.
360
+ // `buildDP1Playlist` signs when a playlist private key is configured and
361
+ // silently continues unsigned otherwise — so when no key is set, the user
362
+ // sees an actionable error here instead of an unverifiable playlist on
363
+ // the wall.
364
+ if (!skipVerify) {
365
+ const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-verifier')));
366
+ console.log(chalk_1.default.cyan('Verify playlist'));
367
+ const verifyResult = await verifyPlaylist(playlist);
368
+ if (!verifyResult.valid) {
369
+ console.error(chalk_1.default.red('Playlist verification failed:'), verifyResult.error);
370
+ if (verifyResult.details && verifyResult.details.length > 0) {
371
+ for (const d of verifyResult.details) {
372
+ console.error(chalk_1.default.dim(` ${d.path}: ${d.message}`));
373
+ }
374
+ }
375
+ console.error(chalk_1.default.dim(' Tip: configure a playlist signing key (config or PLAYLIST_PRIVATE_KEY), ' +
376
+ 'or pass --skip-verify to bypass.'));
377
+ process.exitCode = 1;
378
+ return;
379
+ }
380
+ console.log(chalk_1.default.green('✓ Verified\n'));
381
+ }
382
+ console.log(chalk_1.default.blue('Play on FF1'));
383
+ const result = await (0, ff1_device_1.sendPlaylistToDevice)({ playlist, deviceName });
384
+ if (result.success) {
385
+ console.log(chalk_1.default.green('✓ Playing'));
386
+ if (result.deviceName) {
387
+ console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
388
+ }
389
+ if (result.device) {
390
+ console.log(chalk_1.default.dim(` Host: ${result.device}`));
391
+ }
392
+ console.log();
393
+ return;
394
+ }
395
+ console.error(chalk_1.default.red('Play failed:'), result.error);
396
+ if (result.details) {
397
+ console.error(chalk_1.default.dim(` Details: ${result.details}`));
398
+ }
399
+ process.exitCode = 1;
400
+ }
401
+ async function doPublish(savedPath, serverArg, nonInteractive) {
402
+ console.log(chalk_1.default.blue('Publish to feed'));
403
+ const { getFeedConfig } = await Promise.resolve().then(() => __importStar(require('../config')));
404
+ const { publishPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-publisher')));
405
+ const feedConfig = getFeedConfig();
406
+ if (!feedConfig.baseURLs || feedConfig.baseURLs.length === 0) {
407
+ console.error(chalk_1.default.red('No feed servers configured.'));
408
+ console.error(chalk_1.default.yellow(' Add feed server URLs to config.json: feed.baseURLs'));
409
+ process.exitCode = 1;
410
+ return;
411
+ }
412
+ // Validate --server regardless of server count. parseInt('0abc') would
413
+ // silently truncate to 0 and route to a different server; with a single
414
+ // server configured the old code skipped --server validation entirely and
415
+ // accepted any garbage value while publishing to baseURLs[0].
416
+ let serverIndex = 0;
417
+ if (serverArg !== undefined) {
418
+ const n = Number(serverArg);
419
+ if (!Number.isInteger(n) || n < 0 || n >= feedConfig.baseURLs.length) {
420
+ console.error(chalk_1.default.red(`Invalid --server value: ${serverArg} (expected integer in 0..${feedConfig.baseURLs.length - 1})`));
421
+ process.exitCode = 1;
422
+ return;
423
+ }
424
+ serverIndex = n;
425
+ }
426
+ else if (feedConfig.baseURLs.length > 1) {
427
+ if (nonInteractive) {
428
+ console.error(chalk_1.default.red(`Multiple feed servers configured (${feedConfig.baseURLs.length}); pass --server <index> when running with --yes.`));
429
+ process.exitCode = 1;
430
+ return;
431
+ }
432
+ console.log(chalk_1.default.yellow(`Multiple feed servers configured:`));
433
+ feedConfig.baseURLs.forEach((url, i) => {
434
+ console.log(chalk_1.default.cyan(` ${i}: ${url}`));
435
+ });
436
+ const prompt = (0, prompt_1.createPrompt)();
437
+ const answer = await prompt.ask('Select server (0-based index): ');
438
+ prompt.close();
439
+ console.log();
440
+ const n = Number(answer);
441
+ if (!Number.isInteger(n) || n < 0 || n >= feedConfig.baseURLs.length) {
442
+ console.error(chalk_1.default.red(`Invalid selection: ${answer} (expected integer in 0..${feedConfig.baseURLs.length - 1})`));
443
+ process.exitCode = 1;
444
+ return;
445
+ }
446
+ serverIndex = n;
447
+ }
448
+ const serverUrl = feedConfig.baseURLs[serverIndex];
449
+ const serverApiKey = feedConfig.servers?.[serverIndex]?.apiKey ?? feedConfig.apiKey;
450
+ const result = await publishPlaylist(savedPath, serverUrl, serverApiKey);
451
+ if (result.success) {
452
+ console.log(chalk_1.default.green('✓ Published'));
453
+ if (result.playlistId) {
454
+ console.log(chalk_1.default.dim(` Playlist ID: ${result.playlistId}`));
455
+ }
456
+ if (result.feedServer) {
457
+ console.log(chalk_1.default.dim(` Server: ${result.feedServer}`));
458
+ }
459
+ console.log();
460
+ return;
461
+ }
462
+ console.error(chalk_1.default.red('Publish failed:'), result.error);
463
+ if (result.message) {
464
+ console.error(chalk_1.default.dim(` ${result.message}`));
465
+ }
466
+ process.exitCode = 1;
467
+ }
468
+ /**
469
+ * Neort flow. Off-chain platform with no on-chain coords, so we bypass
470
+ * Raster + FF indexer and construct a single-item DP-1 playlist directly
471
+ * from Neort's API response using the off-chain `provenance.offChainURI`
472
+ * shape produced by `buildUrlItem`.
473
+ */
474
+ async function runNeortFind(id, options) {
475
+ const art = await (0, neort_marketplace_1.resolveNeortArt)(id);
476
+ const artistLabel = art.artistName || 'Unknown artist';
477
+ console.log(chalk_1.default.cyan(`Neort — ${artistLabel} — ${art.title}`));
478
+ console.log(chalk_1.default.dim(' (Off-chain platform; building a one-item playlist directly from Neort.)'));
479
+ console.log();
480
+ const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, 1));
481
+ if (!shouldBuild) {
482
+ console.log(chalk_1.default.dim('Cancelled.'));
483
+ return;
484
+ }
485
+ const item = buildUrlItem(art.assetUrl, 10, { title: art.title });
486
+ const playlistTitle = `${artistLabel} — ${art.title}`;
487
+ const playlist = await buildDP1Playlist({ items: [item], title: playlistTitle });
488
+ const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
489
+ await fs_1.promises.writeFile(outputPath, JSON.stringify(playlist, null, 2));
490
+ console.log(chalk_1.default.green(`✓ Playlist saved to ${outputPath}`));
491
+ console.log(chalk_1.default.dim(' 1 item'));
492
+ console.log();
493
+ const actions = await decideActions(options);
494
+ for (const action of actions) {
495
+ if (action === 'play') {
496
+ await doPlay(playlist, options.device, !!options.skipVerify);
497
+ }
498
+ else if (action === 'publish') {
499
+ await doPublish(outputPath, options.server, !!options.yes);
500
+ }
501
+ }
502
+ }
@@ -12,7 +12,7 @@ const device_normalize_1 = require("../../utilities/device-normalize");
12
12
  * Run mDNS discovery and prompt the user to pick a device, fall back to
13
13
  * manual entry, or skip when an existing device is already configured.
14
14
  *
15
- * Shared between `ff1 setup` and `ff1 device add` so both flows present
15
+ * Shared between `ff-cli setup` and `ff-cli device add` so both flows present
16
16
  * the same selection UX and matching rules.
17
17
  */
18
18
  async function discoverAndSelectDevice(ask, existingDevices, options) {
@@ -45,11 +45,13 @@ const playlist_display_1 = require("./helpers/playlist-display");
45
45
  // ff1-device is still CommonJS; require keeps the interop simple.
46
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
47
  const { sendPlaylistToDevice } = require('../utilities/ff1-device');
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const { signPlaylist } = require('../utilities/playlist-signer');
48
50
  exports.playCommand = new commander_1.Command('play')
49
51
  .description('Play a playlist or media URL on an FF1 device')
50
52
  .argument('<source>', 'Playlist file, playlist URL, or media URL')
51
53
  .option('-d, --device <name>', 'Device name (uses first device if not specified)')
52
- .option('--skip-verify', 'Skip DP-1 structure validation (parse/schema) before playing; use only when you accept malformed envelopes')
54
+ .option('--skip-verify', 'Skip signature verification and auto-sign gating before playing; use only when you accept malformed envelopes')
53
55
  .action(async (source, options) => {
54
56
  try {
55
57
  const config = (0, config_1.getConfig)();
@@ -60,20 +62,59 @@ exports.playCommand = new commander_1.Command('play')
60
62
  : resolved.source;
61
63
  console.log(chalk_1.default.blue('\nPlay on FF1\n'));
62
64
  if (!options.skipVerify) {
63
- // Structure-only validation (same as `validate`, `verify`, send, and publish).
64
- // Synthesized media-URL playlists are unsigned but parse as valid DP-1.
65
+ const playlistConfig = config.playlist;
66
+ const privateKey = playlistConfig?.privateKey || process.env.PLAYLIST_PRIVATE_KEY;
67
+ const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-verifier')));
65
68
  if (isPlaylistSource) {
66
- console.log(chalk_1.default.cyan(`Validate playlist (${sourceLabel})`));
69
+ console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
67
70
  }
68
- const verifier = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-verifier')));
69
- const { validatePlaylist } = verifier;
70
- const validateResult = await validatePlaylist(resolved.playlist);
71
- if (!validateResult.valid) {
72
- (0, playlist_display_1.printPlaylistVerificationFailure)(validateResult, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
71
+ const verifyResult = resolved.kind === 'media'
72
+ ? await (async () => {
73
+ try {
74
+ if (!privateKey) {
75
+ return {
76
+ valid: false,
77
+ error: 'Cannot sign playlist for playback: no playlist signing key is configured',
78
+ };
79
+ }
80
+ const signature = await signPlaylist(resolved.playlist, privateKey);
81
+ const signedPlaylist = {
82
+ ...resolved.playlist,
83
+ signature: undefined,
84
+ signatures: [signature],
85
+ };
86
+ const signedVerification = await verifyPlaylist(signedPlaylist);
87
+ if (signedVerification.valid) {
88
+ resolved.playlist = signedPlaylist;
89
+ return { valid: true, playlist: signedPlaylist };
90
+ }
91
+ return {
92
+ valid: false,
93
+ error: signedVerification.error,
94
+ details: signedVerification.details,
95
+ };
96
+ }
97
+ catch (error) {
98
+ return {
99
+ valid: false,
100
+ error: `Failed to sign playlist for playback: ${error.message}`,
101
+ };
102
+ }
103
+ })()
104
+ : await verifyPlaylist(resolved.playlist);
105
+ if (!verifyResult.valid) {
106
+ (0, playlist_display_1.printPlaylistVerificationFailure)({
107
+ valid: false,
108
+ error: verifyResult.error,
109
+ details: verifyResult.details,
110
+ }, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
73
111
  process.exit(1);
74
112
  }
75
- if (isPlaylistSource) {
76
- console.log(chalk_1.default.green('✓ Validated\n'));
113
+ if (resolved.kind === 'media') {
114
+ console.log(chalk_1.default.green('✓ Signed and verified\n'));
115
+ }
116
+ else if (isPlaylistSource) {
117
+ console.log(chalk_1.default.green('✓ Verified\n'));
77
118
  }
78
119
  }
79
120
  const result = await sendPlaylistToDevice({
@@ -34,7 +34,7 @@ exports.setupCommand = new commander_1.Command('setup')
34
34
  console.error(chalk_1.default.red('No models found in config.json'));
35
35
  process.exit(1);
36
36
  }
37
- console.log(chalk_1.default.blue('\nFF1 Setup\n'));
37
+ console.log(chalk_1.default.blue('\nff-cli setup\n'));
38
38
  prompt = (0, prompt_1.createPrompt)();
39
39
  const ask = prompt.ask;
40
40
  const currentModel = config.defaultModel && modelNames.includes(config.defaultModel)
@@ -17,7 +17,7 @@ exports.statusCommand = new commander_1.Command('status')
17
17
  const configPath = await (0, config_files_1.resolveExistingConfigPath)();
18
18
  if (!configPath) {
19
19
  console.log(chalk_1.default.red('config.json not found'));
20
- console.log(chalk_1.default.dim('Run: ff1 setup'));
20
+ console.log(chalk_1.default.dim('Run: ff-cli setup'));
21
21
  process.exit(1);
22
22
  }
23
23
  const config = await (0, config_files_1.readConfigFile)(configPath);
@@ -106,7 +106,7 @@ exports.statusCommand = new commander_1.Command('status')
106
106
  });
107
107
  const hasRequired = statuses.some((status) => !status.ok && (!status.optional || Boolean(status.invalid)));
108
108
  if (hasRequired) {
109
- console.log(chalk_1.default.dim('\nRun: ff1 setup'));
109
+ console.log(chalk_1.default.dim('\nRun: ff-cli setup'));
110
110
  process.exit(1);
111
111
  }
112
112
  }