@abyrd9/harbor-cli 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +246 -239
  2. package/package.json +3 -2
  3. package/scripts/dev.sh +50 -45
package/dist/index.js CHANGED
@@ -8,6 +8,20 @@ import { readFileSync } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
10
10
  import readline from 'node:readline';
11
+ import pc from 'picocolors';
12
+ // Colored output helpers
13
+ const log = {
14
+ error: (msg) => console.log(`${pc.red('✗')} ${msg}`),
15
+ success: (msg) => console.log(`${pc.green('✓')} ${msg}`),
16
+ info: (msg) => console.log(`${pc.blue('ℹ')} ${msg}`),
17
+ warn: (msg) => console.log(`${pc.yellow('⚠')} ${msg}`),
18
+ step: (msg) => console.log(`${pc.cyan('→')} ${msg}`),
19
+ dim: (msg) => console.log(pc.dim(msg)),
20
+ plain: (msg) => console.log(msg),
21
+ header: (msg) => console.log(`\n${pc.bold(msg)}`),
22
+ cmd: (msg) => console.log(` ${pc.dim('$')} ${pc.cyan(msg)}`),
23
+ label: (label, value) => console.log(` ${pc.dim(label)} ${value}`),
24
+ };
11
25
  // Read version from package.json
12
26
  const __filename = fileURLToPath(import.meta.url);
13
27
  const __dirname = path.dirname(__filename);
@@ -144,20 +158,20 @@ async function checkDependencies() {
144
158
  }
145
159
  }
146
160
  if (missingDeps.length > 0) {
147
- console.log('Missing required dependencies:');
148
- console.log(`\n🖥️ Detected OS: ${osInfo.platform} ${osInfo.arch}${osInfo.isWSL ? ' (WSL)' : ''}`);
161
+ log.error('Missing required dependencies');
162
+ log.plain(`\n${pc.dim('Detected OS:')} ${osInfo.platform} ${osInfo.arch}${osInfo.isWSL ? ' (WSL)' : ''}`);
149
163
  for (const dep of missingDeps) {
150
- console.log(`\n📦 ${dep.name} (required for ${dep.requiredFor})`);
164
+ log.plain(`\n${pc.yellow(dep.name)} ${pc.dim(`(required for ${dep.requiredFor})`)}`);
151
165
  const instructions = getInstallInstructions(dep.name, osInfo);
152
166
  if (instructions.length > 0) {
153
- console.log(' Installation options:');
154
- instructions.forEach(instruction => { console.log(instruction); });
167
+ log.plain(pc.dim(' Installation options:'));
168
+ instructions.forEach(instruction => { log.plain(instruction); });
155
169
  }
156
170
  else {
157
- console.log(` General instructions: ${dep.installMsg}`);
171
+ log.plain(` ${pc.dim('Instructions:')} ${dep.installMsg}`);
158
172
  }
159
173
  }
160
- console.log('\n💡 After installing the dependencies, run Harbor again.');
174
+ log.plain(`\n${pc.dim('After installing dependencies, run Harbor again.')}`);
161
175
  throw new Error('Please install missing dependencies before continuing');
162
176
  }
163
177
  }
@@ -167,11 +181,11 @@ function promptConfigLocation() {
167
181
  output: process.stdout,
168
182
  });
