Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`, the CLI matches the device by `name` (exact match). If not found, you’ll see an error listing available devices.
- If you pass `-d <name>`, 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:

Expand Down
30 changes: 10 additions & 20 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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.`));
Expand Down Expand Up @@ -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`));
Expand Down
19 changes: 2 additions & 17 deletions src/utilities/device-default.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { normalizeDeviceHost } from './device-normalize';
import { findConfiguredDeviceIndex } from './device-lookup';
import type { DeviceEntry } from './device-upsert';

export interface PromoteDefaultResult {
Expand All @@ -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`);
Expand Down
35 changes: 35 additions & 0 deletions src/utilities/device-lookup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeDeviceHost } from './device-normalize';

/**
* Find an existing configured device that corresponds to a newly discovered host.
*
Expand Down Expand Up @@ -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)
);
}
19 changes: 2 additions & 17 deletions src/utilities/device-rename.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { normalizeDeviceHost } from './device-normalize';
import { findConfiguredDeviceIndex } from './device-lookup';
import type { DeviceEntry } from './device-upsert';

export interface RenameDeviceResult {
Expand Down Expand Up @@ -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`);
Expand Down
7 changes: 5 additions & 2 deletions src/utilities/ff1-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { getFF1DeviceConfig } from '../config';
import { findConfiguredDeviceIndex } from './device-lookup';
import type { FF1Device, FF1DeviceConfig } from '../types';
import * as logger from '../logger';

Expand Down Expand Up @@ -50,7 +51,8 @@ const FF1_COMMAND_POLICIES: Record<FF1Command, FF1CommandPolicy> = {
/**
* 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
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/avahi-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
105 changes: 105 additions & 0 deletions tests/device-add-conflict-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
29 changes: 27 additions & 2 deletions tests/multi-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -89,15 +111,18 @@ 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' },
]);

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', () => {
Expand Down
Loading