@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/config.json.example +96 -0
- package/dist/index.js +54 -0
- package/dist/src/ai-orchestrator/index.js +1019 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/commands/build.js +69 -0
- package/dist/src/commands/chat.js +189 -0
- package/dist/src/commands/config.js +68 -0
- package/dist/src/commands/device.js +278 -0
- package/dist/src/commands/helpers/config-files.js +62 -0
- package/dist/src/commands/helpers/device-discovery.js +111 -0
- package/dist/src/commands/helpers/playlist-display.js +161 -0
- package/dist/src/commands/helpers/prompt.js +65 -0
- package/dist/src/commands/helpers/ssh-helpers.js +44 -0
- package/dist/src/commands/play.js +110 -0
- package/dist/src/commands/publish.js +115 -0
- package/dist/src/commands/setup.js +225 -0
- package/dist/src/commands/sign.js +41 -0
- package/dist/src/commands/ssh.js +108 -0
- package/dist/src/commands/status.js +126 -0
- package/dist/src/commands/validate.js +18 -0
- package/dist/src/config.js +441 -0
- package/dist/src/intent-parser/index.js +1382 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +82 -0
- package/dist/src/main.js +459 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/device-default.js +36 -0
- package/dist/src/utilities/device-lookup.js +107 -0
- package/dist/src/utilities/device-normalize.js +62 -0
- package/dist/src/utilities/device-upsert.js +91 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/ed25519-key-derive.js +155 -0
- package/dist/src/utilities/feed-fetcher.js +471 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +250 -0
- package/dist/src/utilities/ff1-discovery.js +330 -0
- package/dist/src/utilities/functions.js +308 -0
- package/dist/src/utilities/index.js +469 -0
- package/dist/src/utilities/nft-indexer.js +1024 -0
- package/dist/src/utilities/playlist-builder.js +523 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +260 -0
- package/dist/src/utilities/playlist-signer.js +204 -0
- package/dist/src/utilities/playlist-signing-role.js +41 -0
- package/dist/src/utilities/playlist-source.js +128 -0
- package/dist/src/utilities/playlist-verifier.js +274 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +206 -0
- package/docs/EXAMPLES.md +390 -0
- package/docs/FUNCTION_CALLING.md +96 -0
- package/docs/PROJECT_SPEC.md +228 -0
- package/docs/README.md +348 -0
- package/docs/RELEASING.md +73 -0
- package/package.json +76 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseAvahiBrowseOutput = parseAvahiBrowseOutput;
|
|
4
|
+
exports.resolveAvahiResult = resolveAvahiResult;
|
|
5
|
+
exports.discoverFF1Devices = discoverFF1Devices;
|
|
6
|
+
const bonjour_service_1 = require("bonjour-service");
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
9
|
+
/**
|
|
10
|
+
* Normalize mDNS TXT records to string values.
|
|
11
|
+
*
|
|
12
|
+
* @param {Record<string, unknown> | undefined} record - Raw TXT record object from mDNS
|
|
13
|
+
* @returns {Record<string, string>} Normalized TXT records
|
|
14
|
+
* @example
|
|
15
|
+
* normalizeTxtRecords({ id: 'ff1-1234', name: 'Studio FF1' });
|
|
16
|
+
*/
|
|
17
|
+
function normalizeTxtRecords(record) {
|
|
18
|
+
if (!record) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
return Object.entries(record).reduce((accumulator, [key, value]) => {
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
accumulator[key] = value;
|
|
24
|
+
return accumulator;
|
|
25
|
+
}
|
|
26
|
+
if (Buffer.isBuffer(value)) {
|
|
27
|
+
accumulator[key] = value.toString('utf8');
|
|
28
|
+
return accumulator;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
31
|
+
accumulator[key] = String(value);
|
|
32
|
+
}
|
|
33
|
+
return accumulator;
|
|
34
|
+
}, {});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Normalize mDNS hostnames by trimming a trailing dot.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} host - Raw host from mDNS results
|
|
40
|
+
* @returns {string} Normalized host
|
|
41
|
+
* @example
|
|
42
|
+
* normalizeMdnsHost('ff1-1234.local.');
|
|
43
|
+
*/
|
|
44
|
+
function normalizeMdnsHost(host) {
|
|
45
|
+
return host.endsWith('.') ? host.slice(0, -1) : host;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Extract a hostname-based ID from an mDNS host when possible.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} host - Normalized mDNS host
|
|
51
|
+
* @returns {string} Hostname-based ID when available
|
|
52
|
+
* @example
|
|
53
|
+
* getHostnameId('ff1-03vdu3x1.local');
|
|
54
|
+
*/
|
|
55
|
+
function getHostnameId(host) {
|
|
56
|
+
if (!host) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
if (host.includes(':')) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
if (/^[0-9.]+$/.test(host)) {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
if (host.endsWith('.local')) {
|
|
66
|
+
return host.split('.')[0] || '';
|
|
67
|
+
}
|
|
68
|
+
if (!host.includes('.')) {
|
|
69
|
+
return host;
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse avahi-browse -t -r output into FF1DiscoveredDevice list.
|
|
75
|
+
* Handles resolved records (lines starting with '=') with hostname/address/port/txt fields.
|
|
76
|
+
* Exported for testing.
|
|
77
|
+
*/
|
|
78
|
+
function parseAvahiBrowseOutput(output) {
|
|
79
|
+
const devices = new Map();
|
|
80
|
+
const lines = output.split('\n');
|
|
81
|
+
let current = null;
|
|
82
|
+
const flushCurrent = () => {
|
|
83
|
+
if (!current?.rawHost) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const host = normalizeMdnsHost(current.rawHost).toLowerCase();
|
|
87
|
+
const key = `${host}:${current.port ?? 1111}`;
|
|
88
|
+
const id = getHostnameId(host) || current.id;
|
|
89
|
+
const newAddresses = current.rawAddresses ?? [];
|
|
90
|
+
// Merge with an existing entry for the same key (e.g. IPv4 + IPv6 records
|
|
91
|
+
// for the same .local hostname both resolve to the same host:port key).
|
|
92
|
+
// Prefer TXT-sourced metadata from whichever record has it; a later partial
|
|
93
|
+
// record must not clobber a previously complete name/id/txt.
|
|
94
|
+
const existing = devices.get(key);
|
|
95
|
+
const mergedAddresses = [...(existing?.addresses ?? []), ...newAddresses].filter((addr, i, arr) => arr.indexOf(addr) === i); // deduplicate
|
|
96
|
+
// Select the richer txt: ignore empty objects (avahi emits txt=[] → {}) so a
|
|
97
|
+
// partial record with no real TXT data does not block a later complete payload.
|
|
98
|
+
const existingTxtContent = existing?.txt && Object.keys(existing.txt).length > 0 ? existing.txt : undefined;
|
|
99
|
+
const currentTxtContent = current.txt && Object.keys(current.txt).length > 0 ? current.txt : undefined;
|
|
100
|
+
// Prefer whichever txt is non-empty; if both are non-empty, keep the first seen.
|
|
101
|
+
const mergedTxt = existingTxtContent ?? currentTxtContent;
|
|
102
|
+
// For name: prefer TXT-sourced name from whichever record has content.
|
|
103
|
+
const mergedName = existingTxtContent?.name ||
|
|
104
|
+
currentTxtContent?.name ||
|
|
105
|
+
existing?.name ||
|
|
106
|
+
current.name ||
|
|
107
|
+
id ||
|
|
108
|
+
host;
|
|
109
|
+
// For id: prefer existing id (already validated) over newly derived id.
|
|
110
|
+
const mergedId = existing?.id || id;
|
|
111
|
+
devices.set(key, {
|
|
112
|
+
name: mergedName,
|
|
113
|
+
host,
|
|
114
|
+
port: current.port ?? 1111,
|
|
115
|
+
id: mergedId,
|
|
116
|
+
txt: mergedTxt,
|
|
117
|
+
addresses: mergedAddresses.length > 0 ? mergedAddresses : undefined,
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
// Resolved record header: "= wlan0 IPv4 FF1-HH9JSNOC _ff1._tcp local"
|
|
122
|
+
if (/^=\s/.test(line)) {
|
|
123
|
+
flushCurrent();
|
|
124
|
+
const parts = line.trim().split(/\s+/);
|
|
125
|
+
// parts: ['=', 'wlan0', 'IPv4', 'My Device Name', '_ff1._tcp', 'local']
|
|
126
|
+
// Service name may be multi-word; find the type token to bound the slice.
|
|
127
|
+
// Use a prefix regex so "_ff1._tcp.local" and "_ff1._tcp" both match.
|
|
128
|
+
// Preserve original case — resolveConfiguredDevice does exact-match lookups.
|
|
129
|
+
const typeIndex = parts.findIndex((p) => /^_ff1\._tcp/.test(p));
|
|
130
|
+
const serviceName = typeIndex > 3 ? parts.slice(3, typeIndex).join(' ') : parts[3] || '';
|
|
131
|
+
current = { name: serviceName };
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!current) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const hostnameMatch = line.match(/^\s+hostname\s*=\s*\[(.+)\]/);
|
|
138
|
+
if (hostnameMatch) {
|
|
139
|
+
current.rawHost = hostnameMatch[1];
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const addressMatch = line.match(/^\s+address\s*=\s*\[(.+)\]/);
|
|
143
|
+
if (addressMatch) {
|
|
144
|
+
if (!current.rawAddresses) {
|
|
145
|
+
current.rawAddresses = [];
|
|
146
|
+
}
|
|
147
|
+
current.rawAddresses.push(addressMatch[1].trim());
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const portMatch = line.match(/^\s+port\s*=\s*\[(\d+)\]/);
|
|
151
|
+
if (portMatch) {
|
|
152
|
+
current.port = parseInt(portMatch[1], 10);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const txtMatch = line.match(/^\s+txt\s*=\s*\[(.+)\]/);
|
|
156
|
+
if (txtMatch) {
|
|
157
|
+
const txt = {};
|
|
158
|
+
const pairs = txtMatch[1].matchAll(/"([^=]+)=([^"]*)"/g);
|
|
159
|
+
for (const [, k, v] of pairs) {
|
|
160
|
+
txt[k] = v;
|
|
161
|
+
}
|
|
162
|
+
current.txt = txt;
|
|
163
|
+
if (txt.name) {
|
|
164
|
+
current.name = txt.name;
|
|
165
|
+
}
|
|
166
|
+
if (txt.id) {
|
|
167
|
+
current.id = txt.id;
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Flush last record
|
|
173
|
+
flushCurrent();
|
|
174
|
+
return Array.from(devices.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the result of an avahi-browse subprocess call into an FF1DiscoveryResult
|
|
178
|
+
* or null (which triggers the Bonjour fallback).
|
|
179
|
+
*
|
|
180
|
+
* Rules:
|
|
181
|
+
* - Clean exit (error === null): parse stdout and return whatever was found.
|
|
182
|
+
* - Non-zero exit + usable stdout: avahi-browse can be killed by the execFile
|
|
183
|
+
* timeout after emitting fully resolved records. Use the parsed devices if
|
|
184
|
+
* ≥1 was found; otherwise return empty result (avahi is present but slow —
|
|
185
|
+
* do NOT fall back to Bonjour, which is less reliable on Linux).
|
|
186
|
+
* - Timeout (error.killed === true) + no usable stdout: avahi is present but
|
|
187
|
+
* the scan produced nothing before the deadline. Return empty rather than
|
|
188
|
+
* falling back to Bonjour.
|
|
189
|
+
* - Command not found (ENOENT): avahi-browse is not installed — return null
|
|
190
|
+
* so the caller falls back to Bonjour.
|
|
191
|
+
* - Other error + no usable stdout: treat as unavailable — null.
|
|
192
|
+
*
|
|
193
|
+
* Exported for unit testing.
|
|
194
|
+
*/
|
|
195
|
+
function resolveAvahiResult(error, stdout) {
|
|
196
|
+
if (error) {
|
|
197
|
+
if (stdout) {
|
|
198
|
+
try {
|
|
199
|
+
const devices = parseAvahiBrowseOutput(stdout);
|
|
200
|
+
if (devices.length > 0) {
|
|
201
|
+
return { devices };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// unparseable output — fall through
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Timeout: avahi is present but the scan was slow. Return empty rather than
|
|
209
|
+
// falling back to Bonjour, which is unreliable on Linux.
|
|
210
|
+
if (error.killed) {
|
|
211
|
+
return { devices: [], error: 'avahi-browse timed out before finding any devices' };
|
|
212
|
+
}
|
|
213
|
+
// ENOENT: avahi-browse not installed — fall back to Bonjour.
|
|
214
|
+
// All other errors with no usable output: also fall back.
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
return { devices: parseAvahiBrowseOutput(stdout) };
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Discover FF1 devices using avahi-browse (Linux).
|
|
226
|
+
* Returns null if avahi-browse is not available.
|
|
227
|
+
*/
|
|
228
|
+
function discoverViaAvahi(options) {
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
231
|
+
(0, child_process_1.execFile)('avahi-browse', ['-t', '-r', '_ff1._tcp'], { timeout: timeoutMs }, (error, stdout, _stderr) => {
|
|
232
|
+
resolve(resolveAvahiResult(error, stdout));
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Discover FF1 devices via mDNS using the `_ff1._tcp` service.
|
|
238
|
+
* On Linux, uses avahi-browse for reliable multi-device discovery.
|
|
239
|
+
* Falls back to bonjour-service on other platforms or if avahi is unavailable.
|
|
240
|
+
*
|
|
241
|
+
* @param {Object} [options] - Discovery options
|
|
242
|
+
* @param {number} [options.timeoutMs] - How long to browse before returning results (bonjour fallback only)
|
|
243
|
+
* @returns {Promise<FF1DiscoveryResult>} Discovered FF1 devices and optional error
|
|
244
|
+
* @throws {Error} Never throws; returns empty list on errors
|
|
245
|
+
* @example
|
|
246
|
+
* const result = await discoverFF1Devices();
|
|
247
|
+
*/
|
|
248
|
+
async function discoverFF1Devices(options = {},
|
|
249
|
+
// Injectable for testing — callers should omit these; defaults use the real implementations.
|
|
250
|
+
_avahiDiscovery = discoverViaAvahi, _bonjourDiscovery = discoverViaBonjour) {
|
|
251
|
+
if (process.platform === 'linux') {
|
|
252
|
+
const avahiResult = await _avahiDiscovery(options);
|
|
253
|
+
if (avahiResult !== null) {
|
|
254
|
+
return avahiResult;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return _bonjourDiscovery(options);
|
|
258
|
+
}
|
|
259
|
+
function discoverViaBonjour(options) {
|
|
260
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
let resolved = false;
|
|
263
|
+
const finish = (result, error) => {
|
|
264
|
+
if (resolved) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
resolved = true;
|
|
268
|
+
resolve({ devices: result, error });
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
const bonjour = new bonjour_service_1.Bonjour();
|
|
272
|
+
const devices = new Map();
|
|
273
|
+
const browser = bonjour.find({ type: 'ff1', protocol: 'tcp' });
|
|
274
|
+
const finalize = (error) => {
|
|
275
|
+
try {
|
|
276
|
+
browser.stop();
|
|
277
|
+
bonjour.destroy();
|
|
278
|
+
}
|
|
279
|
+
catch (_error) {
|
|
280
|
+
finish([], error || 'mDNS discovery failed while stopping the browser');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const result = Array.from(devices.values()).sort((left, right) => left.name.localeCompare(right.name));
|
|
284
|
+
finish(result, error);
|
|
285
|
+
};
|
|
286
|
+
const timeoutHandle = setTimeout(() => finalize(), timeoutMs);
|
|
287
|
+
browser.on('up', (service) => {
|
|
288
|
+
const host = normalizeMdnsHost(service.host || service.fqdn || '');
|
|
289
|
+
if (!host) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const port = service.port || 1111;
|
|
293
|
+
const txt = normalizeTxtRecords(service.txt);
|
|
294
|
+
const name = txt.name || service.name || txt.id || host;
|
|
295
|
+
const hostId = getHostnameId(host);
|
|
296
|
+
const id = hostId || txt.id || undefined;
|
|
297
|
+
const key = `${host}:${port}`;
|
|
298
|
+
const addresses = Array.isArray(service.addresses) && service.addresses.length > 0
|
|
299
|
+
? service.addresses
|
|
300
|
+
: undefined;
|
|
301
|
+
devices.set(key, {
|
|
302
|
+
name,
|
|
303
|
+
host,
|
|
304
|
+
port,
|
|
305
|
+
id,
|
|
306
|
+
fqdn: service.fqdn,
|
|
307
|
+
txt,
|
|
308
|
+
addresses,
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
browser.on('error', (error) => {
|
|
312
|
+
clearTimeout(timeoutHandle);
|
|
313
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
314
|
+
try {
|
|
315
|
+
browser.stop();
|
|
316
|
+
bonjour.destroy();
|
|
317
|
+
}
|
|
318
|
+
catch (_error) {
|
|
319
|
+
finish([], `mDNS discovery failed: ${message || 'failed to stop browser after error'}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
finalize(`mDNS discovery failed: ${message || 'discovery error'}`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
327
|
+
finish([], `mDNS discovery failed: ${message || 'discovery error'}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Function Calling Layer
|
|
3
|
+
* These are the actual implementations called by AI orchestrator via function calling
|
|
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
|
+
const chalk = require('chalk');
|
|
39
|
+
const playlistBuilder = require('./playlist-builder');
|
|
40
|
+
const ff1Device = require('./ff1-device');
|
|
41
|
+
const domainResolver = require('./domain-resolver');
|
|
42
|
+
const logger = require('../logger');
|
|
43
|
+
/**
|
|
44
|
+
* Build DP1 v1.0.0 compliant playlist
|
|
45
|
+
*
|
|
46
|
+
* This is the actual implementation called by AI orchestrator's function calling.
|
|
47
|
+
* Uses core playlist-builder utilities.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} params - Build parameters
|
|
50
|
+
* @param {Array<Object>} params.items - Playlist items
|
|
51
|
+
* @param {string} [params.title] - Playlist title (auto-generated if not provided)
|
|
52
|
+
* @param {string} [params.slug] - Playlist slug (auto-generated if not provided)
|
|
53
|
+
* @returns {Promise<Object>} DP1 playlist
|
|
54
|
+
* @example
|
|
55
|
+
* const playlist = await buildDP1Playlist({ items, title: 'My Playlist' });
|
|
56
|
+
*/
|
|
57
|
+
async function buildDP1Playlist(params) {
|
|
58
|
+
const { items, title, slug } = params;
|
|
59
|
+
return await playlistBuilder.buildDP1Playlist({ items, title, slug });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Send playlist to FF1 device
|
|
63
|
+
*
|
|
64
|
+
* This is the actual implementation called by AI orchestrator's function calling.
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} params - Send parameters
|
|
67
|
+
* @param {Object} params.playlist - DP1 playlist
|
|
68
|
+
* @param {string} [params.deviceName] - Device name (null for first device)
|
|
69
|
+
* @returns {Promise<Object>} Result
|
|
70
|
+
* @returns {boolean} returns.success - Whether send succeeded
|
|
71
|
+
* @returns {string} [returns.deviceHost] - Device host address
|
|
72
|
+
* @returns {string} [returns.deviceName] - Device name
|
|
73
|
+
* @returns {string} [returns.error] - Error message if failed
|
|
74
|
+
* @example
|
|
75
|
+
* const result = await sendPlaylistToDevice({ playlist, deviceName: 'MyDevice' });
|
|
76
|
+
*/
|
|
77
|
+
async function sendPlaylistToDevice(params) {
|
|
78
|
+
const { playlist, deviceName } = params;
|
|
79
|
+
const result = await ff1Device.sendPlaylistToDevice({
|
|
80
|
+
playlist,
|
|
81
|
+
deviceName,
|
|
82
|
+
});
|
|
83
|
+
if (result.success) {
|
|
84
|
+
console.log(chalk.green('\nSent to device'));
|
|
85
|
+
if (result.deviceName) {
|
|
86
|
+
console.log(chalk.dim(` ${result.deviceName}`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.error(chalk.red('\nSend failed'));
|
|
91
|
+
if (result.error) {
|
|
92
|
+
console.error(chalk.red(` ${result.error}`));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve blockchain domain names to addresses
|
|
99
|
+
*
|
|
100
|
+
* This is the actual implementation called by AI orchestrator's function calling.
|
|
101
|
+
* Supports ENS (.eth) and TNS (.tez) domains with batch resolution.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} params - Resolution parameters
|
|
104
|
+
* @param {Array<string>} params.domains - Array of domain names to resolve
|
|
105
|
+
* @param {boolean} [params.displayResults] - Whether to display results (default: true)
|
|
106
|
+
* @returns {Promise<Object>} Resolution result
|
|
107
|
+
* @returns {boolean} returns.success - Whether at least one domain was resolved
|
|
108
|
+
* @returns {Object} returns.domainMap - Map of domain to resolved address
|
|
109
|
+
* @returns {Array<Object>} returns.resolutions - Detailed resolution results
|
|
110
|
+
* @returns {Array<string>} returns.errors - Array of error messages
|
|
111
|
+
* @example
|
|
112
|
+
* const result = await resolveDomains({ domains: ['vitalik.eth', 'alice.tez'] });
|
|
113
|
+
* console.log(result.domainMap); // { 'vitalik.eth': '0x...', 'alice.tez': 'tz...' }
|
|
114
|
+
*/
|
|
115
|
+
async function resolveDomains(params) {
|
|
116
|
+
const { domains, displayResults = false } = params;
|
|
117
|
+
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
|
118
|
+
const error = 'No domains provided for resolution';
|
|
119
|
+
console.error(chalk.red(`\n${error}`));
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
domainMap: {},
|
|
123
|
+
resolutions: [],
|
|
124
|
+
errors: [error],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const result = await domainResolver.resolveDomainsBatch(domains);
|
|
128
|
+
if (displayResults) {
|
|
129
|
+
domainResolver.displayResolutionResults(result);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Verify a playlist against DP-1 specification
|
|
135
|
+
*
|
|
136
|
+
* This is the actual implementation called by AI orchestrator's function calling.
|
|
137
|
+
* Uses dp1-js parse/structure validation (same as the `validate` CLI). Does not
|
|
138
|
+
* verify signatures; use the `verify` CLI for cryptographic checks. Must be
|
|
139
|
+
* called before sending a playlist to a device from the orchestrator.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} params - Verification parameters
|
|
142
|
+
* @param {Object} params.playlist - Playlist object to verify
|
|
143
|
+
* @returns {Promise<Object>} Verification result
|
|
144
|
+
* @returns {boolean} returns.valid - Whether playlist is valid
|
|
145
|
+
* @returns {string} [returns.error] - Error message if invalid
|
|
146
|
+
* @returns {Array<Object>} [returns.details] - Detailed validation errors
|
|
147
|
+
* @example
|
|
148
|
+
* const result = await verifyPlaylist({ playlist });
|
|
149
|
+
* if (result.valid) {
|
|
150
|
+
* console.log('Playlist is valid');
|
|
151
|
+
* } else {
|
|
152
|
+
* console.error('Invalid:', result.error);
|
|
153
|
+
* }
|
|
154
|
+
*/
|
|
155
|
+
async function verifyPlaylist(params) {
|
|
156
|
+
const { playlist } = params;
|
|
157
|
+
if (!playlist) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
error: 'No playlist provided for verification',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
logger.verbose(chalk.cyan('\nValidate playlist'));
|
|
164
|
+
// Dynamic import to avoid circular dependency
|
|
165
|
+
const playlistVerifier = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
|
|
166
|
+
const validate = playlistVerifier.validatePlaylist ||
|
|
167
|
+
(playlistVerifier.default && playlistVerifier.default.validatePlaylist) ||
|
|
168
|
+
playlistVerifier.default;
|
|
169
|
+
if (typeof validate !== 'function') {
|
|
170
|
+
return {
|
|
171
|
+
valid: false,
|
|
172
|
+
error: 'Playlist verifier is not available',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const result = await validate(playlist);
|
|
176
|
+
if (result.valid) {
|
|
177
|
+
logger.verbose(chalk.green('Playlist looks good'));
|
|
178
|
+
if (playlist.title) {
|
|
179
|
+
logger.verbose(chalk.dim(` Title: ${playlist.title}`));
|
|
180
|
+
}
|
|
181
|
+
if (playlist.items) {
|
|
182
|
+
logger.verbose(chalk.dim(` Items: ${playlist.items.length}`));
|
|
183
|
+
}
|
|
184
|
+
logger.verbose();
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
console.error(chalk.red('Playlist has issues'));
|
|
188
|
+
console.error(chalk.red(` ${result.error}`));
|
|
189
|
+
if (result.details && result.details.length > 0) {
|
|
190
|
+
console.log(chalk.yellow('\n Missing or invalid fields:'));
|
|
191
|
+
result.details.forEach((detail) => {
|
|
192
|
+
console.log(chalk.yellow(` • ${detail.path}: ${detail.message}`));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
console.log();
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Verify and validate Ethereum and Tezos addresses
|
|
201
|
+
*
|
|
202
|
+
* This function is called by the intent parser to validate addresses entered by users.
|
|
203
|
+
* It provides detailed feedback on address validity and format issues.
|
|
204
|
+
*
|
|
205
|
+
* @param {Object} params - Verification parameters
|
|
206
|
+
* @param {Array<string>} params.addresses - Array of addresses to verify
|
|
207
|
+
* @returns {Promise<Object>} Verification result
|
|
208
|
+
* @returns {boolean} returns.valid - Whether all addresses are valid
|
|
209
|
+
* @returns {Array<Object>} returns.results - Detailed validation for each address
|
|
210
|
+
* @returns {Array<string>} returns.errors - List of validation errors
|
|
211
|
+
* @example
|
|
212
|
+
* const result = await verifyAddresses({
|
|
213
|
+
* addresses: ['0x1234567890123456789012345678901234567890', 'tz1VSUr8wwNhLAzempoch5d6hLKEUNvD14']
|
|
214
|
+
* });
|
|
215
|
+
* if (!result.valid) {
|
|
216
|
+
* result.errors.forEach(err => console.error(err));
|
|
217
|
+
* }
|
|
218
|
+
*/
|
|
219
|
+
async function verifyAddresses(params) {
|
|
220
|
+
const { addresses } = params;
|
|
221
|
+
if (!addresses || !Array.isArray(addresses) || addresses.length === 0) {
|
|
222
|
+
return {
|
|
223
|
+
valid: false,
|
|
224
|
+
results: [],
|
|
225
|
+
errors: ['No addresses provided for verification'],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// Dynamic import to avoid circular dependency
|
|
229
|
+
const addressValidator = await Promise.resolve().then(() => __importStar(require('./address-validator')));
|
|
230
|
+
const validateAddresses = addressValidator.validateAddresses ||
|
|
231
|
+
(addressValidator.default && addressValidator.default.validateAddresses) ||
|
|
232
|
+
addressValidator.default;
|
|
233
|
+
if (typeof validateAddresses !== 'function') {
|
|
234
|
+
return {
|
|
235
|
+
valid: false,
|
|
236
|
+
results: [],
|
|
237
|
+
errors: ['Address validator is not available'],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const result = validateAddresses(addresses);
|
|
241
|
+
// Display results
|
|
242
|
+
if (!result.valid) {
|
|
243
|
+
console.error(chalk.red('\nAddress validation failed'));
|
|
244
|
+
result.errors.forEach((err) => {
|
|
245
|
+
console.error(chalk.red(` • ${err}`));
|
|
246
|
+
});
|
|
247
|
+
console.log();
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Get list of configured FF1 devices
|
|
253
|
+
*
|
|
254
|
+
* This function retrieves the list of all configured FF1 devices from config.
|
|
255
|
+
* Called by intent parser to resolve generic device references like "FF1", "my device".
|
|
256
|
+
*
|
|
257
|
+
* @returns {Promise<Object>} Device list result
|
|
258
|
+
* @returns {boolean} returns.success - Whether devices were retrieved
|
|
259
|
+
* @returns {Array<Object>} returns.devices - Array of device configurations
|
|
260
|
+
* @returns {string} returns.devices[].name - Device name
|
|
261
|
+
* @returns {string} returns.devices[].host - Device host URL
|
|
262
|
+
* @returns {string} [returns.devices[].topicID] - Optional topic ID
|
|
263
|
+
* @returns {string} [returns.error] - Error message if no devices configured
|
|
264
|
+
* @example
|
|
265
|
+
* const result = await getConfiguredDevices();
|
|
266
|
+
* if (result.success && result.devices.length > 0) {
|
|
267
|
+
* const firstDevice = result.devices[0].name;
|
|
268
|
+
* }
|
|
269
|
+
*/
|
|
270
|
+
async function getConfiguredDevices() {
|
|
271
|
+
const configModule = await Promise.resolve().then(() => __importStar(require('../config')));
|
|
272
|
+
const getFF1DeviceConfig = configModule.getFF1DeviceConfig ||
|
|
273
|
+
(configModule.default && configModule.default.getFF1DeviceConfig) ||
|
|
274
|
+
configModule.default;
|
|
275
|
+
if (typeof getFF1DeviceConfig !== 'function') {
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
devices: [],
|
|
279
|
+
error: 'FF1 device configuration is not available',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const deviceConfig = getFF1DeviceConfig();
|
|
283
|
+
if (!deviceConfig.devices || deviceConfig.devices.length === 0) {
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
devices: [],
|
|
287
|
+
error: 'No FF1 devices configured',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// Return sanitized device list (without API keys)
|
|
291
|
+
const devices = deviceConfig.devices.map((d) => ({
|
|
292
|
+
name: d.name || d.host,
|
|
293
|
+
host: d.host,
|
|
294
|
+
topicID: d.topicID,
|
|
295
|
+
}));
|
|
296
|
+
return {
|
|
297
|
+
success: true,
|
|
298
|
+
devices,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
module.exports = {
|
|
302
|
+
buildDP1Playlist,
|
|
303
|
+
sendPlaylistToDevice,
|
|
304
|
+
resolveDomains,
|
|
305
|
+
verifyPlaylist,
|
|
306
|
+
getConfiguredDevices,
|
|
307
|
+
verifyAddresses,
|
|
308
|
+
};
|