diff --git a/README.md b/README.md index 2e4c2f1..af1028c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play npm run dev -- play "https://example.com/video.mp4" --skip-verify ``` +> **npm 12+:** `npm ci`/`npm install` fail with `Fetching packages of type "git" have been disabled` because `@feralfile/source-resolver` is a git-URL dependency and npm 12 blocks those by default. Install with `npm ci --allow-git=all` until the resolver is published to the npm registry. + ## Documentation - Getting started and usage: `./docs/README.md` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 18df944..4c0876e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -151,7 +151,7 @@ Setup and `device add` both preserve existing devices. Adding a device with the Selection rules when sending: - If you omit `-d`, the first configured device is used. -- If you pass `-d `, the CLI matches the device by `name` (exact match). If not found, you’ll see an error listing available devices. +- If you pass `-d `, the CLI matches the device by `name` (case-insensitive) or by host URL — the same rules as `device remove`/`default`/`rename`, so unnamed entries can be targeted by host. If not found, you’ll see an error listing available devices. Device cast contract: diff --git a/src/commands/device.ts b/src/commands/device.ts index e3542b3..236924a 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { promises as fs } from 'fs'; -import { findExistingDeviceEntry } from '../utilities/device-lookup'; +import { findConfiguredDeviceIndex, findExistingDeviceEntry } from '../utilities/device-lookup'; import { normalizeDeviceHost } from '../utilities/device-normalize'; import { upsertDevice } from '../utilities/device-upsert'; import { promoteDeviceToDefault } from '../utilities/device-default'; @@ -229,9 +229,13 @@ deviceCommand // 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. + // Case-insensitive: lookups everywhere else ignore case, so "Kitchen" + // alongside "kitchen" would make one of them unreachable by name. const nameConflict = existingIndex !== -1 - ? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex) + ? existingDevices.find( + (d, i) => d.name?.toLowerCase() === deviceName.toLowerCase() && i !== existingIndex + ) : undefined; if (nameConflict) { if (options.name) { @@ -253,7 +257,9 @@ deviceCommand deviceName = retryAnswer || 'ff1'; const retryConflict = existingIndex !== -1 - ? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex) + ? existingDevices.find( + (d, i) => d.name?.toLowerCase() === deviceName.toLowerCase() && i !== existingIndex + ) : undefined; if (retryConflict) { console.error(chalk.red(`\nName "${deviceName}" is also taken. No changes made.`)); @@ -296,23 +302,7 @@ deviceCommand const config = await readConfigFile(configPath); const existingDevices = config.ff1Devices?.devices || []; - // Match by name (case-insensitive) or by host URL so unnamed legacy/manual - // entries (stored without a name field) can still be targeted and removed. - const normalizedArg = name.toLowerCase(); - let normalizedArgHost = ''; - try { - normalizedArgHost = 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 && - normalizeDeviceHost(d.host).toLowerCase() === normalizedArgHost) - ); + const deviceIndex = findConfiguredDeviceIndex(existingDevices, name); if (deviceIndex === -1) { console.error(chalk.red(`\nDevice "${name}" not found`)); diff --git a/src/utilities/device-default.ts b/src/utilities/device-default.ts index 353f7ee..500f246 100644 --- a/src/utilities/device-default.ts +++ b/src/utilities/device-default.ts @@ -1,4 +1,4 @@ -import { normalizeDeviceHost } from './device-normalize'; +import { findConfiguredDeviceIndex } from './device-lookup'; import type { DeviceEntry } from './device-upsert'; export interface PromoteDefaultResult { @@ -20,22 +20,7 @@ export function promoteDeviceToDefault( devices: DeviceEntry[], identifier: string ): PromoteDefaultResult { - const normalizedArg = identifier.toLowerCase(); - let normalizedArgHost = ''; - try { - normalizedArgHost = normalizeDeviceHost(identifier).toLowerCase(); - } catch { - // not a valid URL — host matching will not apply - } - - const index = devices.findIndex( - (d) => - (d.name && d.name.toLowerCase() === normalizedArg) || - (d.host && d.host.toLowerCase() === normalizedArg) || - (normalizedArgHost && - d.host && - normalizeDeviceHost(d.host).toLowerCase() === normalizedArgHost) - ); + const index = findConfiguredDeviceIndex(devices, identifier); if (index === -1) { throw new Error(`Device "${identifier}" not found`); diff --git a/src/utilities/device-lookup.ts b/src/utilities/device-lookup.ts index 924ee29..b548f69 100644 --- a/src/utilities/device-lookup.ts +++ b/src/utilities/device-lookup.ts @@ -1,3 +1,5 @@ +import { normalizeDeviceHost } from './device-normalize'; + /** * Find an existing configured device that corresponds to a newly discovered host. * @@ -115,3 +117,36 @@ export function findExistingDeviceEntry( return undefined; } + +/** + * Find the index of a configured device by a user-supplied identifier. + * + * Matches by friendly name (case-insensitive) or by host URL — raw or + * normalized — so unnamed legacy/manual entries (stored without a name field) + * can still be targeted. Shared by `device remove`, `device default`, + * `device rename`, and runtime `-d` resolution so every surface answers to + * the same identifier the same way. + * + * Returns -1 when nothing matches. + */ +export function findConfiguredDeviceIndex( + devices: Array<{ host?: string; name?: string }>, + identifier: string +): number { + const normalizedArg = identifier.toLowerCase(); + let normalizedArgHost = ''; + try { + normalizedArgHost = normalizeDeviceHost(identifier).toLowerCase(); + } catch { + // not a valid URL — host matching will not apply + } + + return devices.findIndex( + (d) => + (d.name && d.name.toLowerCase() === normalizedArg) || + (d.host && d.host.toLowerCase() === normalizedArg) || + (normalizedArgHost && + d.host && + normalizeDeviceHost(d.host).toLowerCase() === normalizedArgHost) + ); +} diff --git a/src/utilities/device-rename.ts b/src/utilities/device-rename.ts index fc653a9..4c6f31d 100644 --- a/src/utilities/device-rename.ts +++ b/src/utilities/device-rename.ts @@ -1,4 +1,4 @@ -import { normalizeDeviceHost } from './device-normalize'; +import { findConfiguredDeviceIndex } from './device-lookup'; import type { DeviceEntry } from './device-upsert'; export interface RenameDeviceResult { @@ -33,22 +33,7 @@ export function renameDevice( throw new Error('New device name must not be empty'); } - const normalizedArg = identifier.toLowerCase(); - let normalizedArgHost = ''; - try { - normalizedArgHost = normalizeDeviceHost(identifier).toLowerCase(); - } catch { - // not a valid URL — host matching will not apply - } - - const index = devices.findIndex( - (d) => - (d.name && d.name.toLowerCase() === normalizedArg) || - (d.host && d.host.toLowerCase() === normalizedArg) || - (normalizedArgHost && - d.host && - normalizeDeviceHost(d.host).toLowerCase() === normalizedArgHost) - ); + const index = findConfiguredDeviceIndex(devices, identifier); if (index === -1) { throw new Error(`Device "${identifier}" not found`); diff --git a/src/utilities/ff1-compatibility.ts b/src/utilities/ff1-compatibility.ts index cfc43b8..062c197 100644 --- a/src/utilities/ff1-compatibility.ts +++ b/src/utilities/ff1-compatibility.ts @@ -3,6 +3,7 @@ */ import { getFF1DeviceConfig } from '../config'; +import { findConfiguredDeviceIndex } from './device-lookup'; import type { FF1Device, FF1DeviceConfig } from '../types'; import * as logger from '../logger'; @@ -50,7 +51,8 @@ const FF1_COMMAND_POLICIES: Record = { /** * Load and validate the configured FF1 device selected by name. * - * @param {string} [deviceName] - Optional device name, exact match required + * @param {string} [deviceName] - Optional device name (case-insensitive) or host URL, + * resolved with the same matching rules as `device remove`/`default`/`rename` * @param {Object} [options] - Optional dependency overrides * @param {Function} [options.getFF1DeviceConfigFn] - Optional config loader override * @returns {FF1DeviceSelectionResult} Selected device or reason for failure @@ -75,7 +77,8 @@ export function resolveConfiguredDevice( let device = deviceConfig.devices[0]; if (deviceName) { - device = deviceConfig.devices.find((item) => item.name === deviceName) as FF1Device | undefined; + const index = findConfiguredDeviceIndex(deviceConfig.devices, deviceName); + device = index === -1 ? undefined : (deviceConfig.devices[index] as FF1Device); if (!device) { const availableNames = deviceConfig.devices .map((item) => item.name) diff --git a/tests/avahi-parse.test.ts b/tests/avahi-parse.test.ts index cc047a9..d8d7277 100644 --- a/tests/avahi-parse.test.ts +++ b/tests/avahi-parse.test.ts @@ -2,8 +2,8 @@ * Regression tests for parseAvahiBrowseOutput. * * The Linux mDNS path must preserve original case and handle multi-word service - * names. resolveConfiguredDevice() does exact-match lookups, so any case - * mutation or truncation makes a discovered device impossible to target later. + * names. Lookups are case-insensitive, but stored names are display labels and + * round-trip into config.json — truncation or case mangling corrupts them. */ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; diff --git a/tests/device-add-conflict-cli.test.ts b/tests/device-add-conflict-cli.test.ts new file mode 100644 index 0000000..cc9f2c9 --- /dev/null +++ b/tests/device-add-conflict-cli.test.ts @@ -0,0 +1,105 @@ +/** + * Integration test for the case-insensitive duplicate-name guard in + * `ff1 device add --host --name`. + * + * Lookups (`-d`, remove, default, rename) ignore case, so allowing "Kitchen" + * to be saved alongside "kitchen" would make one of them unreachable by name. + */ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const projectRoot = resolve(__dirname, '..'); +// Spawn node directly with tsx's JS entry to avoid Windows .cmd shim +// limitations in spawnSync (Node refuses to execute .bat/.cmd without shell). +const tsxCli = resolve(projectRoot, 'node_modules/tsx/dist/cli.mjs'); +const cliEntry = resolve(projectRoot, 'index.ts'); + +function makeConfig() { + return { + defaultDuration: 10, + ff1Devices: { + devices: [ + { name: 'kitchen', host: 'http://192.168.1.10:1111' }, + { name: 'office', host: 'http://192.168.1.11:1111' }, + ], + }, + }; +} + +function withTempConfig(fn: (dir: string, configPath: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'ff1-device-add-conflict-')); + const configPath = join(dir, 'config.json'); + writeFileSync(configPath, `${JSON.stringify(makeConfig(), null, 2)}\n`, 'utf-8'); + try { + fn(dir, configPath); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function runDeviceAdd( + cwd: string, + ...args: string[] +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync(process.execPath, [tsxCli, cliEntry, 'device', 'add', ...args], { + cwd, + // XDG_CONFIG_HOME is redirected so the user's real ~/.config/ff1 is never touched + // even if cwd-based local config resolution ever changes. + env: { ...process.env, XDG_CONFIG_HOME: join(cwd, '.xdg') }, + encoding: 'utf-8', + }); + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('ff1 device add — case-insensitive duplicate-name guard', () => { + test('rejects renaming a device to a name that differs only by case from another device', () => { + withTempConfig((cwd, configPath) => { + const originalBytes = readFileSync(configPath); + + // Host matches "office", so this is an update — but "Kitchen" collides + // with the existing "kitchen" on the other row. + const { status, stderr } = runDeviceAdd( + cwd, + '--host', + 'http://192.168.1.11:1111', + '--name', + 'Kitchen' + ); + + assert.notEqual(status, 0, 'must exit non-zero on case-insensitive name conflict'); + assert.match(stderr, /already used/i); + + const afterBytes = readFileSync(configPath); + assert.ok(originalBytes.equals(afterBytes), 'config.json must be untouched on conflict'); + }); + }); + + test('still allows a case-only rename of the same device via add', () => { + withTempConfig((cwd, configPath) => { + const { status, stdout } = runDeviceAdd( + cwd, + '--host', + 'http://192.168.1.11:1111', + '--name', + 'Office' + ); + + assert.equal(status, 0, `expected exit 0, got ${status}: ${stdout}`); + + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + assert.deepEqual( + written.ff1Devices.devices.map((d: { name?: string }) => d.name), + ['kitchen', 'Office'], + 'same-row case change is not a conflict' + ); + }); + }); +}); diff --git a/tests/multi-device.test.ts b/tests/multi-device.test.ts index 611f894..ea16aec 100644 --- a/tests/multi-device.test.ts +++ b/tests/multi-device.test.ts @@ -63,6 +63,28 @@ describe('multi-device resolution', () => { assert.equal(result.device?.host, 'http://192.168.1.12:1111'); }); + test('matches device name case-insensitively', () => { + writeDeviceConfig([ + { name: 'kitchen', host: 'http://192.168.1.10:1111' }, + { name: 'office', host: 'http://192.168.1.11:1111' }, + ]); + + const result = resolveConfiguredDevice('OFFICE'); + assert.equal(result.success, true); + assert.equal(result.device?.name, 'office'); + }); + + test('matches by host URL so unnamed entries can be targeted with -d', () => { + writeDeviceConfig([ + { name: 'kitchen', host: 'http://192.168.1.10:1111' }, + { host: 'http://192.168.1.99:1111' }, + ]); + + const result = resolveConfiguredDevice('http://192.168.1.99:1111'); + assert.equal(result.success, true); + assert.equal(result.device?.host, 'http://192.168.1.99:1111'); + }); + test('returns error with available device names when requested device not found', () => { writeDeviceConfig([ { name: 'kitchen', host: 'http://192.168.1.10:1111' }, @@ -89,7 +111,10 @@ describe('multi-device resolution', () => { assert.equal(result.device?.host, 'http://10.0.0.3:1111'); }); - test('device name matching is exact (case-sensitive)', () => { + test('first match wins for legacy configs with case-duplicate names', () => { + // device add/rename reject names that differ only by case, but configs + // written before that guard may still contain both. Lookups are + // case-insensitive, so the earlier row wins deterministically. writeDeviceConfig([ { name: 'Kitchen', host: 'http://192.168.1.10:1111' }, { name: 'kitchen', host: 'http://192.168.1.11:1111' }, @@ -97,7 +122,7 @@ describe('multi-device resolution', () => { const result = resolveConfiguredDevice('kitchen'); assert.equal(result.success, true); - assert.equal(result.device?.host, 'http://192.168.1.11:1111'); + assert.equal(result.device?.host, 'http://192.168.1.10:1111'); }); test('preserves device-specific apiKey and stable ID', () => {