ff1-cli 1.0.2 → 1.0.3

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.
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ /**
3
+ * FF1 device compatibility helpers for command preflight checks.
4
+ */
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.resolveConfiguredDevice = resolveConfiguredDevice;
40
+ exports.assertFF1CommandCompatibility = assertFF1CommandCompatibility;
41
+ const config_1 = require("../config");
42
+ const logger = __importStar(require("../logger"));
43
+ const FF1_COMMAND_POLICIES = {
44
+ displayPlaylist: {
45
+ minimumVersion: '1.0.0',
46
+ },
47
+ sshAccess: {
48
+ minimumVersion: '1.0.9',
49
+ },
50
+ };
51
+ /**
52
+ * Load and validate the configured FF1 device selected by name.
53
+ *
54
+ * @param {string} [deviceName] - Optional device name, exact match required
55
+ * @param {Object} [options] - Optional dependency overrides
56
+ * @param {Function} [options.getFF1DeviceConfigFn] - Optional config loader override
57
+ * @returns {FF1DeviceSelectionResult} Selected device or reason for failure
58
+ * @throws {Error} Never throws; malformed configuration is returned as an error result
59
+ * @example
60
+ * const result = resolveConfiguredDevice('Living Room');
61
+ */
62
+ function resolveConfiguredDevice(deviceName, options = {}) {
63
+ const getFF1DeviceConfigFn = options.getFF1DeviceConfigFn || config_1.getFF1DeviceConfig;
64
+ const deviceConfig = getFF1DeviceConfigFn();
65
+ if (!deviceConfig.devices || deviceConfig.devices.length === 0) {
66
+ return {
67
+ success: false,
68
+ error: 'No FF1 devices configured. Add devices to config.json under "ff1Devices"',
69
+ };
70
+ }
71
+ let device = deviceConfig.devices[0];
72
+ if (deviceName) {
73
+ device = deviceConfig.devices.find((item) => item.name === deviceName);
74
+ if (!device) {
75
+ const availableNames = deviceConfig.devices
76
+ .map((item) => item.name)
77
+ .filter(Boolean)
78
+ .join(', ');
79
+ return {
80
+ success: false,
81
+ error: `Device "${deviceName}" not found. Available devices: ${availableNames || 'none with names'}`,
82
+ };
83
+ }
84
+ logger.info(`Found device by name: ${deviceName}`);
85
+ }
86
+ else {
87
+ logger.info('Using first configured device');
88
+ }
89
+ if (!device.host) {
90
+ return {
91
+ success: false,
92
+ error: 'Invalid device configuration: must include host',
93
+ };
94
+ }
95
+ return {
96
+ success: true,
97
+ device,
98
+ };
99
+ }
100
+ /**
101
+ * Ensure the target device supports the requested FF1 command.
102
+ *
103
+ * @param {Object} device - FF1 device configuration
104
+ * @param {FF1Command} command - Command to execute
105
+ * @param {Object} [options] - Optional dependency overrides
106
+ * @param {Function} [options.fetchFn] - Optional fetch implementation
107
+ * @returns {Promise<FF1CompatibilityResult>} Compatibility result
108
+ * @throws {Error} Never throws; network and parsing failures produce a compatible result
109
+ * @example
110
+ * const result = await assertFF1CommandCompatibility(device, 'displayPlaylist');
111
+ */
112
+ async function assertFF1CommandCompatibility(device, command, options = {}) {
113
+ const fetchFn = options.fetchFn || globalThis.fetch.bind(globalThis);
114
+ const policy = getCommandPolicy(command);
115
+ const versionResult = await detectFF1VersionSafely(device.host, buildVersionHeaders(device), fetchFn);
116
+ return resolveCompatibility(device, command, policy, versionResult);
117
+ }
118
+ /**
119
+ * Return command compatibility requirements.
120
+ *
121
+ * @param {FF1Command} command - Command to check
122
+ * @returns {FF1CommandPolicy} Policy metadata
123
+ * @example
124
+ * getCommandPolicy('sshAccess'); // { minimumVersion: '1.0.0' }
125
+ */
126
+ function getCommandPolicy(command) {
127
+ return FF1_COMMAND_POLICIES[command];
128
+ }
129
+ /**
130
+ * Detect FF1 version and recover compatibility when detection fails.
131
+ *
132
+ * @param {string} host - Device host URL
133
+ * @param {Object} headers - Request headers
134
+ * @param {Function} fetchFn - Fetch implementation
135
+ * @returns {Promise<FF1VersionProbe | null>} Detected version metadata
136
+ */
137
+ async function detectFF1VersionSafely(host, headers, fetchFn) {
138
+ try {
139
+ return await detectFF1Version(host, headers, fetchFn);
140
+ }
141
+ catch (error) {
142
+ logger.debug('FF1 version detection failed; continuing with command', error.message);
143
+ return null;
144
+ }
145
+ }
146
+ /**
147
+ * Resolve final compatibility decision from detected version and policy.
148
+ *
149
+ * @param {FF1Device} device - Target device
150
+ * @param {FF1Command} command - Command requested
151
+ * @param {FF1CommandPolicy} policy - Version policy
152
+ * @param {FF1VersionProbe | null} versionResult - Detected version probe
153
+ * @returns {FF1CompatibilityResult} Compatibility decision
154
+ */
155
+ function resolveCompatibility(device, command, policy, versionResult) {
156
+ if (!versionResult) {
157
+ logger.warn(`Could not verify FF1 OS version for ${device.name || device.host}`);
158
+ return { compatible: true };
159
+ }
160
+ const normalizedVersion = normalizeVersion(versionResult.version);
161
+ if (!normalizedVersion) {
162
+ return {
163
+ compatible: true,
164
+ version: versionResult.version,
165
+ };
166
+ }
167
+ if (compareVersions(normalizedVersion, policy.minimumVersion) < 0) {
168
+ return {
169
+ compatible: false,
170
+ version: normalizedVersion,
171
+ error: `Unsupported FF1 OS ${normalizedVersion} for ${command}. FF1 OS must be ${policy.minimumVersion} or newer.`,
172
+ };
173
+ }
174
+ return {
175
+ compatible: true,
176
+ version: normalizedVersion,
177
+ };
178
+ }
179
+ /**
180
+ * Detect FF1 OS version via POST /api/cast with getDeviceStatus command.
181
+ *
182
+ * Reads `message.installedVersion` from the device status response.
183
+ *
184
+ * @param {string} host - Device host URL
185
+ * @param {Record<string, string>} headers - Request headers (e.g. API-KEY)
186
+ * @param {FetchFunction} fetchFn - Fetch implementation
187
+ * @returns {Promise<FF1VersionProbe | null>} Version probe or null if unavailable
188
+ * @example
189
+ * const probe = await detectFF1Version('http://ff1.local', {}, fetch);
190
+ */
191
+ async function detectFF1Version(host, headers, fetchFn) {
192
+ try {
193
+ const response = await fetchFn(`${host}/api/cast`, {
194
+ method: 'POST',
195
+ headers: { ...headers, 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({ command: 'getDeviceStatus', request: {} }),
197
+ });
198
+ if (!response.ok) {
199
+ return null;
200
+ }
201
+ const data = (await response.json());
202
+ const version = data?.message?.installedVersion;
203
+ if (!version) {
204
+ return null;
205
+ }
206
+ return { version };
207
+ }
208
+ catch (_error) {
209
+ return null;
210
+ }
211
+ }
212
+ /**
213
+ * Build headers shared by cast requests.
214
+ *
215
+ * @param {FF1Device} device - Target device
216
+ * @returns {Record<string, string>} Headers map
217
+ * @example
218
+ * const headers = buildVersionHeaders(device);
219
+ */
220
+ function buildVersionHeaders(device) {
221
+ const headers = {};
222
+ if (device.apiKey) {
223
+ headers['API-KEY'] = device.apiKey;
224
+ }
225
+ return headers;
226
+ }
227
+ /**
228
+ * Parse and normalize a version string to x.y.z format.
229
+ *
230
+ * @param {string} version - Raw version string
231
+ * @returns {string | null} Normalized semver-like version
232
+ * @example
233
+ * normalizeVersion('v1.2') // '1.2.0'
234
+ */
235
+ function normalizeVersion(version) {
236
+ const raw = version.trim();
237
+ const match = raw.match(/(?:v)?(\d+)\.(\d+)(?:\.(\d+))?/);
238
+ if (!match) {
239
+ return null;
240
+ }
241
+ const major = match[1];
242
+ const minor = match[2];
243
+ const patch = match[3] || '0';
244
+ return `${major}.${minor}.${patch}`;
245
+ }
246
+ /**
247
+ * Compare two semantic versions in x.y.z format.
248
+ *
249
+ * @param {string} left - First version
250
+ * @param {string} right - Second version
251
+ * @returns {number} 1 if left > right, -1 if left < right, 0 if equal
252
+ * @example
253
+ * compareVersions('1.2.1', '1.2.0'); // 1
254
+ */
255
+ function compareVersions(left, right) {
256
+ const leftParts = left.split('.').map((value) => Number.parseInt(value, 10));
257
+ const rightParts = right.split('.').map((value) => Number.parseInt(value, 10));
258
+ for (let i = 0; i < 3; i++) {
259
+ const leftPart = leftParts[i] || 0;
260
+ const rightPart = rightParts[i] || 0;
261
+ if (leftPart > rightPart) {
262
+ return 1;
263
+ }
264
+ if (leftPart < rightPart) {
265
+ return -1;
266
+ }
267
+ }
268
+ return 0;
269
+ }
@@ -38,8 +38,8 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.sendPlaylistToDevice = sendPlaylistToDevice;
41
- const config_1 = require("../config");
42
41
  const logger = __importStar(require("../logger"));
42
+ const ff1_compatibility_1 = require("./ff1-compatibility");
43
43
  /**
44
44
  * Send a DP1 playlist to an FF1 device using the cast API
45
45
  *
@@ -80,38 +80,20 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
80
80
  error: 'Invalid playlist: must provide a valid DP1 playlist object',
81
81
  };
82
82
  }
83
- // Get device configuration
84
- const deviceConfig = (0, config_1.getFF1DeviceConfig)();
85
- if (!deviceConfig.devices || deviceConfig.devices.length === 0) {
83
+ const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
84
+ if (!resolved.success || !resolved.device) {
86
85
  return {
87
86
  success: false,
88
- error: 'No FF1 devices configured. Please add devices to config.json under "ff1Devices"',
87
+ error: resolved.error || 'FF1 device is not configured correctly',
89
88
  };
90
89
  }
91
- // Find device by name if provided, otherwise use first device
92
- let device;
93
- if (deviceName) {
94
- device = deviceConfig.devices.find((d) => d.name === deviceName);
95
- if (!device) {
96
- const availableNames = deviceConfig.devices
97
- .map((d) => d.name)
98
- .filter(Boolean)
99
- .join(', ');
100
- return {
101
- success: false,
102
- error: `Device "${deviceName}" not found. Available devices: ${availableNames || 'none with names'}`,
103
- };
104
- }
105
- logger.info(`Found device by name: ${deviceName}`);
106
- }
107
- else {
108
- device = deviceConfig.devices[0];
109
- logger.info('Using first configured device');
110
- }
111
- if (!device.host) {
90
+ const device = resolved.device;
91
+ const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist');
92
+ if (!compatibility.compatible) {
112
93
  return {
113
94
  success: false,
114
- error: 'Invalid device configuration: must include host',
95
+ error: compatibility.error || 'FF1 OS does not support playlist casting',
96
+ details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
115
97
  };
116
98
  }
117
99
  logger.info(`Sending playlist to FF1 device: ${device.host}`);
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoverFF1Devices = discoverFF1Devices;
4
+ const bonjour_service_1 = require("bonjour-service");
5
+ const DEFAULT_TIMEOUT_MS = 2000;
6
+ /**
7
+ * Normalize mDNS TXT records to string values.
8
+ *
9
+ * @param {Record<string, unknown> | undefined} record - Raw TXT record object from mDNS
10
+ * @returns {Record<string, string>} Normalized TXT records
11
+ * @example
12
+ * normalizeTxtRecords({ id: 'ff1-1234', name: 'Studio FF1' });
13
+ */
14
+ function normalizeTxtRecords(record) {
15
+ if (!record) {
16
+ return {};
17
+ }
18
+ return Object.entries(record).reduce((accumulator, [key, value]) => {
19
+ if (typeof value === 'string') {
20
+ accumulator[key] = value;
21
+ return accumulator;
22
+ }
23
+ if (Buffer.isBuffer(value)) {
24
+ accumulator[key] = value.toString('utf8');
25
+ return accumulator;
26
+ }
27
+ if (typeof value === 'number' || typeof value === 'boolean') {
28
+ accumulator[key] = String(value);
29
+ }
30
+ return accumulator;
31
+ }, {});
32
+ }
33
+ /**
34
+ * Normalize mDNS hostnames by trimming a trailing dot.
35
+ *
36
+ * @param {string} host - Raw host from mDNS results
37
+ * @returns {string} Normalized host
38
+ * @example
39
+ * normalizeMdnsHost('ff1-1234.local.');
40
+ */
41
+ function normalizeMdnsHost(host) {
42
+ return host.endsWith('.') ? host.slice(0, -1) : host;
43
+ }
44
+ /**
45
+ * Extract a hostname-based ID from an mDNS host when possible.
46
+ *
47
+ * @param {string} host - Normalized mDNS host
48
+ * @returns {string} Hostname-based ID when available
49
+ * @example
50
+ * getHostnameId('ff1-03vdu3x1.local');
51
+ */
52
+ function getHostnameId(host) {
53
+ if (!host) {
54
+ return '';
55
+ }
56
+ if (host.includes(':')) {
57
+ return '';
58
+ }
59
+ if (/^[0-9.]+$/.test(host)) {
60
+ return '';
61
+ }
62
+ if (host.endsWith('.local')) {
63
+ return host.split('.')[0] || '';
64
+ }
65
+ if (!host.includes('.')) {
66
+ return host;
67
+ }
68
+ return '';
69
+ }
70
+ /**
71
+ * Discover FF1 devices via mDNS using the `_ff1._tcp` service.
72
+ *
73
+ * @param {Object} [options] - Discovery options
74
+ * @param {number} [options.timeoutMs] - How long to browse before returning results
75
+ * @returns {Promise<FF1DiscoveryResult>} Discovered FF1 devices and optional error
76
+ * @throws {Error} Never throws; returns empty list on errors
77
+ * @example
78
+ * const result = await discoverFF1Devices({ timeoutMs: 2000 });
79
+ */
80
+ async function discoverFF1Devices(options = {}) {
81
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
82
+ return new Promise((resolve) => {
83
+ let resolved = false;
84
+ const finish = (result, error) => {
85
+ if (resolved) {
86
+ return;
87
+ }
88
+ resolved = true;
89
+ resolve({ devices: result, error });
90
+ };
91
+ try {
92
+ const bonjour = new bonjour_service_1.Bonjour();
93
+ const devices = new Map();
94
+ const browser = bonjour.find({ type: 'ff1', protocol: 'tcp' });
95
+ const finalize = (error) => {
96
+ try {
97
+ browser.stop();
98
+ bonjour.destroy();
99
+ }
100
+ catch (_error) {
101
+ finish([], error || 'mDNS discovery failed while stopping the browser');
102
+ return;
103
+ }
104
+ const result = Array.from(devices.values()).sort((left, right) => left.name.localeCompare(right.name));
105
+ finish(result, error);
106
+ };
107
+ const timeoutHandle = setTimeout(() => finalize(), timeoutMs);
108
+ browser.on('up', (service) => {
109
+ const host = normalizeMdnsHost(service.host || service.fqdn || '');
110
+ if (!host) {
111
+ return;
112
+ }
113
+ const port = service.port || 1111;
114
+ const txt = normalizeTxtRecords(service.txt);
115
+ const name = txt.name || service.name || txt.id || host;
116
+ const hostId = getHostnameId(host);
117
+ const id = hostId || txt.id || undefined;
118
+ const key = `${host}:${port}`;
119
+ devices.set(key, {
120
+ name,
121
+ host,
122
+ port,
123
+ id,
124
+ fqdn: service.fqdn,
125
+ txt,
126
+ });
127
+ });
128
+ browser.on('error', (error) => {
129
+ clearTimeout(timeoutHandle);
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ try {
132
+ browser.stop();
133
+ bonjour.destroy();
134
+ }
135
+ catch (_error) {
136
+ finish([], `mDNS discovery failed: ${message || 'failed to stop browser after error'}`);
137
+ return;
138
+ }
139
+ finalize(`mDNS discovery failed: ${message || 'discovery error'}`);
140
+ });
141
+ }
142
+ catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ finish([], `mDNS discovery failed: ${message || 'discovery error'}`);
145
+ }
146
+ });
147
+ }
@@ -39,6 +39,7 @@ const chalk = require('chalk');
39
39
  const playlistBuilder = require('./playlist-builder');
