ff1-cli 1.0.1 → 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.
@@ -52,8 +52,8 @@ function loadConfig() {
52
52
  gpt: {
53
53
  apiKey: process.env.OPENAI_API_KEY || '',
54
54
  baseURL: 'https://api.openai.com/v1',
55
- model: 'gpt-4o',
56
- availableModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'],
55
+ model: 'gpt-4.1',
56
+ availableModels: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'],
57
57
  timeout: 30000,
58
58
  maxRetries: 3,
59
59
  temperature: 0.3,
@@ -80,7 +80,7 @@ function loadConfig() {
80
80
  feed: {
81
81
  baseURLs: process.env.FEED_BASE_URLS
82
82
  ? process.env.FEED_BASE_URLS.split(',')
83
- : ['https://feed.feralfile.com/api/v1'],
83
+ : ['https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1'],
84
84
  },
85
85
  };
86
86
  // Try to load config.json if it exists
@@ -105,8 +105,9 @@ function loadConfig() {
105
105
  models: mergedModels,
106
106
  };
107
107
  }
108
- catch (_error) {
109
- console.warn('Warning: Failed to parse config.json, using defaults');
108
+ catch (error) {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ console.warn(`Warning: Failed to parse config at ${configPath}. ${message}. Using defaults.`);
110
111
  return defaultConfig;
111
112
  }
112
113
  }
@@ -161,7 +162,7 @@ function getBrowserConfig() {
161
162
  * Get playlist configuration including private key for signing
162
163
  *
163
164
  * @returns {Object} Playlist configuration
164
- * @returns {string|null} returns.privateKey - Ed25519 private key in base64 format (null if not configured)
165
+ * @returns {string|null} returns.privateKey - Ed25519 private key in base64 or hex format (null if not configured)
165
166
  */
166
167
  function getPlaylistConfig() {
167
168
  const config = getConfig();
@@ -202,7 +203,7 @@ function getFeedConfig() {
202
203
  }
203
204
  else {
204
205
  // Default feed URL
205
- urls = ['https://feed.feralfile.com/api/v1'];
206
+ urls = ['https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1'];
206
207
  }
207
208
  return {
208
209
  baseURLs: urls,
@@ -241,24 +242,9 @@ function getFF1DeviceConfig() {
241
242
  * @returns {boolean} returns.supportsFunctionCalling - Whether model supports function calling
242
243
  * @throws {Error} If model is not configured or doesn't support function calling
243
244
  */
244
- function resolveModelName(config, modelName) {
245
- const selectedModel = modelName || config.defaultModel;
246
- if (config.models[selectedModel]) {
247
- return selectedModel;
248
- }
249
- const aliasMap = {
250
- gpt: 'chatgpt',
251
- chatgpt: 'gpt',
252
- };
253
- const alias = aliasMap[selectedModel];
254
- if (alias && config.models[alias]) {
255
- return alias;
256
- }
257
- return selectedModel;
258
- }
259
245
  function getModelConfig(modelName) {
260
246
  const config = getConfig();
261
- const selectedModel = resolveModelName(config, modelName);
247
+ const selectedModel = modelName || config.defaultModel;
262
248
  if (!config.models[selectedModel]) {
263
249
  throw new Error(`Model "${selectedModel}" is not configured. Available models: ${Object.keys(config.models).join(', ')}`);
264
250
  }
@@ -285,7 +271,7 @@ function validateConfig(modelName) {
285
271
  const errors = [];
286
272
  try {
287
273
  const config = getConfig();
288
- const selectedModel = resolveModelName(config, modelName);
274
+ const selectedModel = modelName || config.defaultModel;
289
275
  if (!config.models[selectedModel]) {
290
276
  errors.push(`Model "${selectedModel}" is not configured. Available: ${Object.keys(config.models).join(', ')}`);
291
277
  return { valid: false, errors };
@@ -318,13 +304,12 @@ function validateConfig(modelName) {
318
304
  // Validate playlist configuration (optional, but warn if configured incorrectly)
319
305
  if (config.playlist && config.playlist.privateKey) {
320
306
  const key = config.playlist.privateKey;
321
- if (key !== 'your_ed25519_private_key_base64_here' &&
322
- typeof key === 'string' &&
323
- key.length > 0) {
324
- // Check if it looks like valid base64
307
+ const placeholderPattern = /your_ed25519_private_key/i;
308
+ if (!placeholderPattern.test(key) && typeof key === 'string' && key.length > 0) {
325
309
  const base64Regex = /^[A-Za-z0-9+/]+=*$/;
326
- if (!base64Regex.test(key)) {
327
- errors.push('playlist.privateKey must be a valid base64-encoded ed25519 private key');
310
+ const hexRegex = /^(0x)?[0-9a-fA-F]+$/;
311
+ if (!base64Regex.test(key) && !hexRegex.test(key)) {
312
+ errors.push('playlist.privateKey must be a valid base64- or hex-encoded ed25519 private key');
328
313
  }
329
314
  }
330
315
  }
@@ -377,12 +362,5 @@ async function createSampleConfig(targetPath) {
377
362
  */
378
363
  function listAvailableModels() {
379
364
  const config = getConfig();
380
- const modelNames = new Set(Object.keys(config.models));
381
- if (modelNames.has('gpt')) {
382
- modelNames.add('chatgpt');
383
- }
384
- if (modelNames.has('chatgpt')) {
385
- modelNames.add('gpt');
386
- }
387
- return Array.from(modelNames);
365
+ return Object.keys(config.models);
388
366
  }
@@ -48,6 +48,7 @@ const openai_1 = __importDefault(require("openai"));
48
48
  const chalk_1 = __importDefault(require("chalk"));
49
49
  const config_1 = require("../config");
50
50
  const utils_1 = require("./utils");
51
+ const logger = __importStar(require("../logger"));
51
52
  // Cache for AI clients
52
53
  const clientCache = new Map();
53
54
  /**
@@ -210,7 +211,7 @@ PUBLISH INTENT (CRITICAL)
210
211
  2. If only 1 server → use it directly in playlistSettings.feedServer
211
212
  3. If 2+ servers → ask user "Which feed server?" with numbered list (e.g., "1) https://feed.feralfile.com 2) http://localhost:8787")
212
213
  4. After selection, set playlistSettings.feedServer = { baseUrl, apiKey } from selected server
213
- 5. Acknowledge in Settings bullets (e.g., "publish to: https://feed.feralfile.com/api/v1")
214
+ 5. Acknowledge in Settings bullets (e.g., "publish to: https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1")
214
215
  - User can request both device display AND publishing (e.g., "send to FF1 and publish to feed") → set both deviceName and feedServer
215
216
  - Publishing happens automatically after playlist verification passes
216
217
 
@@ -369,13 +370,13 @@ const intentParserFunctionSchemas = [
369
370
  type: 'function',
370
371
  function: {
371
372
  name: 'confirm_send_playlist',
372
- description: 'Confirm the playlist file path and device name for sending. This function is called after the user mentions "send" or similar phrases.',
373
+ description: 'Confirm the playlist file path or hosted URL and device name for sending. This function is called after the user mentions "send" or similar phrases.',
373
374
  parameters: {
374
375
  type: 'object',
375
376
  properties: {
376
377
  filePath: {
377
378
  type: 'string',
378
- description: 'Path to the playlist file (default: "./playlist.json")',
379
+ description: 'Path to playlist file or playlist URL (default: "./playlist.json")',
379
380
  },
380
381
  deviceName: {
381
382
  type: 'string',
@@ -483,16 +484,53 @@ function printMarkdownContent(content) {
483
484
  const lines = content.split('\n');
484
485
  for (const line of lines) {
485
486
  if (line.trim()) {
486
- console.log(formatMarkdown(line));
487
+ logger.verbose(formatMarkdown(line));
487
488
  }
488
489
  else if (line === '') {
489
- console.log();
490
+ logger.verbose();
490
491
  }
491
492
  }
492
493
  }
493
494
  function sleep(ms) {
494
495
  return new Promise((resolve) => setTimeout(resolve, ms));
495
496
  }
497
+ function extractDomains(text) {
498
+ if (!text) {
499
+ return [];
500
+ }
501
+ const matches = text.match(/[a-z0-9-]+\.(eth|tez)/gi);
502
+ return matches ? matches.map((match) => match.toLowerCase()) : [];
503
+ }
504
+ function normalizeDomainInput(address, userDomains) {
505
+ if (!address || userDomains.length === 0) {
506
+ return address;
507
+ }
508
+ const lower = address.toLowerCase();
509
+ if (!lower.endsWith('.eth') && !lower.endsWith('.tez')) {
510
+ return address;
511
+ }
512
+ const baseName = lower.replace(/\.(eth|tez)$/i, '');
513
+ const match = userDomains.find((domain) => domain.replace(/\.(eth|tez)$/i, '') === baseName);
514
+ return match || address;
515
+ }
516
+ function normalizeRequirementDomains(params, userDomains) {
517
+ if (userDomains.length === 0) {
518
+ return params;
519
+ }
520
+ const normalized = params.requirements.map((req) => {
521
+ if (req.type !== 'query_address' || typeof req.ownerAddress !== 'string') {
522
+ return req;
523
+ }
524
+ return {
525
+ ...req,
526
+ ownerAddress: normalizeDomainInput(req.ownerAddress, userDomains),
527
+ };
528
+ });
529
+ return {
530
+ ...params,
531
+ requirements: normalized,
532
+ };
533
+ }
496
534
  function buildToolResponseMessages(toolCalls, responses) {
497
535
  return toolCalls
498
536
  .filter((toolCall) => toolCall.id)
@@ -514,7 +552,7 @@ async function processNonStreamingResponse(response) {
514
552
  }
515
553
  if (message.content) {
516
554
  printMarkdownContent(message.content);
517
- console.log();
555
+ logger.verbose();
518
556
  }
519
557
  return { message };
520
558
  }
@@ -586,10 +624,10 @@ async function processStreamingResponse(stream) {
586
624
  for (const line of lines) {
587
625
  if (line.trim()) {
588
626
  const formatted = formatMarkdown(line);
589
- console.log(formatted);
627
+ logger.verbose(formatted);
590
628
  }
591
629
  else if (line === '') {
592
- console.log();
630
+ logger.verbose();
593
631
  }
594
632
  }
595
633
  printedUpTo = lastNewlineIndex + 1;
@@ -635,11 +673,11 @@ async function processStreamingResponse(stream) {
635
673
  const remainingText = contentBuffer.substring(printedUpTo);
636
674
  if (remainingText.trim()) {
637
675
  const formatted = formatMarkdown(remainingText);
638
- console.log(formatted);
676
+ logger.verbose(formatted);
639
677
  }
640
678
  }
641
679
  if (contentBuffer.length > 0) {
642
- console.log(); // Extra newline after AI response
680
+ logger.verbose(); // Extra newline after AI response
643
681
  }
644
682
  // Convert toolCallsMap to array
645
683
  toolCalls = Object.values(toolCallsMap).filter((tc) => tc.id);
@@ -664,6 +702,7 @@ async function processStreamingResponse(stream) {
664
702
  */
665
703
  async function processIntentParserRequest(userRequest, options = {}) {
666
704
  const { modelName, conversationContext } = options;
705
+ const userDomains = extractDomains(userRequest);
667
706
  const client = createIntentParserClient(modelName);
668
707
  const modelConfig = (0, config_1.getModelConfig)(modelName);
669
708
  const config = (0, config_1.getConfig)();
@@ -734,7 +773,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
734
773
  if (followUpMessage.tool_calls && followUpMessage.tool_calls.length > 0) {
735
774
  const followUpToolCall = followUpMessage.tool_calls[0];
736
775
  if (followUpToolCall.function.name === 'parse_requirements') {
737
- const params = JSON.parse(followUpToolCall.function.arguments);
776
+ const params = normalizeRequirementDomains(JSON.parse(followUpToolCall.function.arguments), userDomains);
738
777
  // Apply constraints and defaults
739
778
  const validatedParams = (0, utils_1.applyConstraints)(params, config);
740
779
  return {
@@ -786,7 +825,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
786
825
  if (feedFollowUpMessage.tool_calls && feedFollowUpMessage.tool_calls.length > 0) {
787
826
  const feedToolCall = feedFollowUpMessage.tool_calls[0];
788
827
  if (feedToolCall.function.name === 'parse_requirements') {
789
- const params = JSON.parse(feedToolCall.function.arguments);
828
+ const params = normalizeRequirementDomains(JSON.parse(feedToolCall.function.arguments), userDomains);
790
829
  // Apply constraints and defaults
791
830
  const validatedParams = (0, utils_1.applyConstraints)(params, config);
792
831
  return {
@@ -951,7 +990,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
951
990
  if (followUpMessage.tool_calls && followUpMessage.tool_calls.length > 0) {
952
991
  const followUpToolCall = followUpMessage.tool_calls[0];
953
992
  if (followUpToolCall.function.name === 'parse_requirements') {
954
- const params = JSON.parse(followUpToolCall.function.arguments);
993
+ const params = normalizeRequirementDomains(JSON.parse(followUpToolCall.function.arguments), userDomains);
955
994
  // Apply constraints and defaults
956
995
  const validatedParams = (0, utils_1.applyConstraints)(params, config);
957
996
  return {
@@ -1014,7 +1053,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
1014
1053
  };
1015
1054
  }
1016
1055
  else if (toolCall.function.name === 'parse_requirements') {
1017
- const params = JSON.parse(toolCall.function.arguments);
1056
+ const params = normalizeRequirementDomains(JSON.parse(toolCall.function.arguments), userDomains);
1018
1057
  // Apply constraints and defaults
1019
1058
  const validatedParams = (0, utils_1.applyConstraints)(params, config);
1020
1059
  return {
@@ -1117,6 +1156,9 @@ async function processIntentParserRequest(userRequest, options = {}) {
1117
1156
  }
1118
1157
  else if (toolCall.function.name === 'verify_addresses') {
1119
1158
  const args = JSON.parse(toolCall.function.arguments);
1159
+ if (Array.isArray(args.addresses) && userDomains.length > 0) {
1160
+ args.addresses = args.addresses.map((address) => normalizeDomainInput(address, userDomains));
1161
+ }
1120
1162
  const { verifyAddresses } = await Promise.resolve().then(() => __importStar(require('../utilities/functions')));
1121
1163
  const verificationResult = await verifyAddresses({ addresses: args.addresses });
1122
1164
  if (!verificationResult.valid) {
@@ -1168,7 +1210,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
1168
1210
  if (followUpMessage.tool_calls && followUpMessage.tool_calls.length > 0) {
1169
1211
  const followUpToolCall = followUpMessage.tool_calls[0];
1170
1212
  if (followUpToolCall.function.name === 'parse_requirements') {
1171
- const params = JSON.parse(followUpToolCall.function.arguments);
1213
+ const params = normalizeRequirementDomains(JSON.parse(followUpToolCall.function.arguments), userDomains);
1172
1214
  // Apply constraints and defaults
1173
1215
  const validatedParams = (0, utils_1.applyConstraints)(params, config);
1174
1216
  return {
@@ -1221,7 +1263,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
1221
1263
  if (feedFollowUpMessage.tool_calls && feedFollowUpMessage.tool_calls.length > 0) {
1222
1264
  const feedToolCall = feedFollowUpMessage.tool_calls[0];
1223
1265
  if (feedToolCall.function.name === 'parse_requirements') {
1224
- const params = JSON.parse(feedToolCall.function.arguments);
1266
+ const params = normalizeRequirementDomains(JSON.parse(feedToolCall.function.arguments), userDomains);
1225
1267
  // Apply constraints and defaults
1226
1268
  const validatedParams = (0, utils_1.applyConstraints)(params, config);
1227
1269
  return {
@@ -57,8 +57,11 @@ function applyConstraints(params, config) {
57
57
  // Set default quantity if not provided
58
58
  // Allow "all" as a string value for query_address type
59
59
  let quantity;
60
- if (r.quantity === 'all' || r.quantity === null || r.quantity === undefined) {
61
- quantity = r.type === 'query_address' ? 'all' : 5;
60
+ if (r.quantity === 'all') {
61
+ quantity = 'all';
62
+ }
63
+ else if (r.quantity === null || r.quantity === undefined) {
64
+ quantity = 5;
62
65
  }
63
66
  else if (typeof r.quantity === 'string') {
64
67
  // Try to parse string numbers
@@ -10,6 +10,7 @@ exports.setVerbose = setVerbose;
10
10
  exports.debug = debug;
11
11
  exports.info = info;
12
12
  exports.warn = warn;
13
+ exports.verbose = verbose;
13
14
  exports.error = error;
14
15
  exports.always = always;
15
16
  const chalk_1 = __importDefault(require("chalk"));
@@ -49,6 +50,15 @@ function warn(...args) {
49
50
  console.warn(chalk_1.default.yellow('[WARN]'), ...args);
50
51
  }
51
52
  }
53
+ /**
54
+ * Log message only in verbose mode (no prefix)
55
+ * @param {...any} args - Arguments to log
56
+ */
57
+ function verbose(...args) {
58
+ if (isVerbose) {
59
+ console.log(...args);
60
+ }
61
+ }
52
62
  /**
53
63
  * Log error message (always shown, but with more details in verbose mode)
54
64
  * @param {...any} args - Arguments to log
@@ -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}`);