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
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <new-name>` – 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 <name>` – Remove a device by name.
- `ff-cli device default <name>` – 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
Expand Down
3 changes: 3 additions & 0 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>`, `--name <name>`, `--id <id>`
- `device pair [name]` – Securely receive relayer access from Feral File mobile
- `device rename <name> <new-name>` – Rename a configured FF1 device (host, API key, and default status are untouched)
- `device remove <name>` – Remove a configured FF1 device
- `device default <name>` – Set the default FF1 device (used when `-d` is omitted)
- `config <init|show|validate>` – Manage configuration
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
50 changes: 50 additions & 0 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -336,6 +337,55 @@ deviceCommand
}
});

deviceCommand
.command('rename')
.description('Rename a configured FF1 device (host, API key, and default status are untouched)')
.argument('<name>', 'Current device name or host')
.argument('<new-name>', '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)')
Expand Down
74 changes: 74 additions & 0 deletions src/utilities/device-rename.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
154 changes: 154 additions & 0 deletions tests/device-rename-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Integration test for the `ff1 device rename <name> <new-name>` 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<string, unknown>;
}

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 <name> <new-name> — 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');
});
});
});
Loading
Loading