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.
- package/README.md +12 -2
- package/config.json.example +11 -9
- package/dist/index.js +271 -82
- package/dist/src/ai-orchestrator/index.js +62 -5
- package/dist/src/config.js +16 -38
- package/dist/src/intent-parser/index.js +58 -16
- 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 +22 -8
- package/docs/EXAMPLES.md +5 -1
- package/docs/README.md +24 -4
- package/docs/RELEASING.md +33 -4
- package/package.json +3 -10
|
@@ -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
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPlaylistSourceUrl = isPlaylistSourceUrl;
|
|
4
|
+
exports.loadPlaylistSource = loadPlaylistSource;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
/**
|
|
7
|
+
* Determine whether a playlist source is an HTTP(S) URL.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} source - Playlist source value
|
|
10
|
+
* @returns {boolean} Whether the value parses as http:// or https:// URL
|
|
11
|
+
*/
|
|
12
|
+
function isPlaylistSourceUrl(source) {
|
|
13
|
+
const trimmed = source.trim();
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(trimmed);
|
|
19
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Load a DP-1 playlist from a local file or hosted URL.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} source - Playlist file path or URL
|
|
29
|
+
* @returns {Promise<LoadedPlaylist>} Loaded playlist payload with source metadata
|
|
30
|
+
* @throws {Error} When source is empty, cannot be loaded, or JSON is invalid
|
|
31
|
+
*/
|
|
32
|
+
async function loadPlaylistSource(source) {
|
|
33
|
+
const trimmedSource = source.trim();
|
|
34
|
+
if (!trimmedSource) {
|
|
35
|
+
throw new Error('Playlist source is required');
|
|
36
|
+
}
|
|
37
|
+
if (isPlaylistSourceUrl(trimmedSource)) {
|
|
38
|
+
const response = await fetch(trimmedSource);
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`);
|
|
41
|
+
}
|
|
42
|
+
let playlistText;
|
|
43
|
+
try {
|
|
44
|
+
playlistText = await response.text();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new Error(`Failed to read playlist response from ${trimmedSource}: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return {
|
|
51
|
+
playlist: JSON.parse(playlistText),
|
|
52
|
+
source: trimmedSource,
|
|
53
|
+
sourceType: 'url',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new Error(`Invalid JSON from playlist URL ${trimmedSource}: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let fileText;
|
|
61
|
+
try {
|
|
62
|
+
fileText = await fs_1.promises.readFile(trimmedSource, 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
catch (_error) {
|
|
65
|
+
throw new Error(`Playlist file not found at ${trimmedSource}`);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return {
|
|
69
|
+
playlist: JSON.parse(fileText),
|
|
70
|
+
source: trimmedSource,
|
|
71
|
+
sourceType: 'file',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new Error(`Invalid JSON in ${trimmedSource}: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SSH access control for FF1 devices.
|
|
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.sendSshAccessCommand = sendSshAccessCommand;
|
|
40
|
+
const logger = __importStar(require("../logger"));
|
|
41
|
+
const ff1_compatibility_1 = require("./ff1-compatibility");
|
|
42
|
+
/**
|
|
43
|
+
* Send an SSH access command to an FF1 device.
|
|
44
|
+
*
|
|
45
|
+
* @param {Object} params - Function parameters
|
|
46
|
+
* @param {boolean} params.enabled - Whether to enable SSH access
|
|
47
|
+
* @param {string} [params.deviceName] - Device name to target (defaults to first configured)
|
|
48
|
+
* @param {string} [params.publicKey] - SSH public key to authorize (required for enable)
|
|
49
|
+
* @param {number} [params.ttlSeconds] - Time-to-live in seconds for auto-disable
|
|
50
|
+
* @returns {Promise<Object>} Result object
|
|
51
|
+
* @returns {boolean} returns.success - Whether the command succeeded
|
|
52
|
+
* @returns {string} [returns.device] - Device host used
|
|
53
|
+
* @returns {string} [returns.deviceName] - Device name used
|
|
54
|
+
* @returns {Object} [returns.response] - Response from device
|
|
55
|
+
* @returns {string} [returns.error] - Error message if failed
|
|
56
|
+
* @throws {Error} When device configuration is invalid or missing
|
|
57
|
+
* @example
|
|
58
|
+
* // Enable SSH for 30 minutes
|
|
59
|
+
* const result = await sendSshAccessCommand({
|
|
60
|
+
* enabled: true,
|
|
61
|
+
* publicKey: 'ssh-ed25519 AAAAC3... user@host',
|
|
62
|
+
* ttlSeconds: 1800,
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds, }) {
|
|
66
|
+
try {
|
|
67
|
+
if (enabled && (!publicKey || !publicKey.trim())) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: 'Public key is required to enable SSH access',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
|
|
74
|
+
if (!resolved.success || !resolved.device) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: resolved.error || 'FF1 device is not configured correctly',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const device = resolved.device;
|
|
81
|
+
const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'sshAccess');
|
|
82
|
+
if (!compatibility.compatible) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: compatibility.error || 'FF1 OS does not support SSH access command',
|
|
86
|
+
details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let apiUrl = `${device.host}/api/cast`;
|
|
90
|
+
if (device.topicID && device.topicID.trim() !== '') {
|
|
91
|
+
apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
|
|
92
|
+
logger.debug(`Using topicID: ${device.topicID}`);
|
|
93
|
+
}
|
|
94
|
+
const request = {
|
|
95
|
+
enabled,
|
|
96
|
+
};
|
|
97
|
+
if (publicKey && publicKey.trim()) {
|
|
98
|
+
request.publicKey = publicKey.trim();
|
|
99
|
+
}
|
|
100
|
+
if (typeof ttlSeconds === 'number') {
|
|
101
|
+
request.ttlSeconds = ttlSeconds;
|
|
102
|
+
}
|
|
103
|
+
const requestBody = {
|
|
104
|
+
command: 'sshAccess',
|
|
105
|
+
request,
|
|
106
|
+
};
|
|
107
|
+
const headers = {
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
};
|
|
110
|
+
if (device.apiKey) {
|
|
111
|
+
headers['API-KEY'] = device.apiKey;
|
|
112
|
+
}
|
|
113
|
+
const response = await fetch(apiUrl, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers,
|
|
116
|
+
body: JSON.stringify(requestBody),
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const errorText = await response.text();
|
|
120
|
+
logger.error(`SSH access request failed: ${response.status} ${response.statusText}`);
|
|
121
|
+
logger.debug(`Error details: ${errorText}`);
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
error: `Device returned error ${response.status}: ${response.statusText}`,
|
|
125
|
+
details: errorText,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const responseData = (await response.json());
|
|
129
|
+
logger.info('SSH access command succeeded');
|
|
130
|
+
logger.debug(`Device response: ${JSON.stringify(responseData)}`);
|
|
131
|
+
return {
|
|
132
|
+
success: true,
|
|
133
|
+
device: device.host,
|
|
134
|
+
deviceName: device.name || device.host,
|
|
135
|
+
response: responseData,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
logger.error(`Error sending SSH access command: ${error.message}`);
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: error.message,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -94,7 +94,7 @@ DP‑1 Feed API configuration.
|
|
|
94
94
|
|
|
95
95
|
- `feed.baseURLs` (string[]): Array of DP‑1 Feed Operator API v1 base URLs. The CLI queries all feeds in parallel.
|
|
96
96
|
- Legacy support: `feed.baseURL` (string) is still accepted and normalized to an array.
|
|
97
|
-
- Default: `https://feed.
|
|
97
|
+
- Default: `https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1` if not set.
|
|
98
98
|
- Compatibility: API v1 of the DP‑1 Feed Operator server. See the repository for endpoints and behavior: [dp1-feed](https://github.com/display-protocol/dp1-feed).
|
|
99
99
|
|
|
100
100
|
Endpoints used by the CLI:
|
|
@@ -105,7 +105,7 @@ Endpoints used by the CLI:
|
|
|
105
105
|
Environment variable alternative:
|
|
106
106
|
|
|
107
107
|
```env
|
|
108
|
-
FEED_BASE_URLS=https://feed.
|
|
108
|
+
FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
## ff1Devices
|
|
@@ -116,11 +116,29 @@ Configure devices you want to send playlists to.
|
|
|
116
116
|
- `name` (string): Friendly device label. Free‑form; pick anything memorable.
|
|
117
117
|
- `host` (string): Device base URL. For LAN devices, use `http://<ip>:1111`. The device typically listens on port `1111`.
|
|
118
118
|
|
|
119
|
+
During `ff1 setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp`). If devices are found, you can pick one and the host will be filled in automatically. If discovery returns nothing, setup falls back to manual entry.
|
|
120
|
+
|
|
119
121
|
Selection rules when sending:
|
|
120
122
|
|
|
121
123
|
- If you omit `-d`, the first configured device is used.
|
|
122
124
|
- If you pass `-d <name>`, the CLI matches the device by `name` (exact match). If not found, you’ll see an error listing available devices.
|
|
123
125
|
|
|
126
|
+
Compatibility checks:
|
|
127
|
+
|
|
128
|
+
- `send` and `ssh` perform a compatibility preflight before sending commands to FF1. The CLI gets the device version by calling `POST /api/cast` with `{ "command": "getDeviceStatus", "request": {} }` and reads `message.installedVersion` from the response.
|
|
129
|
+
|
|
130
|
+
- Minimum supported FF1 OS versions:
|
|
131
|
+
|
|
132
|
+
- `send` (`displayPlaylist`): `1.0.0` or newer
|
|
133
|
+
- `ssh` (`sshAccess`): `1.0.9` or newer
|
|
134
|
+
|
|
135
|
+
- If the CLI cannot get a version from the device (e.g. network or malformed response), it continues and sends the command.
|
|
136
|
+
- If the detected version is below the minimum, the command fails early with an error that includes the detected version.
|
|
137
|
+
|
|
138
|
+
Troubleshooting note:
|
|
139
|
+
|
|
140
|
+
- If you get an unsupported-version error, update your FF1 OS and retry. If version detection seems inconsistent, check that device host and key are correct and retry with the device directly reachable.
|
|
141
|
+
|
|
124
142
|
Examples:
|
|
125
143
|
|
|
126
144
|
```bash
|
|
@@ -131,7 +149,7 @@ npm run dev -- send playlist.json
|
|
|
131
149
|
npm run dev -- send playlist.json -d "Living Room Display"
|
|
132
150
|
```
|
|
133
151
|
|
|
134
|
-
Minimal `config.json` example (selected fields):
|
|
152
|
+
Minimal `config.json` example (selected fields):
|
|
135
153
|
|
|
136
154
|
```json
|
|
137
155
|
{
|
|
@@ -149,9 +167,7 @@ Minimal `config.json` example (selected fields):
|
|
|
149
167
|
"privateKey": "your_ed25519_private_key_hex_or_base64_here"
|
|
150
168
|
},
|
|
151
169
|
"feed": {
|
|
152
|
-
"baseURLs": [
|
|
153
|
-
"https://feed.feralfile.com/api/v1"
|
|
154
|
-
]
|
|
170
|
+
"baseURLs": ["https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"]
|
|
155
171
|
},
|
|
156
172
|
"ff1Devices": {
|
|
157
173
|
"devices": [
|
|
@@ -174,5 +190,3 @@ npm run dev -- config validate
|
|
|
174
190
|
```
|
|
175
191
|
|
|
176
192
|
If configuration is invalid, the CLI prints actionable errors and a non‑zero exit code.
|
|
177
|
-
|
|
178
|
-
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -25,6 +25,8 @@ npm run dev -- chat "Get 3 items from Social Codes and 2 from 0xdef" -v
|
|
|
25
25
|
npm run dev -- chat "your request" --model grok
|
|
26
26
|
npm run dev -- chat "your request" --model gpt
|
|
27
27
|
npm run dev -- chat "your request" --model gemini
|
|
28
|
+
|
|
29
|
+
# Model names must match keys in config.json under `models`.
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
## Deterministic Build (no AI)
|
|
@@ -67,7 +69,7 @@ The CLI recognizes publishing keywords like "publish", "publish to my feed", "pu
|
|
|
67
69
|
npm run dev -- chat "Build playlist from Ethereum contract 0xb932a70A57673d89f4acfFBE830E8ed7f75Fb9e0 with tokens 52932 and 52457; publish to my feed" -o playlist.json -v
|
|
68
70
|
|
|
69
71
|
# With feed selection (if multiple servers configured)
|
|
70
|
-
# The CLI will ask: "Which feed server? 1) https://feed.
|
|
72
|
+
# The CLI will ask: "Which feed server? 1) https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1 2) http://localhost:8787"
|
|
71
73
|
npm run dev -- chat "Get 3 from Social Codes and publish to feed" -v
|
|
72
74
|
|
|
73
75
|
# Publish existing playlist (defaults to ./playlist.json)
|
|
@@ -117,12 +119,14 @@ Output shows:
|
|
|
117
119
|
```bash
|
|
118
120
|
# Validate playlist
|
|
119
121
|
npm run dev -- validate playlist.json
|
|
122
|
+
npm run dev -- validate "https://cdn.example.com/playlist.json"
|
|
120
123
|
|
|
121
124
|
# Sign playlist
|
|
122
125
|
npm run dev -- sign playlist.json -o signed.json
|
|
123
126
|
|
|
124
127
|
# Send to device
|
|
125
128
|
npm run dev -- send playlist.json -d "Living Room Display"
|
|
129
|
+
npm run dev -- send "https://cdn.example.com/playlist.json" -d "Living Room Display"
|
|
126
130
|
```
|
|
127
131
|
|
|
128
132
|
## Publish to Feed Server
|