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.
- package/README.md +4 -2
- package/config.json.example +10 -8
- package/dist/index.js +255 -80
- package/dist/src/ai-orchestrator/index.js +62 -5
- package/dist/src/config.js +12 -12
- package/dist/src/intent-parser/index.js +110 -84
- package/dist/src/intent-parser/utils.js +5 -2
- package/dist/src/logger.js +10 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +9 -27
- package/dist/src/utilities/ff1-discovery.js +147 -0
- package/dist/src/utilities/functions.js +8 -26
- package/dist/src/utilities/index.js +9 -3
- package/dist/src/utilities/playlist-send.js +36 -17
- package/dist/src/utilities/playlist-source.js +77 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/docs/CONFIGURATION.md +20 -6
- package/docs/EXAMPLES.md +13 -4
- package/docs/README.md +25 -5
- package/docs/RELEASING.md +6 -1
- package/package.json +3 -2
|
@@ -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
|
-
|
|
84
|
-
|
|
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:
|
|
87
|
+
error: resolved.error || 'FF1 device is not configured correctly',
|
|
89
88
|
};
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
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: '
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
176
|
+
logger.verbose(chalk.green('Playlist looks good'));
|
|
176
177
|
if (playlist.title) {
|
|
177
|
-
|
|
178
|
+
logger.verbose(chalk.dim(` Title: ${playlist.title}`));
|
|
178
179
|
}
|
|
179
180
|
if (playlist.items) {
|
|
180
|
-
|
|
181
|
+
logger.verbose(chalk.dim(` Items: ${playlist.items.length}`));
|
|
181
182
|
}
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 -
|
|
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
|
-
//
|
|
94
|
-
console.log(chalk_1.default.cyan(`Playlist
|
|
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
|
|
99
|
-
playlist =
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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: `
|
|
113
|
-
message:
|
|
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
|
-
|
|
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
|
|
141
|
+
fileExists,
|
|
123
142
|
playlistValid: false,
|
|
124
|
-
error:
|
|
143
|
+
error: `Playlist source is empty: ${loadedFrom}`,
|
|
125
144
|
};
|
|
126
145
|
}
|
|
127
146
|
// Validate playlist structure
|