@feralfile/cli 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/config.json.example +96 -0
  4. package/dist/index.js +54 -0
  5. package/dist/src/ai-orchestrator/index.js +1019 -0
  6. package/dist/src/ai-orchestrator/registry.js +96 -0
  7. package/dist/src/commands/build.js +69 -0
  8. package/dist/src/commands/chat.js +189 -0
  9. package/dist/src/commands/config.js +68 -0
  10. package/dist/src/commands/device.js +278 -0
  11. package/dist/src/commands/helpers/config-files.js +62 -0
  12. package/dist/src/commands/helpers/device-discovery.js +111 -0
  13. package/dist/src/commands/helpers/playlist-display.js +161 -0
  14. package/dist/src/commands/helpers/prompt.js +65 -0
  15. package/dist/src/commands/helpers/ssh-helpers.js +44 -0
  16. package/dist/src/commands/play.js +110 -0
  17. package/dist/src/commands/publish.js +115 -0
  18. package/dist/src/commands/setup.js +225 -0
  19. package/dist/src/commands/sign.js +41 -0
  20. package/dist/src/commands/ssh.js +108 -0
  21. package/dist/src/commands/status.js +126 -0
  22. package/dist/src/commands/validate.js +18 -0
  23. package/dist/src/config.js +441 -0
  24. package/dist/src/intent-parser/index.js +1382 -0
  25. package/dist/src/intent-parser/utils.js +108 -0
  26. package/dist/src/logger.js +82 -0
  27. package/dist/src/main.js +459 -0
  28. package/dist/src/types.js +5 -0
  29. package/dist/src/utilities/address-validator.js +242 -0
  30. package/dist/src/utilities/device-default.js +36 -0
  31. package/dist/src/utilities/device-lookup.js +107 -0
  32. package/dist/src/utilities/device-normalize.js +62 -0
  33. package/dist/src/utilities/device-upsert.js +91 -0
  34. package/dist/src/utilities/domain-resolver.js +291 -0
  35. package/dist/src/utilities/ed25519-key-derive.js +155 -0
  36. package/dist/src/utilities/feed-fetcher.js +471 -0
  37. package/dist/src/utilities/ff1-compatibility.js +269 -0
  38. package/dist/src/utilities/ff1-device.js +250 -0
  39. package/dist/src/utilities/ff1-discovery.js +330 -0
  40. package/dist/src/utilities/functions.js +308 -0
  41. package/dist/src/utilities/index.js +469 -0
  42. package/dist/src/utilities/nft-indexer.js +1024 -0
  43. package/dist/src/utilities/playlist-builder.js +523 -0
  44. package/dist/src/utilities/playlist-publisher.js +131 -0
  45. package/dist/src/utilities/playlist-send.js +260 -0
  46. package/dist/src/utilities/playlist-signer.js +204 -0
  47. package/dist/src/utilities/playlist-signing-role.js +41 -0
  48. package/dist/src/utilities/playlist-source.js +128 -0
  49. package/dist/src/utilities/playlist-verifier.js +274 -0
  50. package/dist/src/utilities/ssh-access.js +145 -0
  51. package/dist/src/utils.js +48 -0
  52. package/docs/CONFIGURATION.md +206 -0
  53. package/docs/EXAMPLES.md +390 -0
  54. package/docs/FUNCTION_CALLING.md +96 -0
  55. package/docs/PROJECT_SPEC.md +228 -0
  56. package/docs/README.md +348 -0
  57. package/docs/RELEASING.md +73 -0
  58. package/package.json +76 -0
