@feralfile/cli 2.0.3 → 2.1.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/config.json.example +5 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/device.js +66 -7
- package/dist/src/commands/find.js +165 -98
- package/dist/src/commands/helpers/device-discovery.js +23 -3
- package/dist/src/commands/helpers/device-removal.js +40 -0
- package/dist/src/commands/play.js +3 -3
- package/dist/src/commands/sign.js +1 -1
- package/dist/src/config.js +55 -1
- package/dist/src/utilities/device-lookup.js +3 -2
- package/dist/src/utilities/device-upsert.js +19 -5
- package/dist/src/utilities/ff1-device.js +127 -25
- package/dist/src/utilities/ff1-relayer.js +105 -0
- package/dist/src/utilities/functions.js +2 -2
- package/dist/src/utilities/handoff-client.js +135 -0
- package/dist/src/utilities/handoff-crypto.js +132 -0
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/playlist-builder.js +2 -2
- package/dist/src/utilities/ssh-access.js +1 -5
- package/dist/src/utilities/topic-store.js +33 -0
- package/docs/CONFIGURATION.md +31 -2
- package/docs/PROJECT_SPEC.md +6 -0
- package/docs/README.md +4 -3
- package/package.json +5 -2
- package/dist/src/utilities/marketplace-url.js +0 -443
package/config.json.example
CHANGED
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
"privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate",
|
|
20
20
|
"role": "agent"
|
|
21
21
|
},
|
|
22
|
+
"ff1Relayer": {
|
|
23
|
+
"baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev"
|
|
24
|
+
},
|
|
22
25
|
"ff1Devices": {
|
|
23
26
|
"devices": [
|
|
24
27
|
{
|
|
25
28
|
"name": "DISPLAY_NAME_FOR_YOUR_FF1_DEVICE",
|
|
29
|
+
"id": "FF1-DEVICE_ID",
|
|
26
30
|
"host": "http://YOUR_FF1_IP_ADDRESS:1111",
|
|
27
|
-
"apiKey": "YOUR_FF1_DEVICE_API_KEY_FROM_DEVICE_SETTINGS"
|
|
28
|
-
"topicID": "YOUR_FF1_DEVICE_TOPIC_ID_FROM_DEVICE_SETTINGS"
|
|
31
|
+
"apiKey": "YOUR_FF1_DEVICE_API_KEY_FROM_DEVICE_SETTINGS"
|
|
29
32
|
}
|
|
30
33
|
]
|
|
31
34
|
}
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"start": "node dist/index.js",
|
|
33
33
|
"dev": "tsx index.ts",
|
|
34
34
|
"smoke": "npm run build && node dist/index.js validate examples/sample-playlist-v11.json && node dist/index.js config validate",
|
|
35
|
-
"check": "npm run format:check && npm run lint && npm run test",
|
|
35
|
+
"check": "npm run format:check && npm run lint && npm run check:copy && npm run test",
|
|
36
|
+
"check:copy": "node scripts/check-copy.js",
|
|
36
37
|
"verify": "npm run check && npm run smoke",
|
|
37
38
|
"prepublishOnly": "npm run verify",
|
|
38
39
|
"test": "node scripts/run-tests.js",
|
|
@@ -57,6 +58,8 @@
|
|
|
57
58
|
"author": "",
|
|
58
59
|
"license": "MIT",
|
|
59
60
|
"dependencies": {
|
|
61
|
+
"@napi-rs/keyring": "1.3.0",
|
|
62
|
+
"@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79",
|
|
60
63
|
"axios": "^1.6.2",
|
|
61
64
|
"bonjour-service": "^1.3.0",
|
|
62
65
|
"chalk": "^4.1.2",
|
|
@@ -14,8 +14,57 @@ const device_default_1 = require("../utilities/device-default");
|
|
|
14
14
|
const config_files_1 = require("./helpers/config-files");
|
|
15
15
|
const device_discovery_1 = require("./helpers/device-discovery");
|
|
16
16
|
const prompt_1 = require("./helpers/prompt");
|
|
17
|
+
const ff1_compatibility_1 = require("../utilities/ff1-compatibility");
|
|
18
|
+
const handoff_client_1 = require("../utilities/handoff-client");
|
|
19
|
+
const topic_store_1 = require("../utilities/topic-store");
|
|
20
|
+
const device_removal_1 = require("./helpers/device-removal");
|
|
17
21
|
const deviceCommand = new commander_1.Command('device').description('Manage configured FF1 devices');
|
|
18
22
|
exports.deviceCommand = deviceCommand;
|
|
23
|
+
deviceCommand
|
|
24
|
+
.command('pair')
|
|
25
|
+
.description('Securely receive relayer access for a configured FF1 from the mobile app')
|
|
26
|
+
.argument('[name]', 'Configured device name (uses the default device when omitted)')
|
|
27
|
+
.action(async (name) => {
|
|
28
|
+
let receiver;
|
|
29
|
+
try {
|
|
30
|
+
const selected = (0, ff1_compatibility_1.resolveConfiguredDevice)(name);
|
|
31
|
+
if (!selected.success || !selected.device) {
|
|
32
|
+
throw new Error(selected.error || 'FF1 device is not configured correctly');
|
|
33
|
+
}
|
|
34
|
+
const device = selected.device;
|
|
35
|
+
if (!device.id) {
|
|
36
|
+
throw new Error(`Device "${device.name || device.host}" has no stable device ID. Re-add it with ff-cli device add before pairing.`);
|
|
37
|
+
}
|
|
38
|
+
receiver = await handoff_client_1.TopicHandoffReceiver.create();
|
|
39
|
+
console.log(chalk_1.default.blue('\nPair ff-cli with Feral File mobile\n'));
|
|
40
|
+
console.log(` Device: ${chalk_1.default.bold(device.name || device.id)}`);
|
|
41
|
+
console.log(` Device ID: ${chalk_1.default.dim(device.id)}`);
|
|
42
|
+
console.log("\nIn the mobile app, open this Art Computer's options and choose");
|
|
43
|
+
console.log(chalk_1.default.bold('“Pair with ff-cli”'), 'then enter:');
|
|
44
|
+
console.log(`\n ${chalk_1.default.bold(receiver.shortCode)}\n`);
|
|
45
|
+
console.log('Before sharing, confirm the mobile security check matches:');
|
|
46
|
+
console.log(`\n ${chalk_1.default.bold(receiver.verificationCode)}\n`);
|
|
47
|
+
console.log(chalk_1.default.dim(`This one-time code expires at ${receiver.expiresAt}. Waiting...`));
|
|
48
|
+
const payload = await receiver.receiveTopic({ expectedDeviceId: device.id });
|
|
49
|
+
(0, topic_store_1.storeTopicId)(device.id, payload.topicId);
|
|
50
|
+
console.log(chalk_1.default.green('\n✓ Relayer access stored in the operating system secure storage.\n'));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
console.error(chalk_1.default.red('\nPairing failed:'), error.message);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
if (receiver) {
|
|
58
|
+
try {
|
|
59
|
+
await receiver.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The channel is short-lived and expires automatically. A close
|
|
63
|
+
// failure must not erase or misreport an already stored credential.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
19
68
|
deviceCommand
|
|
20
69
|
.command('list')
|
|
21
70
|
.description('List all configured FF1 devices')
|
|
@@ -45,8 +94,8 @@ deviceCommand
|
|
|
45
94
|
if (device.apiKey) {
|
|
46
95
|
console.log(` API key: ${chalk_1.default.green('Set')}`);
|
|
47
96
|
}
|
|
48
|
-
if (device.
|
|
49
|
-
console.log(`
|
|
97
|
+
if (device.id) {
|
|
98
|
+
console.log(` Device ID: ${chalk_1.default.dim(device.id)}`);
|
|
50
99
|
}
|
|
51
100
|
if (isFirst) {
|
|
52
101
|
console.log(` ${chalk_1.default.dim('(default)')}`);
|
|
@@ -64,6 +113,7 @@ deviceCommand
|
|
|
64
113
|
.description('Add a new FF1 device (with mDNS discovery)')
|
|
65
114
|
.option('--host <host>', 'Device host (skip discovery)')
|
|
66
115
|
.option('--name <name>', 'Device name')
|
|
116
|
+
.option('--id <id>', 'Stable FF1 device ID (required for manual relayer pairing)')
|
|
67
117
|
.action(async (options) => {
|
|
68
118
|
// Lazy prompt: non-interactive paths (--host + --name) must never block on stdin.
|
|
69
119
|
let prompt = null;
|
|
@@ -89,7 +139,14 @@ deviceCommand
|
|
|
89
139
|
const existingDevices = config.ff1Devices?.devices || [];
|
|
90
140
|
let hostValue = '';
|
|
91
141
|
let discoveredName = '';
|
|
92
|
-
|
|
142
|
+
const suppliedId = options.id?.trim().toUpperCase();
|
|
143
|
+
if (suppliedId && !/^FF1-[A-Z0-9]{4,}$/.test(suppliedId)) {
|
|
144
|
+
throw new Error('Device ID must use the FF1-XXXXXXXX format');
|
|
145
|
+
}
|
|
146
|
+
if (suppliedId && !options.host) {
|
|
147
|
+
throw new Error('--id is only needed with --host; discovered devices provide their ID');
|
|
148
|
+
}
|
|
149
|
+
let discoveredId = suppliedId;
|
|
93
150
|
let discoveredAddresses;
|
|
94
151
|
if (options.host) {
|
|
95
152
|
hostValue = (0, device_normalize_1.normalizeDeviceHost)(options.host);
|
|
@@ -220,10 +277,12 @@ deviceCommand
|
|
|
220
277
|
}
|
|
221
278
|
process.exit(1);
|
|
222
279
|
}
|
|
223
|
-
const removed =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
280
|
+
const { removed, updatedDevices } = await (0, device_removal_1.persistDeviceRemoval)(config, deviceIndex, {
|
|
281
|
+
persistConfig: async (updatedConfig) => {
|
|
282
|
+
await fs_1.promises.writeFile(configPath, `${JSON.stringify(updatedConfig, null, 2)}\n`, 'utf-8');
|
|
283
|
+
},
|
|
284
|
+
warn: (message) => console.warn(chalk_1.default.yellow(message)),
|
|
285
|
+
});
|
|
227
286
|
console.log(chalk_1.default.green(`\nRemoved device: ${removed.name || removed.host}`));
|
|
228
287
|
console.log(chalk_1.default.dim(`Remaining devices: ${updatedDevices.length}\n`));
|
|
229
288
|
}
|
|
@@ -43,8 +43,8 @@ exports.decideActions = decideActions;
|
|
|
43
43
|
const commander_1 = require("commander");
|
|
44
44
|
const chalk_1 = __importDefault(require("chalk"));
|
|
45
45
|
const fs_1 = require("fs");
|
|
46
|
+
const source_resolver_1 = require("@feralfile/source-resolver");
|
|
46
47
|
const prompt_1 = require("./helpers/prompt");
|
|
47
|
-
const marketplace_url_1 = require("../utilities/marketplace-url");
|
|
48
48
|
const ff_marketplace_1 = require("../utilities/ff-marketplace");
|
|
49
49
|
const objkt_marketplace_1 = require("../utilities/objkt-marketplace");
|
|
50
50
|
const ab_marketplace_1 = require("../utilities/ab-marketplace");
|
|
@@ -81,7 +81,7 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
81
81
|
.action(async (input, options) => {
|
|
82
82
|
try {
|
|
83
83
|
console.log(chalk_1.default.blue('\nFind on Feral File CLI\n'));
|
|
84
|
-
const parsed = (0,
|
|
84
|
+
const parsed = (0, source_resolver_1.parseFindInput)(input);
|
|
85
85
|
if (!parsed) {
|
|
86
86
|
console.error(chalk_1.default.red('Could not understand input.'));
|
|
87
87
|
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
|
|
@@ -90,6 +90,11 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
90
90
|
process.exit(1);
|
|
91
91
|
}
|
|
92
92
|
if (parsed.kind === 'unsupported') {
|
|
93
|
+
const resolved = await resolveTokenListInput(input, resolverLimitFromOption(options.limit));
|
|
94
|
+
if (resolved) {
|
|
95
|
+
await runResolvedTarget(resolved, options);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
93
98
|
console.error(chalk_1.default.red(parsed.reason));
|
|
94
99
|
process.exit(1);
|
|
95
100
|
}
|
|
@@ -99,112 +104,132 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
99
104
|
await runNeortFind(parsed.id, options);
|
|
100
105
|
return;
|
|
101
106
|
}
|
|
102
|
-
const target = await resolveTarget(parsed, !!options.yes);
|
|
103
|
-
|
|
104
|
-
console.log(chalk_1.default.cyan((0, raster_client_1.formatSummaryLine)(target.summary)));
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
const { chain, contract, tokenId } = target.coords;
|
|
108
|
-
console.log(chalk_1.default.cyan(`Single token — ${chain} ${contract}:${tokenId}`));
|
|
109
|
-
console.log(chalk_1.default.dim(" (Raster doesn't index this series — building a one-item playlist.)"));
|
|
110
|
-
}
|
|
111
|
-
console.log();
|
|
112
|
-
const userLimit = parseLimitOption(options.limit);
|
|
113
|
-
// Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
|
|
114
|
-
// clear warning. Explicit `--limit > 1024` was already rejected by
|
|
115
|
-
// parseLimitOption, so reaching here means either capped-by-default or
|
|
116
|
-
// the user picked a value within spec.
|
|
117
|
-
const limit = Math.min(userLimit, DP1_MAX_ITEMS);
|
|
118
|
-
// Raster's GraphQL API exposes no series token counts, so enumerate
|
|
119
|
-
// tokens first (up to the effective limit) and confirm with the real
|
|
120
|
-
// number afterward. `hasMore` means the series continues past `limit`.
|
|
121
|
-
let tokens;
|
|
122
|
-
let seriesHasMore = false;
|
|
123
|
-
if (target.kind === 'series') {
|
|
124
|
-
const fetched = await fetchTokens(target.summary, limit);
|
|
125
|
-
tokens = fetched.tokens;
|
|
126
|
-
seriesHasMore = fetched.hasMore;
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
|
|
130
|
-
}
|
|
131
|
-
if (tokens.length === 0) {
|
|
132
|
-
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
133
|
-
process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
|
|
136
|
-
console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
|
|
137
|
-
`Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
|
|
138
|
-
console.log();
|
|
139
|
-
}
|
|
140
|
-
const shouldBuild = !!options.yes ||
|
|
141
|
-
!!options.output ||
|
|
142
|
-
(await confirmMakePlaylist(tokens.length, seriesHasMore));
|
|
143
|
-
if (!shouldBuild) {
|
|
144
|
-
console.log(chalk_1.default.dim('Cancelled.'));
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
|
|
148
|
-
// Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
|
|
149
|
-
// display seconds), not concurrency — concurrency is hardcoded inside.
|
|
150
|
-
// Omit it for auto timing: video/audio items carry no duration and play
|
|
151
|
-
// their natural length (DP-1 §4.1); static items get the config default.
|
|
152
|
-
let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
|
|
153
|
-
chain: t.chain,
|
|
154
|
-
contractAddress: t.contract,
|
|
155
|
-
tokenId: t.tokenId,
|
|
156
|
-
})));
|
|
157
|
-
// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
|
|
158
|
-
// Raster's tokens, so it returns nothing — but Raster handed us each
|
|
159
|
-
// token's playable `media.contentUrl`. Build DP-1 items straight from
|
|
160
|
-
// that (off-chain provenance, same shape as the Neort path), so the
|
|
161
|
-
// series still builds into a signable, verifiable playlist instead of
|
|
162
|
-
// failing the way it did before. The indexer is tried first so tokens
|
|
163
|
-
// it *can* resolve keep their richer on-chain provenance.
|
|
164
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
165
|
-
const rasterItems = buildRasterMediaItems(tokens, target);
|
|
166
|
-
if (rasterItems.length > 0) {
|
|
167
|
-
console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
|
|
168
|
-
items = rasterItems;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
172
|
-
console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
|
|
173
|
-
console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
|
|
174
|
-
console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
const title = playlistTitleFor(target, items);
|
|
178
|
-
const playlist = await buildDP1Playlist({ items, title });
|
|
179
|
-
const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
|
|
180
|
-
await fs_1.promises.writeFile(outputPath, JSON.stringify(playlist, null, 2));
|
|
181
|
-
console.log(chalk_1.default.green(`✓ Playlist saved to ${outputPath}`));
|
|
182
|
-
console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
|
|
183
|
-
const dropped = tokens.length - items.length;
|
|
184
|
-
if (dropped > 0) {
|
|
185
|
-
console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} dropped during indexing`));
|
|
186
|
-
}
|
|
187
|
-
console.log();
|
|
188
|
-
const actions = await decideActions(options);
|
|
189
|
-
for (const action of actions) {
|
|
190
|
-
if (action === 'play') {
|
|
191
|
-
await doPlay(playlist, options.device, !!options.skipVerify);
|
|
192
|
-
}
|
|
193
|
-
else if (action === 'publish') {
|
|
194
|
-
await doPublish(outputPath, options.server, !!options.yes);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
107
|
+
const target = await resolveTarget(input, parsed, !!options.yes, options.limit);
|
|
108
|
+
await runResolvedTarget(target, options);
|
|
197
109
|
}
|
|
198
110
|
catch (error) {
|
|
199
111
|
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
200
112
|
process.exit(1);
|
|
201
113
|
}
|
|
202
114
|
});
|
|
203
|
-
async function
|
|
115
|
+
async function runResolvedTarget(target, options) {
|
|
116
|
+
if (target.kind === 'series') {
|
|
117
|
+
console.log(chalk_1.default.cyan((0, raster_client_1.formatSummaryLine)(target.summary)));
|
|
118
|
+
}
|
|
119
|
+
else if (target.kind === 'token-list') {
|
|
120
|
+
console.log(chalk_1.default.cyan(target.title));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const { chain, contract, tokenId } = target.coords;
|
|
124
|
+
console.log(chalk_1.default.cyan(`Single token — ${chain} ${contract}:${tokenId}`));
|
|
125
|
+
console.log(chalk_1.default.dim(" (Raster doesn't index this series — building a one-item playlist.)"));
|
|
126
|
+
}
|
|
127
|
+
console.log();
|
|
128
|
+
const userLimit = parseLimitOption(options.limit);
|
|
129
|
+
// Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
|
|
130
|
+
// clear warning. Explicit `--limit > 1024` was already rejected by
|
|
131
|
+
// parseLimitOption, so reaching here means either capped-by-default or
|
|
132
|
+
// the user picked a value within spec.
|
|
133
|
+
const limit = Math.min(userLimit, DP1_MAX_ITEMS);
|
|
134
|
+
// Raster's GraphQL API exposes no series token counts, so enumerate
|
|
135
|
+
// tokens first (up to the effective limit) and confirm with the real
|
|
136
|
+
// number afterward. `hasMore` means the series continues past `limit`.
|
|
137
|
+
let tokens;
|
|
138
|
+
let seriesHasMore = false;
|
|
139
|
+
if (target.kind === 'series') {
|
|
140
|
+
const fetched = await fetchTokens(target.summary, limit);
|
|
141
|
+
tokens = fetched.tokens;
|
|
142
|
+
seriesHasMore = fetched.hasMore;
|
|
143
|
+
}
|
|
144
|
+
else if (target.kind === 'token-list') {
|
|
145
|
+
const allTokens = target.coords.map((coords) => ({
|
|
146
|
+
...coords,
|
|
147
|
+
mediaUrl: null,
|
|
148
|
+
mediaType: null,
|
|
149
|
+
}));
|
|
150
|
+
tokens = allTokens.slice(0, limit);
|
|
151
|
+
seriesHasMore = allTokens.length > limit || !!target.hasMore;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
|
|
155
|
+
}
|
|
156
|
+
if (tokens.length === 0) {
|
|
157
|
+
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
|
|
161
|
+
console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
|
|
162
|
+
`Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
|
|
163
|
+
console.log();
|
|
164
|
+
}
|
|
165
|
+
const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(tokens.length, seriesHasMore));
|
|
166
|
+
if (!shouldBuild) {
|
|
167
|
+
console.log(chalk_1.default.dim('Cancelled.'));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
|
|
171
|
+
// Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
|
|
172
|
+
// display seconds), not concurrency — concurrency is hardcoded inside.
|
|
173
|
+
// Omit it for auto timing: video/audio items carry no duration and play
|
|
174
|
+
// their natural length (DP-1 §4.1); static items get the config default.
|
|
175
|
+
let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
|
|
176
|
+
chain: t.chain,
|
|
177
|
+
contractAddress: t.contract,
|
|
178
|
+
tokenId: t.tokenId,
|
|
179
|
+
})));
|
|
180
|
+
// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
|
|
181
|
+
// Raster's tokens, so it returns nothing — but Raster handed us each
|
|
182
|
+
// token's playable `media.contentUrl`. Build DP-1 items straight from
|
|
183
|
+
// that (off-chain provenance, same shape as the Neort path), so the
|
|
184
|
+
// series still builds into a signable, verifiable playlist instead of
|
|
185
|
+
// failing the way it did before. The indexer is tried first so tokens
|
|
186
|
+
// it *can* resolve keep their richer on-chain provenance.
|
|
187
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
188
|
+
const rasterItems = buildRasterMediaItems(tokens, target);
|
|
189
|
+
if (rasterItems.length > 0) {
|
|
190
|
+
console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
|
|
191
|
+
items = rasterItems;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
195
|
+
console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
|
|
196
|
+
console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
|
|
197
|
+
console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
const title = playlistTitleFor(target, items);
|
|
201
|
+
const playlist = await buildDP1Playlist({ items, title });
|
|
202
|
+
const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
|
|
203
|
+
await fs_1.promises.writeFile(outputPath, JSON.stringify(playlist, null, 2));
|
|
204
|
+
console.log(chalk_1.default.green(`✓ Playlist saved to ${outputPath}`));
|
|
205
|
+
console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
|
|
206
|
+
const dropped = tokens.length - items.length;
|
|
207
|
+
if (dropped > 0) {
|
|
208
|
+
console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} dropped during indexing`));
|
|
209
|
+
}
|
|
210
|
+
console.log();
|
|
211
|
+
const actions = await decideActions(options);
|
|
212
|
+
for (const action of actions) {
|
|
213
|
+
if (action === 'play') {
|
|
214
|
+
await doPlay(playlist, options.device, !!options.skipVerify);
|
|
215
|
+
}
|
|
216
|
+
else if (action === 'publish') {
|
|
217
|
+
await doPublish(outputPath, options.server, !!options.yes);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function resolveTarget(input, parsed, skipPrompt, limitOption) {
|
|
204
222
|
if (parsed.kind === 'token') {
|
|
205
223
|
return resolveCoords(parsed.coords);
|
|
206
224
|
}
|
|
207
225
|
if (parsed.kind === 'ff-url') {
|
|
226
|
+
if (parsed.urlKind === 'show') {
|
|
227
|
+
const target = await resolveTokenListInput(input, resolverLimitFromOption(limitOption), `Feral File show ${parsed.identifier}`);
|
|
228
|
+
if (target === null) {
|
|
229
|
+
throw new Error(`Feral File: no supported tokens found for show "${parsed.identifier}".`);
|
|
230
|
+
}
|
|
231
|
+
return target;
|
|
232
|
+
}
|
|
208
233
|
const coords = await (0, ff_marketplace_1.resolveFeralFileToken)(parsed);
|
|
209
234
|
return resolveCoords(coords);
|
|
210
235
|
}
|
|
@@ -212,6 +237,13 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
212
237
|
const contract = await (0, objkt_marketplace_1.resolveObjktAlias)(parsed.alias);
|
|
213
238
|
return resolveCoords({ chain: 'tezos', contract, tokenId: parsed.tokenId });
|
|
214
239
|
}
|
|
240
|
+
if (parsed.kind === 'objkt-collection') {
|
|
241
|
+
const target = await resolveTokenListInput(input, resolverLimitFromOption(limitOption), `Objkt collection ${parsed.slug}`);
|
|
242
|
+
if (target === null) {
|
|
243
|
+
throw new Error(`Objkt: no supported tokens found for collection "${parsed.slug}".`);
|
|
244
|
+
}
|
|
245
|
+
return target;
|
|
246
|
+
}
|
|
215
247
|
if (parsed.kind === 'ab-collection') {
|
|
216
248
|
const coords = await (0, ab_marketplace_1.resolveArtBlocksCollection)(parsed.slug);
|
|
217
249
|
return resolveCoords(coords);
|
|
@@ -229,6 +261,10 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
229
261
|
return resolveCoords(coords);
|
|
230
262
|
}
|
|
231
263
|
if (parsed.kind === 'verse-series') {
|
|
264
|
+
const target = await resolveTokenListInput(input, resolverLimitFromOption(limitOption), `Verse series ${parsed.slug}`);
|
|
265
|
+
if (target !== null) {
|
|
266
|
+
return target;
|
|
267
|
+
}
|
|
232
268
|
const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
|
|
233
269
|
return resolveCoords(coords);
|
|
234
270
|
}
|
|
@@ -259,6 +295,34 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
259
295
|
// `unsupported` is handled upstream; this branch keeps the type narrowing honest.
|
|
260
296
|
throw new Error(`Unsupported parse result: ${parsed.kind}`);
|
|
261
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* resolverLimitFromOption returns the exact bound to give source-resolver
|
|
300
|
+
* before any collection-like network fetch starts. The CLI still slices
|
|
301
|
+
* locally in `runResolvedTarget`, but passing this value early keeps large
|
|
302
|
+
* marketplace catalogs from being over-resolved when the user requested a
|
|
303
|
+
* smaller playlist.
|
|
304
|
+
*/
|
|
305
|
+
function resolverLimitFromOption(limitStr) {
|
|
306
|
+
return Math.min(parseLimitOption(limitStr), DP1_MAX_ITEMS);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Resolve collection-like URLs through the source-resolver package when its
|
|
310
|
+
* committed support goes beyond the synchronous parser marker. This keeps the
|
|
311
|
+
* CLI aligned with the library without duplicating every marketplace API and
|
|
312
|
+
* DOM extraction fallback locally.
|
|
313
|
+
*/
|
|
314
|
+
async function resolveTokenListInput(input, limit, fallbackTitle = 'Resolved source') {
|
|
315
|
+
const result = await (0, source_resolver_1.resolveTokenInfos)(input, { limit });
|
|
316
|
+
if (result.kind !== 'tokens') {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
kind: 'token-list',
|
|
321
|
+
title: result.title ?? fallbackTitle,
|
|
322
|
+
coords: result.coords,
|
|
323
|
+
hasMore: result.hasMore,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
262
326
|
/**
|
|
263
327
|
* Resolve on-chain coords to a target. If Raster doesn't index this token,
|
|
264
328
|
* fall back to single-token mode so we still build something playable.
|
|
@@ -280,6 +344,9 @@ function playlistTitleFor(target, items) {
|
|
|
280
344
|
const artistLabel = target.summary.artists.map((a) => a.name).join(', ') || 'Unknown artist';
|
|
281
345
|
return `${artistLabel} — ${target.summary.title}`;
|
|
282
346
|
}
|
|
347
|
+
if (target.kind === 'token-list') {
|
|
348
|
+
return target.title;
|
|
349
|
+
}
|
|
283
350
|
return items[0]?.title ?? `Token ${target.coords.tokenId}`;
|
|
284
351
|
}
|
|
285
352
|
/**
|
|
@@ -3,11 +3,30 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveManualDeviceInput = resolveManualDeviceInput;
|
|
6
7
|
exports.discoverAndSelectDevice = discoverAndSelectDevice;
|
|
7
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
8
9
|
const ff1_discovery_1 = require("../../utilities/ff1-discovery");
|
|
9
10
|
const device_lookup_1 = require("../../utilities/device-lookup");
|
|
10
11
|
const device_normalize_1 = require("../../utilities/device-normalize");
|
|
12
|
+
/** Resolve manual input while retaining a stable ID for later relayer pairing. */
|
|
13
|
+
function resolveManualDeviceInput(rawInput) {
|
|
14
|
+
const trimmed = rawInput.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
return { hostValue: '', discoveredName: '', skipped: false };
|
|
17
|
+
}
|
|
18
|
+
const lower = trimmed.toLowerCase();
|
|
19
|
+
const looksLikeHost = lower.includes('.') || lower.includes(':') || lower.startsWith('http');
|
|
20
|
+
const discoveredId = looksLikeHost
|
|
21
|
+
? undefined
|
|
22
|
+
: (lower.startsWith('ff1-') ? lower : `ff1-${lower}`).toUpperCase();
|
|
23
|
+
return {
|
|
24
|
+
hostValue: (0, device_normalize_1.normalizeDeviceIdToHost)(trimmed),
|
|
25
|
+
discoveredName: '',
|
|
26
|
+
discoveredId,
|
|
27
|
+
skipped: false,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
11
30
|
/**
|
|
12
31
|
* Run mDNS discovery and prompt the user to pick a device, fall back to
|
|
13
32
|
* manual entry, or skip when an existing device is already configured.
|
|
@@ -25,7 +44,7 @@ async function discoverAndSelectDevice(ask, existingDevices, options) {
|
|
|
25
44
|
: `${discoveryResult.error}.`;
|
|
26
45
|
console.log(chalk_1.default.dim(`mDNS discovery failed: ${errorMessage} Continuing with manual entry.`));
|
|
27
46
|
console.log(chalk_1.default.dim(' Tip: you can skip discovery entirely with ' +
|
|
28
|
-
'ff-cli device add --host http://<device-ip>:1111 --name <name>'));
|
|
47
|
+
'ff-cli device add --host http://<device-ip>:1111 --name <name> --id FF1-XXXXXXXX'));
|
|
29
48
|
}
|
|
30
49
|
else if (discoveryResult.error) {
|
|
31
50
|
console.log(chalk_1.default.dim(`mDNS discovery warning: ${discoveryResult.error}`));
|
|
@@ -107,12 +126,13 @@ async function discoverAndSelectDevice(ask, existingDevices, options) {
|
|
|
107
126
|
// with mesh routers that put wired and Wi-Fi clients on different /24s).
|
|
108
127
|
// Point users at the reliable fallback.
|
|
109
128
|
console.log(chalk_1.default.dim(' Tip: mDNS often does not cross subnets. If the device is reachable by IP, ' +
|
|
110
|
-
'add it directly: ff-cli device add --host http://<device-ip>:1111
|
|
129
|
+
'add it directly: ff-cli device add --host http://<device-ip>:1111 ' +
|
|
130
|
+
'--name <name> --id FF1-XXXXXXXX'));
|
|
111
131
|
}
|
|
112
132
|
// Manual entry fallback
|
|
113
133
|
const idAnswer = await ask('Device ID or host (e.g. ff1-ABCD1234): ');
|
|
114
134
|
if (!idAnswer) {
|
|
115
135
|
return { hostValue: '', discoveredName: '', skipped: false };
|
|
116
136
|
}
|
|
117
|
-
return
|
|
137
|
+
return resolveManualDeviceInput(idAnswer);
|
|
118
138
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.persistDeviceRemoval = persistDeviceRemoval;
|
|
4
|
+
const topic_store_1 = require("../../utilities/topic-store");
|
|
5
|
+
/**
|
|
6
|
+
* persistDeviceRemoval saves the authoritative config change before attempting
|
|
7
|
+
* best-effort credential cleanup.
|
|
8
|
+
*
|
|
9
|
+
* The operating-system credential vault can be locked or temporarily
|
|
10
|
+
* unavailable. That must not trap a stale device in config.json; the user can
|
|
11
|
+
* remove the orphaned credential later after the vault becomes available.
|
|
12
|
+
*
|
|
13
|
+
* @param {Config} config - Parsed CLI configuration to update
|
|
14
|
+
* @param {number} deviceIndex - Index of the configured device to remove
|
|
15
|
+
* @param {DeviceRemovalDependencies} dependencies - Persistence and credential operations
|
|
16
|
+
* @returns {Promise<DeviceRemovalResult>} Removed device and remaining device list
|
|
17
|
+
*/
|
|
18
|
+
async function persistDeviceRemoval(config, deviceIndex, dependencies) {
|
|
19
|
+
const existingDevices = config.ff1Devices?.devices ?? [];
|
|
20
|
+
const removed = existingDevices[deviceIndex];
|
|
21
|
+
if (!removed) {
|
|
22
|
+
throw new Error('Configured FF1 device no longer exists');
|
|
23
|
+
}
|
|
24
|
+
const updatedDevices = existingDevices.filter((_, index) => index !== deviceIndex);
|
|
25
|
+
config.ff1Devices = { devices: updatedDevices };
|
|
26
|
+
await dependencies.persistConfig(config);
|
|
27
|
+
if (removed.id) {
|
|
28
|
+
try {
|
|
29
|
+
const readTopic = dependencies.getTopicId ?? topic_store_1.getTopicId;
|
|
30
|
+
const deleteTopic = dependencies.deleteTopicId ?? topic_store_1.deleteTopicId;
|
|
31
|
+
if (readTopic(removed.id)) {
|
|
32
|
+
deleteTopic(removed.id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
(dependencies.warn ?? console.warn)('Warning: device was removed, but its secure topic credential could not be deleted.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { removed, updatedDevices };
|
|
40
|
+
}
|
|
@@ -7,9 +7,9 @@ exports.playCommand = void 0;
|
|
|
7
7
|
exports.describeDiscoveryInput = describeDiscoveryInput;
|
|
8
8
|
const commander_1 = require("commander");
|
|
9
9
|
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const source_resolver_1 = require("@feralfile/source-resolver");
|
|
10
11
|
const config_1 = require("../config");
|
|
11
12
|
const playlist_source_1 = require("../utilities/playlist-source");
|
|
12
|
-
const marketplace_url_1 = require("../utilities/marketplace-url");
|
|
13
13
|
const playlist_cast_1 = require("../utilities/playlist-cast");
|
|
14
14
|
const playlist_display_1 = require("./helpers/playlist-display");
|
|
15
15
|
/**
|
|
@@ -29,7 +29,7 @@ const playlist_display_1 = require("./helpers/playlist-display");
|
|
|
29
29
|
* redirected to `find` too, which surfaces the specific reason.
|
|
30
30
|
*/
|
|
31
31
|
function describeDiscoveryInput(source) {
|
|
32
|
-
const parsed = (0,
|
|
32
|
+
const parsed = (0, source_resolver_1.parseFindInput)(source);
|
|
33
33
|
if (!parsed) {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
@@ -75,7 +75,7 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
75
75
|
const discovery = describeDiscoveryInput(source);
|
|
76
76
|
if (discovery) {
|
|
77
77
|
console.error(chalk_1.default.red('\n`play` casts an exact playlist or media URL — it does not search marketplaces.'));
|
|
78
|
-
console.error(chalk_1.default.dim(` "${source}" looks like ${discovery} to
|
|
78
|
+
console.error(chalk_1.default.dim(` "${source}" looks like ${discovery}, which needs to be resolved first.`));
|
|
79
79
|
const deviceFlag = options.device ? ` -d "${options.device}"` : '';
|
|
80
80
|
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
|
|
81
81
|
process.exit(1);
|
|
@@ -10,7 +10,7 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
10
10
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
11
|
const { signPlaylistFile } = require('../utilities/playlist-signer');
|
|
12
12
|
exports.signCommand = new commander_1.Command('sign')
|
|
13
|
-
.description('Sign a
|
|
13
|
+
.description('Sign a DP-1 playlist file with a DP-1 signature envelope')
|
|
14
14
|
.argument('<file>', 'Path to the playlist file to sign')
|
|
15
15
|
.option('-k, --key <privateKey>', 'Ed25519 private key in base64 format (overrides config)')
|
|
16
16
|
.option('-r, --role <role>', 'DP-1 signing role (overrides config)')
|