amaran-light-cli
Version:
Command line tool for controlling Aputure Amaran lights via WebSocket to a local Amaran desktop app.
309 lines • 13.6 kB
JavaScript
import { realpathSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
import { Command } from 'commander';
import registerCommands from './commands.js';
import { loadConfig, saveConfig } from './config.js';
import { handleAutostart } from './deviceControl/autostart.js';
import { discoverLocalWebSocket } from './deviceControl/discovery.js';
import LightController from './deviceControl/lightControl.js';
import { enableGlobalTimestamps } from './deviceControl/logging.js';
// Enable global timestamps only if running in service mode
if (process.argv.includes('--service-mode')) {
enableGlobalTimestamps();
console.error(chalk.blue('Circadian lighting service started'));
}
const program = new Command();
// Configure help output
import packageJson from '../package.json' with { type: 'json' };
const { version } = packageJson;
function getRuntimeLabel() {
return fileURLToPath(import.meta.url).endsWith(path.join('src', 'cli.ts')) ? ' (dev)' : '';
}
program
.name('amaran-cli')
.description('Command line tool for controlling Aputure Amaran lights via WebSocket and a circadian lighting service that uses the amaran lights.')
.version(version, '-v, --version', 'output the current version')
.option('--service-mode', 'Internal flag for being run from background service')
.configureHelp({
sortSubcommands: true,
sortOptions: true,
showGlobalOptions: true,
formatHelp: (cmd, helper) => {
const isRoot = cmd.name() === 'amaran-cli';
const commandPath = isRoot ? [] : [cmd.name()];
let current = cmd.parent;
// Build the full command path
while (current && current.name() !== 'amaran-cli') {
commandPath.unshift(current.name());
current = current.parent;
}
const commandName = commandPath.join(' ');
const displayName = 'amaran-cli';
const sections = [
`${chalk.blue(`Amaran Light Control CLI - v${version}${getRuntimeLabel()}`)}`,
'',
`${chalk.blue('Usage:')} ${displayName}${commandName ? ` ${commandName}` : ''} [options]${isRoot ? ' [command]' : ''}`,
'',
];
// Add command description if available
if (cmd.description()) {
sections.push(`${chalk.blue('Description:')} ${cmd.description()}`, '');
}
// Add command usage pattern if available
if (cmd.usage() && cmd.usage() !== '[options] [command]') {
const usage = cmd.usage().replace(/^\s*/, '');
sections.push(`${chalk.blue('Usage:')} ${cmd.name()} ${chalk.blue(usage)}`, '');
}
// Add options
const options = helper.visibleOptions(cmd);
if (options.length > 0) {
sections.push(chalk.blue('Options:'));
// Calculate the maximum length of the raw flags (visible text)
const maxOptionWidth = Math.max(...options.map((o) => o.flags.length), 20 // Minimum width
);
sections.push(...options.map((option) => {
// Split the flags and replace parameter placeholders with bright white
const formattedFlags = option.flags
.split(/\s+/)
.map((part) => {
// Match parameter placeholders like <curve> or <date>
const match = part.match(/^(--?[\w-]+)(?:\s+(<[^>]+>))?/);
if (!match)
return part;
const [_, flag, param] = match;
// Use blue for parameters and long options for better visibility in light mode
const isShortFlag = flag.startsWith('-') && !flag.startsWith('--');
const flagColor = isShortFlag ? chalk.cyan : chalk.blue;
return param ? `${flagColor(flag)} ${chalk.blue(param)}` : flagColor(flag);
})
.join(' ');
// Calculate padding needed based on the original flags length
const padding = ' '.repeat(maxOptionWidth - option.flags.length + 2);
return ` ${formattedFlags}${padding}${option.description}`;
}));
sections.push('');
}
// Add commands
const commands = helper.visibleCommands(cmd);
if (commands.length > 0) {
sections.push(chalk.blue('Commands:'));
// Get the max command + usage length for alignment
const maxCommandWidth = Math.max(...commands.map((c) => {
const usage = c.usage() || '';
return c.name().length + (usage ? usage.length + 1 : 0);
}), 25 // Minimum width
);
sections.push(...commands.map((cmd) => {
const name = cmd.name();
const usage = cmd.usage() || '';
const desc = cmd.description() || '';
const _commandPart = usage ? `${name} ${chalk.blue(usage)}` : name;
return ` ${chalk.green(name)} ${chalk.blue(usage || '').padEnd(maxCommandWidth - name.length - 1)} ${desc}`;
}));
sections.push('');
}
// Add examples for the root command
if (cmd.name() === 'amaran-cli') {
sections.push(chalk.blue('Examples:'), ' $ amaran-cli power on --all # Turn on all connected lights', ' $ amaran-cli cct 5000 --intensity 80 # Set color temperature to 5000K at 80%', ' $ amaran-cli color 255 100 50 # Set RGB color (R:255 G:100 B:50)', '');
}
sections.push(`Run ${chalk.blue('amaran-cli <command> --help')} for more information about a command.`);
return sections.join('\n');
},
})
.showHelpAfterError('(add --help for additional information)');
function saveCliConfig(config, changes) {
try {
saveConfig(config);
if (changes && changes.length > 0) {
console.log(chalk.green('Configuration saved successfully:'));
changes.forEach((change) => {
console.log(chalk.green(` • ${change}`));
});
}
else {
console.log(chalk.green('Configuration saved successfully'));
}
}
catch (error) {
console.error(chalk.red('Error saving configuration:'), error);
throw error;
}
}
function saveWsUrl(url) {
const current = loadConfig() || {};
current.wsUrl = url;
saveCliConfig(current, [`WebSocket URL: ${url}`]);
}
// Create light controller with connection handling
async function createController(wsUrl, clientId, debug) {
const config = loadConfig();
let url = wsUrl || config?.wsUrl;
const id = clientId || config?.clientId || 'amaran-cli';
const debugMode = debug !== undefined ? debug : config?.debug || false;
const connectWithUrl = (candidateUrl) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
}, 8000);
let hasResolved = false;
let hasRejected = false;
const controller = new LightController(candidateUrl, id, undefined, debugMode);
// Set up error handling on the WebSocket to catch connection errors
const ws = controller.getWebSocket();
ws.on('error', (error) => {
if (debugMode) {
console.error('WebSocket error:', error);
}
else {
// Extract address and port from the error message for cleaner output
const addressMatch = error.message.match(/(\S+:\d+)/);
const addressPort = addressMatch ? addressMatch[1] : candidateUrl;
console.error(chalk.red(`WebSocket connection failed to ${addressPort}`));
}
if (!hasResolved && !hasRejected) {
hasRejected = true;
clearTimeout(timeout);
reject(new Error(`WebSocket connection failed: ${error.message}`));
}
});
// Resolve once we have a device list back (works even if zero devices)
controller.getDeviceList((success, message) => {
if (hasRejected)
return; // Don't resolve if we already rejected due to error
clearTimeout(timeout);
if (!success) {
if (!hasRejected) {
hasRejected = true;
reject(new Error(message || 'Failed to fetch device list'));
}
return;
}
if (!hasResolved) {
hasResolved = true;
if (debugMode) {
console.log(chalk.green('✓ Connected (device list received)'));
}
resolve(controller);
}
});
});
};
// If no URL is known, try discovery first and persist
if (!url) {
const found = await discoverLocalWebSocket('127.0.0.1', debugMode);
if (found) {
url = found.url;
if (debugMode) {
console.log(chalk.green(`✓ Discovered WebSocket at ${url} (process: ${found.process})`));
}
saveWsUrl(url);
}
else {
url = 'ws://localhost:60124';
if (debugMode) {
console.log(chalk.yellow(`⚠︎ Discovery failed, falling back to ${url}`));
}
}
}
// Try to connect; on failure, attempt autostart, discover, persist, and retry once
try {
return await connectWithUrl(url);
}
catch (e) {
const config = loadConfig();
const autoStartEnabled = config?.autoStartApp !== false; // Default to true
if (autoStartEnabled) {
console.log(chalk.blue('🚀 Amaran desktop app not running, starting...'));
}
if (debugMode) {
console.log(chalk.yellow(`Initial connection to ${url} failed; attempting autostart and discovery...`));
}
// Attempt to start the Amaran desktop app
const autostartSuccess = await handleAutostart(debugMode);
if (autostartSuccess) {
// Give the app a moment to fully start up and begin listening
if (debugMode) {
console.log(chalk.blue('Waiting for app to initialize...'));
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
// Try discovery again (in case the app started on a different port)
const found = await discoverLocalWebSocket('127.0.0.1', debugMode);
if (found) {
if (debugMode) {
console.log(chalk.green(`✓ Discovered fallback WebSocket at ${found.url}`));
}
saveWsUrl(found.url);
return await connectWithUrl(found.url);
}
// Provide appropriate error message based on what happened
if (!autoStartEnabled) {
console.log(chalk.yellow('Amaran desktop app is not running and autostart is disabled.'));
console.log(chalk.yellow('Enable autostart with: amaran config --auto-start-app true'));
}
else if (!autostartSuccess) {
console.log(chalk.yellow('Could not start Amaran desktop app. Please ensure it is installed and try again.'));
}
else {
console.log(chalk.yellow('Amaran desktop app started but connection still failed. Please try again.'));
}
throw e;
}
}
// Helper function to handle async commands
function asyncCommand(fn) {
return (...args) => {
return fn(...args).catch((error) => {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
});
};
}
// Helper function to find device by name or ID
function findDevice(controller, deviceQuery) {
const devices = controller.getDevices();
// Try to find by exact ID first
let device = devices.find((d) => d.node_id === deviceQuery || d.id === deviceQuery);
// If not found, try to find by name (case insensitive)
if (!device) {
const q = deviceQuery.toLowerCase();
device = devices.find((d) => {
const nm = (d.device_name || d.name || '').toLowerCase();
return nm.includes(q);
});
}
return device || null;
}
// (The 'config' command is registered centrally in src/commands/config.ts via registerCommands.)
// Register all commands
registerCommands(program, {
createController,
findDevice,
asyncCommand,
saveWsUrl,
loadConfig,
saveConfig: saveCliConfig,
});
// Add custom help command that preserves the formatting
program.helpCommand('help [command]', 'Display help for a specific command');
function isDirectRun() {
const entrypoint = process.argv[1];
if (!entrypoint)
return false;
try {
return realpathSync(entrypoint) === realpathSync(fileURLToPath(import.meta.url));
}
catch {
return false;
}
}
// If this file is run directly, parse the arguments. Resolve symlinks so npm
// global bins like /opt/homebrew/bin/amaran-cli still execute the CLI.
if (isDirectRun()) {
program.parse(process.argv);
}
export { program, createController, findDevice, asyncCommand, registerCommands };
//# sourceMappingURL=cli.js.map