diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4d4fabe..18df944 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -139,6 +139,7 @@ You can also manage devices independently with: non-interactively with `--host`, `--name`, and the physical `--id` shown in Feral File mobile. Manual devices need the ID before relayer pairing. - `ff-cli device list` – Show all configured devices. +- `ff-cli device rename ` – Rename a device without touching its host, API key, or default status. Works offline; also accepts the host URL as the identifier, so unnamed legacy entries can be given a name. - `ff-cli device remove ` – Remove a device by name. - `ff-cli device default ` – Promote a device to the top of the list so it is used when `-d` is omitted. - `ff-cli device pair [name]` – Display a one-time handoff code and key-bound diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index cadd8a7..cfb698c 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -385,6 +385,9 @@ npm run dev -- device add # Add a device non-interactively npm run dev -- device add --host 192.168.1.100 --name kitchen +# Rename a device (host, API key, and default status are untouched) +npm run dev -- device rename kitchen gallery + # Remove a device by name npm run dev -- device remove kitchen diff --git a/docs/README.md b/docs/README.md index c9737bf..052d693 100644 --- a/docs/README.md +++ b/docs/README.md @@ -120,6 +120,7 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1 - `device add` – Add a device with mDNS or manual `--host`, `--name`, and `--id` - Options: `--host `, `--name `, `--id ` - `device pair [name]` – Securely receive relayer access from Feral File mobile +- `device rename ` – Rename a configured FF1 device (host, API key, and default status are untouched) - `device remove ` – Remove a configured FF1 device - `device default ` – Set the default FF1 device (used when `-d` is omitted) - `config ` – Manage configuration @@ -274,6 +275,9 @@ ff-cli device add # Add a device non-interactively ff-cli device add --host 192.168.1.100 --name kitchen --id FF1-XXXXXXXX +# Rename a device (host, API key, and default status are untouched) +ff-cli device rename kitchen gallery + # Remove a device by name ff-cli device remove kitchen diff --git a/package-lock.json b/package-lock.json index 04919b0..ce45039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@feralfile/cli", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@feralfile/cli", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79", diff --git a/package.json b/package.json index d2ed26b..938c138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@feralfile/cli", - "version": "2.1.0", + "version": "2.2.0", "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices", "repository": { "type": "git", diff --git a/src/commands/device.ts b/src/commands/device.ts index 3dce8e0..e3542b3 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -5,6 +5,7 @@ import { findExistingDeviceEntry } from '../utilities/device-lookup'; import { normalizeDeviceHost } from '../utilities/device-normalize'; import { upsertDevice } from '../utilities/device-upsert'; import { promoteDeviceToDefault } from '../utilities/device-default'; +import { renameDevice } from '../utilities/device-rename'; import { readConfigFile, resolveExistingConfigPath } from './helpers/config-files'; import { discoverAndSelectDevice } from './helpers/device-discovery'; import { createPrompt, promptYesNo } from './helpers/prompt'; @@ -336,6 +337,55 @@ deviceCommand } }); +deviceCommand + .command('rename') + .description('Rename a configured FF1 device (host, API key, and default status are untouched)') + .argument('', 'Current device name or host') + .argument('', 'New device name') + .action(async (name: string, newName: string) => { + try { + const configPath = await resolveExistingConfigPath(); + if (!configPath) { + console.log(chalk.red('config.json not found')); + console.log(chalk.dim('Run: ff-cli setup')); + process.exit(1); + } + + const config = await readConfigFile(configPath); + const existingDevices = config.ff1Devices?.devices || []; + + if (existingDevices.length === 0) { + console.log(chalk.yellow('\nNo devices configured')); + console.log(chalk.dim('Run: ff-cli device add\n')); + process.exit(1); + } + + let result; + try { + result = renameDevice(existingDevices, name, newName); + } catch (error) { + console.error(chalk.red(`\n${(error as Error).message}`)); + const names = existingDevices.map((d) => d.name || d.host).join(', '); + console.log(chalk.dim(`Available devices: ${names}\n`)); + process.exit(1); + } + + if (result.unchanged) { + console.log(chalk.dim(`\n"${result.renamed.name}" is already named that.\n`)); + return; + } + + config.ff1Devices = { devices: result.devices }; + await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); + + const from = result.previousName || result.renamed.host; + console.log(chalk.green(`\nRenamed device: ${from} → ${result.renamed.name}\n`)); + } catch (error) { + console.error(chalk.red('\nError:'), (error as Error).message); + process.exit(1); + } + }); + deviceCommand .command('default') .description('Set the default FF1 device (reorders so this device is used when -d is omitted)') diff --git a/src/utilities/device-rename.ts b/src/utilities/device-rename.ts new file mode 100644 index 0000000..fc653a9 --- /dev/null +++ b/src/utilities/device-rename.ts @@ -0,0 +1,74 @@ +import { normalizeDeviceHost } from './device-normalize'; +import type { DeviceEntry } from './device-upsert'; + +export interface RenameDeviceResult { + devices: DeviceEntry[]; + renamed: DeviceEntry; + /** Previous name of the renamed entry (undefined for unnamed legacy entries). */ + previousName?: string; + /** True when the entry already had exactly the requested name; callers can skip persisting. */ + unchanged: boolean; +} + +/** + * Rename a configured device without touching host, apiKey, topicID, or list + * position — a pure config edit that needs no discovery or network access + * (unlike `device add`, whose update path requires the host or mDNS). + * + * Matches by name (case-insensitive) or by host URL, mirroring `device remove` + * so unnamed legacy entries can still be targeted (renaming gives them a name + * for the first time). + * + * @throws {Error} When no device matches the identifier, the new name is + * empty, or the new name is already used by a different device + * (case-insensitive, so lookups elsewhere stay unambiguous). + */ +export function renameDevice( + devices: DeviceEntry[], + identifier: string, + newName: string +): RenameDeviceResult { + const trimmedName = newName.trim(); + if (!trimmedName) { + 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) + ); + + if (index === -1) { + throw new Error(`Device "${identifier}" not found`); + } + + const conflict = devices.find( + (d, i) => i !== index && d.name && d.name.toLowerCase() === trimmedName.toLowerCase() + ); + if (conflict) { + throw new Error( + `Device name "${trimmedName}" is already used by another device (${conflict.host})` + ); + } + + const target = devices[index]; + if (target.name === trimmedName) { + return { devices: [...devices], renamed: target, previousName: target.name, unchanged: true }; + } + + const updated = [...devices]; + updated[index] = { ...target, name: trimmedName }; + return { devices: updated, renamed: updated[index], previousName: target.name, unchanged: false }; +} diff --git a/tests/device-rename-cli.test.ts b/tests/device-rename-cli.test.ts new file mode 100644 index 0000000..f0ab14a --- /dev/null +++ b/tests/device-rename-cli.test.ts @@ -0,0 +1,154 @@ +/** + * Integration test for the `ff1 device rename ` subcommand. + * + * Exercises the CLI wiring that reads and rewrites config.json — the + * highest-risk regression point not covered by the pure-helper unit tests. + */ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, statSync, 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'); + +interface TestConfig { + defaultDuration: number; + playlist: { privateKey: string }; + ff1Devices: { + devices: Array<{ name?: string; host: string; id?: string; apiKey?: string }>; + }; + /** Arbitrary top-level field — must survive a rename. */ + experimental?: Record; +} + +function makeConfig(): TestConfig { + return { + defaultDuration: 10, + playlist: { privateKey: 'TESTKEY' }, + ff1Devices: { + devices: [ + { name: 'kitchen', host: 'http://192.168.1.10:1111', id: 'ff1-kkk', apiKey: 'KEY-K' }, + { name: 'office', host: 'http://192.168.1.11:1111', id: 'ff1-ooo' }, + { name: 'studio', host: 'http://192.168.1.12:1111' }, + ], + }, + experimental: { flagA: true, nested: { count: 3 } }, + }; +} + +function withTempConfig(fn: (dir: string, configPath: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'ff1-device-rename-')); + 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 runDeviceRename( + cwd: string, + ...args: string[] +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync(process.execPath, [tsxCli, cliEntry, 'device', 'rename', ...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 rename — CLI integration', () => { + test('renames the device in place and preserves all other fields and config', () => { + withTempConfig((cwd, configPath) => { + const { status, stdout } = runDeviceRename(cwd, 'kitchen', 'gallery'); + + assert.equal(status, 0, `expected exit 0, got ${status}: ${stdout}`); + assert.match(stdout, /Renamed device: kitchen → gallery/); + + const written = JSON.parse(readFileSync(configPath, 'utf-8')) as TestConfig; + + assert.deepEqual( + written.ff1Devices.devices.map((d) => d.name), + ['gallery', 'office', 'studio'], + 'name changes in place; order (and thus default status) is preserved' + ); + + // Sibling fields on the renamed entry must survive. + const renamed = written.ff1Devices.devices[0]; + assert.equal(renamed.host, 'http://192.168.1.10:1111'); + assert.equal(renamed.id, 'ff1-kkk'); + assert.equal(renamed.apiKey, 'KEY-K'); + + // Unrelated top-level fields must be preserved. + assert.equal(written.defaultDuration, 10); + assert.equal(written.playlist.privateKey, 'TESTKEY'); + assert.deepEqual(written.experimental, { flagA: true, nested: { count: 3 } }); + }); + }); + + test('leaves config.json untouched when the name is already the requested one', () => { + withTempConfig((cwd, configPath) => { + const originalBytes = readFileSync(configPath); + const originalMtime = statSync(configPath).mtimeMs; + + const { status, stdout } = runDeviceRename(cwd, 'kitchen', 'kitchen'); + + assert.equal(status, 0, `expected exit 0, got ${status}: ${stdout}`); + assert.match(stdout, /already named/i); + + const afterBytes = readFileSync(configPath); + assert.ok( + originalBytes.equals(afterBytes), + 'config.json bytes must be identical when nothing changes' + ); + + const afterMtime = statSync(configPath).mtimeMs; + assert.equal(afterMtime, originalMtime, 'config.json must not be rewritten on no-op'); + }); + }); + + test('exits non-zero and leaves config untouched when the device is not found', () => { + withTempConfig((cwd, configPath) => { + const originalBytes = readFileSync(configPath); + + const { status, stderr } = runDeviceRename(cwd, 'bathroom', 'gallery'); + + assert.notEqual(status, 0, 'must exit non-zero on missing device'); + assert.match(stderr, /not found/i); + + const afterBytes = readFileSync(configPath); + assert.ok( + originalBytes.equals(afterBytes), + 'config.json must be untouched on not-found error' + ); + }); + }); + + test('exits non-zero when the new name belongs to another device', () => { + withTempConfig((cwd, configPath) => { + const originalBytes = readFileSync(configPath); + + const { status, stderr } = runDeviceRename(cwd, 'office', 'kitchen'); + + assert.notEqual(status, 0, 'must exit non-zero on name conflict'); + assert.match(stderr, /already used/i); + + const afterBytes = readFileSync(configPath); + assert.ok(originalBytes.equals(afterBytes), 'config.json must be untouched on conflict'); + }); + }); +}); diff --git a/tests/device-rename.test.ts b/tests/device-rename.test.ts new file mode 100644 index 0000000..fed1eec --- /dev/null +++ b/tests/device-rename.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { renameDevice } from '../src/utilities/device-rename'; +import type { DeviceEntry } from '../src/utilities/device-upsert'; + +const sample = (): DeviceEntry[] => [ + { name: 'kitchen', host: 'http://192.168.1.10:1111', id: 'ff1-kkk', apiKey: 'KEY-K' }, + { name: 'office', host: 'http://192.168.1.11:1111', id: 'ff1-ooo', topicID: 'topic-o' }, + { name: 'studio', host: 'http://192.168.1.12:1111' }, +]; + +describe('renameDevice', () => { + test('renames in place, preserving position and all other fields', () => { + const { devices, renamed, previousName, unchanged } = renameDevice( + sample(), + 'office', + 'gallery' + ); + assert.equal(unchanged, false); + assert.equal(previousName, 'office'); + assert.equal(renamed.name, 'gallery'); + assert.deepEqual( + devices.map((d) => d.name), + ['kitchen', 'gallery', 'studio'], + 'only the target name changes; order is preserved' + ); + assert.equal(devices[1].host, 'http://192.168.1.11:1111'); + assert.equal(devices[1].id, 'ff1-ooo'); + assert.equal(devices[1].topicID, 'topic-o'); + }); + + test('is case-insensitive on the identifier', () => { + const { renamed } = renameDevice(sample(), 'OFFICE', 'gallery'); + assert.equal(renamed.name, 'gallery'); + }); + + test('matches by host URL and names an unnamed legacy entry', () => { + const devices: DeviceEntry[] = [ + { name: 'kitchen', host: 'http://192.168.1.10:1111' }, + { host: 'http://192.168.1.99:1111' }, + ]; + const result = renameDevice(devices, 'http://192.168.1.99:1111', 'hallway'); + assert.equal(result.devices[1].name, 'hallway'); + assert.equal(result.previousName, undefined); + }); + + test('allows a case-only rename of the same device', () => { + const { devices, unchanged } = renameDevice(sample(), 'office', 'Office'); + assert.equal(unchanged, false); + assert.equal(devices[1].name, 'Office'); + }); + + test('reports unchanged when the name is already exactly the requested one', () => { + const { devices, unchanged } = renameDevice(sample(), 'office', 'office'); + assert.equal(unchanged, true); + assert.deepEqual( + devices.map((d) => d.name), + ['kitchen', 'office', 'studio'] + ); + }); + + test('trims surrounding whitespace from the new name', () => { + const { renamed } = renameDevice(sample(), 'office', ' gallery '); + assert.equal(renamed.name, 'gallery'); + }); + + test('throws when the new name is empty or whitespace', () => { + assert.throws(() => renameDevice(sample(), 'office', ' '), /must not be empty/); + }); + + test('throws when the new name is used by a different device (case-insensitive)', () => { + assert.throws(() => renameDevice(sample(), 'office', 'kitchen'), /already used/); + assert.throws(() => renameDevice(sample(), 'office', 'KITCHEN'), /already used/); + }); + + test('throws when identifier does not match any device', () => { + assert.throws(() => renameDevice(sample(), 'bathroom', 'gallery'), /not found/); + }); + + test('does not mutate the input array', () => { + const input = sample(); + const before = input.map((d) => d.name); + renameDevice(input, 'office', 'gallery'); + assert.deepEqual( + input.map((d) => d.name), + before + ); + }); +});