@abyrd9/harbor-cli
Version:
A CLI tool for managing local development services with automatic subdomain routing
538 lines (532 loc) • 21 kB
JavaScript
import { Command } from '@commander-js/extra-typings';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { chmodSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJson = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
const requiredDependencies = [
{
name: 'Caddy',
command: 'caddy version',
installMsg: 'https://caddyserver.com/docs/install',
requiredFor: 'reverse proxy functionality',
},
{
name: 'tmux',
command: 'tmux -V',
installMsg: 'https://github.com/tmux/tmux/wiki/Installing',
requiredFor: 'terminal multiplexing',
},
{
name: 'jq',
command: 'jq --version',
installMsg: 'https://stedolan.github.io/jq/download/',
requiredFor: 'JSON processing in service management',
},
];
async function checkDependencies() {
const missingDeps = [];
for (const dep of requiredDependencies) {
try {
await new Promise((resolve, reject) => {
const process = spawn('sh', ['-c', dep.command]);
process.on('close', (code) => {
if (code === 0)
resolve(null);
else
reject();
});
});
}
catch {
missingDeps.push(dep);
}
}
if (missingDeps.length > 0) {
console.log('❌ Missing required dependencies:');
for (const dep of missingDeps) {
console.log(`\n${dep.name} (required for ${dep.requiredFor})`);
console.log(`Install instructions: ${dep.installMsg}`);
}
throw new Error('Please install missing dependencies before continuing');
}
}
const possibleProjectFiles = [
'package.json', // Node.js projects
'go.mod', // Go projects
'Cargo.toml', // Rust projects
'composer.json', // PHP projects
'requirements.txt', // Python projects
'Gemfile', // Ruby projects
'pom.xml', // Java Maven projects
'build.gradle', // Java Gradle projects
];
const program = new Command();
program
.name('harbor')
.description(`A CLI tool for managing your project's local development services
Harbor helps you manage multiple local development services with ease.
It provides a simple way to configure and run your services with automatic
subdomain routing through Caddy reverse proxy.
Available Commands:
dock Initialize a new Harbor project
moor Add new services to your configuration
anchor Generate Caddy reverse proxy configuration
launch Start all services in tmux sessions`)
.version(packageJson.version)
.action(async () => await checkDependencies())
.addHelpCommand(false);
// If no command is provided, display help
if (process.argv.length <= 2) {
program.help();
}
program.command('dock')
.description(`Prepares your development environment by creating both:
- harbor.json configuration file (or harbor field in package.json)
- Caddyfile for reverse proxy (if needed)
This is typically the first command you'll run in a new project.`)
.option('-p, --path <path>', 'The path to the root of your project', './')
.action(async (options) => {
try {
const caddyFileExists = fileExists('Caddyfile');
const configExists = checkHasHarborConfig();
if (caddyFileExists || configExists) {
console.log('❌ Error: Harbor project already initialized');
if (caddyFileExists) {
console.log(' - Caddyfile already exists');
}
if (configExists) {
console.log(' - Harbor configuration already exists');
}
console.log('\nTo reinitialize, please remove these files first.');
process.exit(1);
}
const servicesAdded = await generateDevFile(options.path);
// Only try to generate Caddyfile if services were actually added
if (servicesAdded) {
try {
await generateCaddyFile();
console.log('✨ Environment successfully prepared and anchored!');
}
catch (err) {
if (err instanceof Error && err.message.includes('No harbor configuration found')) {
// This is expected if no services were added
console.log('✨ Environment prepared! No services configured yet.');
}
else {
console.log('❌ Error generating Caddyfile:', err instanceof Error ? err.message : 'Unknown error');
process.exit(1);
}
}
}
else {
console.log('✨ Environment prepared! No services configured yet.');
}
}
catch (err) {
console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
process.exit(1);
}
});
program.command('moor')
.description('Add new services to your harbor configuration')
.option('-p, --path <path>', 'The path to the root of your project', './')
.action(async (options) => {
if (!checkHasHarborConfig()) {
console.log('❌ No harbor configuration found');
console.log('\nTo initialize a new Harbor project, please use:');
console.log(' harbor dock');
process.exit(1);
}
await generateDevFile(options.path);
});
program.command('anchor')
.description(`Add new services to your Caddyfile
Note: This command will stop any active Caddy processes, including those from other Harbor projects.`)
.action(async () => {
if (!checkHasHarborConfig()) {
console.log('❌ No harbor configuration found');
console.log('\nTo initialize a new Harbor project, please use:');
console.log(' harbor dock');
process.exit(1);
}
await generateCaddyFile();
});
program.command('launch')
.description(`Launch your services in the harbor terminal multiplexer (Using tmux)
Note: This command will stop any active Caddy processes, including those from other Harbor projects.`)
.action(async () => {
await runServices();
});
program.parse();
function fileExists(path) {
return fs.existsSync(`${process.cwd()}/${path}`);
}
function isProjectDirectory(dirPath) {
return possibleProjectFiles.some(file => {
try {
return fs.existsSync(path.join(process.cwd(), dirPath, file));
}
catch {
return false;
}
});
}
function validateConfig(config) {
if (!config.domain) {
return 'Domain is required';
}
if (!Array.isArray(config.services)) {
return 'Services must be an array';
}
for (const service of config.services) {
if (!service.name) {
return 'Service name is required';
}
if (!service.path) {
return 'Service path is required';
}
}
return null;
}
async function generateDevFile(dirPath) {
let config;
let writeToPackageJson = false;
try {
// First try to read from harbor.json
try {
const existing = await fs.promises.readFile('harbor.json', 'utf-8');
config = JSON.parse(existing);
console.log('Found existing harbor.json, scanning for new services...');
}
catch (err) {
if (err.code !== 'ENOENT') {
throw new Error(`Error reading harbor.json: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
// If harbor.json doesn't exist, try package.json
try {
const packageData = await fs.promises.readFile('package.json', 'utf-8');
const packageJson = JSON.parse(packageData);
if (packageJson.harbor) {
config = packageJson.harbor;
writeToPackageJson = true;
console.log('Found existing harbor config in package.json, scanning for new services...');
}
else if (fileExists('package.json')) {
// If package.json exists but no harbor config, use it
writeToPackageJson = true;
config = {
domain: 'localhost',
useSudo: false,
services: [],
};
console.log('Creating new harbor config in package.json...');
}
else {
// No package.json, create harbor.json
config = {
domain: 'localhost',
useSudo: false,
services: [],
};
console.log('Creating new harbor.json...');
}
}
catch (err) {
if (err.code !== 'ENOENT') {
throw new Error(`Error reading package.json: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
// No package.json, create harbor.json
config = {
domain: 'localhost',
useSudo: false,
services: [],
};
console.log('Creating new harbor.json...');
}
}
// Create a map of existing services for easy lookup
const existing = new Set(config.services.map(s => s.name));
let newServicesAdded = false;
const folders = await fs.promises.readdir(dirPath, { withFileTypes: true });
for (const folder of folders) {
if (folder.isDirectory()) {
const folderPath = path.join(dirPath, folder.name);
// Only add directories that contain project files and aren't already in config
if (isProjectDirectory(folderPath) && !existing.has(folder.name)) {
const service = {
name: folder.name,
path: folderPath,
subdomain: folder.name.toLowerCase().replace(/\s+/g, ''),
};
// Try to determine default command based on project type
if (fs.existsSync(path.join(folderPath, 'package.json'))) {
service.command = 'npm run dev';
}
else if (fs.existsSync(path.join(folderPath, 'go.mod'))) {
service.command = 'go run .';
}
config.services.push(service);
console.log(`Added new service: ${folder.name}`);
newServicesAdded = true;
}
else if (existing.has(folder.name)) {
console.log(`Skipping existing service: ${folder.name}`);
}
else {
console.log(`Skipping directory ${folder.name} (no recognized project files)`);
}
}
}
if (!newServicesAdded) {
console.log('No new services found to add, feel free to add them manually');
// Still write the initial config even if no services were found
const validationError = validateConfig(config);
if (validationError) {
throw new Error(`Invalid harbor configuration: ${validationError}`);
}
if (writeToPackageJson) {
// Update package.json
const packageData = await fs.promises.readFile('package.json', 'utf-8');
const packageJson = JSON.parse(packageData);
packageJson.harbor = config;
await fs.promises.writeFile('package.json', JSON.stringify(packageJson, null, 2), 'utf-8');
console.log('\npackage.json updated successfully with harbor configuration');
}
else {
// Write to harbor.json
await fs.promises.writeFile('harbor.json', JSON.stringify(config, null, 2), 'utf-8');
console.log('\nharbor.json created successfully');
}
return false;
}
const validationError = validateConfig(config);
if (validationError) {
throw new Error(`Invalid harbor configuration: ${validationError}`);
}
if (writeToPackageJson) {
// Update package.json
const packageData = await fs.promises.readFile('package.json', 'utf-8');
const packageJson = JSON.parse(packageData);
packageJson.harbor = config;
await fs.promises.writeFile('package.json', JSON.stringify(packageJson, null, 2), 'utf-8');
console.log('\npackage.json updated successfully with harbor configuration');
}
else {
// Write to harbor.json
await fs.promises.writeFile('harbor.json', JSON.stringify(config, null, 2), 'utf-8');
console.log('\nharbor.json created successfully');
}
console.log('\nImportant:');
console.log(' - Update the \'Port\' field for each service to match its actual port or leave blank to ignore in the Caddyfile');
console.log(' - Verify the auto-detected commands are correct for your services');
return true;
}
catch (err) {
throw new Error(`Error processing directory: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
async function readHarborConfig() {
// First try to read from harbor.json
try {
const data = await fs.promises.readFile('harbor.json', 'utf-8');
const config = JSON.parse(data);
const validationError = validateConfig(config);
if (validationError) {
throw new Error(`Invalid configuration in harbor.json: ${validationError}`);
}
return config;
}
catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
// If harbor.json doesn't exist, try package.json
try {
const packageData = await fs.promises.readFile('package.json', 'utf-8');
const packageJson = JSON.parse(packageData);
if (packageJson.harbor) {
const config = packageJson.harbor;
const validationError = validateConfig(config);
if (validationError) {
throw new Error(`Invalid configuration in package.json harbor field: ${validationError}`);
}
return config;
}
}
catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
throw new Error('No harbor configuration found in harbor.json or package.json');
}
async function stopCaddy() {
try {
console.log('\n⚠️ Stopping any existing Caddy processes...');
console.log(' This will interrupt any active Harbor or Caddy services\n');
// Try to kill any existing Caddy processes
await new Promise((resolve) => {
const isWindows = process.platform === 'win32';
const killCommand = isWindows ? 'taskkill /F /IM caddy.exe' : 'pkill caddy';
const childProcess = spawn('sh', ['-c', killCommand]);
childProcess.on('close', () => {
// It's okay if there was no process to kill (code 1)
resolve();
});
});
// Give it a moment to fully release ports
await new Promise(resolve => setTimeout(resolve, 1000));
}
catch (err) {
// Ignore errors as the process might not exist
}
}
async function generateCaddyFile() {
try {
const config = await readHarborConfig();
let caddyfileContent = '';
// Check if any services need a Caddyfile
const needsCaddyfile = config.services.some(svc => svc.port && svc.subdomain);
if (!needsCaddyfile) {
// If no services need a Caddyfile, remove it if it exists
if (fileExists('Caddyfile')) {
await fs.promises.unlink('Caddyfile');
console.log('Removed Caddyfile as no services require subdomains');
}
return;
}
for (const svc of config.services) {
if (!svc.port || !svc.subdomain) {
continue;
}
const serverName = `${svc.subdomain}.${config.domain}`;
caddyfileContent += `${serverName} {\n`;
caddyfileContent += ` reverse_proxy localhost:${svc.port}\n`;
caddyfileContent += "}\n\n";
}
await fs.promises.writeFile('Caddyfile', caddyfileContent, 'utf-8');
// Stop existing Caddy process before proceeding
await stopCaddy();
console.log('Caddyfile generated successfully');
}
catch (err) {
console.error('Error generating Caddyfile:', err);
process.exit(1);
}
}
async function runServices() {
const hasHarborConfig = checkHasHarborConfig();
if (!hasHarborConfig) {
console.log('❌ No harbor configuration found');
console.log('\nTo initialize a new Harbor project, please use:');
console.log(' harbor dock');
process.exit(1);
}
// Load and validate config
let config;
try {
config = await readHarborConfig();
const validationError = validateConfig(config);
if (validationError) {
console.log(`❌ Invalid harbor.json configuration: ${validationError}`);
process.exit(1);
}
}
catch (err) {
console.error('Error reading config:', err);
process.exit(1);
}
// Check if any services need a Caddyfile
const needsCaddyfile = config.services.some(svc => svc.port && svc.subdomain);
if (needsCaddyfile && !fileExists('Caddyfile')) {
console.log('❌ No Caddyfile found, but some services require subdomains');
console.log('\nTo generate the Caddyfile:');
console.log(' harbor anchor');
process.exit(1);
}
// Stop any existing Caddy process if we need it
if (needsCaddyfile) {
await stopCaddy();
}
// Ensure scripts exist and are executable
await ensureScriptsExist();
// Execute the script directly using spawn to handle I/O streams
const scriptPath = path.join(getScriptsDir(), 'dev.sh');
const command = spawn('bash', [scriptPath], {
stdio: 'inherit', // This will pipe stdin/stdout/stderr to the parent process
});
return new Promise((resolve, reject) => {
command.on('error', (err) => {
console.error(`Error running dev.sh: ${err}`);
process.exit(1);
});
command.on('close', (code) => {
if (code !== 0) {
console.error(`dev.sh exited with code ${code}`);
process.exit(1);
}
resolve();
});
});
}
// Get the package root directory
function getPackageRoot() {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
return path.join(__dirname, '..');
}
// Get the template scripts directory (where our source scripts live)
function getTemplateScriptsDir() {
return path.join(getPackageRoot(), 'scripts');
}
// Get the scripts directory (where we'll create the scripts for the user)
function getScriptsDir() {
return path.join(getPackageRoot(), 'scripts');
}
async function ensureScriptsExist() {
const scriptsDir = getScriptsDir();
const templateDir = getTemplateScriptsDir();
// Ensure scripts directory exists
if (!fs.existsSync(scriptsDir)) {
fs.mkdirSync(scriptsDir, { recursive: true });
}
try {
const scriptPath = path.join(scriptsDir, 'dev.sh');
const templatePath = path.join(templateDir, 'dev.sh');
// Create the script if it doesn't exist
if (!fs.existsSync(scriptPath)) {
const templateContent = readFileSync(templatePath, 'utf-8');
fs.writeFileSync(scriptPath, templateContent, 'utf-8');
console.log('Created dev.sh');
}
// Make the script executable
chmodSync(scriptPath, '755');
}
catch (err) {
console.error('Error setting up dev.sh:', err);
throw err;
}
}
function checkHasHarborConfig() {
// Check for harbor.json
if (fileExists('harbor.json')) {
return true;
}
// Check for harbor config in package.json
try {
const packageData = fs.readFileSync(`${process.cwd()}/package.json`, 'utf-8');
const packageJson = JSON.parse(packageData);
return !!packageJson.harbor;
}
catch {
return false;
}
}