@abyrd9/harbor-cli 1.1.0 → 2.0.0

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.
package/README.md CHANGED
@@ -10,9 +10,14 @@ A CLI tool for managing local development services with ease. Harbor helps you o
10
10
  - 🔄 **Dependency Management**: Automatically checks for required system dependencies
11
11
  - 🎯 **Multi-Language Support**: Works with Node.js, Go, Rust, Python, PHP, Java, and more
12
12
  - 🖥️ **Tmux Integration**: Professional terminal multiplexing for service management
13
+ - 🤖 **AI Agent Friendly**: Stream service logs to files for AI coding assistants to monitor
13
14
 
14
15
  ## Installation
15
16
 
17
+ ```bash
18
+ bun add -g @abyrd9/harbor-cli
19
+ ```
20
+
16
21
  ```bash
17
22
  npm install -g @abyrd9/harbor-cli
18
23
  ```
@@ -60,12 +65,15 @@ Create a dedicated configuration file:
60
65
  {
61
66
  "name": "frontend",
62
67
  "path": "./vite-frontend",
63
- "command": "npm run dev"
68
+ "command": "npm run dev",
69
+ "log": true
64
70
  },
65
71
  {
66
72
  "name": "api",
67
73
  "path": "./go-api",
68
- "command": "go run ."
74
+ "command": "go run .",
75
+ "log": true,
76
+ "maxLogLines": 500
69
77
  },
70
78
  {
71
79
  "name": "database",
@@ -202,6 +210,42 @@ Override auto-detected commands by editing your configuration:
202
210
  }
