@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.
@@ -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",
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.topicID) {
49
- console.log(` Topic: ${chalk_1.default.dim(device.topicID)}`);
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
- let discoveredId;
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 = existingDevices[deviceIndex];
224
- const updatedDevices = existingDevices.filter((_, i) => i !== deviceIndex);
225
- config.ff1Devices = { devices: updatedDevices };
226
- await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
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, marketplace_url_1.parseFindInput)(input);
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
- if (target.kind === 'series') {
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 resolveTarget(parsed, skipPrompt) {
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 --name <name>'));
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 { hostValue: (0, device_normalize_1.normalizeDeviceIdToHost)(idAnswer), discoveredName: '', skipped: false };
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, marketplace_url_1.parseFindInput)(source);
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 resolve first.`));
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 DP1 playlist file with a DP-1 signature envelope')
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)')