40
40
  const ff1Device = require('./ff1-device');
41
41
  const domainResolver = require('./domain-resolver');
42
+ const logger = require('../logger');
42
43
  /**
43
44
  * Build DP1 v1.0.0 compliant playlist
44
45
  *
@@ -112,7 +113,7 @@ async function sendPlaylistToDevice(params) {
112
113
  * console.log(result.domainMap); // { 'vitalik.eth': '0x...', 'alice.tez': 'tz...' }
113
114
  */
114
115
  async function resolveDomains(params) {
115
- const { domains, displayResults = true } = params;
116
+ const { domains, displayResults = false } = params;
116
117
  if (!domains || !Array.isArray(domains) || domains.length === 0) {
117
118
  const error = 'No domains provided for resolution';
118
119
  console.error(chalk.red(`\n${error}`));
@@ -158,7 +159,7 @@ async function verifyPlaylist(params) {
158
159
  error: 'No playlist provided for verification',
159
160
  };
160
161
  }
161
- console.log(chalk.cyan('\nValidate playlist'));
162
+ logger.verbose(chalk.cyan('\nValidate playlist'));
162
163
  // Dynamic import to avoid circular dependency
163
164
  const playlistVerifier = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
164
165
  const verify = playlistVerifier.verifyPlaylist ||
@@ -172,14 +173,14 @@ async function verifyPlaylist(params) {
172
173
  }
173
174
  const result = verify(playlist);
174
175
  if (result.valid) {
175
- console.log(chalk.green('Playlist looks good'));
176
+ logger.verbose(chalk.green('Playlist looks good'));
176
177
  if (playlist.title) {
177
- console.log(chalk.dim(` Title: ${playlist.title}`));
178
+ logger.verbose(chalk.dim(` Title: ${playlist.title}`));
178
179
  }
179
180
  if (playlist.items) {
180
- console.log(chalk.dim(` Items: ${playlist.items.length}`));
181
+ logger.verbose(chalk.dim(` Items: ${playlist.items.length}`));
181
182
  }
182
- console.log();
183
+ logger.verbose();
183
184
  }
184
185
  else {
185
186
  console.error(chalk.red('Playlist has issues'));
@@ -237,26 +238,7 @@ async function verifyAddresses(params) {
237
238
  }
238
239
  const result = validateAddresses(addresses);
239
240
  // Display results
240
- if (result.valid) {
241
- console.log(chalk.green('\n✓ All addresses are valid'));
242
- result.results.forEach((r) => {
243
- const typeLabel = r.type === 'ethereum'
244
- ? 'Ethereum'
245
- : r.type === 'ens'
246
- ? 'ENS Domain'
247
- : r.type === 'tezos-domain'
248
- ? 'Tezos Domain'
249
- : r.type === 'contract'
250
- ? 'Tezos Contract'
251
- : 'Tezos User';
252
- console.log(chalk.dim(` • ${r.address} (${typeLabel})`));
253
- if (r.normalized) {
254
- console.log(chalk.dim(` Checksummed: ${r.normalized}`));
255
- }
256
- });
257
- console.log();
258
- }
259
- else {
241
+ if (!result.valid) {
260
242
  console.error(chalk.red('\nAddress validation failed'));
261
243
  result.errors.forEach((err) => {
262
244
  console.error(chalk.red(` • ${err}`));
@@ -8,6 +8,8 @@ const feedFetcher = require('./feed-fetcher');
8
8
  const playlistBuilder = require('./playlist-builder');
9
9
  const functions = require('./functions');
10
10
  const domainResolver = require('./domain-resolver');
11
+ const logger = require('../logger');
12
+ const printedTokenCountKeys = new Set();
11
13
  /**
12
14
  * Initialize utilities with configuration
13
15
  *
@@ -94,7 +96,11 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10) {
94
96
  if (typeof quantity === 'number' && selectedTokens.length > quantity) {
95
97
  selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
96
98
  }
97
- console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
99
+ const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration}`;
100
+ if (!printedTokenCountKeys.has(tokenCountKey)) {
101
+ console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
102
+ printedTokenCountKeys.add(tokenCountKey);
103
+ }
98
104
  // Convert tokens to DP1 items
99
105
  const items = [];
100
106
  let skippedCount = 0;
@@ -147,10 +153,10 @@ async function queryRequirement(requirement, duration = 10) {
147
153
  if (type === 'query_address') {
148
154
  // Check if ownerAddress is a domain name (.eth or .tez)
149
155
  if (ownerAddress && (ownerAddress.endsWith('.eth') || ownerAddress.endsWith('.tez'))) {
150
- console.log(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
156
+ logger.verbose(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
151
157
  const resolution = await domainResolver.resolveDomain(ownerAddress);
152
158
  if (resolution.resolved && resolution.address) {
153
- console.log(chalk.dim(` ${resolution.domain} → ${resolution.address}`));
159
+ console.log(chalk.green(`${resolution.domain} → ${resolution.address}`));
154
160
  // Use resolved address instead of domain
155
161
  return await queryTokensByAddress(resolution.address, quantity, duration);
156
162
  }
@@ -38,7 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.confirmPlaylistForSending = confirmPlaylistForSending;
40
40
  const chalk_1 = __importDefault(require("chalk"));
41
- const fs_1 = require("fs");
41
+ const playlist_source_1 = require("./playlist-source");
42
42
  /**
43
43
  * Get available FF1 devices from config
44
44
  *
@@ -77,7 +77,7 @@ async function getAvailableDevices() {
77
77
  * Reads the playlist file, validates it against DP-1 spec,
78
78
  * and returns confirmation result for user review.
79
79
  *
80
- * @param {string} filePath - Path to playlist file
80
+ * @param {string} filePath - Playlist file path or URL
81
81
  * @param {string} [deviceName] - Device name (optional)
82
82
  * @returns {Promise<PlaylistSendConfirmation>} Validation result
83
83
  */
@@ -90,38 +90,57 @@ async function confirmPlaylistForSending(filePath, deviceName) {
90
90
  console.error(chalk_1.default.dim(`[DEBUG] confirmPlaylistForSending called with: filePath="${filePath}", deviceName="${deviceName}" -> "${actualDeviceName}"`));
91
91
  }
92
92
  try {
93
- // Check if file exists
94
- console.log(chalk_1.default.cyan(`Playlist file: ${resolvedPath}`));
95
- let _fileExists = false;
93
+ // Load playlist from file or URL
94
+ console.log(chalk_1.default.cyan(`Playlist source: ${resolvedPath}`));
96
95
  let playlist;
96
+ let fileExists = false;
97
+ let loadedFrom = 'file';
97
98
  try {
98
- const content = await fs_1.promises.readFile(resolvedPath, 'utf-8');
99
- playlist = JSON.parse(content);
100
- _fileExists = true;
101
- console.log(chalk_1.default.green('File loaded'));
99
+ const loaded = await (0, playlist_source_1.loadPlaylistSource)(resolvedPath);
100
+ playlist = loaded.playlist;
101
+ fileExists = loaded.sourceType === 'file';
102
+ loadedFrom = loaded.sourceType;
103
+ console.log(chalk_1.default.green(`Loaded from ${loadedFrom}: ${resolvedPath}`));
102
104
  }
103
105
  catch (error) {
104
- const errorMsg = error.message;
105
- if (errorMsg.includes('ENOENT') || errorMsg.includes('no such file')) {
106
- console.log(chalk_1.default.red(`File not found: ${resolvedPath}`));
106
+ const message = error.message;
107
+ const isUrl = (0, playlist_source_1.isPlaylistSourceUrl)(resolvedPath);
108
+ if (isUrl) {
107
109
  return {
108
110
  success: false,
109
111
  filePath: resolvedPath,
110
112
  fileExists: false,
111
113
  playlistValid: false,
112
- error: `Playlist file not found at ${resolvedPath}`,
113
- message: `Could not find playlist file. Try:\n • Run a playlist build first\n • Check the file path is correct\n • Use "send ./path/to/playlist.json"`,
114
+ error: `Could not load playlist URL: ${resolvedPath}`,
115
+ message: `${message}\n\nHint:\n • Check the URL is reachable\n • Confirm it returns JSON\n • Use "send ./path/to/playlist.json" for local files`,
114
116
  };
115
117
  }
116
- throw error;
118
+ if (message.includes('Invalid JSON in')) {
119
+ return {
120
+ success: false,
121
+ filePath: resolvedPath,
122
+ fileExists: true,
123
+ playlistValid: false,
124
+ error: message,
125
+ message: `${message}\n\nHint:\n • Check the JSON payload is valid DP-1 format\n • Check local path points to a playlist file`,
126
+ };
127
+ }
128
+ return {
129
+ success: false,
130
+ filePath: resolvedPath,
131
+ fileExists: false,
132
+ playlistValid: false,
133
+ error: `Playlist file not found at ${resolvedPath}`,
134
+ message: `Could not find playlist file. Try:\n • Run a playlist build first\n • Check the file path is correct\n • Use "send ./path/to/playlist.json"`,
135
+ };
117
136
  }
118
137
  if (!playlist) {
119
138
  return {
120
139
  success: false,
121
140
  filePath: resolvedPath,
122
- fileExists: true,
141
+ fileExists,
123
142
  playlistValid: false,
124
- error: 'Playlist file is empty',
143
+ error: `Playlist source is empty: ${loadedFrom}`,
125
144
  };
126
145
  }
127
146
  // Validate playlist structure