@@ -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
+ }
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ /**
3
+ * FF1 Device Communication Module
4
+ * Handles sending DP1 playlists to FF1 devices via the Relayer API
5
+ */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
41
+ exports.sendPlaylistToDevice = sendPlaylistToDevice;
42
+ const logger = __importStar(require("../logger"));
43
+ const ff1_compatibility_1 = require("./ff1-compatibility");
44
+ const SEND_RETRY_ATTEMPTS = 3;
45
+ const SEND_RETRY_BASE_DELAY_MS = 750;
46
+ /**
47
+ * Sleep for a short duration before retrying transient network errors.
48
+ *
49
+ * @param {number} delayMs - Milliseconds to wait
50
+ * @returns {Promise<void>} Promise that resolves after the delay
51
+ */
52
+ function waitForRetry(delayMs) {
53
+ return new Promise((resolve) => {
54
+ setTimeout(resolve, delayMs);
55
+ });
56
+ }
57
+ /**
58
+ * isTransientDeviceNetworkError returns true when a send failure is likely temporary.
59
+ *
60
+ * This classifier intentionally targets resolver and route-level failures that are
61
+ * common on local mDNS/Wi-Fi environments. Permanent command errors should surface
62
+ * immediately without retry loops.
63
+ *
64
+ * @param {unknown} error - Error thrown by fetch
65
+ * @returns {boolean} True when retrying is likely to recover
66
+ */
67
+ function isTransientDeviceNetworkError(error) {
68
+ if (!(error instanceof Error)) {
69
+ return false;
70
+ }
71
+ const networkCodes = new Set([
72
+ 'EHOSTUNREACH',
73
+ 'ENETUNREACH',
74
+ 'ENOTFOUND',
75
+ 'EAI_AGAIN',
76
+ 'ETIMEDOUT',
77
+ 'ECONNREFUSED',
78
+ 'ECONNRESET',
79
+ ]);
80
+ const message = error.message || '';
81
+ const messageLooksTransient = message.includes('fetch failed') ||
82
+ message.includes('getaddrinfo') ||
83
+ message.includes('EHOSTUNREACH') ||
84
+ message.includes('ENOTFOUND') ||
85
+ message.includes('ETIMEDOUT') ||
86
+ message.includes('network timeout') ||
87
+ message.includes('No route to host');
88
+ if (messageLooksTransient) {
89
+ return true;
90
+ }
91
+ const causeCode = error.cause &&
92
+ typeof error.cause === 'object' &&
93
+ 'code' in error.cause &&
94
+ typeof error.cause.code === 'string'
95
+ ? error.cause.code
96
+ : undefined;
97
+ return Boolean(causeCode && networkCodes.has(causeCode));
98
+ }
99
+ /**
100
+ * Send a DP1 playlist to an FF1 device using the cast API
101
+ *
102
+ * This function sends the entire DP1 JSON payload to a configured FF1 device.
103
+ * If a device name is provided, it searches for a device with that exact name.
104
+ * If no device name is provided, it uses the first configured device.
105
+ * The API-KEY header is only included if the device has an apiKey configured.
106
+ *
107
+ * @param {Object} params - Function parameters
108
+ * @param {Object} params.playlist - Complete DP1 v1.0.0 playlist object to send
109
+ * @param {string} [params.deviceName] - Name of the device to send to (exact match required)
110
+ * @returns {Promise<Object>} Result object
111
+ * @returns {boolean} returns.success - Whether the cast was successful
112
+ * @returns {string} [returns.device] - Device host that received the playlist
113
+ * @returns {string} [returns.deviceName] - Name of the device used
114
+ * @returns {Object} [returns.response] - Response from the device
115
+ * @returns {string} [returns.error] - Error message if failed
116
+ * @throws {Error} When device configuration is invalid or missing
117
+ * @example
118
+ * // Send to first device
119
+ * const result = await sendPlaylistToDevice({
120
+ * playlist: { version: '1.0.0', title: 'My Collection', items: [...] }
121
+ * });
122
+ *
123
+ * @example
124
+ * // Send to specific device by name
125
+ * const result = await sendPlaylistToDevice({
126
+ * playlist: { version: '1.0.0', title: 'My Collection', items: [...] },
127
+ * deviceName: 'Living Room Display'
128
+ * });
129
+ */
130
+ async function sendPlaylistToDevice({ playlist, deviceName, }) {
131
+ let device;
132
+ try {
133
+ // Validate input
134
+ if (!playlist || typeof playlist !== 'object') {
135
+ return {
136
+ success: false,
137
+ error: 'Invalid playlist: must provide a valid DP1 playlist object',
138
+ };
139
+ }
140
+ const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
141
+ if (!resolved.success || !resolved.device) {
142
+ return {
143
+ success: false,
144
+ error: resolved.error || 'FF1 device is not configured correctly',
145
+ };
146
+ }
147
+ device = resolved.device;
148
+ const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist');
149
+ if (!compatibility.compatible) {
150
+ return {
151
+ success: false,
152
+ error: compatibility.error || 'FF1 OS does not support playlist casting',
153
+ details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
154
+ };
155
+ }
156
+ logger.info(`Sending playlist to FF1 device: ${device.host}`);
157
+ // Construct API URL with optional topicID
158
+ let apiUrl = `${device.host}/api/cast`;
159
+ if (device.topicID && device.topicID.trim() !== '') {
160
+ apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
161
+ logger.debug(`Using topicID: ${device.topicID}`);
162
+ }
163
+ // Wrap playlist in required structure
164
+ const requestBody = {
165
+ command: 'displayPlaylist',
166
+ request: {
167
+ dp1_call: playlist,
168
+ intent: { action: 'now_display' },
169
+ },
170
+ };
171
+ // Prepare headers
172
+ const headers = {
173
+ 'Content-Type': 'application/json',
174
+ };
175
+ // Add API-KEY header only if apiKey is provided
176
+ if (device.apiKey) {
177
+ headers['API-KEY'] = device.apiKey;
178
+ }
179
+ // Make the API request with bounded retries for transient local network errors.
180
+ let response = null;
181
+ for (let attempt = 1; attempt <= SEND_RETRY_ATTEMPTS; attempt += 1) {
182
+ try {
183
+ response = await fetch(apiUrl, {
184
+ method: 'POST',
185
+ headers,
186
+ body: JSON.stringify(requestBody),
187
+ });
188
+ break;
189
+ }
190
+ catch (error) {
191
+ const shouldRetry = attempt < SEND_RETRY_ATTEMPTS && isTransientDeviceNetworkError(error);
192
+ if (!shouldRetry) {
193
+ throw error;
194
+ }
195
+ const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
196
+ logger.warn(`Transient network error while sending playlist (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}): ${error.message}`);
197
+ logger.debug(`Retrying playlist send in ${retryDelay}ms`);
198
+ await waitForRetry(retryDelay);
199
+ }
200
+ }
201
+ if (!response) {
202
+ const deviceLabel = device.name || device.host;
203
+ return {
204
+ success: false,
205
+ error: `Could not reach device "${deviceLabel}" at ${device.host}`,
206
+ details: 'Check that the device is powered on and reachable on your network. ' +
207
+ 'If the device IP changed (e.g. after a factory reset), run: ff1 setup',
208
+ };
209
+ }
210
+ // Check response status
211
+ if (!response.ok) {
212
+ const errorText = await response.text();
213
+ logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
214
+ logger.debug(`Error details: ${errorText}`);
215
+ return {
216
+ success: false,
217
+ error: `Device returned error ${response.status}: ${response.statusText}`,
218
+ details: errorText,
219
+ };
220
+ }
221
+ // Parse response
222
+ const responseData = (await response.json());
223
+ logger.info('Successfully sent playlist to FF1 device');
224
+ logger.debug(`Device response: ${JSON.stringify(responseData)}`);
225
+ return {
226
+ success: true,
227
+ device: device.host,
228
+ deviceName: device.name || device.host,
229
+ response: responseData,
230
+ message: 'Playlist successfully sent to FF1 device',
231
+ };
232
+ }
233
+ catch (error) {
234
+ const errorMessage = error.message;
235
+ logger.error(`Error sending playlist to device: ${errorMessage}`);
236
+ if (device && isTransientDeviceNetworkError(error)) {
237
+ const deviceLabel = device.name || device.host;
238
+ return {
239
+ success: false,
240
+ error: `Could not reach device "${deviceLabel}" at ${device.host}`,
241
+ details: 'Check that the device is powered on and reachable on your network. ' +
242
+ 'If the device IP changed (e.g. after a factory reset), run: ff1 setup',
243
+ };
244
+ }
245
+ return {
246
+ success: false,
247
+ error: errorMessage,
248
+ };
249
+ }
250
+ }