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.
@@ -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 = true } = params;
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
- console.log(chalk.cyan('\nValidate playlist'));
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
- console.log(chalk.green('Playlist looks good'));
176
+ logger.verbose(chalk.green('Playlist looks good'));
176
177
  if (playlist.title) {
177
- console.log(chalk.dim(` Title: ${playlist.title}`));
178
+ logger.verbose(chalk.dim(` Title: ${playlist.title}`));
178
179
  }
179
180
  if (playlist.items) {
180
- console.log(chalk.dim(` Items: ${playlist.items.length}`));
181
+ logger.verbose(chalk.dim(` Items: ${playlist.items.length}`));
181
182
  }
182
- console.log();
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
- console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
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
- console.log(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
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.dim(` ${resolution.domain} → ${resolution.address}`));
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 fs_1 = require("fs");
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 - Path to playlist file
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
- // Check if file exists
94
- console.log(chalk_1.default.cyan(`Playlist file: ${resolvedPath}`));
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 content = await fs_1.promises.readFile(resolvedPath, 'utf-8');
99
- playlist = JSON.parse(content);
100
- _fileExists = true;
101
- console.log(chalk_1.default.green('File loaded'));
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 errorMsg = error.message;
105
- if (errorMsg.includes('ENOENT') || errorMsg.includes('no such file')) {
106
- console.log(chalk_1.default.red(`File not found: ${resolvedPath}`));
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: `Playlist file not found at ${resolvedPath}`,
113
- 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"`,
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
- throw error;
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: true,
141
+ fileExists,
123
142
  playlistValid: false,
124
- error: 'Playlist file is empty',
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
+ }
@@ -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.feralfile.com/api/v1` if not set.
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.feralfile.com/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
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.feralfile.com/api/v1 2) http://localhost:8787"
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