203
211
  ```
204
212
 
213
+ ### Service Logging for AI Agents
214
+
215
+ Enable logging to stream service output to files in `.harbor/`. This is particularly useful when working with AI coding assistants (like Opencode, Codex, or Claude) that can read log files to understand what's happening in your services.
216
+
217
+ ```json
218
+ {
219
+ "services": [
220
+ {
221
+ "name": "api",
222
+ "path": "./api",
223
+ "command": "go run .",
224
+ "log": true,
225
+ "maxLogLines": 500
226
+ },
227
+ {
228
+ "name": "frontend",
229
+ "path": "./frontend",
230
+ "command": "npm run dev",
231
+ "log": true
232
+ }
233
+ ]
234
+ }
235
+ ```
236
+
237
+ **Options:**
238
+ - `log`: `true` to enable logging (default: `false`)
239
+ - `maxLogLines`: Maximum lines to keep in log file (default: `1000`)
240
+
241
+ **Log files are:**
242
+ - Stored in `.harbor/` directory (automatically added to `.gitignore`)
243
+ - Named `{session}-{service}.log` (e.g., `local-dev-test-api.log`)
244
+ - Automatically trimmed to prevent unbounded growth
245
+ - Stripped of ANSI escape codes for clean, readable output
246
+
247
+ **Use case:** Point your AI assistant to the `.harbor/` folder so it can monitor service logs, spot errors, and understand runtime behavior while helping you develop.
248
+
205
249
  ### Before/After Scripts
206
250
  Run custom scripts before and after your services start:
207
251
 
package/dist/harbor ADDED
Binary file
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
6
6
  import { chmodSync } from 'node:fs';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import os from 'node:os';
9
10
  // Read version from package.json
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = path.dirname(__filename);
@@ -24,8 +25,107 @@ const requiredDependencies = [
24
25
  requiredFor: 'JSON processing in service management',
25
26
  },
26
27
  ];
28
+ function detectOS() {
29
+ const platform = os.platform();
30
+ const arch = os.arch();
31
+ const isWindows = platform === 'win32';
32
+ const isMac = platform === 'darwin';
33
+ const isLinux = platform === 'linux';
34
+ // Check if running in WSL
35
+ let isWSL = false;
36
+ if (isLinux) {
37
+ try {
38
+ const release = fs.readFileSync('/proc/version', 'utf-8');
39
+ isWSL = release.toLowerCase().includes('microsoft') || release.toLowerCase().includes('wsl');
40
+ }
41
+ catch {
42
+ // If we can't read /proc/version, assume not WSL
43
+ }
44
+ }
45
+ return {
46
+ platform,
47
+ arch,
48
+ isWindows,
49
+ isMac,
50
+ isLinux,
51
+ isWSL,
52
+ };
53
+ }
54
+ function getInstallInstructions(dependency, osInfo) {
55
+ const instructions = [];
56
+ if (dependency === 'tmux') {
57
+ if (osInfo.isMac) {
58
+ instructions.push('🍎 macOS:');
59
+ instructions.push(' • Using Homebrew: brew install tmux');
60
+ instructions.push(' • Using MacPorts: sudo port install tmux');
61
+ instructions.push(' • Manual: https://github.com/tmux/tmux/wiki/Installing');
62
+ }
63
+ else if (osInfo.isLinux) {
64
+ if (osInfo.isWSL) {
65
+ instructions.push('🐧 WSL/Linux:');
66
+ instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install tmux');
67
+ instructions.push(' • CentOS/RHEL: sudo yum install tmux');
68
+ instructions.push(' • Fedora: sudo dnf install tmux');
69
+ instructions.push(' • Arch: sudo pacman -S tmux');
70
+ instructions.push(' • openSUSE: sudo zypper install tmux');
71
+ }
72
+ else {
73
+ instructions.push('🐧 Linux:');
74
+ instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install tmux');
75
+ instructions.push(' • CentOS/RHEL: sudo yum install tmux');
76
+ instructions.push(' • Fedora: sudo dnf install tmux');
77
+ instructions.push(' • Arch: sudo pacman -S tmux');
78
+ instructions.push(' • openSUSE: sudo zypper install tmux');
79
+ }
80
+ }
81
+ else if (osInfo.isWindows) {
82
+ instructions.push('🪟 Windows:');
83
+ instructions.push(' • Using Chocolatey: choco install tmux');
84
+ instructions.push(' • Using Scoop: scoop install tmux');
85
+ instructions.push(' • Using WSL: Install in WSL and use from there');
86
+ instructions.push(' • Manual: https://github.com/tmux/tmux/wiki/Installing');
87
+ }
88
+ }
89
+ else if (dependency === 'jq') {
90
+ if (osInfo.isMac) {
91
+ instructions.push('🍎 macOS:');
92
+ instructions.push(' • Using Homebrew: brew install jq');
93
+ instructions.push(' • Using MacPorts: sudo port install jq');
94
+ instructions.push(' • Using Fink: fink install jq');
95
+ instructions.push(' • Manual: https://jqlang.org/download/');
96
+ }
97
+ else if (osInfo.isLinux) {
98
+ if (osInfo.isWSL) {
99
+ instructions.push('🐧 WSL/Linux:');
100
+ instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install jq');
101
+ instructions.push(' • CentOS/RHEL: sudo yum install jq');
102
+ instructions.push(' • Fedora: sudo dnf install jq');
103
+ instructions.push(' • Arch: sudo pacman -S jq');
104
+ instructions.push(' • openSUSE: sudo zypper install jq');
105
+ }
106
+ else {
107
+ instructions.push('🐧 Linux:');
108
+ instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install jq');
109
+ instructions.push(' • CentOS/RHEL: sudo yum install jq');
110
+ instructions.push(' • Fedora: sudo dnf install jq');
111
+ instructions.push(' • Arch: sudo pacman -S jq');
112
+ instructions.push(' • openSUSE: sudo zypper install jq');
113
+ }
114
+ }
115
+ else if (osInfo.isWindows) {
116
+ instructions.push('🪟 Windows:');
117
+ instructions.push(' • Using winget: winget install jqlang.jq');
118
+ instructions.push(' • Using Chocolatey: choco install jq');
119
+ instructions.push(' • Using Scoop: scoop install jq');
120
+ instructions.push(' • Using WSL: Install in WSL and use from there');
121
+ instructions.push(' • Manual: https://jqlang.org/download/');
122
+ }
123
+ }
124
+ return instructions;
125
+ }
27
126
  async function checkDependencies() {
28
127
  const missingDeps = [];
128
+ const osInfo = detectOS();
29
129
  for (const dep of requiredDependencies) {
30
130
  try {
31
131
  await new Promise((resolve, reject) => {
@@ -44,10 +144,19 @@ async function checkDependencies() {
44
144
  }
45
145
  if (missingDeps.length > 0) {
46
146
  console.log('❌ Missing required dependencies:');
147
+ console.log(`\n🖥️ Detected OS: ${osInfo.platform} ${osInfo.arch}${osInfo.isWSL ? ' (WSL)' : ''}`);
47
148
  for (const dep of missingDeps) {
48
- console.log(`\n${dep.name} (required for ${dep.requiredFor})`);
49
- console.log(`Install instructions: ${dep.installMsg}`);
149
+ console.log(`\n📦 ${dep.name} (required for ${dep.requiredFor})`);
150
+ const instructions = getInstallInstructions(dep.name, osInfo);
151
+ if (instructions.length > 0) {
152
+ console.log(' Installation options:');
153
+ instructions.forEach(instruction => console.log(instruction));
154
+ }
155
+ else {
156
+ console.log(` General instructions: ${dep.installMsg}`);
157
+ }
50
158
  }
159
+ console.log('\n💡 After installing the dependencies, run Harbor again.');
51
160
  throw new Error('Please install missing dependencies before continuing');
52
161
  }
53
162
  }
@@ -88,6 +197,7 @@ This is typically the first command you'll run in a new project.`)
88
197
  .option('-p, --path <path>', 'The path to the root of your project', './')
