amaran-light-cli
Version:
Command line tool for controlling Aputure Amaran lights via WebSocket to a local Amaran desktop app.
361 lines • 15.1 kB
JavaScript
import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { CURVE_HELP_TEXT } from '../../daylightSimulation/constants.js';
import { escapeXmlText } from '../parseUtils.js';
export function registerService(program, deps) {
const { asyncCommand } = deps;
const serviceCommand = program
.command('service')
.description('Manage auto-cct circadian lighting background service');
// Install service
serviceCommand
.command('install')
.description('Install auto-cct as a circadian lighting background service (runs every minute)')
.option('-i, --interval <seconds>', 'Interval in seconds (default: 60)', '60')
.option('-C, --curve <curve>', `${CURVE_HELP_TEXT} (default: hann)`, 'hann')
.action(asyncCommand(handleInstall(deps)));
// Uninstall service
serviceCommand
.command('uninstall')
.description('Uninstall circadian lighting background service')
.action(asyncCommand(handleUninstall()));
// Status command
serviceCommand
.command('status')
.description('Check circadian lighting service status')
.action(asyncCommand(handleStatus()));
// Start service
serviceCommand.command('start').description('Start circadian lighting service').action(asyncCommand(handleStart()));
// Stop service
serviceCommand.command('stop').description('Stop circadian lighting service').action(asyncCommand(handleStop()));
// Logs command
serviceCommand
.command('logs')
.description('Show circadian lighting service logs')
.option('-f, --follow', 'Follow log output')
.option('-e, --errors', 'Show error logs instead')
.action(asyncCommand(handleLogs()));
}
function handleInstall(_deps) {
return async (options) => {
// dynamic import for ESM
const { parseCurveType } = await import('../../daylightSimulation/cctUtil.js');
const interval = parseInt(options.interval ?? '60', 10);
if (Number.isNaN(interval) || interval < 10) {
console.error(chalk.red('Interval must be at least 10 seconds'));
process.exit(1);
}
// Validate curve option
let curveType;
if (options.curve) {
try {
curveType = parseCurveType(options.curve);
}
catch (error) {
console.error(chalk.red(error.message));
process.exit(1);
}
}
else {
curveType = 'HANN';
}
// Determine CLI path - check if globally installed or local
let cliPath;
let isGlobal = false;
// Check if running from global installation
try {
const { stdout } = await runCommand('which', ['amaran-cli']);
const globalPath = stdout.trim();
if (globalPath && fs.existsSync(globalPath)) {
cliPath = globalPath;
isGlobal = true;
console.log(chalk.blue(`✓ Detected global installation: ${cliPath}`));
}
else {
throw new Error('Not found in PATH');
}
}
catch {
// Fall back to local development path
const projectDir = path.resolve(__dirname, '..', '..', '..');
cliPath = path.join(projectDir, 'dist', 'cli.js');
// Check if CLI is built locally
if (!fs.existsSync(cliPath)) {
console.error(chalk.red('CLI not found. Please either:'));
console.error(chalk.red(' 1. Install globally: npm install -g .'));
console.error(chalk.red(' 2. Or build locally: npm run build'));
process.exit(1);
}
console.log(chalk.blue(`✓ Using local development build: ${cliPath}`));
}
const homeDir = process.env.HOME;
if (!homeDir) {
console.error(chalk.red('HOME environment variable not set'));
process.exit(1);
}
const plistName = 'com.hmmfn.amaran.circadian-service';
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${plistName}.plist`);
const logDir = path.join(homeDir, 'Library', 'Logs');
const nodePath = process.execPath;
// Ensure log directory exists
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const escapedPlistName = escapeXmlText(plistName);
const escapedNodePath = escapeXmlText(nodePath);
const escapedCliPath = escapeXmlText(cliPath);
const escapedCurveType = escapeXmlText(curveType);
const escapedStdoutPath = escapeXmlText(path.join(logDir, 'amaran-circadian-service.log'));
const escapedStderrPath = escapeXmlText(path.join(logDir, 'amaran-circadian-service-error.log'));
const escapedWorkingDirectory = escapeXmlText(path.dirname(path.dirname(cliPath)));
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${escapedPlistName}</string>
<key>ProgramArguments</key>
<array>
<string>${escapedNodePath}</string>
<string>${escapedCliPath}</string>
<string>auto-cct</string>
<string>--service-mode</string>
<string>--curve</string>
<string>${escapedCurveType}</string>
</array>
<key>StartInterval</key>
<integer>${interval}</integer>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>${escapedStdoutPath}</string>
<key>StandardErrorPath</key>
<string>${escapedStderrPath}</string>
${!isGlobal
? `<key>WorkingDirectory</key>
<string>${escapedWorkingDirectory}</string>`
: ''}
</dict>
</plist>`;
try {
// Write plist file
fs.writeFileSync(plistPath, plistContent);
console.log(chalk.green(`✓ Created service file: ${plistPath}`));
// Load the service
await runCommand('launchctl', ['load', plistPath]);
console.log(chalk.green(`✓ Circadian lighting service installed and started`));
console.log(chalk.blue(` Installation type: ${isGlobal ? 'Global' : 'Local development'}`));
console.log(chalk.blue(` CLI path: ${cliPath}`));
console.log(chalk.blue(` Running auto-cct every ${interval} seconds`));
console.log(chalk.blue(` Curve type: ${curveType}`));
console.log(chalk.gray(` Logs: ${path.join(logDir, 'amaran-circadian-service.log')}`));
console.log(chalk.gray(` Errors: ${path.join(logDir, 'amaran-circadian-service-error.log')}`));
appendServiceLog(`Service installed (${isGlobal ? 'global' : 'local'}) interval=${interval}s curve=${curveType}`);
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to install circadian lighting service:'), err.message);
process.exit(1);
}
};
}
function handleUninstall() {
return async () => {
const homeDir = process.env.HOME;
if (!homeDir) {
console.error(chalk.red('HOME environment variable not set'));
process.exit(1);
}
const plistName = 'com.hmmfn.amaran.circadian-service';
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${plistName}.plist`);
try {
if (fs.existsSync(plistPath)) {
// Unload the service
await runCommand('launchctl', ['unload', plistPath]);
// Remove the plist file
fs.unlinkSync(plistPath);
console.log(chalk.green('✓ Circadian lighting service uninstalled successfully'));
appendServiceLog('Service uninstalled');
}
else {
console.log(chalk.yellow('Circadian lighting service not found (already uninstalled)'));
}
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to uninstall circadian lighting service:'), err.message);
process.exit(1);
}
};
}
function handleStatus() {
return async () => {
const homeDir = process.env.HOME;
if (!homeDir) {
console.error(chalk.red('HOME environment variable not set'));
process.exit(1);
}
const plistName = 'com.hmmfn.amaran.circadian-service';
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${plistName}.plist`);
const logPath = path.join(homeDir, 'Library', 'Logs', 'amaran-circadian-service.log');
const errorLogPath = path.join(homeDir, 'Library', 'Logs', 'amaran-circadian-service-error.log');
try {
if (!fs.existsSync(plistPath)) {
console.log(chalk.yellow('Circadian lighting service not installed'));
return;
}
// Check if service is loaded
const { stdout } = await runCommand('launchctl', ['list']);
const isLoaded = stdout.includes(plistName);
console.log(chalk.blue('Circadian Lighting Service Status:'));
console.log(` Installed: ${chalk.green('✓')}`);
console.log(` Running: ${isLoaded ? chalk.green('✓') : chalk.red('✗')}`);
console.log(` Config: ${plistPath}`);
if (fs.existsSync(plistPath)) {
const content = fs.readFileSync(plistPath, 'utf8');
const match = content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>.*?<\/string>\s*<string>(.*?)<\/string>/s);
if (match?.[1]) {
console.log(` Path: ${match[1]}`);
}
}
if (fs.existsSync(logPath)) {
const logStats = fs.statSync(logPath);
console.log(` Last run: ${logStats.mtime.toLocaleString()}`);
// Show last few lines of log
const logContent = fs.readFileSync(logPath, 'utf8');
const lastLines = logContent.trim().split('\n').slice(-3);
if (lastLines.length > 0 && lastLines[0]) {
console.log(chalk.gray(' Recent output:'));
for (const line of lastLines) {
console.log(chalk.gray(` ${line}`));
}
}
}
if (fs.existsSync(errorLogPath)) {
const errorContent = fs.readFileSync(errorLogPath, 'utf8').trim();
if (errorContent) {
console.log(chalk.red(' Recent errors:'));
errorContent
.split('\n')
.slice(-3)
.forEach((line) => {
if (line.trim())
console.log(chalk.red(` ${line}`));
});
}
}
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to check circadian lighting service status:'), err.message);
}
};
}
function handleStart() {
return async () => {
const plistName = 'com.hmmfn.amaran.circadian-service';
try {
await runCommand('launchctl', ['start', plistName]);
console.log(chalk.green('✓ Circadian lighting service started'));
appendServiceLog('Service start requested');
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to start circadian lighting service:'), err.message);
}
};
}
function handleStop() {
return async () => {
const plistName = 'com.hmmfn.amaran.circadian-service';
try {
await runCommand('launchctl', ['stop', plistName]);
console.log(chalk.green('✓ Circadian lighting service stopped'));
appendServiceLog('Service stop requested');
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to stop circadian lighting service:'), err.message);
}
};
}
function handleLogs() {
return async (options) => {
const homeDir = process.env.HOME;
if (!homeDir) {
console.error(chalk.red('HOME environment variable not set'));
process.exit(1);
}
const logFile = options.errors ? 'amaran-circadian-service-error.log' : 'amaran-circadian-service.log';
const logPath = path.join(homeDir, 'Library', 'Logs', logFile);
if (!fs.existsSync(logPath)) {
console.log(chalk.yellow(`No ${options.errors ? 'error ' : ''}logs found`));
return;
}
try {
if (options.follow) {
console.log(chalk.blue(`Following ${logFile}... (Ctrl+C to stop)`));
spawn('tail', ['-f', logPath], { stdio: 'inherit' });
}
else {
const content = fs.readFileSync(logPath, 'utf8');
console.log(content);
}
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to read logs:'), err.message);
}
};
}
function appendServiceLog(message) {
const homeDir = process.env.HOME;
if (!homeDir) {
return;
}
const logDir = path.join(homeDir, 'Library', 'Logs');
const logPath = path.join(logDir, 'amaran-circadian-service.log');
try {
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const timestamp = new Date().toISOString();
fs.appendFileSync(logPath, `[${timestamp}] ${message}\n`);
}
catch (error) {
const err = error;
console.error(chalk.red('Failed to write to service log:'), err.message);
}
}
// Helper function to run shell commands
function runCommand(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'pipe' });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
}
else {
reject(new Error(`Command failed with code ${code}: ${stderr}`));
}
});
});
}
export default registerService;
//# sourceMappingURL=service.js.map