169
183
  return new Promise((resolve) => {
170
- console.log('\nFound package.json. Where would you like to store harbor config?');
171
- console.log(' 1. package.json (keeps everything in one place)');
172
- console.log(' 2. harbor.json (separate config file, auto-IntelliSense)\n');
184
+ log.plain(`\n${pc.bold('Found package.json.')} Where would you like to store harbor config?`);
185
+ log.plain(` ${pc.cyan('1.')} package.json ${pc.dim('(keeps everything in one place)')}`);
186
+ log.plain(` ${pc.cyan('2.')} harbor.json ${pc.dim('(separate config file, auto-IntelliSense)')}\n`);
173
187
  const ask = () => {
174
- rl.question('Enter choice (1 or 2): ', (answer) => {
188
+ rl.question(`Enter choice ${pc.dim('(1 or 2)')}: `, (answer) => {
175
189
  const choice = answer.trim();
176
190
  if (choice === '1') {
177
191
  rl.close();
@@ -182,7 +196,7 @@ function promptConfigLocation() {
182
196
  resolve('harbor.json');
183
197
  }
184
198
  else {
185
- console.log('Please enter 1 or 2');
199
+ log.warn('Please enter 1 or 2');
186
200
  ask();
187
201
  }
188
202
  });
@@ -201,219 +215,208 @@ const possibleProjectFiles = [
201
215
  'build.gradle', // Java Gradle projects
202
216
  ];
203
217
  const program = new Command();
204
- program
205
- .name('harbor')
206
- .description(`A CLI tool for orchestrating multiple local development services in tmux.
218
+ // Custom help formatting
219
+ function showCustomHelp() {
220
+ const dim = pc.dim;
221
+ const bold = pc.bold;
222
+ const cyan = pc.cyan;
223
+ const yellow = pc.yellow;
224
+ const green = pc.green;
225
+ console.log(`
226
+ ${bold('⚓ Harbor')} ${dim(`v${packageJson.version}`)}
227
+ ${dim('Orchestrate local dev services in tmux')}
207
228
 
208
- WHAT IT DOES:
209
- Harbor manages multiple services (APIs, frontends, databases) in a single tmux
210
- session. It auto-discovers projects, starts them together, and provides logging.
229
+ ${yellow('Usage:')}
230
+ ${dim('$')} harbor ${cyan('<command>')} ${dim('[options]')}
211
231
 
212
- REQUIREMENTS:
213
- - tmux (terminal multiplexer)
214
- - jq (JSON processor)
232
+ ${yellow('Commands:')}
233
+ ${green('dock')} Scan directories and create harbor.json config
234
+ ${green('moor')} Add new services to existing config
235
+ ${green('launch')} Start all services in tmux ${dim('(-d for headless)')}
236
+ ${green('anchor')} Attach to a running Harbor session
237
+ ${green('scuttle')} Stop all services
238
+ ${green('bearings')} Show status of running services
215
239
 
216
- TYPICAL WORKFLOW:
217
- 1. harbor dock # First time: scan and create harbor.json config
218
- 2. harbor launch # Start all services in interactive tmux session
219
- harbor launch -d # Or start in headless/background mode
220
- 3. harbor bearings # Check status of running services
221
- 4. harbor anchor # Attach to running session (if headless)
222
- 5. harbor scuttle # Stop all services when done
240
+ ${yellow('Quick Start:')}
241
+ ${dim('$')} harbor dock ${dim('# Create config')}
242
+ ${dim('$')} harbor launch ${dim('# Start services')}
243
+ ${dim('$')} harbor launch -d ${dim('# Start headless')}
223
244
 
224
- CONFIGURATION:
225
- Config is stored in harbor.json or package.json under "harbor" key.
226
- Services can specify: name, path, command, log (boolean), maxLogLines.
245
+ ${yellow('Options:')}
246
+ ${cyan('-V, --version')} Show version
247
+ ${cyan('-h, --help')} Show help for command
227
248
 
228
- COMMANDS:
229
- dock Create new harbor.json by scanning for projects (package.json, go.mod, etc.)
230
- moor Add newly discovered services to existing config
231
- launch Start all configured services (use -d/--headless for background mode)
232
- bearings Show status: running services, session info, log file locations
233
- anchor Attach terminal to a running Harbor tmux session
234
- scuttle Stop all services by killing the tmux session`)
249
+ ${dim('Run')} harbor ${cyan('<command>')} --help ${dim('for detailed command info')}
250
+ `);
251
+ }
252
+ program
253
+ .name('harbor')
254
+ .description('Orchestrate local dev services in tmux')
235
255
  .version(packageJson.version)
236
256
  .action(async () => await checkDependencies())
237
- .addHelpCommand(false);
238
- // If no command is provided, display help
257
+ .addHelpCommand(false)
258
+ .configureHelp({
259
+ sortSubcommands: false,
260
+ sortOptions: false,
261
+ });
262
+ // Override help display
263
+ program.helpInformation = () => '';
264
+ program.on('--help', () => { });
265
+ // If no command is provided, display custom help
239
266
  if (process.argv.length <= 2) {
240
- program.help();
267
+ showCustomHelp();
268
+ process.exit(0);
269
+ }
270
+ // Handle -h and --help for main command
271
+ if (process.argv.includes('-h') || process.argv.includes('--help')) {
272
+ if (process.argv.length === 3) {
273
+ showCustomHelp();
274
+ process.exit(0);
275
+ }
241
276
  }
242
277
  program.command('dock')
243
- .description(`Initialize a new Harbor project by scanning directories for services.
244
-
245
- WHEN TO USE: Run this first in a new project that has no harbor.json yet.
246
-
247
- WHAT IT DOES:
248
- - Scans subdirectories for project files (package.json, go.mod, Cargo.toml, etc.)
249
- - Creates harbor.json with discovered services
250
- - Auto-detects run commands (npm run dev, go run ., etc.)
251
-
252
- PREREQUISITES: No existing harbor.json or harbor config in package.json.
253
-
254
- EXAMPLE:
255
- harbor dock # Scan current directory
256
- harbor dock -p ./apps # Scan specific subdirectory`)
257
- .option('-p, --path <path>', 'Directory to scan for service projects (scans subdirectories)', './')
278
+ .description('Scan directories and create harbor.json config')
279
+ .option('-p, --path <path>', 'Directory to scan for service projects', './')
258
280
  .action(async (options) => {
259
281
  try {
260
282
  await checkDependencies();
261
283
  const configExists = checkHasHarborConfig();
262
284
  if (configExists) {
263
- console.log('❌ Error: Harbor project already initialized');
264
- console.log(' - Harbor configuration already exists');
265
- console.log('\nTo reinitialize, please remove the configuration first.');
285
+ log.error('Harbor project already initialized');
286
+ log.dim(' Configuration already exists');
287
+ log.plain('');
288
+ log.info('To reinitialize, remove the existing configuration first.');
266
289
  process.exit(1);
267
290
  }
268
291
  await generateDevFile(options.path);
269
- console.log('✨ Environment prepared!');
292
+ log.plain('');
293
+ log.success(pc.green('Environment prepared!'));
270
294
  }
271
295
  catch (err) {
272
- console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
296
+ log.error(err instanceof Error ? err.message : 'Unknown error');
273
297
  process.exit(1);
274
298
  }
275
299
  });
276
300
  program.command('moor')
277
- .description(`Add newly discovered services to an existing Harbor configuration.
278
-
279
- WHEN TO USE: Run when you've added new service directories to your project.
280
-
281
- WHAT IT DOES:
282
- - Scans for new project directories not already in config
283
- - Adds them to existing harbor.json or package.json harbor config
284
- - Skips directories already configured
285
-
286
- PREREQUISITES: Existing harbor.json or harbor config in package.json (run 'dock' first).
287
-
288
- EXAMPLE:
289
- harbor moor # Scan and add new services
290
- harbor moor -p ./apps # Scan specific subdirectory for new services`)
301
+ .description('Add new services to existing config')
291
302
  .option('-p, --path <path>', 'Directory to scan for new service projects', './')
292
303
  .action(async (options) => {
293
304
  try {
294
305
  await checkDependencies();
295
306
  if (!checkHasHarborConfig()) {
296
- console.log('No harbor configuration found');
297
- console.log('\nTo initialize a new Harbor project, please use:');
298
- console.log(' harbor dock');
307
+ log.error('No harbor configuration found');
308
+ log.plain('');
309
+ log.info('To initialize a new Harbor project:');
310
+ log.cmd('harbor dock');
299
311
  process.exit(1);
300
312
  }
301
313
  await generateDevFile(options.path);
302
314
  }
303
315
  catch (err) {
304
- console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
316
+ log.error(err instanceof Error ? err.message : 'Unknown error');
305
317
  process.exit(1);
306
318
  }
307
319
  });
308
320
  program.command('launch')
309
- .description(`Start all configured services in a tmux session.
310
-
311
- WHEN TO USE: Run to start your development environment.
312
-
313
- WHAT IT DOES:
314
- - Kills any existing Harbor tmux session
315
- - Runs 'before' scripts if configured
316
- - Creates tmux session with a window per service
317
- - Starts each service with its configured command
318
- - Enables logging to .harbor/*.log if log:true in config
319
- - Runs 'after' scripts when session ends
320
-
321
- MODES:
322
- Interactive (default): Opens tmux session, use Shift+Left/Right to switch tabs
323
- Headless (-d/--headless): Runs in background, use 'anchor' to attach later
324
-
325
- PREREQUISITES: harbor.json or harbor config in package.json.
326
-
327
- EXAMPLES:
328
- harbor launch # Start and attach to tmux session
329
- harbor launch -d # Start in background (headless mode)
330
- harbor launch --headless # Same as -d
331
- harbor launch --name my-session # Use custom session name`)
332
- .option('-d, --detach', 'Run in background without attaching (headless mode). Use "anchor" to attach later.')
321
+ .description('Start all services in tmux (-d for headless)')
322
+ .option('-d, --detach', 'Run in background (headless mode)')
333
323
  .option('--headless', 'Alias for --detach')
334
- .option('--name <name>', 'Override tmux session name from config')
324
+ .option('--name <name>', 'Override tmux session name')
335
325
  .action(async (options) => {
336
326
  try {
327
+ const isDetached = options.detach || options.headless;
328
+ // Check if already inside a tmux session (only matters for attached mode)
329
+ if (!isDetached && process.env.TMUX) {
330
+ log.error('Cannot launch in attached mode from inside a tmux session');
331
+ log.plain('');
332
+ log.info('Options:');
333
+ log.plain(` ${pc.cyan('1.')} Use headless mode: ${pc.cyan('harbor launch -d')}`);
334
+ log.plain(` ${pc.cyan('2.')} Detach from current session ${pc.dim('(Ctrl+b then d)')} and try again`);
335
+ process.exit(1);
336
+ }
337
337
  await checkDependencies();
338
- await runServices({ detach: options.detach || options.headless, name: options.name });
338
+ await runServices({ detach: isDetached, name: options.name });
339
339
  }
340
340
  catch (err) {
341
- console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
341
+ log.error(err instanceof Error ? err.message : 'Unknown error');
342
342
  process.exit(1);
343
343
  }
344
344
  });
345
345
  program.command('anchor')
346
- .description(`Attach your terminal to a running Harbor tmux session.
347
-
348
- WHEN TO USE: After starting services with 'launch -d' (headless mode).
349
-
350
- WHAT IT DOES:
351
- - Checks if a Harbor tmux session is running
352
- - Attaches your terminal to it
353
- - You can then switch between service tabs with Shift+Left/Right
354
- - Press Ctrl+q to kill session, or detach with Ctrl+b then d
355
-
356
- PREREQUISITES: Services must be running (started with 'harbor launch').
357
-
358
- EXAMPLES:
359
- harbor launch -d # Start in background
360
- harbor anchor # Attach to default session
361
- harbor anchor --name my-app # Attach to a specific named session`)
362
- .option('--name <name>', 'Specify which tmux session to attach to (defaults to config sessionName or "harbor")')
346
+ .description('Attach to a running Harbor session')
347
+ .option('--name <name>', 'Session name to attach to')
363
348
  .action(async (options) => {
364
349
  try {
350
+ // Check if already inside a tmux session
351
+ if (process.env.TMUX) {
352
+ log.error('Cannot anchor from inside a tmux session');
353
+ log.plain('');
354
+ log.info('You are already inside a tmux session. To attach:');
355
+ log.plain(` ${pc.cyan('1.')} Detach from current session ${pc.dim('(Ctrl+b then d)')}`);
356
+ log.plain(` ${pc.cyan('2.')} Run ${pc.cyan('harbor anchor')} from a regular terminal`);
357
+ process.exit(1);
358
+ }
365
359
  const config = await readHarborConfig();
366
360
  const sessionName = options.name || config.sessionName || 'harbor';
361
+ const socketName = `harbor-${sessionName}`;
367
362
  // Check if session exists
368
- const checkSession = spawn('tmux', ['has-session', '-t', sessionName], {
363
+ const checkSession = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], {
369
364
  stdio: 'pipe',
370
365
  });
371
366
  await new Promise((resolve) => {
372
367
  checkSession.on('close', (code) => {
373
368
  if (code !== 0) {
374
- console.log(`❌ No running Harbor session found (looking for: ${sessionName})`);
375
- console.log('\nTo start services, run:');
376
- console.log(' harbor launch');
369
+ log.error(`No running Harbor session found ${pc.dim(`(looking for: ${sessionName})`)}`);
370
+ log.plain('');
371
+ log.info('To start services:');
372
+ log.cmd('harbor launch');
377
373
  process.exit(1);
378
374
  }
379
375
  resolve();
380
376
  });
381
377
  });
382
378
  // Attach to the session
383
- const attach = spawn('tmux', ['attach-session', '-t', sessionName], {
379
+ const attach = spawn('tmux', ['-L', socketName, 'attach-session', '-t', sessionName], {
384
380
  stdio: 'inherit',
385
381
  });
386
- attach.on('close', (code) => {
382
+ attach.on('close', async (code) => {
383
+ // Check if session was killed (vs just detached)
384
+ const checkAfter = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], {
385
+ stdio: 'pipe',
386
+ });
387
+ const sessionStillExists = await new Promise((resolve) => {
388
+ checkAfter.on('close', (checkCode) => {
389
+ resolve(checkCode === 0);
390
+ });
391
+ });
392
+ // If session no longer exists, it was killed - run after scripts
393
+ if (!sessionStillExists && config.after && config.after.length > 0) {
394
+ try {
395
+ await execute(config.after, 'after');
396
+ }
397
+ catch {
398
+ log.error('After scripts failed');
399
+ process.exit(1);
400
+ }
401
+ }
387
402
  process.exit(code || 0);
388
403
  });
389
404
  }
390
405
  catch (err) {
391
- console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
406
+ log.error(err instanceof Error ? err.message : 'Unknown error');
392
407
  process.exit(1);
393
408
  }
394
409
  });
395
410
  program.command('scuttle')
396
- .description(`Stop all running Harbor services by killing the tmux session.
397
-
398
- WHEN TO USE: When you want to stop all services and free up resources.
399
-
400
- WHAT IT DOES:
401
- - Finds the running Harbor tmux session
402
- - Kills the entire session (all service windows)
403
- - All services stop immediately
404
-
405
- SAFE TO RUN: If no session is running, it simply reports that and exits cleanly.
406
-
407
- EXAMPLES:
408
- harbor scuttle # Stop default session
409
- harbor scuttle --name my-app # Stop a specific named session`)
410
- .option('--name <name>', 'Specify which tmux session to stop (defaults to config sessionName or "harbor")')
411
+ .description('Stop all services and kill the session')
412
+ .option('--name <name>', 'Session name to stop')
411
413
  .action(async (options) => {
412
414
  try {
413
415
  const config = await readHarborConfig();
414
416
  const sessionName = options.name || config.sessionName || 'harbor';
417
+ const socketName = `harbor-${sessionName}`;
415
418
  // Check if session exists
416
- const checkSession = spawn('tmux', ['has-session', '-t', sessionName], {
419
+ const checkSession = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], {
417
420
  stdio: 'pipe',
418
421
  });
419
422
  const sessionExists = await new Promise((resolve) => {
@@ -422,58 +425,48 @@ EXAMPLES:
422
425
  });
423
426
  });
424
427
  if (!sessionExists) {
425
- console.log(`ℹ️ No running Harbor session found (looking for: ${sessionName})`);
428
+ log.info(`No running Harbor session found ${pc.dim(`(looking for: ${sessionName})`)}`);
426
429
  process.exit(0);
427
430
  }
428
431
  // Kill the session
429
- const killSession = spawn('tmux', ['kill-session', '-t', sessionName], {
432
+ const killSession = spawn('tmux', ['-L', socketName, 'kill-session', '-t', sessionName], {
430
433
  stdio: 'inherit',
431
434
  });
432
- killSession.on('close', (code) => {
435
+ killSession.on('close', async (code) => {
433
436
  if (code === 0) {
434
- console.log(`✅ Harbor session '${sessionName}' stopped`);
437
+ log.success(`Harbor session ${pc.cyan(sessionName)} stopped`);
438
+ // Execute after scripts when session is killed
439
+ if (config.after && config.after.length > 0) {
440
+ try {
441
+ await execute(config.after, 'after');
442
+ }
443
+ catch {
444
+ log.error('After scripts failed');
445
+ process.exit(1);
446
+ }
447
+ }
435
448
  }
436
449
  else {
437
- console.log('Failed to stop Harbor session');
450
+ log.error('Failed to stop Harbor session');
438
451
  }
439
452
  process.exit(code || 0);
440
453
  });
441
454
  }
442
455
  catch (err) {
443
- console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
456
+ log.error(err instanceof Error ? err.message : 'Unknown error');
444
457
  process.exit(1);
445
458
  }
446
459
  });
447
460
  program.command('bearings')
448
- .description(`Show status of running Harbor services and session information.
449
-
450
- WHEN TO USE: To check if services are running, especially in headless mode.
451
-
452
- WHAT IT SHOWS:
453
- - Session name and running status
454
- - List of service windows (with 📄 indicator if logging enabled)
455
- - Log file paths and sizes
456
- - Available commands (anchor, scuttle)
457
-
458
- OUTPUT EXAMPLE:
459
- ⚓ Harbor Status
460
- Session: harbor
461
- Status: Running ✓
462
- Services: [0] Terminal, [1] web 📄, [2] api 📄
463
- Logs: .harbor/harbor-web.log (1.2 KB)
464
-
465
- SAFE TO RUN: Works whether services are running or not.
466
-
467
- EXAMPLES:
468
- harbor bearings # Check default session
469
- harbor bearings --name my-app # Check a specific named session`)
470
- .option('--name <name>', 'Specify which tmux session to check (defaults to config sessionName or "harbor")')
461
+ .description('Show status of running services')
462
+ .option('--name <name>', 'Session name to check')
471
463
  .action(async (options) => {
472
464
  try {
473
465
  const config = await readHarborConfig();
474
466
  const sessionName = options.name || config.sessionName || 'harbor';
467
+ const socketName = `harbor-${sessionName}`;
475
468
  // Check if session exists
476
- const checkSession = spawn('tmux', ['has-session', '-t', sessionName], {
469
+ const checkSession = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], {
477
470
  stdio: 'pipe',
478
471
  });
479
472
  const sessionExists = await new Promise((resolve) => {
@@ -482,16 +475,19 @@ EXAMPLES:
482
475
  });
483
476
  });
484
477
  if (!sessionExists) {
485
- console.log(`\n⚓ Harbor Status\n`);
486
- console.log(` Session: ${sessionName}`);
487
- console.log(` Status: Not running\n`);
488
- console.log(` To start services, run:`);
489
- console.log(` harbor launch # interactive mode`);
490
- console.log(` harbor launch -d # headless mode\n`);
478
+ log.header(`${pc.cyan('')} Harbor Status`);
479
+ log.plain('');
480
+ log.label('Session:', sessionName);
481
+ log.label('Status:', pc.yellow('Not running'));
482
+ log.plain('');
483
+ log.info('To start services:');
484
+ log.cmd(`harbor launch ${pc.dim('# interactive mode')}`);
485
+ log.cmd(`harbor launch -d ${pc.dim('# headless mode')}`);
486
+ log.plain('');
491
487
  process.exit(0);
492
488
  }
493
489
  // Get list of windows (services)
494
- const listWindows = spawn('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_index}|#{window_name}|#{pane_current_command}'], {
490
+ const listWindows = spawn('tmux', ['-L', socketName, 'list-windows', '-t', sessionName, '-F', '#{window_index}|#{window_name}|#{pane_current_command}'], {
495
491
  stdio: ['pipe', 'pipe', 'pipe'],
496
492
  });
497
493
  let windowOutput = '';
@@ -502,38 +498,43 @@ EXAMPLES:
502
498
  listWindows.on('close', () => resolve());
503
499
  });
504
500
  const windows = windowOutput.trim().split('\n').filter(Boolean);
505
- console.log(`\n⚓ Harbor Status\n`);
506
- console.log(` Session: ${sessionName}`);
507
- console.log(` Status: Running ✓`);
508
- console.log(` Windows: ${windows.length}\n`);
509
- console.log(` Services:`);
501
+ log.header(`${pc.cyan('')} Harbor Status`);
502
+ log.plain('');
503
+ log.label('Session:', sessionName);
504
+ log.label('Status:', pc.green('Running ✓'));
505
+ log.label('Windows:', String(windows.length));
506
+ log.plain('');
507
+ log.plain(` ${pc.dim('Services:')}`);
510
508
  for (const window of windows) {
511
- const [index, name, cmd] = window.split('|');
509
+ const [index, name] = window.split('|');
512
510
  const logFile = `.harbor/${sessionName}-${name}.log`;
513
511
  const hasLog = fs.existsSync(path.join(process.cwd(), logFile));
514
- const logIndicator = hasLog ? ` 📄` : '';
515
- console.log(` [${index}] ${name}${logIndicator}`);
512
+ const logIndicator = hasLog ? pc.dim(' 📄') : '';
513
+ log.plain(` ${pc.dim(`[${index}]`)} ${pc.cyan(name)}${logIndicator}`);
516
514
  }
517
515
  // Check for log files
518
516
  const harborDir = path.join(process.cwd(), '.harbor');
519
517
  if (fs.existsSync(harborDir)) {
520
518
  const logFiles = fs.readdirSync(harborDir).filter(f => f.endsWith('.log'));
521
519
  if (logFiles.length > 0) {
522
- console.log(`\n Logs:`);
520
+ log.plain('');
521
+ log.plain(` ${pc.dim('Logs:')}`);
523
522
  for (const logFile of logFiles) {
524
523
  const logPath = path.join(harborDir, logFile);
525
524
  const stats = fs.statSync(logPath);
526
525
  const sizeKB = (stats.size / 1024).toFixed(1);
527
- console.log(` .harbor/${logFile} (${sizeKB} KB)`);
526
+ log.plain(` ${pc.dim(`.harbor/${logFile}`)} ${pc.dim(`(${sizeKB} KB)`)}`);
528
527
  }
529
528
  }
530
529
  }
531
- console.log(`\n Commands:`);
532
- console.log(` harbor anchor - Anchor to the session`);
533
- console.log(` harbor scuttle - Stop all services\n`);
530
+ log.plain('');
531
+ log.plain(` ${pc.dim('Commands:')}`);
532
+ log.plain(` ${pc.cyan('harbor anchor')} ${pc.dim('Attach to the session')}`);
533
+ log.plain(` ${pc.cyan('harbor scuttle')} ${pc.dim('Stop all services')}`);
534
+ log.plain('');
534
535
  }
535
536
  catch (err) {
536
- console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
537
+ log.error(err instanceof Error ? err.message : 'Unknown error');
537
538
  process.exit(1);
538
539
  }
539
540
  });
@@ -603,7 +604,7 @@ async function generateDevFile(dirPath) {
603
604
  try {
604
605
  const existing = await fs.promises.readFile('harbor.json', 'utf-8');
605
606
  config = JSON.parse(existing);
606
- console.log('Found existing harbor.json, scanning for new services...');
607
+ log.info('Found existing harbor.json, scanning for new services...');
607
608
  }
608
609
  catch (err) {
609
610
  if (err.code !== 'ENOENT') {
@@ -617,7 +618,7 @@ async function generateDevFile(dirPath) {
617
618
  // Existing harbor config in package.json, use it
618
619
  config = packageJson.harbor;
619
620
  writeToPackageJson = true;
620
- console.log('Found existing harbor config in package.json, scanning for new services...');
621
+ log.info('Found existing harbor config in package.json, scanning for new services...');
621
622
  }
622
623
  else {
623
624
  // package.json exists but no harbor config - ask user where to store it
@@ -628,7 +629,7 @@ async function generateDevFile(dirPath) {
628
629
  before: [],
629
630
  after: [],
630
631
  };
631
- console.log(`Creating new harbor config in ${choice}...`);
632
+ log.step(`Creating new harbor config in ${pc.cyan(choice)}...`);
632
633
  }
633
634
  }
634
635
  catch (err) {
@@ -639,7 +640,7 @@ async function generateDevFile(dirPath) {
639
640
  config = {
640
641
  services: [],
641
642
  };
642
- console.log('Creating new harbor.json...');
643
+ log.step(`Creating new ${pc.cyan('harbor.json')}...`);
643
644
  }
644
645
  }
645
646
  // Create a map of existing services for easy lookup
@@ -663,19 +664,19 @@ async function generateDevFile(dirPath) {
663
664
  service.command = 'go run .';
664
665
  }
665
666
  config.services.push(service);
666
- console.log(`Added new service: ${folder.name}`);
667
+ log.success(`Added service: ${pc.green(folder.name)}`);
667
668
  newServicesAdded = true;
668
669
  }
669
670
  else if (existing.has(folder.name)) {
670
- console.log(`Skipping existing service: ${folder.name}`);
671
+ log.dim(` Skipping existing service: ${folder.name}`);
671
672
  }
672
673
  else {
673
- console.log(`Skipping directory ${folder.name} (no recognized project files)`);
674
+ log.dim(` Skipping directory ${folder.name} (no recognized project files)`);
674
675
  }
675
676
  }
676
677
  }
677
678
  if (!newServicesAdded) {
678
- console.log('No new services found to add, feel free to add them manually');
679
+ log.info('No new services found to add, feel free to add them manually');
679
680
  }
680
681
  const validationError = validateConfig(config);
681
682
  if (validationError) {
@@ -687,15 +688,15 @@ async function generateDevFile(dirPath) {
687
688
  const packageJson = JSON.parse(packageData);
688
689
  packageJson.harbor = config;
689
690
  await fs.promises.writeFile('package.json', JSON.stringify(packageJson, null, 2), 'utf-8');
690
- console.log('\npackage.json updated successfully with harbor configuration');
691
- console.log('\n💡 Tip: To enable IntelliSense for the harbor config in package.json,');
692
- console.log(' add this to your .vscode/settings.json:');
693
- console.log(' {');
694
- console.log(' "json.schemas": [{');
695
- console.log(' "fileMatch": ["package.json"],');
696
- console.log(' "url": "https://raw.githubusercontent.com/Abyrd9/harbor-cli/main/harbor.package-json.schema.json"');
697
- console.log(' }]');
698
- console.log(' }');
691
+ log.success(`${pc.cyan('package.json')} updated with harbor configuration`);
692
+ log.plain('');
693
+ log.info(`${pc.dim('Tip:')} To enable IntelliSense, add this to ${pc.cyan('.vscode/settings.json')}:`);
694
+ log.plain(pc.dim(' {'));
695
+ log.plain(pc.dim(' "json.schemas": [{'));
696
+ log.plain(pc.dim(' "fileMatch": ["package.json"],'));
697
+ log.plain(pc.dim(' "url": "https://raw.githubusercontent.com/Abyrd9/harbor-cli/main/harbor.package-json.schema.json"'));
698
+ log.plain(pc.dim(' }]'));
699
+ log.plain(pc.dim(' }'));
699
700
  }
700
701
  else {
701
702
  // Write to harbor.json with $schema for IntelliSense
@@ -704,10 +705,10 @@ async function generateDevFile(dirPath) {
704
705
  ...config,
705
706
  };
706
707
  await fs.promises.writeFile('harbor.json', JSON.stringify(configWithSchema, null, 2), 'utf-8');
707
- console.log('\nharbor.json created successfully');
708
+ log.success(`${pc.cyan('harbor.json')} created successfully`);
708
709
  }
709
- console.log('\nImportant:');
710
- console.log(' - Verify the auto-detected commands are correct for your services');
710
+ log.plain('');
711
+ log.info(`${pc.dim('Important:')} Verify the auto-detected commands are correct for your services`);
711
712
  return true;
712
713
  }
713
714
  catch (err) {
@@ -754,11 +755,12 @@ async function execute(scripts, scriptType) {
754
755
  if (!scripts || scripts.length === 0) {
755
756
  return;
756
757
  }
757
- console.log(`\n🚀 Executing ${scriptType} scripts...`);
758
+ log.header(`Running ${scriptType} scripts...`);
758
759
  for (let i = 0; i < scripts.length; i++) {
759
760
  const script = scripts[i];
760
- console.log(`\n📋 Running ${scriptType} script ${i + 1}/${scripts.length}: ${script.command}`);
761
- console.log(` 📁 In directory: ${script.path}`);
761
+ log.plain('');
762
+ log.step(`${pc.dim(`[${i + 1}/${scripts.length}]`)} ${pc.cyan(script.command)}`);
763
+ log.dim(` in ${script.path}`);
762
764
  try {
763
765
  await new Promise((resolve, reject) => {
764
766
  const process = spawn('sh', ['-c', `cd "${script.path}" && ${script.command}`], {
@@ -766,7 +768,7 @@ async function execute(scripts, scriptType) {
766
768
  });
767
769
  process.on('close', (code) => {
768
770
  if (code === 0) {
769
- console.log(`✅ ${scriptType} script ${i + 1} completed successfully`);
771
+ log.success(`${scriptType} script ${i + 1} completed`);
770
772
  resolve(null);
771
773
  }
772
774
  else {
@@ -779,18 +781,20 @@ async function execute(scripts, scriptType) {
779
781
  });
780
782
  }
781
783
  catch (err) {
782
- console.error(`❌ Error executing ${scriptType} script ${i + 1}:`, err instanceof Error ? err.message : 'Unknown error');
784
+ log.error(`Error executing ${scriptType} script ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`);
783
785
  throw err;
784
786
  }
785
787
  }
786
- console.log(`\n✅ All ${scriptType} scripts completed successfully`);
788
+ log.plain('');
789
+ log.success(`All ${scriptType} scripts completed`);
787
790
  }
788
791
  async function runServices(options = {}) {
789
792
  const hasHarborConfig = checkHasHarborConfig();
790
793
  if (!hasHarborConfig) {
791
- console.log('No harbor configuration found');
792
- console.log('\nTo initialize a new Harbor project, please use:');
793
- console.log(' harbor dock');
794
+ log.error('No harbor configuration found');
795
+ log.plain('');
796
+ log.info('To initialize a new Harbor project:');
797
+ log.cmd('harbor dock');
794
798
  process.exit(1);
795
799
  }
796
800
  // Load and validate config
@@ -799,12 +803,12 @@ async function runServices(options = {}) {
799
803
  config = await readHarborConfig();
800
804
  const validationError = validateConfig(config);
801
805
  if (validationError) {
802
- console.log(`❌ Invalid harbor.json configuration: ${validationError}`);
806
+ log.error(`Invalid harbor.json configuration: ${validationError}`);
803
807
  process.exit(1);
804
808
  }
805
809
  }
806
810
  catch (err) {
807
- console.error('Error reading config:', err);
811
+ log.error(`Error reading config: ${err}`);
808
812
  process.exit(1);
809
813
  }
810
814
  ensureLogSetup(config);
@@ -813,7 +817,7 @@ async function runServices(options = {}) {
813
817
  await execute(config.before || [], 'before');
814
818
  }
815
819
  catch {
816
- console.error('Before scripts failed, aborting launch');
820
+ log.error('Before scripts failed, aborting launch');
817
821
  process.exit(1);
818
822
  }
819
823
  // Ensure scripts exist and are executable
@@ -831,23 +835,26 @@ async function runServices(options = {}) {
831
835
  });
832
836
  return new Promise((resolve) => {
833
837
  command.on('error', (err) => {
834
- console.error(`Error running dev.sh: ${err}`);
838
+ log.error(`Error running dev.sh: ${err}`);
835
839
  process.exit(1);
836
840
  });
837
841
  command.on('close', async (code) => {
838
842
  if (code !== 0) {
839
- console.error(`dev.sh exited with code ${code}`);
843
+ log.error(`dev.sh exited with code ${code}`);
840
844
  process.exit(1);
841
845
  }
842
- // Execute after scripts
843
- try {
844
- await execute(config.after || [], 'after');
845
- resolve();
846
- }
847
- catch {
848
- console.error('❌ After scripts failed');
849
- process.exit(1);
846
+ // Only execute after scripts in attached mode
847
+ // In headless mode, after scripts are run by 'scuttle' when session is killed
848
+ if (!options.detach) {
849
+ try {
850
+ await execute(config.after || [], 'after');
851
+ }
852
+ catch {
853
+ log.error('After scripts failed');
854
+ process.exit(1);
855
+ }
850
856
  }
857
+ resolve();
851
858
  });
852
859
  });
853
860
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abyrd9/harbor-cli",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "A CLI tool for orchestrating local development services in a tmux session. Perfect for microservices and polyglot projects with automatic service discovery and before/after script support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,8 @@
42
42
  "node": ">=18.0.0"
43
43
  },
44
44
  "dependencies": {
45
- "@commander-js/extra-typings": "^14.0.0"
45
+ "@commander-js/extra-typings": "^14.0.0",
46
+ "picocolors": "^1.1.1"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^24.0.3",
package/scripts/dev.sh CHANGED
@@ -14,10 +14,15 @@ get_harbor_config() {
14
14
  # Get session name from env, config, or use default
15
15
  session_name="${HARBOR_SESSION_NAME:-$(get_harbor_config | jq -r '.sessionName // "harbor"')}"
16
16
 
17
+ # Use a separate tmux socket for Harbor to avoid conflicts with existing tmux sessions
18
+ # This prevents Harbor's global options from affecting other tmux sessions
19
+ socket_name="harbor-${session_name}"
20
+ tmux_cmd="tmux -L $socket_name"
21
+
17
22
  # Check if the session already exists and kill it
18
- if tmux has-session -t "$session_name" 2>/dev/null; then
23
+ if $tmux_cmd has-session -t "$session_name" 2>/dev/null; then
19
24
  echo "Killing existing tmux session '$session_name'"
20
- tmux kill-session -t "$session_name"
25
+ $tmux_cmd kill-session -t "$session_name"
21
26
  fi
22
27
  repo_root="$(pwd)"
23
28
  max_log_lines=1000
@@ -51,70 +56,70 @@ start_log_trim() {
51
56
  trap cleanup_logs EXIT
52
57
 
53
58
  # Start a new tmux session and rename the initial window
54
- tmux new-session -d -s "$session_name"
59
+ $tmux_cmd new-session -d -s "$session_name"
55
60
 
56
61
  # Set tmux options
57
- tmux set-option -g prefix C-a
58
- tmux bind-key C-a send-prefix
59
- tmux set-option -g mouse on
60
- tmux set-option -g history-limit 50000
61
- tmux set-window-option -g mode-keys vi
62
+ $tmux_cmd set-option -g prefix C-a
63
+ $tmux_cmd bind-key C-a send-prefix
64
+ $tmux_cmd set-option -g mouse on
65
+ $tmux_cmd set-option -g history-limit 50000
66
+ $tmux_cmd set-window-option -g mode-keys vi
62
67
 
63
68
  # Enable extended keys so modifier combinations (like Shift+Enter) pass through to applications
64
- tmux set-option -g extended-keys on
65
- tmux set-option -g xterm-keys on
69
+ $tmux_cmd set-option -g extended-keys on
70
+ $tmux_cmd set-option -g xterm-keys on
66
71
 
67
72
  # Add binding to kill session with Ctrl+q
68
- tmux bind-key -n C-q kill-session
73
+ $tmux_cmd bind-key -n C-q kill-session
69
74
 
70
75
  # Add padding and styling to panes
71
- tmux set-option -g pane-border-style fg="#3f3f3f"
72
- tmux set-option -g pane-active-border-style fg="#6366f1"
73
- tmux set-option -g pane-border-status top
74
- tmux set-option -g pane-border-format ""
76
+ $tmux_cmd set-option -g pane-border-style fg="#3f3f3f"
77
+ $tmux_cmd set-option -g pane-active-border-style fg="#6366f1"
78
+ $tmux_cmd set-option -g pane-border-status top
79
+ $tmux_cmd set-option -g pane-border-format ""
75
80
 
76
81
  # Add padding inside panes
77
- tmux set-option -g status-left-length 100
78
- tmux set-option -g status-right-length 100
79
- tmux set-window-option -g window-style 'fg=colour247,bg=colour236'
80
- tmux set-window-option -g window-active-style 'fg=colour250,bg=black'
82
+ $tmux_cmd set-option -g status-left-length 100
83
+ $tmux_cmd set-option -g status-right-length 100
84
+ $tmux_cmd set-window-option -g window-style 'fg=colour247,bg=colour236'
85
+ $tmux_cmd set-window-option -g window-active-style 'fg=colour250,bg=black'
81
86
 
82
87
  # Set inner padding
83
- tmux set-option -g window-style "bg=#1c1917 fg=#a8a29e"
84
- tmux set-option -g window-active-style "bg=#1c1917 fg=#ffffff"
88
+ $tmux_cmd set-option -g window-style "bg=#1c1917 fg=#a8a29e"
89
+ $tmux_cmd set-option -g window-active-style "bg=#1c1917 fg=#ffffff"
85
90
 
86
91
  # Improve copy mode and mouse behavior
87
- tmux set-option -g set-clipboard external
88
- tmux bind-key -T copy-mode-vi v send-keys -X begin-selection
89
- tmux bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
90
- tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"
92
+ $tmux_cmd set-option -g set-clipboard external
93
+ $tmux_cmd bind-key -T copy-mode-vi v send-keys -X begin-selection
94
+ $tmux_cmd bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
95
+ $tmux_cmd bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"
91
96
 
92
97
  # Set easier window navigation shortcuts (Shift+Left/Right to switch windows)
93
- tmux bind-key -n S-Left select-window -t :-
94
- tmux bind-key -n S-Right select-window -t :+
98
+ $tmux_cmd bind-key -n S-Left select-window -t :-
99
+ $tmux_cmd bind-key -n S-Right select-window -t :+
95
100
 
96
101
  # Configure status bar
97
- tmux set-option -g status-position top
98
- tmux set-option -g status-style bg="#1c1917",fg="#a8a29e"
99
- tmux set-option -g status-left ""
100
- tmux set-option -g status-right "#[fg=#a8a29e]shift+←/→ switch · ctrl+q close · #[fg=white]%H:%M#[default]"
101
- tmux set-window-option -g window-status-current-format "\
102
+ $tmux_cmd set-option -g status-position top
103
+ $tmux_cmd set-option -g status-style bg="#1c1917",fg="#a8a29e"
104
+ $tmux_cmd set-option -g status-left ""
105
+ $tmux_cmd set-option -g status-right "#[fg=#a8a29e]shift+←/→ switch · ctrl+q close · #[fg=white]%H:%M#[default]"
106
+ $tmux_cmd set-window-option -g window-status-current-format "\
102
107
  #[fg=#6366f1, bg=#1c1917] →\
103
108
  #[fg=#6366f1, bg=#1c1917, bold] #W\
104
109
  #[fg=#6366f1, bg=#1c1917] "
105
- tmux set-window-option -g window-status-format "\
110
+ $tmux_cmd set-window-option -g window-status-format "\
106
111
  #[fg=#a8a29e, bg=#1c1917] \
107
112
  #[fg=#a8a29e, bg=#1c1917] #W \
108
113
  #[fg=#a8a29e, bg=#1c1917] "
109
114
 
110
115
  # Add padding below status bar
111
- tmux set-option -g status 2
112
- tmux set-option -Fg 'status-format[1]' '#{status-format[0]}'
113
- tmux set-option -g 'status-format[0]' ''
116
+ $tmux_cmd set-option -g status 2
117
+ $tmux_cmd set-option -Fg 'status-format[1]' '#{status-format[0]}'
118
+ $tmux_cmd set-option -g 'status-format[0]' ''
114
119
 
115
120
  # Create a new window for the interactive shell
116
121
  echo "Creating window for interactive shell"
117
- tmux rename-window -t "$session_name":0 'Terminal'
122
+ $tmux_cmd rename-window -t "$session_name":0 'Terminal'
118
123
 
119
124
  window_index=1 # Start from index 1
120
125
 
@@ -142,24 +147,24 @@ while read service; do
142
147
  log_file="$repo_root/.harbor/${session_name}-${name}.log"
143
148
  : > "$log_file"
144
149
  # Use pipe-pane to capture ALL terminal output (works with any program, no buffering issues)
145
- tmux new-window -t "$session_name":$window_index -n "$name"
146
- tmux pipe-pane -t "$session_name":$window_index "cat >> \"$log_file\""
147
- tmux send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
150
+ $tmux_cmd new-window -t "$session_name":$window_index -n "$name"
151
+ $tmux_cmd pipe-pane -t "$session_name":$window_index "cat >> \"$log_file\""
152
+ $tmux_cmd send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
148
153
  # Start background process to trim logs if they get too large
149
154
  start_log_trim "$log_file" "$effective_max_lines"
150
155
  else
151
- tmux new-window -t "$session_name":$window_index -n "$name"
152
- tmux send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
156
+ $tmux_cmd new-window -t "$session_name":$window_index -n "$name"
157
+ $tmux_cmd send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
153
158
  fi
154
159
 
155
160
  ((window_index++))
156
161
  done < <(get_harbor_config | jq -c '.services[]')
157
162
 
158
163
  # Bind 'Home' key to switch to the terminal window
159
- tmux bind-key -n Home select-window -t :0
164
+ $tmux_cmd bind-key -n Home select-window -t :0
160
165
 
161
166
  # Select the terminal window
162
- tmux select-window -t "$session_name":0
167
+ $tmux_cmd select-window -t "$session_name":0
163
168
 
164
169
  # Attach to the tmux session (unless running in detached/headless mode)
165
170
  if [ "${HARBOR_DETACH:-0}" = "1" ]; then
@@ -179,5 +184,5 @@ if [ "${HARBOR_DETACH:-0}" = "1" ]; then
179
184
  echo ""
180
185
  fi
181
186
  else
182
- tmux attach-session -t "$session_name"
187
+ $tmux_cmd attach-session -t "$session_name"
183
188
  fi