89
198
  .action(async (options) => {
90
199
  try {
200
+ await checkDependencies();
91
201
  const configExists = checkHasHarborConfig();
92
202
  if (configExists) {
93
203
  console.log('❌ Error: Harbor project already initialized');
@@ -107,20 +217,34 @@ program.command('moor')
107
217
  .description('Add new services to your harbor configuration')
108
218
  .option('-p, --path <path>', 'The path to the root of your project', './')
109
219
  .action(async (options) => {
110
- if (!checkHasHarborConfig()) {
111
- console.log('❌ No harbor configuration found');
112
- console.log('\nTo initialize a new Harbor project, please use:');
113
- console.log(' harbor dock');
220
+ try {
221
+ await checkDependencies();
222
+ if (!checkHasHarborConfig()) {
223
+ console.log('❌ No harbor configuration found');
224
+ console.log('\nTo initialize a new Harbor project, please use:');
225
+ console.log(' harbor dock');
226
+ process.exit(1);
227
+ }
228
+ await generateDevFile(options.path);
229
+ }
230
+ catch (err) {
231
+ console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
114
232
  process.exit(1);
115
233
  }
116
- await generateDevFile(options.path);
117
234
  });
118
235
  program.command('launch')
119
236
  .description(`Launch your services in the harbor terminal multiplexer (Using tmux)
120
237
 
121
238
  Note: This command will stop any active Caddy processes, including those from other Harbor projects.`)
122
239
  .action(async () => {
123
- await runServices();
240
+ try {
241
+ await checkDependencies();
242
+ await runServices();
243
+ }
244
+ catch (err) {
245
+ console.log('❌ Error:', err instanceof Error ? err.message : 'Unknown error');
246
+ process.exit(1);
247
+ }
124
248
  });
125
249
  program.parse();
126
250
  function fileExists(path) {
@@ -387,6 +511,7 @@ async function runServices() {
387
511
  console.error('Error reading config:', err);
388
512
  process.exit(1);
389
513
  }
514
+ ensureLogSetup(config);
390
515
  // Execute before scripts
391
516
  try {
392
517
  await execute(config.before || [], 'before');
@@ -424,6 +549,27 @@ async function runServices() {
424
549
  });
425
550
  });
426
551
  }
552
+ function ensureLogSetup(config) {
553
+ const shouldLog = config.services.some((service) => service.log);
554
+ if (!shouldLog) {
555
+ return;
556
+ }
557
+ const harborDir = path.join(process.cwd(), '.harbor');
558
+ if (!fs.existsSync(harborDir)) {
559
+ fs.mkdirSync(harborDir, { recursive: true });
560
+ }
561
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
562
+ if (!fs.existsSync(gitignorePath)) {
563
+ return;
564
+ }
565
+ const gitignore = fs.readFileSync(gitignorePath, 'utf-8');
566
+ if (gitignore.includes('.harbor')) {
567
+ return;
568
+ }
569
+ const needsNewline = gitignore.length > 0 && !gitignore.endsWith('\n');
570
+ const entry = `${needsNewline ? '\n' : ''}.harbor/\n`;
571
+ fs.appendFileSync(gitignorePath, entry, 'utf-8');
572
+ }
427
573
  // Get the package root directory
428
574
  function getPackageRoot() {
429
575
  const __filename = fileURLToPath(import.meta.url);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@abyrd9/harbor-cli",
3
- "version": "1.1.0",
4
- "description": "A CLI tool for managing simple local development services with tmux sessions",
3
+ "version": "2.0.0",
4
+ "description": "A CLI tool for orchestrating local development services in tmux sessions. Perfect for microservices and polyglot projects with automatic service discovery and before/after script support.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "harbor": "dist/index.js"
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "check": "bunx npm-check -u",
15
- "build": "tsc",
15
+ "build": "bunx tsc",
16
16
  "prepare": "bun run build",
17
17
  "start": "bun dist/index.js",
18
18
  "harbor": "bun dist/index.js",
@@ -49,14 +49,18 @@
49
49
  "harbor": {
50
50
  "services": [
51
51
  {
52
- "name": "vite-project",
53
- "path": "test-services/vite-project",
54
- "command": "npm run dev"
52
+ "name": "web",
53
+ "path": "test-services/web",
54
+ "command": "bun run dev",
55
+ "log": true,
56
+ "maxLogLines": 500
55
57
  },
56
58
  {
57
59
  "name": "go-api",
58
60
  "path": "test-services/go-api",
59
- "command": "go run ."
61
+ "command": "go run .",
62
+ "log": true,
63
+ "maxLogLines": 500
60
64
  }
61
65
  ]
62
66
  }
package/scripts/dev.sh CHANGED
@@ -6,6 +6,26 @@ if tmux has-session -t local-dev-test 2>/dev/null; then
6
6
  tmux kill-session -t local-dev-test
7
7
  fi
8
8
 
9
+ session_name="local-dev-test"
10
+ repo_root="$(pwd)"
11
+ max_log_lines=1000
12
+ log_pids=()
13
+
14
+ cleanup_logs() {
15
+ for pid in "${log_pids[@]}"; do
16
+ kill "$pid" 2>/dev/null
17
+ done
18
+ }
19
+
20
+ start_log_trim() {
21
+ local log_file="$1"
22
+ local max_lines="$2"
23
+ # Run trimmer inside tmux so it survives script exit
24
+ tmux send-keys -t "$session_name":0 "( while true; do sleep 5; if [ -f \"$log_file\" ]; then lines=\$(wc -l < \"$log_file\"); if [ \"\$lines\" -gt $max_lines ]; then tail -n $max_lines \"$log_file\" > \"${log_file}.tmp\" && mv \"${log_file}.tmp\" \"$log_file\"; fi; fi; done ) &" C-m
25
+ }
26
+
27
+ trap cleanup_logs EXIT
28
+
9
29
  # Function to get harbor config
10
30
  get_harbor_config() {
11
31
  if [ -f "harbor.json" ]; then
@@ -18,7 +38,7 @@ get_harbor_config() {
18
38
  }
19
39
 
20
40
  # Start a new tmux session named 'local-dev-test' and rename the initial window
21
- tmux new-session -d -s local-dev-test
41
+ tmux new-session -d -s "$session_name"
22
42
 
23
43
  # Set tmux options
24
44
  tmux set-option -g prefix C-a
@@ -77,31 +97,51 @@ tmux set-option -g 'status-format[0]' ''
77
97
 
78
98
  # Create a new window for the interactive shell
79
99
  echo "Creating window for interactive shell"
80
- tmux rename-window -t local-dev-test:0 'Terminal'
100
+ tmux rename-window -t "$session_name":0 'Terminal'
81
101
 
82
102
  window_index=1 # Start from index 1
83
103
 
104
+ if get_harbor_config | jq -e '.services[] | select(.log == true)' >/dev/null 2>&1; then
105
+ mkdir -p "$repo_root/.harbor"
106
+ rm -f "$repo_root/.harbor/${session_name}-"*.log
107
+ fi
108
+
84
109
  # Create windows dynamically based on harbor config
85
- get_harbor_config | jq -c '.services[]' | while read service; do
110
+ while read service; do
86
111
  name=$(echo $service | jq -r '.name')
87
112
  path=$(echo $service | jq -r '.path')
88
113
  command=$(echo $service | jq -r '.command')
114
+ log=$(echo $service | jq -r '.log // false')
115
+ service_max_lines=$(echo $service | jq -r '.maxLogLines // empty')
116
+
117
+ # Use service-specific maxLogLines or fall back to default
118
+ effective_max_lines="${service_max_lines:-$max_log_lines}"
89
119
 
90
120
  echo "Creating window for service: $name"
91
121
  echo "Path: $path"
92
122
  echo "Command: $command"
93
123
 
94
- tmux new-window -t local-dev-test:$window_index -n "$name"
95
- tmux send-keys -t local-dev-test:$window_index "cd $path && $command" C-m
124
+ if [ "$log" = "true" ]; then
125
+ log_file="$repo_root/.harbor/${session_name}-${name}.log"
126
+ : > "$log_file"
127
+ # Run command directly (not via send-keys) to avoid shell echo
128
+ # Strip ANSI escape sequences and control chars before writing to log file
129
+ tmux new-window -t "$session_name":$window_index -n "$name" "cd \"$path\" && $command 2>&1 | sed -u 's/\\x1b\\[[0-9;]*[mGKHJ]//g' | tee -a \"$log_file\"; exec bash"
130
+ # Start background process to trim logs if they get too large
131
+ start_log_trim "$log_file" "$effective_max_lines"
132
+ else
133
+ tmux new-window -t "$session_name":$window_index -n "$name"
134
+ tmux send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
135
+ fi
96
136
 
97
137
  ((window_index++))
98
- done
138
+ done < <(get_harbor_config | jq -c '.services[]')
99
139
 
100
140
  # Bind 'Home' key to switch to the terminal window
101
141
  tmux bind-key -n Home select-window -t :0
102
142
 
103
143
  # Select the terminal window
104
- tmux select-window -t local-dev-test:0
144
+ tmux select-window -t "$session_name":0
105
145
 
106
146
  # Attach to the tmux session
107
- tmux attach-session -t local-dev-test
147
+ tmux attach-session -t "$session_name"