Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

ff1-cli

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ff1-cli - npm Package Compare versions

Comparing version
1.0.10
to
1.0.11
+107
dist/src/utilities/device-lookup.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.findExistingDeviceEntry = findExistingDeviceEntry;
/**
* Find an existing configured device that corresponds to a newly discovered host.
*
* Priority (most reliable to least):
* 1. mDNS device ID match — the device's stable hardware identity. Checked first
* so a stale row that happens to share the same IP cannot shadow the correct
* entry. Requires the stored entry to have been written with an id field.
* 2. Exact URL match — normal case after ID (same host format, no migration).
* 3. mDNS hostname component match — both URLs are parsed and only the hostname
* part is compared (same .local name, different underlying IP or port).
* 4a. Discovered IP → stored IP: the mDNS discovery reports a resolved IP that
* matches the IP in a stored entry. Bridges .local ↔ stored-IP migration for
* pre-id configs with no stored id.
* 4b. New-host IP → stored addresses: the caller provides an IP host (e.g. from
* --host <ip>) and a stored entry has that IP in its addresses list. Bridges
* the reverse direction (.local stored, IP provided by the user).
* 5. TXT-name match — if the device advertises a name that matches a stored
* friendly name, treat them as the same device (last-resort fallback for
* configs written before the id field existed).
*
* Returns the matched entry so callers can preserve the stored friendly name as
* the default when prompting, rather than falling back to the raw mDNS label.
*/
function findExistingDeviceEntry(existingDevices, newHost, discoveredName, discoveredId, discoveredAddresses) {
// 1. mDNS device ID — stable hardware identity, checked before URL so stale
// host entries for other devices at the same IP do not shadow the result.
if (discoveredId) {
const byId = existingDevices.find((d) => d.id === discoveredId);
if (byId) {
return byId;
}
}
// 2. Exact URL match
const byHost = existingDevices.find((d) => d.host === newHost);
if (byHost) {
return byHost;
}
// 3. mDNS hostname component match (ignores port and protocol differences)
let newHostname = '';
try {
newHostname = new URL(newHost).hostname;
}
catch {
// not a valid URL — skip hostname matching
}
if (newHostname) {
const byHostname = existingDevices.find((d) => {
try {
return new URL(d.host || '').hostname === newHostname;
}
catch {
return false;
}
});
if (byHostname) {
return byHostname;
}
}
// Node.js URL.hostname wraps IPv6 addresses in brackets: [fe80::1].
// Strip them so comparisons work against the bracket-free strings stored in addresses[].
const stripBrackets = (h) => (h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h);
// 4a. Discovered IP → stored IP: mDNS reported addresses include the stored entry's IP.
// Skip only when both the stored entry AND the discovered device carry an id that
// differs — in that case the devices are provably distinct. If discoveredId is absent
// (partial avahi result, --host path) allow the address match regardless of whether
// the stored entry has an id; the address is the best identity signal available.
if (discoveredAddresses && discoveredAddresses.length > 0) {
const byDiscoveredAddress = existingDevices.find((d) => {
if (d.id && discoveredId && d.id !== discoveredId) {
return false; // different physical devices — do not conflate
}
try {
const storedIp = stripBrackets(new URL(d.host || '').hostname);
return discoveredAddresses.includes(storedIp);
}
catch {
return false;
}
});
if (byDiscoveredAddress) {
return byDiscoveredAddress;
}
}
// 4b. New-host IP → stored addresses: the new host is an IP URL (IPv4 or IPv6)
// and a stored entry has that IP in its stored addresses list (populated from
// prior mDNS discoveries). This bridges --host <ip/ipv6> → existing .local entry.
// Strip IPv6 brackets before comparing (Node URL.hostname returns '[fe80::1]').
const rawHostname = stripBrackets(newHostname);
if (rawHostname && (/^[0-9.]+$/.test(rawHostname) || rawHostname.includes(':'))) {
const byStoredAddress = existingDevices.find((d) => d.addresses?.includes(rawHostname));
if (byStoredAddress) {
return byStoredAddress;
}
}
// 5. TXT-name match — only for entries WITHOUT a stored id.
// If a stored entry already has an id and it did not match in step 1, then
// the discovered device is a physically distinct device that merely advertises
// the same friendly name; matching by name alone would resolve to the wrong row
// and cause upsertDevice to overwrite a different device.
if (discoveredName) {
return existingDevices.find((d) => !d.id && d.name === discoveredName);
}
return undefined;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeDeviceHost = normalizeDeviceHost;
exports.normalizeDeviceIdToHost = normalizeDeviceIdToHost;
/**
* Normalize a raw host string into a canonical `http://<host>:<port>` URL.
*
* Handles:
* - Trailing dot removal (mDNS labels sometimes end with '.')
* - Case-insensitive scheme detection (HTTP://, HTTPS://, http://, https://)
* - Bare IPv6 addresses (e.g. fe80::1 → [fe80::1]) so new URL() can parse them
* - Missing scheme → http://
* - Missing port → 1111
*/
function normalizeDeviceHost(host) {
let normalized = host.trim().replace(/\.$/, '');
if (!normalized) {
return normalized;
}
const lower = normalized.toLowerCase();
// Bare IPv6 address (e.g. fe80::1) — must be bracketed before adding http://
// so that new URL() doesn't misparse the colons as port separators.
// Only applies when there is no existing scheme, no existing brackets, and the
// string consists solely of hex digits and colons (the IPv6 character set).
if (!lower.startsWith('http://') &&
!lower.startsWith('https://') &&
!normalized.startsWith('[') &&
/^[0-9a-fA-F:]+$/.test(normalized) &&
normalized.includes(':')) {
normalized = `[${normalized}]`;
}
if (!lower.startsWith('http://') && !lower.startsWith('https://')) {
normalized = `http://${normalized}`;
}
try {
const url = new URL(normalized);
const port = url.port || '1111';
return `${url.protocol}//${url.hostname}:${port}`;
}
catch (_error) {
return normalized;
}
}
/**
* Resolve a raw device identifier or host string into a canonical host URL.
*
* Accepts:
* - Full URLs (http://..., HTTPS://...) — forwarded to normalizeDeviceHost
* - IP addresses (contain dots or colons)
* - .local hostnames (contain dots)
* - Raw device IDs (e.g. 'hh9jsnoc', 'FF1-HH9JSNOC', 'ff1-hh9jsnoc') →
* normalized to lowercase and prefixed with 'ff1-' if missing, then '.local' appended
*/
function normalizeDeviceIdToHost(rawId) {
const lower = rawId.trim().toLowerCase();
const looksLikeHost = lower.includes('.') || lower.includes(':') || lower.startsWith('http');
if (looksLikeHost) {
return normalizeDeviceHost(rawId);
}
const deviceId = lower.startsWith('ff1-') ? lower : `ff1-${lower}`;
return normalizeDeviceHost(`${deviceId}.local`);
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.upsertDevice = upsertDevice;
/**
* Strip undefined values from an object so spreads do not overwrite existing
* keys with undefined (e.g. when a caller passes id: undefined because the
* device was discovered without an ID).
*/
function withoutUndefined(obj) {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
}
/**
* Apply a patch to an entry.
*
* Addresses are handled specially:
* - Incoming non-empty: replace (fresh discovery data supersedes stale IPs).
* - Host changed, no incoming addresses: clear (stale IPs belonged to the old
* network location; keeping them lets --host <old-ip> match the wrong device
* after DHCP churn or a partial avahi timeout that omits the address field).
* - Same host, no incoming addresses: keep existing (--host path provides no
* addresses; the stored set must be preserved so reverse IP lookup still works).
*
* Dual-stack accumulation (IPv4 + IPv6) is handled upstream by parseAvahiBrowseOutput
* before upsertDevice is called, so the full address set is always present in a single
* invocation.
*/
function applyPatch(existing, patch) {
const hostChanged = patch.host !== undefined && patch.host !== existing.host;
let addresses;
if (patch.addresses && patch.addresses.length > 0) {
addresses = patch.addresses; // fresh data: replace
}
else if (hostChanged) {
addresses = undefined; // host changed, no new IPs: clear stale addresses
}
else {
addresses = existing.addresses; // same host, no new IPs: keep stored set
}
return { ...existing, ...patch, addresses };
}
/**
* Insert or update a device in the configured device list.
*
* Priority:
* 0. matchedIndex provided — caller already resolved the row via findExistingDeviceEntry;
* update that position directly. Handles rename + host-change combos where none of
* the id/name/host heuristics below would find the correct row.
* 1. Same mDNS device ID → update in-place (preserves position, handles host change).
* 2. Same host → update in-place (preserves position and metadata).
* 3. Same name, different host → replace in-place (preserves position so that
* devices[0] — the implicit default for play/send/ssh — does not silently change).
* 4. Neither match → append.
*/
function upsertDevice(existingDevices, newDevice,
/** Pre-resolved row index from findExistingDeviceEntry. When provided, the
* heuristics below are skipped and this row is updated directly. */
matchedIndex) {
const devices = [...existingDevices];
const patch = withoutUndefined(newDevice);
// Case 0: caller already resolved the match — update directly.
if (matchedIndex !== undefined && matchedIndex >= 0 && matchedIndex < devices.length) {
const isSameHost = devices[matchedIndex].host === newDevice.host;
devices[matchedIndex] = applyPatch(devices[matchedIndex], patch);
return { devices, updated: isSameHost };
}
// Case 1: same mDNS device ID — update in-place even when host changed.
if (newDevice.id) {
const sameIdIndex = devices.findIndex((d) => d.id === newDevice.id);
if (sameIdIndex !== -1) {
const isSameHost = devices[sameIdIndex].host === newDevice.host;
devices[sameIdIndex] = applyPatch(devices[sameIdIndex], patch);
return { devices, updated: isSameHost };
}
}
// Case 2: same host — update in-place
const sameHostIndex = devices.findIndex((d) => d.host === newDevice.host);
if (sameHostIndex !== -1) {
devices[sameHostIndex] = applyPatch(devices[sameHostIndex], patch);
return { devices, updated: true };
}
// Case 3: same name, different host — replace in-place to preserve array order.
// Spread existing entry first so apiKey/topicID survive a host change.
const staleNameIndex = devices.findIndex((d) => d.name === newDevice.name);
if (staleNameIndex !== -1) {
devices[staleNameIndex] = applyPatch(devices[staleNameIndex], patch);
return { devices, updated: false };
}
// Case 4: new device
devices.push({ ...patch });
return { devices, updated: false };
}
+376
-148

@@ -59,2 +59,5 @@ #!/usr/bin/env node

const playlist_source_1 = require("./src/utilities/playlist-source");
const device_upsert_1 = require("./src/utilities/device-upsert");
const device_lookup_1 = require("./src/utilities/device-lookup");
const device_normalize_1 = require("./src/utilities/device-normalize");
// Load version from package.json

@@ -162,18 +165,95 @@ // Try built location first (dist/index.js -> ../package.json)

}
function normalizeDeviceHost(host) {
let normalized = host.trim().replace(/\.$/, '');
if (!normalized) {
return normalized;
async function discoverAndSelectDevice(ask, existingDevices, options) {
const allowSkip = options?.allowSkip && existingDevices.length > 0;
const discoveryResult = await (0, ff1_discovery_1.discoverFF1Devices)();
const discoveredDevices = discoveryResult.devices;
if (discoveryResult.error && discoveredDevices.length === 0) {
const errorMessage = discoveryResult.error.endsWith('.')
? discoveryResult.error
: `${discoveryResult.error}.`;
console.log(chalk_1.default.dim(`mDNS discovery failed: ${errorMessage} Continuing with manual entry.`));
}
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
normalized = `http://${normalized}`;
else if (discoveryResult.error) {
console.log(chalk_1.default.dim(`mDNS discovery warning: ${discoveryResult.error}`));
}
try {
const url = new URL(normalized);
const port = url.port || '1111';
return `${url.protocol}//${url.hostname}:${port}`;
if (discoveredDevices.length > 0) {
console.log(chalk_1.default.green('\nFF1 devices on your network:'));
discoveredDevices.forEach((device, index) => {
const displayId = device.id || device.name || device.host;
const normalizedHost = (0, device_normalize_1.normalizeDeviceHost)(`${device.host}:${device.port}`);
const alreadyConfigured = !!(0, device_lookup_1.findExistingDeviceEntry)(existingDevices, normalizedHost, device.name || device.id || '', device.id, device.addresses);
const suffix = alreadyConfigured ? chalk_1.default.dim(' (already configured)') : '';
console.log(chalk_1.default.dim(` ${index + 1}) ${displayId}${suffix}`));
});
const skipHint = allowSkip ? ', press Enter to skip' : '';
const prompt = `Select device [1-${discoveredDevices.length}], enter ID/host${skipHint}, or type m for manual entry: `;
while (true) {
const selectionAnswer = (await ask(prompt)).trim();
if (!selectionAnswer) {
if (allowSkip) {
console.log(chalk_1.default.dim('Keeping existing devices.'));
return { hostValue: '', discoveredName: '', skipped: true };
}
break;
}
const normalizedSelection = selectionAnswer.toLowerCase();
if (normalizedSelection === 'm') {
break;
}
const parsedIndex = Number.parseInt(selectionAnswer, 10);
if (!Number.isNaN(parsedIndex) &&
`${parsedIndex}` === selectionAnswer &&
parsedIndex >= 1 &&
parsedIndex <= discoveredDevices.length) {
const selected = discoveredDevices[parsedIndex - 1];
return {
hostValue: (0, device_normalize_1.normalizeDeviceHost)(`${selected.host}:${selected.port}`),
discoveredName: selected.name || selected.id || '',
discoveredId: selected.id,
discoveredAddresses: selected.addresses,
skipped: false,
};
}
const normalizedWithPrefix = normalizedSelection.startsWith('ff1-')
? normalizedSelection
: `ff1-${normalizedSelection}`;
// Also normalize the answer as a URL-form host so pasted URLs like
// "http://ff1-hh9jsnoc.local:1111" match the device's normalized host.
let normalizedSelectionAsHost = '';
try {
normalizedSelectionAsHost = (0, device_normalize_1.normalizeDeviceHost)(selectionAnswer).toLowerCase();
}
catch {
// not a valid URL — skip URL-form matching
}
const matched = discoveredDevices.find((device) => {
const deviceNormalizedHost = (0, device_normalize_1.normalizeDeviceHost)(`${device.host}:${device.port}`).toLowerCase();
const candidates = [device.id, device.name, device.host, `${device.host}:${device.port}`]
.filter((value) => Boolean(value))
.map((value) => value.toLowerCase());
return (candidates.includes(normalizedSelection) ||
candidates.includes(normalizedWithPrefix) ||
(normalizedSelectionAsHost !== '' && normalizedSelectionAsHost === deviceNormalizedHost));
});
if (matched) {
return {
hostValue: (0, device_normalize_1.normalizeDeviceHost)(`${matched.host}:${matched.port}`),
discoveredName: matched.name || matched.id || '',
discoveredId: matched.id,
discoveredAddresses: matched.addresses,
skipped: false,
};
}
console.log(chalk_1.default.red('Invalid selection. Enter a number, m, or a discovered device ID/host.'));
}
}
catch (_error) {
return normalized;
else if (!discoveryResult.error) {
console.log(chalk_1.default.dim('No FF1 devices found via mDNS. Continuing with manual entry.'));
}
// Manual entry fallback
const idAnswer = await ask('Device ID or host (e.g. ff1-ABCD1234): ');
if (!idAnswer) {
return { hostValue: '', discoveredName: '', skipped: false };
}
return { hostValue: (0, device_normalize_1.normalizeDeviceIdToHost)(idAnswer), discoveredName: '', skipped: false };
}

@@ -368,139 +448,53 @@ /**

}
const existingDevice = config.ff1Devices?.devices?.[0];
const discoveryResult = await (0, ff1_discovery_1.discoverFF1Devices)({ timeoutMs: 2000 });
const discoveredDevices = discoveryResult.devices;
let selectedDeviceIndex = null;
let shouldPromptManualDevice = true;
if (discoveryResult.error && discoveredDevices.length === 0) {
const errorMessage = discoveryResult.error.endsWith('.')
? discoveryResult.error
: `${discoveryResult.error}.`;
console.log(chalk_1.default.dim(`mDNS discovery failed: ${errorMessage} Continuing with manual entry.`));
const existingDevices = config.ff1Devices?.devices || [];
if (existingDevices.length > 0) {
console.log(chalk_1.default.dim(`\nConfigured devices: ${existingDevices.map((d) => d.name || d.host).join(', ')}`));
}
else if (discoveryResult.error) {
console.log(chalk_1.default.dim(`mDNS discovery warning: ${discoveryResult.error}`));
}
if (discoveredDevices.length > 0) {
console.log(chalk_1.default.green('\nFF1 devices on your network:'));
discoveredDevices.forEach((device, index) => {
const displayId = device.id || device.name || device.host;
console.log(chalk_1.default.dim(` ${index + 1}) ${displayId}`));
});
const hasExistingHost = Boolean(existingDevice?.host);
const selectionPrompt = hasExistingHost
? `Select device [1-${discoveredDevices.length}], enter ID/host, press Enter to keep current, or type m for manual entry: `
: `Select device [1-${discoveredDevices.length}], enter ID/host, or press Enter for manual entry: `;
while (true) {
const selectionAnswer = (await ask(selectionPrompt)).trim();
if (!selectionAnswer) {
if (hasExistingHost) {
shouldPromptManualDevice = false;
console.log(chalk_1.default.dim('Keeping existing FF1 device.'));
}
break;
}
const normalizedSelection = selectionAnswer.toLowerCase();
if (normalizedSelection === 'm') {
shouldPromptManualDevice = true;
break;
}
const parsedIndex = Number.parseInt(selectionAnswer, 10);
if (!Number.isNaN(parsedIndex) &&
`${parsedIndex}` === selectionAnswer &&
parsedIndex >= 1 &&
parsedIndex <= discoveredDevices.length) {
selectedDeviceIndex = parsedIndex - 1;
shouldPromptManualDevice = false;
break;
}
const normalizedWithPrefix = normalizedSelection.startsWith('ff1-')
? normalizedSelection
: `ff1-${normalizedSelection}`;
const matchedIndex = discoveredDevices.findIndex((device) => {
const candidates = [
device.id,
device.name,
device.host,
`${device.host}:${device.port}`,
]
.filter((value) => Boolean(value))
.map((value) => value.toLowerCase());
return (candidates.includes(normalizedSelection) || candidates.includes(normalizedWithPrefix));
});
if (matchedIndex !== -1) {
selectedDeviceIndex = matchedIndex;
shouldPromptManualDevice = false;
break;
}
console.log(chalk_1.default.red('Invalid selection. Enter a number, m, or a discovered device ID/host.'));
}
}
else if (!discoveryResult.error) {
console.log(chalk_1.default.dim('No FF1 devices found via mDNS. Continuing with manual entry.'));
}
const selectedDevice = selectedDeviceIndex === null ? null : discoveredDevices[selectedDeviceIndex];
{
const existingHost = existingDevice?.host || '';
let rawDefaultDeviceId = '';
if (existingHost) {
// If host is a .local device, extract just the device ID segment.
// Otherwise keep the full host (IP address or multi-label domain).
const hostWithoutScheme = existingHost.replace(/^https?:\/\//, '');
if (hostWithoutScheme.includes('.local')) {
rawDefaultDeviceId = hostWithoutScheme.split('.')[0] || '';
}
else {
rawDefaultDeviceId = hostWithoutScheme;
}
}
const defaultDeviceId = isMissingConfigValue(rawDefaultDeviceId) ? '' : rawDefaultDeviceId;
let hostValue = '';
if (selectedDevice) {
hostValue = normalizeDeviceHost(`${selectedDevice.host}:${selectedDevice.port}`);
console.log(chalk_1.default.dim(`Using discovered device: ${selectedDevice.name} (${selectedDevice.host}:${selectedDevice.port})`));
}
else if (!shouldPromptManualDevice && existingHost) {
hostValue = normalizeDeviceHost(existingHost);
}
else {
const idPrompt = defaultDeviceId
? `Device ID (e.g. ff1-ABCD1234) [${defaultDeviceId}]: `
: 'Device ID (e.g. ff1-ABCD1234): ';
const idAnswer = await ask(idPrompt);
const rawDeviceId = idAnswer || defaultDeviceId;
if (rawDeviceId) {
const looksLikeHost = rawDeviceId.includes('.') ||
rawDeviceId.includes(':') ||
rawDeviceId.startsWith('http');
if (looksLikeHost) {
hostValue = normalizeDeviceHost(rawDeviceId);
}
else {
const deviceId = rawDeviceId.startsWith('ff1-') ? rawDeviceId : `ff1-${rawDeviceId}`;
hostValue = normalizeDeviceHost(`${deviceId}.local`);
}
}
}
const discoveredName = selectedDevice?.name || selectedDevice?.id || '';
const rawName = existingDevice?.name || discoveredName || 'ff1';
const defaultName = isMissingConfigValue(rawName) ? '' : rawName;
const namePrompt = defaultName
const selection = await discoverAndSelectDevice(ask, existingDevices, { allowSkip: true });
if (selection.hostValue) {
// Prefer the already-stored label so re-running setup (or re-adding a device
// that returned on a new IP) doesn't clobber the friendly name.
const existingEntry = (0, device_lookup_1.findExistingDeviceEntry)(existingDevices, selection.hostValue, selection.discoveredName, selection.discoveredId, selection.discoveredAddresses);
const existingIndex = existingEntry
? existingDevices.findIndex((d) => d === existingEntry)
: -1;
const existingName = existingEntry?.name || '';
const defaultName = existingName || selection.discoveredName || 'ff1';
const namePrompt = defaultName !== 'ff1'
? `Device name (kitchen, office, etc.) [${defaultName}]: `
: 'Device name (kitchen, office, etc.): ';
const nameAnswer = await ask(namePrompt);
const deviceName = nameAnswer || defaultName || 'ff1';
if (hostValue) {
config.ff1Devices = {
devices: [
{
...existingDevice,
name: deviceName,
host: hostValue,
apiKey: existingDevice?.apiKey || '',
topicID: existingDevice?.topicID || '',
},
],
};
let deviceName = nameAnswer || defaultName || 'ff1';
// Same name-collision guard as device add: reject names that would clobber
// a different device entry. Only fires when existingIndex !== -1 (we know the row);
// when existingIndex === -1, a same-name entry is the case-3 migration path.
const setupNameConflict = existingIndex !== -1
? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
: undefined;
if (setupNameConflict) {
console.log(chalk_1.default.yellow(`"${deviceName}" is already used by another device. Please choose a different name.`));
const retryAnswer = await ask('Device name: ');
deviceName = retryAnswer || 'ff1';
const retryConflict = existingIndex !== -1
? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
: undefined;
if (retryConflict) {
console.log(chalk_1.default.yellow(`"${deviceName}" is also taken. Skipping device.`));
config.ff1Devices = { devices: existingDevices };
await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
return;
}
}
const result = (0, device_upsert_1.upsertDevice)(existingDevices, {
name: deviceName,
host: selection.hostValue,
id: selection.discoveredId,
addresses: selection.discoveredAddresses,
}, existingIndex !== -1 ? existingIndex : undefined);
console.log(chalk_1.default.dim(`${result.updated ? 'Updated' : 'Added'} device: ${deviceName}`));
config.ff1Devices = { devices: result.devices };
}
else if (existingDevices.length > 0) {
config.ff1Devices = { devices: existingDevices };
}
await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');

@@ -571,7 +565,8 @@ rl.close();

{
label: 'FF1 device host',
ok: !isMissingConfigValue(config.ff1Devices?.devices?.[0]?.host),
detail: isMissingConfigValue(config.ff1Devices?.devices?.[0]?.host)
? undefined
: config.ff1Devices?.devices?.[0]?.host,
label: `FF1 devices (${config.ff1Devices?.devices?.length || 0})`,
ok: (config.ff1Devices?.devices?.length || 0) > 0 &&
(config.ff1Devices?.devices || []).every((d) => !isMissingConfigValue(d.host)),
detail: (config.ff1Devices?.devices || [])
.map((d) => `${d.name || 'unnamed'} → ${d.host}`)
.join(', ') || undefined,
},

@@ -602,2 +597,3 @@ ];

.option('-m, --model <name>', 'AI model to use (grok, gpt, gemini) - defaults to config setting')
.option('-d, --device <name>', 'Target FF1 device name (defaults to first configured device)')
.option('-v, --verbose', 'Show detailed technical output of function calls', false)

@@ -637,2 +633,3 @@ .action(async (content, options) => {

interactive: false, // Non-interactive mode
deviceName: options.device,
});

@@ -703,2 +700,3 @@ // Print final summary

modelName: modelName,
deviceName: options.device,
});

@@ -1168,2 +1166,232 @@ // Print summary after each response

});
const deviceCommand = program.command('device').description('Manage configured FF1 devices');
deviceCommand
.command('list')
.description('List all configured FF1 devices')
.action(async () => {
try {
const configPath = await resolveExistingConfigPath();
if (!configPath) {
console.log(chalk_1.default.red('config.json not found'));
console.log(chalk_1.default.dim('Run: ff1 setup'));
process.exit(1);
}
const config = await readConfigFile(configPath);
const devices = config.ff1Devices?.devices || [];
if (devices.length === 0) {
console.log(chalk_1.default.yellow('\nNo devices configured'));
console.log(chalk_1.default.dim('Run: ff1 device add'));
console.log();
return;
}
console.log(chalk_1.default.blue(`\nFF1 Devices (${devices.length})\n`));
devices.forEach((device, index) => {
const isFirst = index === 0;
const marker = isFirst ? chalk_1.default.green('→') : ' ';
const nameLabel = device.name || 'unnamed';
console.log(`${marker} ${chalk_1.default.bold(nameLabel)}`);
console.log(` Host: ${chalk_1.default.dim(device.host)}`);
if (device.apiKey) {
console.log(` API key: ${chalk_1.default.green('Set')}`);
}
if (device.topicID) {
console.log(` Topic: ${chalk_1.default.dim(device.topicID)}`);
}
if (isFirst) {
console.log(` ${chalk_1.default.dim('(default)')}`);
}
console.log();
});
}
catch (error) {
console.error(chalk_1.default.red('\nError:'), error.message);
process.exit(1);
}
});
deviceCommand
.command('add')
.description('Add a new FF1 device (with mDNS discovery)')
.option('--host <host>', 'Device host (skip discovery)')
.option('--name <name>', 'Device name')
.action(async (options) => {
let rl = null;
// Create readline lazily so non-interactive paths (--host + --name) never block on stdin
const ask = async (question) => {
if (!rl) {
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
}
return new Promise((resolve) => {
rl.question(chalk_1.default.yellow(question), (answer) => {
resolve(answer.trim());
});
});
};
try {
const configPath = await resolveExistingConfigPath();
if (!configPath) {
console.log(chalk_1.default.red('config.json not found'));
console.log(chalk_1.default.dim('Run: ff1 setup'));
process.exit(1);
}
const config = await readConfigFile(configPath);
const existingDevices = config.ff1Devices?.devices || [];
let hostValue = '';
let discoveredName = '';
let discoveredId;
let discoveredAddresses;
if (options.host) {
hostValue = (0, device_normalize_1.normalizeDeviceHost)(options.host);
}
else {
console.log(chalk_1.default.blue('\nDiscover FF1 devices...\n'));
const selection = await discoverAndSelectDevice(ask, existingDevices);
hostValue = selection.hostValue;
discoveredName = selection.discoveredName;
discoveredId = selection.discoveredId;
discoveredAddresses = selection.discoveredAddresses;
if (!hostValue) {
console.log(chalk_1.default.dim('\nNo device added.'));
if (rl) {
rl.close();
}
return;
}
}
// Find any existing entry that represents this device, including cases
// where the host URL changed (IP ↔ .local) since the device was last added.
const existingEntry = (0, device_lookup_1.findExistingDeviceEntry)(existingDevices, hostValue, discoveredName, discoveredId, discoveredAddresses);
const existingIndex = existingEntry
? existingDevices.findIndex((d) => d === existingEntry)
: -1;
if (existingIndex !== -1) {
if (options.host && options.name) {
// Non-interactive: auto-overwrite when both flags are supplied
console.log(chalk_1.default.yellow(`\nUpdating existing device: ${existingDevices[existingIndex].name || existingDevices[existingIndex].host}`));
}
else {
console.log(chalk_1.default.yellow(`\nDevice already configured: ${existingDevices[existingIndex].name || existingDevices[existingIndex].host}`));
const overwrite = await promptYesNo(ask, 'Update this device?', false);
if (!overwrite) {
console.log(chalk_1.default.dim('No changes made.'));
if (rl) {
rl.close();
}
return;
}
}
}
// Preserve the stored friendly name as the default so a blank prompt never
// clobbers a curated label (even after a host-URL change).
const existingName = existingEntry?.name || '';
let deviceName;
if (options.name) {
deviceName = options.name;
}
else {
const defaultName = existingName || discoveredName || '';
const namePrompt = defaultName
? `Device name (kitchen, office, etc.) [${defaultName}]: `
: 'Device name (kitchen, office, etc.): ';
const nameAnswer = await ask(namePrompt);
deviceName = nameAnswer || defaultName || 'ff1';
}
// Reject a name that is already used by a DIFFERENT device (not the one being updated).
// Only applies when existingIndex !== -1: we know exactly which row to update, so a
// same-name entry at a different index is provably a different device.
// When existingIndex === -1 (no confirmed match, e.g. manual IP → .local migration),
// a same-name entry is the upsertDevice case-3 migration path — blocking it would
// prevent the user from retaining their existing device name during host migration.
const nameConflict = existingIndex !== -1
? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
: undefined;
if (nameConflict) {
if (options.name) {
// Non-interactive flag path: hard error so scripts don't silently clobber.
console.error(chalk_1.default.red(`\nError: device name "${deviceName}" is already used by another device (${nameConflict.host}).`));
console.error(chalk_1.default.dim('Use a different name or run "ff1 device remove" first.'));
if (rl) {
rl.close();
}
process.exit(1);
}
// Interactive path: re-prompt until the user picks a unique name.
console.log(chalk_1.default.yellow(`"${deviceName}" is already used by another device. Please choose a different name.`));
const retryAnswer = await ask('Device name: ');
deviceName = retryAnswer || 'ff1';
const retryConflict = existingIndex !== -1
? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
: undefined;
if (retryConflict) {
console.error(chalk_1.default.red(`\nName "${deviceName}" is also taken. No changes made.`));
if (rl) {
rl.close();
}
return;
}
}
const result = (0, device_upsert_1.upsertDevice)(existingDevices, { name: deviceName, host: hostValue, id: discoveredId, addresses: discoveredAddresses }, existingIndex !== -1 ? existingIndex : undefined);
console.log(chalk_1.default.green(`\n${result.updated ? 'Updated' : 'Added'} device: ${deviceName}`));
config.ff1Devices = { devices: result.devices };
await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
console.log(chalk_1.default.dim(`Total devices: ${result.devices.length}\n`));
if (rl) {
rl.close();
}
}
catch (error) {
console.error(chalk_1.default.red('\nError:'), error.message);
if (rl) {
rl.close();
}
process.exit(1);
}
});
deviceCommand
.command('remove')
.description('Remove a configured FF1 device')
.argument('<name>', 'Device name to remove')
.action(async (name) => {
try {
const configPath = await resolveExistingConfigPath();
if (!configPath) {
console.log(chalk_1.default.red('config.json not found'));
process.exit(1);
}
const config = await readConfigFile(configPath);
const existingDevices = config.ff1Devices?.devices || [];
// Match by name (case-insensitive) or by host URL so that unnamed legacy/manual
// entries (stored without a name field) can still be targeted and removed.
const normalizedArg = name.toLowerCase();
let normalizedArgHost = '';
try {
normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(name).toLowerCase();
}
catch {
// not a valid URL — host matching will not apply
}
const deviceIndex = existingDevices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
(d.host && d.host.toLowerCase() === normalizedArg) ||
(normalizedArgHost &&
d.host &&
(0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
if (deviceIndex === -1) {
console.error(chalk_1.default.red(`\nDevice "${name}" not found`));
if (existingDevices.length > 0) {
const names = existingDevices.map((d) => d.name || d.host).join(', ');
console.log(chalk_1.default.dim(`Available devices: ${names}`));
}
process.exit(1);
}
const removed = existingDevices[deviceIndex];
const updatedDevices = existingDevices.filter((_, i) => i !== deviceIndex);
config.ff1Devices = { devices: updatedDevices };
await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
console.log(chalk_1.default.green(`\nRemoved device: ${removed.name || removed.host}`));
console.log(chalk_1.default.dim(`Remaining devices: ${updatedDevices.length}\n`));
}
catch (error) {
console.error(chalk_1.default.red('\nError:'), error.message);
process.exit(1);
}
});
program.parse();

@@ -541,3 +541,17 @@ /**

async function buildPlaylistWithAI(params, options = {}) {
const { modelName, verbose = false, outputPath = 'playlist.json', interactive = false, conversationContext = null, } = options;
const { modelName, verbose = false, outputPath = 'playlist.json', interactive = false, conversationContext = null, defaultDeviceName, } = options;
// Apply CLI --device fallback when the intent parser detected a send intent
// (playlistSettings.deviceName !== undefined) but could not resolve the device
// name from the user's text (null / empty / "null" sentinel).
// If deviceName is undefined, no send was intended — ignore the CLI flag to
// prevent implicit sends on every build-only `chat --device` invocation.
if (params.playlistSettings &&
params.playlistSettings.deviceName !== undefined &&
(!params.playlistSettings.deviceName || params.playlistSettings.deviceName === 'null') &&
defaultDeviceName) {
params = {
...params,
playlistSettings: { ...params.playlistSettings, deviceName: defaultDeviceName },
};
}
const OpenAI = require('openai');

@@ -544,0 +558,0 @@ const { getModelConfig } = require('../config');

@@ -43,2 +43,6 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.SEND_SHORTCUT_PATTERN = void 0;
exports.resolveSendShortcutDevice = resolveSendShortcutDevice;
exports.resolveEffectiveDeviceName = resolveEffectiveDeviceName;
exports.resolveSendPlaylistDeviceName = resolveSendPlaylistDeviceName;
exports.validateRequirements = validateRequirements;

@@ -71,2 +75,38 @@ exports.applyPlaylistDefaults = applyPlaylistDefaults;

/**
* Regex that matches the inline "send last / send playlist / send to <device>"
* shortcut inside the chat flow. Exported for testing.
*/
exports.SEND_SHORTCUT_PATTERN = /^send(?:\s+(?:last|playlist|the playlist))?(?:\s+to\s+(.+))?$/i;
/**
* Extract the target device from a sendMatch result, falling back to the CLI
* --device flag. Exported for testing.
*/
function resolveSendShortcutDevice(match, cliDefault) {
return match[1]?.trim() || cliDefault;
}
/**
* Resolve the effective device name for a send operation.
* The intent (from NL parsing) takes precedence; CLI flag is the fallback.
* Exported for testing.
*/
function resolveEffectiveDeviceName(fromIntent, fromCLI) {
return fromIntent || fromCLI;
}
/**
* Sanitize and resolve the device name for the direct send_playlist action path.
*
* The intent parser can emit literal "null" or "" as a device name string; these
* must be treated as absent so the CLI --device fallback is used instead.
* Exported for testing — mirrors the sanitization in confirmPlaylistForSending().
*/
function resolveSendPlaylistDeviceName(intentDeviceName, cliDeviceName) {
const sanitized = intentDeviceName === 'null' ||
intentDeviceName === '' ||
intentDeviceName === null ||
intentDeviceName === undefined
? undefined
: intentDeviceName;
return resolveEffectiveDeviceName(sanitized, cliDeviceName);
}
/**
* Validate and apply constraints to requirements

@@ -199,3 +239,3 @@ *

async function buildPlaylist(userRequest, options = {}) {
const { verbose = false, outputPath = 'playlist.json', modelName, interactive = true } = options;
const { verbose = false, outputPath = 'playlist.json', modelName, interactive = true, deviceName: defaultDeviceName, } = options;
// Enable verbose logging if requested

@@ -213,3 +253,3 @@ if (verbose) {

if (sendMatch) {
const deviceName = sendMatch[1]?.trim();
const deviceName = sendMatch[1]?.trim() || defaultDeviceName;
const { confirmPlaylistForSending } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-send')));

@@ -258,2 +298,3 @@ const confirmation = await confirmPlaylistForSending(outputPath, deviceName);

modelName,
defaultDeviceName,
});

@@ -293,2 +334,3 @@ // Handle interactive clarification loop

modelName,
defaultDeviceName,
conversationContext: {

@@ -304,2 +346,8 @@ messages: intentParserResult.messages,

const params = intentParserResult.params;
// NOTE: do NOT merge defaultDeviceName into playlistSettings here.
// buildPlaylistDirect (src/utilities/index.js) sends whenever
// playlistSettings.deviceName is defined, so setting it here would turn
// every build-only `chat --device` invocation into an implicit network send.
// The CLI --device flag is used only in the two explicit send paths below
// (sendMatch shortcut and send_playlist action) via resolveEffectiveDeviceName.
// Check if this is a send_playlist action

@@ -312,3 +360,3 @@ if (params && params.action === 'send_playlist') {

console.log(chalk_1.default.cyan('Sending to device'));
const sendResult = await utilities.sendToDevice(sendParams.playlist, sendParams.deviceName);
const sendResult = await utilities.sendToDevice(sendParams.playlist, resolveSendPlaylistDeviceName(sendParams.deviceName, defaultDeviceName));
if (sendResult.success) {

@@ -369,2 +417,3 @@ console.log(chalk_1.default.green('\nPlaylist sent'));

interactive,
defaultDeviceName,
});

@@ -396,2 +445,3 @@ // Handle confirmation loop in interactive mode

interactive,
defaultDeviceName,
conversationContext: {

@@ -398,0 +448,0 @@ messages: result.messages,

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseAvahiBrowseOutput = parseAvahiBrowseOutput;
exports.resolveAvahiResult = resolveAvahiResult;
exports.discoverFF1Devices = discoverFF1Devices;
const bonjour_service_1 = require("bonjour-service");
const DEFAULT_TIMEOUT_MS = 2000;
const child_process_1 = require("child_process");
const DEFAULT_TIMEOUT_MS = 5000;
/**

@@ -71,12 +74,188 @@ * Normalize mDNS TXT records to string values.

/**
* Parse avahi-browse -t -r output into FF1DiscoveredDevice list.
* Handles resolved records (lines starting with '=') with hostname/address/port/txt fields.
* Exported for testing.
*/
function parseAvahiBrowseOutput(output) {
const devices = new Map();
const lines = output.split('\n');
let current = null;
const flushCurrent = () => {
if (!current?.rawHost) {
return;
}
const host = normalizeMdnsHost(current.rawHost).toLowerCase();
const key = `${host}:${current.port ?? 1111}`;
const id = getHostnameId(host) || current.id;
const newAddresses = current.rawAddresses ?? [];
// Merge with an existing entry for the same key (e.g. IPv4 + IPv6 records
// for the same .local hostname both resolve to the same host:port key).
// Prefer TXT-sourced metadata from whichever record has it; a later partial
// record must not clobber a previously complete name/id/txt.
const existing = devices.get(key);
const mergedAddresses = [...(existing?.addresses ?? []), ...newAddresses].filter((addr, i, arr) => arr.indexOf(addr) === i); // deduplicate
// Select the richer txt: ignore empty objects (avahi emits txt=[] → {}) so a
// partial record with no real TXT data does not block a later complete payload.
const existingTxtContent = existing?.txt && Object.keys(existing.txt).length > 0 ? existing.txt : undefined;
const currentTxtContent = current.txt && Object.keys(current.txt).length > 0 ? current.txt : undefined;
// Prefer whichever txt is non-empty; if both are non-empty, keep the first seen.
const mergedTxt = existingTxtContent ?? currentTxtContent;
// For name: prefer TXT-sourced name from whichever record has content.
const mergedName = existingTxtContent?.name ||
currentTxtContent?.name ||
existing?.name ||
current.name ||
id ||
host;
// For id: prefer existing id (already validated) over newly derived id.
const mergedId = existing?.id || id;
devices.set(key, {
name: mergedName,
host,
port: current.port ?? 1111,
id: mergedId,
txt: mergedTxt,
addresses: mergedAddresses.length > 0 ? mergedAddresses : undefined,
});
};
for (const line of lines) {
// Resolved record header: "= wlan0 IPv4 FF1-HH9JSNOC _ff1._tcp local"
if (/^=\s/.test(line)) {
flushCurrent();
const parts = line.trim().split(/\s+/);
// parts: ['=', 'wlan0', 'IPv4', 'My Device Name', '_ff1._tcp', 'local']
// Service name may be multi-word; find the type token to bound the slice.
// Use a prefix regex so "_ff1._tcp.local" and "_ff1._tcp" both match.
// Preserve original case — resolveConfiguredDevice does exact-match lookups.
const typeIndex = parts.findIndex((p) => /^_ff1\._tcp/.test(p));
const serviceName = typeIndex > 3 ? parts.slice(3, typeIndex).join(' ') : parts[3] || '';
current = { name: serviceName };
continue;
}
if (!current) {
continue;
}
const hostnameMatch = line.match(/^\s+hostname\s*=\s*\[(.+)\]/);
if (hostnameMatch) {
current.rawHost = hostnameMatch[1];
continue;
}
const addressMatch = line.match(/^\s+address\s*=\s*\[(.+)\]/);
if (addressMatch) {
if (!current.rawAddresses) {
current.rawAddresses = [];
}
current.rawAddresses.push(addressMatch[1].trim());
continue;
}
const portMatch = line.match(/^\s+port\s*=\s*\[(\d+)\]/);
if (portMatch) {
current.port = parseInt(portMatch[1], 10);
continue;
}
const txtMatch = line.match(/^\s+txt\s*=\s*\[(.+)\]/);
if (txtMatch) {
const txt = {};
const pairs = txtMatch[1].matchAll(/"([^=]+)=([^"]*)"/g);
for (const [, k, v] of pairs) {
txt[k] = v;
}
current.txt = txt;
if (txt.name) {
current.name = txt.name;
}
if (txt.id) {
current.id = txt.id;
}
continue;
}
}
// Flush last record
flushCurrent();
return Array.from(devices.values()).sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Resolve the result of an avahi-browse subprocess call into an FF1DiscoveryResult
* or null (which triggers the Bonjour fallback).
*
* Rules:
* - Clean exit (error === null): parse stdout and return whatever was found.
* - Non-zero exit + usable stdout: avahi-browse can be killed by the execFile
* timeout after emitting fully resolved records. Use the parsed devices if
* ≥1 was found; otherwise return empty result (avahi is present but slow —
* do NOT fall back to Bonjour, which is less reliable on Linux).
* - Timeout (error.killed === true) + no usable stdout: avahi is present but
* the scan produced nothing before the deadline. Return empty rather than
* falling back to Bonjour.
* - Command not found (ENOENT): avahi-browse is not installed — return null
* so the caller falls back to Bonjour.
* - Other error + no usable stdout: treat as unavailable — null.
*
* Exported for unit testing.
*/
function resolveAvahiResult(error, stdout) {
if (error) {
if (stdout) {
try {
const devices = parseAvahiBrowseOutput(stdout);
if (devices.length > 0) {
return { devices };
}
}
catch {
// unparseable output — fall through
}
}
// Timeout: avahi is present but the scan was slow. Return empty rather than
// falling back to Bonjour, which is unreliable on Linux.
if (error.killed) {
return { devices: [], error: 'avahi-browse timed out before finding any devices' };
}
// ENOENT: avahi-browse not installed — fall back to Bonjour.
// All other errors with no usable output: also fall back.
return null;
}
try {
return { devices: parseAvahiBrowseOutput(stdout) };
}
catch {
return null;
}
}
/**
* Discover FF1 devices using avahi-browse (Linux).
* Returns null if avahi-browse is not available.
*/
function discoverViaAvahi(options) {
return new Promise((resolve) => {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
(0, child_process_1.execFile)('avahi-browse', ['-t', '-r', '_ff1._tcp'], { timeout: timeoutMs }, (error, stdout, _stderr) => {
resolve(resolveAvahiResult(error, stdout));
});
});
}
/**
* Discover FF1 devices via mDNS using the `_ff1._tcp` service.
* On Linux, uses avahi-browse for reliable multi-device discovery.
* Falls back to bonjour-service on other platforms or if avahi is unavailable.
*
* @param {Object} [options] - Discovery options
* @param {number} [options.timeoutMs] - How long to browse before returning results
* @param {number} [options.timeoutMs] - How long to browse before returning results (bonjour fallback only)
* @returns {Promise<FF1DiscoveryResult>} Discovered FF1 devices and optional error
* @throws {Error} Never throws; returns empty list on errors
* @example
* const result = await discoverFF1Devices({ timeoutMs: 2000 });
* const result = await discoverFF1Devices();
*/
async function discoverFF1Devices(options = {}) {
async function discoverFF1Devices(options = {},
// Injectable for testing — callers should omit these; defaults use the real implementations.
_avahiDiscovery = discoverViaAvahi, _bonjourDiscovery = discoverViaBonjour) {
if (process.platform === 'linux') {
const avahiResult = await _avahiDiscovery(options);
if (avahiResult !== null) {
return avahiResult;
}
}
return _bonjourDiscovery(options);
}
function discoverViaBonjour(options) {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;

@@ -120,2 +299,5 @@ return new Promise((resolve) => {

const key = `${host}:${port}`;
const addresses = Array.isArray(service.addresses) && service.addresses.length > 0
? service.addresses
: undefined;
devices.set(key, {

@@ -128,2 +310,3 @@ name,

txt,
addresses,
});

@@ -130,0 +313,0 @@ });

@@ -121,2 +121,10 @@ # Configuration Guide

You can also manage devices independently with:
- `ff1 device add` – Add a device interactively (with mDNS discovery), or non-interactively with `--host` and `--name`.
- `ff1 device list` – Show all configured devices.
- `ff1 device remove <name>` – Remove a device by name.
Setup and `device add` both preserve existing devices. Adding a device with the same host as an existing one updates it in place.
Selection rules when sending:

@@ -123,0 +131,0 @@

@@ -30,3 +30,3 @@ # FF1-CLI Documentation

During setup, you can pick a default FF1 device, so later `send`/`play` commands can run without `-d`.
During setup, you can pick FF1 devices to add. Use `ff1 device add` to add more devices later, and `ff1 device list` to see what's configured. The first device is the default for `send`/`play` commands (override with `-d`).

@@ -74,5 +74,3 @@ Manual config path:

"name": "Living Room Display",
"host": "http://192.168.1.100:1111",
"apiKey": "",
"topicID": ""
"host": "http://192.168.1.100:1111"
}

@@ -136,3 +134,3 @@ ]

- `chat [content]` – AI-driven natural language playlists
- Options: `-o, --output <file>`, `-m, --model <name>`, `-v, --verbose`
- Options: `-o, --output <file>`, `-m, --model <name>`, `-d, --device <name>`, `-v, --verbose`
- `build [params.json]` – Deterministic build from JSON or stdin

@@ -151,2 +149,6 @@ - Options: `-o, --output <file>`, `-v, --verbose`

- Options: `-d, --device <name>`, `--pubkey <path>`, `--ttl <duration>`
- `device list` – List all configured FF1 devices
- `device add` – Add a new FF1 device (with mDNS discovery)
- Options: `--host <host>`, `--name <name>`
- `device remove <name>` – Remove a configured FF1 device
- `config <init|show|validate>` – Manage configuration

@@ -300,6 +302,20 @@

### FF1 device configuration
### FF1 device management
See selection rules and examples in `./CONFIGURATION.md`.
```bash
# List configured devices
ff1 device list
# Add a device (interactive with mDNS discovery)
ff1 device add
# Add a device non-interactively
ff1 device add --host 192.168.1.100 --name kitchen
# Remove a device by name
ff1 device remove kitchen
```
Setup preserves existing devices when adding new ones. See selection rules and examples in `./CONFIGURATION.md`.
### Playlist signing (optional)

@@ -306,0 +322,0 @@

{
"name": "ff1-cli",
"version": "1.0.10",
"version": "1.0.11",
"description": "CLI to fetch NFT information and build DP1 playlists using Grok API",

@@ -5,0 +5,0 @@ "main": "dist/index.js",

Sorry, the diff of this file is too big to display