@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.
- package/README.md +50 -0
- package/dist/index.js +4 -2
- package/dist/package.json +84 -0
- package/dist/src/ai-orchestrator/index.js +14 -2
- package/dist/src/commands/build.js +3 -3
- package/dist/src/commands/chat.js +3 -3
- package/dist/src/commands/config.js +2 -2
- package/dist/src/commands/device.js +6 -6
- package/dist/src/commands/find.js +502 -0
- package/dist/src/commands/helpers/device-discovery.js +1 -1
- package/dist/src/commands/play.js +52 -11
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/commands/status.js +2 -2
- package/dist/src/intent-parser/index.js +16 -5
- package/dist/src/intent-parser/utils.js +7 -2
- package/dist/src/main.js +61 -17
- package/dist/src/utilities/ab-marketplace.js +95 -0
- package/dist/src/utilities/feral-file-artwork.js +89 -0
- package/dist/src/utilities/ff-marketplace.js +115 -0
- package/dist/src/utilities/ff1-device.js +4 -4
- package/dist/src/utilities/functions.js +1 -1
- package/dist/src/utilities/fxhash-marketplace.js +146 -0
- package/dist/src/utilities/index.js +80 -1
- package/dist/src/utilities/marketplace-url.js +373 -0
- package/dist/src/utilities/neort-marketplace.js +102 -0
- package/dist/src/utilities/nft-indexer.js +22 -14
- package/dist/src/utilities/objkt-marketplace.js +77 -0
- package/dist/src/utilities/playlist-publisher.js +9 -42
- package/dist/src/utilities/raster-client.js +252 -0
- package/dist/src/utilities/user-agent.js +20 -0
- package/docs/CONFIGURATION.md +3 -1
- package/docs/EXAMPLES.md +23 -0
- package/docs/FUNCTION_CALLING.md +3 -1
- package/docs/README.md +11 -10
- package/docs/RELEASING.md +92 -22
- 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 `
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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(`
|
|
69
|
+
console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
|
|
67
70
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 (
|
|
76
|
-
console.log(chalk_1.default.green('✓
|
|
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('\
|
|
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:
|
|
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:
|
|
109
|
+
console.log(chalk_1.default.dim('\nRun: ff-cli setup'));
|
|
110
110
|
process.exit(1);
|
|
111
111
|
}
|
|
112
112
|
}
|