| "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'); |
+53
-3
@@ -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 @@ |
+23
-7
@@ -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 @@ |
+1
-1
| { | ||
| "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
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
488815
8.15%39
8.33%10292
7.72%11
10%