├── packages ├── cli │ ├── bin │ │ └── portwatch │ ├── tsconfig.json │ ├── package.json │ └── src │ │ ├── index.ts │ │ ├── commands │ │ ├── show.ts │ │ ├── kill.ts │ │ ├── watch.ts │ │ ├── list.ts │ │ └── config.ts │ │ └── utils │ │ └── formatter.ts ├── app │ ├── test-results │ │ └── .last-run.json │ ├── assets │ │ ├── icon.icns │ │ ├── IconTemplate.png │ │ ├── IconTemplate@2x.png │ │ └── README.md │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── src │ │ ├── renderer │ │ │ ├── main.tsx │ │ │ ├── index.css │ │ │ └── App.tsx │ │ ├── preload │ │ │ └── preload.ts │ │ └── main │ │ │ └── main.ts │ ├── index.html │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── tsconfig.json │ ├── playwright.config.ts │ ├── electron.vite.config.ts │ ├── test │ │ ├── utils.ts │ │ ├── smoke.spec.ts │ │ ├── ipc.spec.ts │ │ └── ui.spec.ts │ ├── create-menubar-icon.js │ ├── package.json │ ├── fix-menubar-icon.js │ ├── generate-simple-icon.js │ └── generate-icon.js ├── core │ ├── src │ │ ├── index.ts │ │ ├── config-manager.ts │ │ ├── process-manager.ts │ │ ├── types.ts │ │ └── port-scanner.ts │ ├── tsconfig.json │ ├── package.json │ └── test │ │ └── port-scanner.test.ts └── mcp-server │ ├── tsconfig.json │ ├── get-config.sh │ ├── package.json │ ├── README.md │ └── src │ └── index.ts ├── tsconfig.json ├── .gitignore ├── package.json ├── homebrew └── portwatch.rb ├── RELEASE.md └── README.md /packages/cli/bin/portwatch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../dist/index.js'); 4 | -------------------------------------------------------------------------------- /packages/app/test-results/.last-run.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "passed", 3 | "failedTests": [] 4 | } -------------------------------------------------------------------------------- /packages/app/assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dingran/portwatch/main/packages/app/assets/icon.icns -------------------------------------------------------------------------------- /packages/app/assets/IconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dingran/portwatch/main/packages/app/assets/IconTemplate.png -------------------------------------------------------------------------------- /packages/app/assets/IconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dingran/portwatch/main/packages/app/assets/IconTemplate@2x.png -------------------------------------------------------------------------------- /packages/app/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/renderer/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | // Export types 2 | export * from './types'; 3 | 4 | // Export port scanner 5 | export * from './port-scanner'; 6 | 7 | // Export process manager 8 | export * from './process-manager'; 9 | 10 | // Export config manager 11 | export * from './config-manager'; 12 | -------------------------------------------------------------------------------- /packages/app/src/renderer/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../core/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "composite": true 7 | }, 8 | "include": ["src/**/*"], 9 | "exclude": ["node_modules", "dist"], 10 | "references": [ 11 | { "path": "../core" } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PortWatch 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/app/assets/README.md: -------------------------------------------------------------------------------- 1 | # App Icons 2 | 3 | TODO: Create proper menu bar icon 4 | 5 | The menu bar icon should be: 6 | - 16x16 pixels (with @2x retina version at 32x32) 7 | - Black with transparency (Template image) 8 | - Simple and recognizable at small sizes 9 | - Named: IconTemplate.png and IconTemplate@2x.png 10 | 11 | For now, the app will use a placeholder or Electron's default icon. 12 | -------------------------------------------------------------------------------- /packages/app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'path'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: './', 8 | build: { 9 | outDir: 'dist/renderer', 10 | emptyOutDir: true, 11 | }, 12 | server: { 13 | port: 5173, 14 | }, 15 | resolve: { 16 | alias: { 17 | '@': path.resolve(__dirname, './src/renderer'), 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /packages/app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node" 14 | }, 15 | "include": ["src/main", "src/preload"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "node" 12 | }, 13 | "files": [], 14 | "references": [ 15 | { "path": "./packages/core" }, 16 | { "path": "./packages/cli" }, 17 | { "path": "./packages/app" } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "composite": true, 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "resolveJsonModule": true, 15 | "moduleResolution": "node" 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/mcp-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "lib": ["ES2022"], 6 | "moduleResolution": "node", 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "declaration": true, 15 | "declarationMap": true, 16 | "sourceMap": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/mcp-server/get-config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get the absolute path to the MCP server 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | MCP_PATH="${SCRIPT_DIR}/dist/index.js" 6 | 7 | echo "Add this to your Claude Desktop config:" 8 | echo "~/Library/Application Support/Claude/claude_desktop_config.json" 9 | echo "" 10 | echo "{" 11 | echo " \"mcpServers\": {" 12 | echo " \"portwatch\": {" 13 | echo " \"command\": \"node\"," 14 | echo " \"args\": [" 15 | echo " \"${MCP_PATH}\"" 16 | echo " ]" 17 | echo " }" 18 | echo " }" 19 | echo "}" 20 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portwatch/core", 3 | "version": "1.0.0", 4 | "description": "Core library for port monitoring", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "dev": "tsc --watch", 10 | "test": "vitest run" 11 | }, 12 | "keywords": [ 13 | "port", 14 | "monitoring" 15 | ], 16 | "author": "dingran", 17 | "license": "MIT", 18 | "dependencies": { 19 | "zod": "^3.22.4" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.11.0", 23 | "typescript": "^5.3.3", 24 | "vitest": "^1.6.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | yarn.lock 5 | pnpm-lock.yaml 6 | 7 | # Build outputs 8 | dist/ 9 | build/ 10 | out/ 11 | 12 | # Logs 13 | logs/ 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Editor directories and files 20 | .vscode/ 21 | .idea/ 22 | *.swp 23 | *.swo 24 | *~ 25 | .DS_Store 26 | 27 | # TypeScript 28 | *.tsbuildinfo 29 | 30 | # Environment variables 31 | .env 32 | .env.local 33 | .env.*.local 34 | 35 | # Testing 36 | coverage/ 37 | packages/app/playwright-report/ 38 | packages/app/test-results/ 39 | .mcp.json 40 | 41 | # macOS 42 | .DS_Store 43 | .AppleDouble 44 | .LSOverride 45 | 46 | # Electron 47 | release/ 48 | 49 | # Config (user-specific) 50 | .portwatch/ 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portwatch-monorepo", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "PortWatch - macOS port monitoring tool", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "npm run build --workspaces", 11 | "dev:cli": "npm run dev -w @portwatch/cli", 12 | "dev:app": "npm run dev -w @portwatch/app", 13 | "test": "npm run test --workspaces --if-present" 14 | }, 15 | "keywords": [ 16 | "port", 17 | "monitoring", 18 | "macos", 19 | "cli", 20 | "electron" 21 | ], 22 | "author": "dingran", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@types/node": "^20.11.0", 26 | "7zip-bin": "~5.2.0", 27 | "typescript": "^5.3.3", 28 | "vitest": "^1.6.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/mcp-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portwatch/mcp-server", 3 | "version": "1.0.0", 4 | "description": "MCP server for PortWatch - query port and process information", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "bin": { 8 | "portwatch-mcp": "dist/index.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "tsc --watch", 13 | "start": "node dist/index.js" 14 | }, 15 | "keywords": [ 16 | "mcp", 17 | "port", 18 | "process", 19 | "monitoring" 20 | ], 21 | "author": "dingran", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@modelcontextprotocol/sdk": "^1.0.4", 25 | "@portwatch/core": "^1.0.0" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^20.11.0", 29 | "typescript": "^5.3.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portwatch/cli", 3 | "version": "1.0.0", 4 | "description": "CLI tool for port monitoring", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "portwatch": "./bin/portwatch" 8 | }, 9 | "scripts": { 10 | "build": "tsc", 11 | "dev": "tsc --watch", 12 | "start": "node dist/index.js", 13 | "test": "echo \"Tests coming soon\"" 14 | }, 15 | "keywords": [ 16 | "port", 17 | "monitoring", 18 | "cli" 19 | ], 20 | "author": "dingran", 21 | "license": "MIT", 22 | "dependencies": { 23 | "@portwatch/core": "^1.0.0", 24 | "chalk": "^4.1.2", 25 | "cli-table3": "^0.6.3", 26 | "commander": "^12.0.0", 27 | "ora": "^5.4.1" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^20.11.0", 31 | "typescript": "^5.3.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/app/src/renderer/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | background-color: #f5f5f7; 14 | } 15 | 16 | #root { 17 | height: 100vh; 18 | overflow: hidden; 19 | } 20 | 21 | /* Custom scrollbar */ 22 | ::-webkit-scrollbar { 23 | width: 8px; 24 | } 25 | 26 | ::-webkit-scrollbar-track { 27 | background: #f1f1f1; 28 | } 29 | 30 | ::-webkit-scrollbar-thumb { 31 | background: #888; 32 | border-radius: 4px; 33 | } 34 | 35 | ::-webkit-scrollbar-thumb:hover { 36 | background: #555; 37 | } 38 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "composite": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | 24 | /* Path aliases */ 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["./src/renderer/*"] 28 | } 29 | }, 30 | "include": ["src/renderer"], 31 | "references": [ 32 | { "path": "../core" } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from 'commander'; 4 | import { listCommand } from './commands/list'; 5 | import { watchCommand } from './commands/watch'; 6 | import { killCommand } from './commands/kill'; 7 | import { showCommand } from './commands/show'; 8 | import { configCommand } from './commands/config'; 9 | 10 | const program = new Command(); 11 | 12 | program 13 | .name('portwatch') 14 | .description('macOS port monitoring tool') 15 | .version('1.0.0'); 16 | 17 | // Register commands 18 | program.addCommand(listCommand); 19 | program.addCommand(watchCommand); 20 | program.addCommand(killCommand); 21 | program.addCommand(showCommand); 22 | program.addCommand(configCommand); 23 | 24 | // Parse arguments 25 | program.parse(process.argv); 26 | 27 | // Show help if no command is provided 28 | if (!process.argv.slice(2).length) { 29 | program.outputHelp(); 30 | } 31 | -------------------------------------------------------------------------------- /packages/app/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Playwright configuration for Electron app testing 5 | * @see https://playwright.dev/docs/test-configuration 6 | */ 7 | export default defineConfig({ 8 | testDir: './test', 9 | 10 | // Maximum time one test can run for 11 | timeout: 30 * 1000, 12 | 13 | // Run tests in files in parallel 14 | fullyParallel: true, 15 | 16 | // Fail the build on CI if you accidentally left test.only in the source code 17 | forbidOnly: !!process.env.CI, 18 | 19 | // Retry on CI only 20 | retries: process.env.CI ? 2 : 0, 21 | 22 | // Opt out of parallel tests on CI 23 | workers: process.env.CI ? 1 : undefined, 24 | 25 | // Reporter to use 26 | reporter: 'html', 27 | 28 | // Shared settings for all the projects below 29 | use: { 30 | // Collect trace when retrying the failed test 31 | trace: 'on-first-retry', 32 | 33 | // Screenshot on failure 34 | screenshot: 'only-on-failure', 35 | }, 36 | 37 | // Configure projects for Electron 38 | projects: [ 39 | { 40 | name: 'electron', 41 | use: { 42 | ...devices['Desktop Chrome'], 43 | }, 44 | }, 45 | ], 46 | }); 47 | -------------------------------------------------------------------------------- /packages/app/electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { resolve } from 'path'; 4 | 5 | export default defineConfig({ 6 | main: { 7 | plugins: [externalizeDepsPlugin()], 8 | build: { 9 | outDir: 'dist/main', 10 | lib: { 11 | entry: 'src/main/main.ts', 12 | formats: ['cjs'] 13 | }, 14 | rollupOptions: { 15 | external: ['electron', 'menubar', '@portwatch/core'] 16 | } 17 | } 18 | }, 19 | preload: { 20 | plugins: [externalizeDepsPlugin()], 21 | build: { 22 | outDir: 'dist/preload', 23 | lib: { 24 | entry: 'src/preload/preload.ts', 25 | formats: ['cjs'] 26 | }, 27 | rollupOptions: { 28 | external: ['electron'] 29 | } 30 | } 31 | }, 32 | renderer: { 33 | root: '.', 34 | plugins: [react()], 35 | base: './', 36 | build: { 37 | outDir: 'dist/renderer', 38 | rollupOptions: { 39 | input: resolve(__dirname, 'index.html') 40 | } 41 | }, 42 | server: { 43 | port: 5173 44 | }, 45 | resolve: { 46 | alias: { 47 | '@': resolve(__dirname, 'src/renderer') 48 | } 49 | } 50 | } 51 | }); 52 | -------------------------------------------------------------------------------- /homebrew/portwatch.rb: -------------------------------------------------------------------------------- 1 | cask "portwatch" do 2 | version "1.1.0" 3 | 4 | on_arm do 5 | sha256 "599ddae8310ed25585ceca70cfb900384839f929870ed0e28c42e07b41317e1a" 6 | url "https://github.com/dingran/portwatch/releases/download/v#{version}/PortWatch-#{version}-arm64-mac.zip" 7 | end 8 | 9 | on_intel do 10 | sha256 "5c6344dd11a5d6999639f44d07797fd4e0d514c246f736b5279234940b49a46d" 11 | url "https://github.com/dingran/portwatch/releases/download/v#{version}/PortWatch-#{version}-mac.zip" 12 | end 13 | 14 | name "PortWatch" 15 | desc "macOS menu bar app for monitoring which processes are running on which ports" 16 | homepage "https://github.com/dingran/portwatch" 17 | 18 | app "PortWatch.app" 19 | 20 | # Users will see a security warning on first launch since the app is not notarized 21 | # They need to right-click > Open or go to System Settings > Privacy & Security 22 | caveats <<~EOS 23 | PortWatch is not notarized with Apple. On first launch: 24 | 1. Right-click the app icon and select "Open", or 25 | 2. Go to System Settings > Privacy & Security and click "Open Anyway" 26 | 27 | After the first launch, the app will open normally. 28 | EOS 29 | 30 | zap trash: [ 31 | "~/Library/Application Support/@portwatch/app", 32 | "~/Library/Preferences/com.portwatch.app.plist", 33 | "~/Library/Saved Application State/com.portwatch.app.savedState", 34 | ] 35 | end 36 | -------------------------------------------------------------------------------- /packages/cli/src/commands/show.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import ora from 'ora'; 3 | import { getPortInfo, enrichPortInfo } from '@portwatch/core'; 4 | import { formatPortDetails, formatError, formatWarning, formatJson } from '../utils/formatter'; 5 | 6 | export const showCommand = new Command('show') 7 | .description('Show detailed information about a specific port') 8 | .argument('', 'Port number to inspect', parseInt) 9 | .option('-j, --json', 'Output as JSON') 10 | .action(async (port: number, options) => { 11 | const spinner = ora('Fetching port information...').start(); 12 | 13 | try { 14 | // Get port info 15 | const portInfo = await getPortInfo(port); 16 | 17 | if (!portInfo) { 18 | spinner.stop(); 19 | console.log(formatWarning(`No process found on port ${port}`)); 20 | process.exit(0); 21 | } 22 | 23 | // Enrich with working directory and command 24 | const enriched = await enrichPortInfo(portInfo); 25 | 26 | spinner.stop(); 27 | 28 | // Output results 29 | if (options.json) { 30 | console.log(formatJson(enriched)); 31 | } else { 32 | console.log(formatPortDetails(enriched)); 33 | } 34 | 35 | process.exit(0); 36 | } catch (error: any) { 37 | spinner.stop(); 38 | console.error(formatError(error.message)); 39 | process.exit(1); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /packages/app/src/preload/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron'; 2 | import { FilterOptions, UserConfig, KillResult, PortInfo } from '@portwatch/core'; 3 | 4 | // Expose protected methods that allow the renderer process to use 5 | // ipcRenderer without exposing the entire object 6 | contextBridge.exposeInMainWorld('portwatchAPI', { 7 | // Scan ports 8 | scanPorts: (filters?: FilterOptions): Promise<{ success: boolean; data?: PortInfo[]; error?: string }> => 9 | ipcRenderer.invoke('scan-ports', filters), 10 | 11 | // Kill process by port 12 | killByPort: (port: number, force?: boolean): Promise => 13 | ipcRenderer.invoke('kill-by-port', port, force), 14 | 15 | // Kill process by PID 16 | killByPid: (pid: number, force?: boolean): Promise => 17 | ipcRenderer.invoke('kill-by-pid', pid, force), 18 | 19 | // Load config 20 | loadConfig: (): Promise<{ success: boolean; data?: UserConfig; error?: string }> => 21 | ipcRenderer.invoke('load-config'), 22 | 23 | // Save config 24 | saveConfig: (config: UserConfig): Promise<{ success: boolean; error?: string }> => 25 | ipcRenderer.invoke('save-config', config), 26 | 27 | // Quit app 28 | quitApp: (): Promise => 29 | ipcRenderer.invoke('quit-app'), 30 | }); 31 | 32 | // TypeScript type declaration for window object 33 | declare global { 34 | interface Window { 35 | portwatchAPI: { 36 | scanPorts: (filters?: FilterOptions) => Promise<{ success: boolean; data?: PortInfo[]; error?: string }>; 37 | killByPort: (port: number, force?: boolean) => Promise; 38 | killByPid: (pid: number, force?: boolean) => Promise; 39 | loadConfig: () => Promise<{ success: boolean; data?: UserConfig; error?: string }>; 40 | saveConfig: (config: UserConfig) => Promise<{ success: boolean; error?: string }>; 41 | quitApp: () => Promise; 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/core/src/config-manager.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import { homedir } from 'os'; 3 | import { join } from 'path'; 4 | import { 5 | UserConfig, 6 | UserConfigSchema, 7 | DEFAULT_USER_CONFIG, 8 | } from './types'; 9 | 10 | const CONFIG_DIR = join(homedir(), '.portwatch'); 11 | const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); 12 | 13 | /** 14 | * Ensures the config directory exists 15 | */ 16 | async function ensureConfigDir(): Promise { 17 | try { 18 | await fs.mkdir(CONFIG_DIR, { recursive: true }); 19 | } catch (error: any) { 20 | throw new Error(`Failed to create config directory: ${error.message}`); 21 | } 22 | } 23 | 24 | /** 25 | * Loads user configuration 26 | */ 27 | export async function loadConfig(): Promise { 28 | try { 29 | const data = await fs.readFile(CONFIG_FILE, 'utf-8'); 30 | const parsed = JSON.parse(data); 31 | 32 | // Validate with Zod 33 | const validated = UserConfigSchema.parse(parsed); 34 | return validated; 35 | } catch (error: any) { 36 | // If file doesn't exist or is invalid, return default config 37 | return DEFAULT_USER_CONFIG; 38 | } 39 | } 40 | 41 | /** 42 | * Saves user configuration 43 | */ 44 | export async function saveConfig(config: UserConfig): Promise { 45 | try { 46 | // Validate before saving 47 | const validated = UserConfigSchema.parse(config); 48 | 49 | await ensureConfigDir(); 50 | await fs.writeFile(CONFIG_FILE, JSON.stringify(validated, null, 2), 'utf-8'); 51 | } catch (error: any) { 52 | throw new Error(`Failed to save config: ${error.message}`); 53 | } 54 | } 55 | 56 | /** 57 | * Resets configuration to defaults 58 | */ 59 | export async function resetConfig(): Promise { 60 | await saveConfig(DEFAULT_USER_CONFIG); 61 | } 62 | 63 | /** 64 | * Gets the config file path 65 | */ 66 | export function getConfigPath(): string { 67 | return CONFIG_FILE; 68 | } 69 | 70 | /** 71 | * Checks if config file exists 72 | */ 73 | export async function configExists(): Promise { 74 | try { 75 | await fs.access(CONFIG_FILE); 76 | return true; 77 | } catch { 78 | return false; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/cli/src/commands/kill.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import ora from 'ora'; 3 | import { killProcessByPort, killProcessByPid } from '@portwatch/core'; 4 | import { formatSuccess, formatError, formatWarning } from '../utils/formatter'; 5 | 6 | export const killCommand = new Command('kill') 7 | .description('Kill a process by port or PID') 8 | .argument('', 'Port number or PID to kill') 9 | .option('-f, --force', 'Force kill (SIGKILL instead of SIGTERM)') 10 | .option('--port', 'Treat target as port number') 11 | .option('--pid', 'Treat target as PID') 12 | .action(async (target: string, options) => { 13 | const targetNum = parseInt(target, 10); 14 | 15 | if (isNaN(targetNum)) { 16 | console.error(formatError('Target must be a number')); 17 | process.exit(1); 18 | } 19 | 20 | const spinner = ora('Killing process...').start(); 21 | 22 | try { 23 | let result; 24 | 25 | // Determine if target is port or PID 26 | if (options.port) { 27 | result = await killProcessByPort(targetNum, options.force); 28 | } else if (options.pid) { 29 | result = await killProcessByPid(targetNum, options.force); 30 | } else { 31 | // Auto-detect: try port first (valid port range), then fall back to PID 32 | const inPortRange = targetNum >= 0 && targetNum <= 65535; 33 | 34 | if (inPortRange) { 35 | spinner.text = 'Trying port...'; 36 | result = await killProcessByPort(targetNum, options.force); 37 | 38 | if (!result.success && result.message.includes('No process found on port')) { 39 | spinner.text = 'No listener found, trying PID...'; 40 | result = await killProcessByPid(targetNum, options.force); 41 | } 42 | } else { 43 | spinner.text = 'Treating as PID...'; 44 | result = await killProcessByPid(targetNum, options.force); 45 | } 46 | } 47 | 48 | spinner.stop(); 49 | 50 | if (result.success) { 51 | console.log(formatSuccess(result.message)); 52 | process.exit(0); 53 | } else { 54 | console.error(formatError(result.message)); 55 | process.exit(1); 56 | } 57 | } catch (error: any) { 58 | spinner.stop(); 59 | console.error(formatError(error.message)); 60 | process.exit(1); 61 | } 62 | }); 63 | -------------------------------------------------------------------------------- /packages/app/test/utils.ts: -------------------------------------------------------------------------------- 1 | import { _electron as electron, ElectronApplication, Page } from 'playwright'; 2 | import path from 'path'; 3 | 4 | export interface ElectronTestContext { 5 | app: ElectronApplication; 6 | window: Page; 7 | } 8 | 9 | /** 10 | * Launch the Electron app for testing 11 | */ 12 | export async function launchElectronApp(): Promise { 13 | // Launch Electron app 14 | const app = await electron.launch({ 15 | args: [path.join(__dirname, '..', 'dist', 'main', 'main.js')], 16 | env: { 17 | ...process.env, 18 | NODE_ENV: 'production', 19 | }, 20 | }); 21 | 22 | // Wait for the first window to be created 23 | const window = await app.firstWindow(); 24 | 25 | // Wait for the app to be ready 26 | await window.waitForLoadState('domcontentloaded'); 27 | 28 | return { app, window }; 29 | } 30 | 31 | /** 32 | * Close the Electron app 33 | */ 34 | export async function closeElectronApp(context: ElectronTestContext): Promise { 35 | await context.app.close(); 36 | } 37 | 38 | /** 39 | * Wait for an element to be visible 40 | */ 41 | export async function waitForElement(window: Page, selector: string, timeout = 5000): Promise { 42 | await window.waitForSelector(selector, { state: 'visible', timeout }); 43 | } 44 | 45 | /** 46 | * Get text content of an element 47 | */ 48 | export async function getElementText(window: Page, selector: string): Promise { 49 | const element = await window.waitForSelector(selector); 50 | return (await element.textContent()) || ''; 51 | } 52 | 53 | /** 54 | * Click an element 55 | */ 56 | export async function clickElement(window: Page, selector: string): Promise { 57 | await window.click(selector); 58 | } 59 | 60 | /** 61 | * Type into an input field 62 | */ 63 | export async function typeIntoInput(window: Page, selector: string, text: string): Promise { 64 | await window.fill(selector, text); 65 | } 66 | 67 | /** 68 | * Wait for a specific condition 69 | */ 70 | export async function waitFor(condition: () => Promise, timeout = 5000): Promise { 71 | const startTime = Date.now(); 72 | 73 | while (Date.now() - startTime < timeout) { 74 | if (await condition()) { 75 | return; 76 | } 77 | await new Promise(resolve => setTimeout(resolve, 100)); 78 | } 79 | 80 | throw new Error(`Condition not met within ${timeout}ms`); 81 | } 82 | -------------------------------------------------------------------------------- /packages/cli/src/commands/watch.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { scanPortsEnriched, FilterOptions, loadConfig } from '@portwatch/core'; 3 | import { formatPortTable, formatError } from '../utils/formatter'; 4 | 5 | export const watchCommand = new Command('watch') 6 | .description('Watch ports with auto-refresh') 7 | .option('-p, --port ', 'Filter by port number', parseInt) 8 | .option('-r, --port-range ', 'Filter by port range (e.g., 3000-3100)') 9 | .option('--pid ', 'Filter by process ID', parseInt) 10 | .option('-n, --name ', 'Filter by process name (substring match)') 11 | .option('--prefix ', 'Filter by process name prefix') 12 | .option('-d, --dir ', 'Filter by working directory') 13 | .option('-i, --interval ', 'Refresh interval in seconds (default: 5)', parseInt, 5) 14 | .action(async (options) => { 15 | // Build filter options 16 | const filters: FilterOptions = {}; 17 | if (options.port) filters.port = options.port; 18 | if (options.portRange) { 19 | const [min, max] = options.portRange.split('-').map((s: string) => parseInt(s.trim(), 10)); 20 | if (isNaN(min) || isNaN(max) || min > max) { 21 | console.error(formatError('Invalid port range. Use format: 3000-3100')); 22 | process.exit(1); 23 | } 24 | filters.portRange = { min, max }; 25 | } 26 | if (options.pid) filters.pid = options.pid; 27 | if (options.name) filters.processName = options.name; 28 | if (options.prefix) filters.processPrefix = options.prefix; 29 | if (options.dir) filters.workingDirectory = options.dir; 30 | 31 | const intervalMs = options.interval * 1000; 32 | 33 | // Load user config 34 | const config = await loadConfig(); 35 | 36 | // Function to refresh and display ports 37 | const refresh = async () => { 38 | try { 39 | // Clear screen 40 | console.clear(); 41 | 42 | // Show header 43 | console.log('PortWatch - Live Mode (Press Ctrl+C to exit)'); 44 | console.log(`Refreshing every ${options.interval}s\n`); 45 | 46 | // Scan ports 47 | const ports = await scanPortsEnriched(filters); 48 | 49 | // Display results 50 | console.log(formatPortTable(ports, config.display)); 51 | } catch (error: any) { 52 | console.error(formatError(error.message)); 53 | } 54 | }; 55 | 56 | // Initial refresh 57 | await refresh(); 58 | 59 | // Set up interval 60 | setInterval(refresh, intervalMs); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/core/test/port-scanner.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | 3 | // Mock child_process to control exec output 4 | const execMock = vi.fn(); 5 | 6 | vi.mock('child_process', () => ({ 7 | exec: execMock, 8 | })); 9 | 10 | describe('port-scanner', () => { 11 | beforeEach(() => { 12 | execMock.mockReset(); 13 | vi.resetModules(); 14 | }); 15 | 16 | it('applies basic filters before enrichment', async () => { 17 | const lsofOutput = [ 18 | 'node 123 user 21u IPv4 0x1 0t0 TCP *:3000 (LISTEN)', 19 | 'python 456 user 22u IPv4 0x2 0t0 TCP 127.0.0.1:8000 (LISTEN)', 20 | ].join('\n'); 21 | 22 | execMock.mockImplementation((command: string, callback: any) => { 23 | if (command.startsWith('lsof -i')) { 24 | callback(null, { stdout: lsofOutput, stderr: '' }); 25 | } else { 26 | callback(new Error(`Unexpected command: ${command}`)); 27 | } 28 | return {} as any; 29 | }); 30 | 31 | const { scanPorts } = await import('../src/port-scanner'); 32 | const result = await scanPorts({ portRange: { min: 3000, max: 3000 } }); 33 | 34 | expect(result).toHaveLength(1); 35 | expect(result[0]?.port).toBe(3000); 36 | expect(result[0]?.pid).toBe(123); 37 | }); 38 | 39 | it('filters workingDirectory after enrichment', async () => { 40 | const lsofOutput = [ 41 | 'node 123 user 21u IPv4 0x1 0t0 TCP *:3000 (LISTEN)', 42 | 'python 456 user 22u IPv4 0x2 0t0 TCP 127.0.0.1:8000 (LISTEN)', 43 | ].join('\n'); 44 | 45 | execMock.mockImplementation((command: string, callback: any) => { 46 | if (command.startsWith('lsof -i')) { 47 | callback(null, { stdout: lsofOutput, stderr: '' }); 48 | } else if (command.startsWith('lsof -p 123')) { 49 | callback(null, { stdout: 'node cwd /Users/test/app', stderr: '' }); 50 | } else if (command.startsWith('lsof -p 456')) { 51 | callback(null, { stdout: 'python cwd /tmp/other', stderr: '' }); 52 | } else if (command.startsWith('ps -p 123')) { 53 | callback(null, { stdout: 'node /Users/test/app/server.js', stderr: '' }); 54 | } else if (command.startsWith('ps -p 456')) { 55 | callback(null, { stdout: 'python /tmp/other/app.py', stderr: '' }); 56 | } else { 57 | callback(new Error(`Unexpected command: ${command}`)); 58 | } 59 | return {} as any; 60 | }); 61 | 62 | const { scanPortsEnriched } = await import('../src/port-scanner'); 63 | const result = await scanPortsEnriched({ workingDirectory: 'app' }); 64 | 65 | expect(result).toHaveLength(1); 66 | expect(result[0]?.pid).toBe(123); 67 | expect(result[0]?.workingDirectory).toContain('/Users/test/app'); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/cli/src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import ora from 'ora'; 3 | import { scanPorts, scanPortsEnriched, FilterOptions, loadConfig } from '@portwatch/core'; 4 | import { formatPortTable, formatError, formatJson, formatWarning } from '../utils/formatter'; 5 | 6 | export const listCommand = new Command('list') 7 | .alias('ls') 8 | .description('List all processes listening on ports') 9 | .option('-p, --port ', 'Filter by port number', parseInt) 10 | .option('-r, --port-range ', 'Filter by port range (e.g., 3000-3100)') 11 | .option('--pid ', 'Filter by process ID', parseInt) 12 | .option('-n, --name ', 'Filter by process name (substring match)') 13 | .option('--prefix ', 'Filter by process name prefix') 14 | .option('-d, --dir ', 'Filter by working directory') 15 | .option('-j, --json', 'Output as JSON') 16 | .option('--no-enriched', 'Skip fetching working directory and command (faster)') 17 | .action(async (options) => { 18 | const spinner = ora('Scanning ports...').start(); 19 | 20 | try { 21 | // Build filter options 22 | const filters: FilterOptions = {}; 23 | if (options.port) filters.port = options.port; 24 | if (options.portRange) { 25 | const [min, max] = options.portRange.split('-').map((s: string) => parseInt(s.trim(), 10)); 26 | if (isNaN(min) || isNaN(max) || min > max) { 27 | spinner.stop(); 28 | console.error(formatError('Invalid port range. Use format: 3000-3100')); 29 | process.exit(1); 30 | } 31 | filters.portRange = { min, max }; 32 | } 33 | if (options.pid) filters.pid = options.pid; 34 | if (options.name) filters.processName = options.name; 35 | if (options.prefix) filters.processPrefix = options.prefix; 36 | if (options.dir) filters.workingDirectory = options.dir; 37 | 38 | const needsEnrichment = options.enriched !== false || !!filters.workingDirectory; 39 | 40 | if (filters.workingDirectory && options.enriched === false) { 41 | console.error(formatWarning('Working directory filtering requires enrichment; ignoring --no-enriched.')); 42 | } 43 | 44 | // Scan ports (honor --no-enriched unless working-directory filtering is requested) 45 | const ports = needsEnrichment 46 | ? await scanPortsEnriched(filters) 47 | : await scanPorts(filters); 48 | 49 | spinner.stop(); 50 | 51 | // Output results 52 | if (options.json) { 53 | console.log(formatJson(ports)); 54 | } else { 55 | // Load user config for display preferences 56 | const config = await loadConfig(); 57 | console.log(formatPortTable(ports, config.display)); 58 | } 59 | 60 | process.exit(0); 61 | } catch (error: any) { 62 | spinner.stop(); 63 | console.error(formatError(error.message)); 64 | process.exit(1); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /packages/app/create-menubar-icon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const { execSync } = require('child_process'); 5 | const path = require('path'); 6 | 7 | // Create SVG icon for menu bar (simple port symbol) 8 | function createMenubarSVG(size) { 9 | const padding = size * 0.2; 10 | const innerSize = size - (padding * 2); 11 | const strokeWidth = Math.max(1.5, size / 12); 12 | 13 | return ` 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | `; 31 | } 32 | 33 | async function generateMenubarIcons() { 34 | const assetsDir = path.join(__dirname, 'assets'); 35 | 36 | console.log('🎨 Creating menu bar icons...'); 37 | 38 | // Create standard and retina versions 39 | const icons = [ 40 | { size: 22, name: 'IconTemplate.png' }, 41 | { size: 44, name: 'IconTemplate@2x.png' } 42 | ]; 43 | 44 | for (const { size, name } of icons) { 45 | const svgContent = createMenubarSVG(size); 46 | const svgPath = path.join(assetsDir, `temp_menubar_${size}.svg`); 47 | const pngPath = path.join(assetsDir, name); 48 | 49 | // Write SVG 50 | fs.writeFileSync(svgPath, svgContent); 51 | 52 | try { 53 | // Convert using qlmanage 54 | execSync(`qlmanage -t -s ${size} -o ${assetsDir} ${svgPath} 2>/dev/null`, { stdio: 'pipe' }); 55 | 56 | // Rename output 57 | const qlOutput = path.join(assetsDir, `temp_menubar_${size}.svg.png`); 58 | if (fs.existsSync(qlOutput)) { 59 | fs.renameSync(qlOutput, pngPath); 60 | const stats = fs.statSync(pngPath); 61 | console.log(` ✓ Created ${name} (${size}×${size}, ${stats.size} bytes)`); 62 | } 63 | } catch (error) { 64 | console.error(` ✗ Failed to create ${name}:`, error.message); 65 | } 66 | 67 | // Clean up SVG 68 | if (fs.existsSync(svgPath)) { 69 | fs.unlinkSync(svgPath); 70 | } 71 | } 72 | 73 | console.log('\n✅ Menu bar icons updated!'); 74 | } 75 | 76 | generateMenubarIcons().catch(console.error); 77 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portwatch/app", 3 | "version": "1.1.0", 4 | "description": "PortWatch menu bar app", 5 | "type": "module", 6 | "main": "dist/main/main.cjs", 7 | "scripts": { 8 | "postinstall": "chmod +x ../../node_modules/7zip-bin/mac/*/7za || true", 9 | "dev": "electron-vite dev", 10 | "build": "electron-vite build", 11 | "preview": "electron-vite preview", 12 | "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json", 13 | "test": "npm run build && playwright test", 14 | "test:headed": "npm run build && playwright test --headed", 15 | "test:debug": "npm run build && playwright test --debug", 16 | "test:ui": "npm run build && playwright test --ui", 17 | "dist": "npm run build && electron-builder", 18 | "dist:mac": "npm run build && electron-builder --mac", 19 | "dist:win": "npm run build && electron-builder --win", 20 | "dist:linux": "npm run build && electron-builder --linux" 21 | }, 22 | "keywords": [ 23 | "port", 24 | "monitoring", 25 | "electron", 26 | "menubar" 27 | ], 28 | "author": "dingran", 29 | "license": "MIT", 30 | "dependencies": { 31 | "@portwatch/core": "^1.0.0", 32 | "electron-store": "^8.1.0", 33 | "menubar": "^9.5.1" 34 | }, 35 | "devDependencies": { 36 | "@playwright/test": "^1.56.1", 37 | "@types/node": "^20.11.0", 38 | "@types/react": "^18.2.48", 39 | "@types/react-dom": "^18.2.18", 40 | "@vitejs/plugin-react": "^4.2.1", 41 | "autoprefixer": "^10.4.17", 42 | "concurrently": "^8.2.2", 43 | "electron": "^28.1.4", 44 | "electron-builder": "^24.9.1", 45 | "electron-vite": "^4.0.1", 46 | "playwright": "^1.56.1", 47 | "pngjs": "^7.0.0", 48 | "postcss": "^8.4.33", 49 | "react": "^18.2.0", 50 | "react-dom": "^18.2.0", 51 | "tailwindcss": "^3.4.1", 52 | "typescript": "^5.3.3", 53 | "vite": "^5.0.11", 54 | "vite-plugin-electron": "^0.28.2", 55 | "wait-on": "^7.2.0" 56 | }, 57 | "build": { 58 | "appId": "com.portwatch.app", 59 | "productName": "PortWatch", 60 | "electronVersion": "28.3.3", 61 | "compression": "store", 62 | "files": [ 63 | "dist/**/*", 64 | "package.json" 65 | ], 66 | "extraResources": [ 67 | { 68 | "from": "assets", 69 | "to": "assets", 70 | "filter": [ 71 | "**/*" 72 | ] 73 | } 74 | ], 75 | "mac": { 76 | "category": "public.app-category.developer-tools", 77 | "target": [ 78 | { 79 | "target": "dmg", 80 | "arch": ["arm64", "x64"] 81 | }, 82 | { 83 | "target": "zip", 84 | "arch": ["arm64", "x64"] 85 | } 86 | ], 87 | "icon": "assets/icon.icns" 88 | }, 89 | "dmg": { 90 | "title": "${productName} ${version}", 91 | "icon": "assets/icon.icns" 92 | }, 93 | "directories": { 94 | "output": "release", 95 | "buildResources": "assets" 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/app/test/smoke.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { launchElectronApp, closeElectronApp, ElectronTestContext } from './utils'; 3 | 4 | test.describe('Smoke Tests', () => { 5 | let context: ElectronTestContext; 6 | 7 | test.beforeAll(async () => { 8 | // Build the app before running tests 9 | // This assumes `npm run build` has already been run 10 | console.log('Launching Electron app...'); 11 | }); 12 | 13 | test.beforeEach(async () => { 14 | context = await launchElectronApp(); 15 | }); 16 | 17 | test.afterEach(async () => { 18 | if (context) { 19 | await closeElectronApp(context); 20 | } 21 | }); 22 | 23 | test('app launches successfully', async () => { 24 | const { app } = context; 25 | 26 | // App should be running 27 | expect(app).toBeTruthy(); 28 | 29 | // Check if window is created 30 | const windows = app.windows(); 31 | expect(windows.length).toBeGreaterThan(0); 32 | }); 33 | 34 | test('window has correct title', async () => { 35 | const { window } = context; 36 | 37 | // Wait for the page to load 38 | await window.waitForLoadState('domcontentloaded'); 39 | 40 | // Check title 41 | const title = await window.title(); 42 | expect(title).toBe('PortWatch'); 43 | }); 44 | 45 | test('main UI elements are rendered', async () => { 46 | const { window } = context; 47 | 48 | // Wait for React to render 49 | await window.waitForSelector('text=PortWatch', { timeout: 10000 }); 50 | 51 | // Wait for initial loading to complete (wait for Loading... to disappear) 52 | await window.waitForFunction(() => { 53 | const loadingText = document.body.textContent; 54 | return !loadingText?.includes('Loading...'); 55 | }, { timeout: 10000 }); 56 | 57 | // Check for header 58 | const header = await window.textContent('h1'); 59 | expect(header).toContain('PortWatch'); 60 | 61 | // Check for refresh button 62 | const refreshButton = await window.locator('button:has-text("↻")'); 63 | expect(await refreshButton.isVisible()).toBe(true); 64 | 65 | // Check for auto-refresh toggle 66 | const autoButton = await window.locator('button:has-text("Auto")'); 67 | expect(await autoButton.isVisible()).toBe(true); 68 | }); 69 | 70 | test('app quits cleanly', async () => { 71 | const { app } = context; 72 | 73 | // Close the app - should complete without errors 74 | await expect(app.close()).resolves.not.toThrow(); 75 | 76 | // Verify all windows are closed 77 | const windows = app.windows(); 78 | expect(windows.length).toBe(0); 79 | }); 80 | 81 | test('window dimensions are correct', async () => { 82 | const { app, window } = context; 83 | 84 | // Get window bounds using Electron API 85 | const bounds = await app.evaluate(async ({ BrowserWindow }) => { 86 | const win = BrowserWindow.getAllWindows()[0]; 87 | return win.getBounds(); 88 | }); 89 | 90 | // Should match the dimensions set in main.ts (400x600) 91 | expect(bounds.width).toBe(400); 92 | expect(bounds.height).toBe(600); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /packages/cli/src/commands/config.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { loadConfig, saveConfig, resetConfig, getConfigPath } from '@portwatch/core'; 3 | import { formatSuccess, formatError, formatInfo, formatJson } from '../utils/formatter'; 4 | 5 | export const configCommand = new Command('config') 6 | .description('Manage configuration') 7 | .addCommand( 8 | new Command('show') 9 | .description('Show current configuration') 10 | .action(async () => { 11 | try { 12 | const config = await loadConfig(); 13 | console.log(formatJson(config)); 14 | } catch (error: any) { 15 | console.error(formatError(error.message)); 16 | process.exit(1); 17 | } 18 | }) 19 | ) 20 | .addCommand( 21 | new Command('path') 22 | .description('Show configuration file path') 23 | .action(() => { 24 | console.log(formatInfo(`Config file: ${getConfigPath()}`)); 25 | }) 26 | ) 27 | .addCommand( 28 | new Command('reset') 29 | .description('Reset configuration to defaults') 30 | .action(async () => { 31 | try { 32 | await resetConfig(); 33 | console.log(formatSuccess('Configuration reset to defaults')); 34 | } catch (error: any) { 35 | console.error(formatError(error.message)); 36 | process.exit(1); 37 | } 38 | }) 39 | ) 40 | .addCommand( 41 | new Command('set') 42 | .description('Set a configuration value') 43 | .argument('', 'Configuration key (e.g., display.showCommand, refresh.intervalMs)') 44 | .argument('', 'Value to set') 45 | .action(async (key: string, value: string) => { 46 | try { 47 | const config = await loadConfig(); 48 | 49 | // Parse key path 50 | const parts = key.split('.'); 51 | 52 | if (parts.length !== 2) { 53 | console.error(formatError('Key must be in format: section.property (e.g., display.showCommand)')); 54 | process.exit(1); 55 | } 56 | 57 | const [section, property] = parts; 58 | 59 | // Validate section 60 | if (section !== 'display' && section !== 'refresh') { 61 | console.error(formatError('Section must be "display" or "refresh"')); 62 | process.exit(1); 63 | } 64 | 65 | // Parse value 66 | let parsedValue: any = value; 67 | if (value === 'true') parsedValue = true; 68 | else if (value === 'false') parsedValue = false; 69 | else if (!isNaN(Number(value))) parsedValue = Number(value); 70 | 71 | // Update config 72 | if (section === 'display') { 73 | (config.display as any)[property] = parsedValue; 74 | } else if (section === 'refresh') { 75 | (config.refresh as any)[property] = parsedValue; 76 | } 77 | 78 | // Save config 79 | await saveConfig(config); 80 | console.log(formatSuccess(`Set ${key} = ${parsedValue}`)); 81 | } catch (error: any) { 82 | console.error(formatError(error.message)); 83 | process.exit(1); 84 | } 85 | }) 86 | ); 87 | -------------------------------------------------------------------------------- /packages/core/src/process-manager.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | import { getPortInfo } from './port-scanner'; 4 | 5 | const execAsync = promisify(exec); 6 | 7 | /** 8 | * Result of a process kill operation 9 | */ 10 | export interface KillResult { 11 | success: boolean; 12 | message: string; 13 | pid?: number; 14 | port?: number; 15 | } 16 | 17 | /** 18 | * Kills a process by PID 19 | */ 20 | export async function killProcessByPid(pid: number, force: boolean = false): Promise { 21 | try { 22 | // Validate PID 23 | if (!Number.isInteger(pid) || pid <= 0) { 24 | return { 25 | success: false, 26 | message: `Invalid PID: ${pid}`, 27 | pid, 28 | }; 29 | } 30 | 31 | // Check if process exists 32 | try { 33 | await execAsync(`ps -p ${pid} -o pid=`); 34 | } catch { 35 | return { 36 | success: false, 37 | message: `Process ${pid} not found`, 38 | pid, 39 | }; 40 | } 41 | 42 | // Kill the process 43 | const signal = force ? '-9' : '-15'; 44 | await execAsync(`kill ${signal} ${pid}`); 45 | 46 | return { 47 | success: true, 48 | message: `Successfully killed process ${pid}${force ? ' (forced)' : ''}`, 49 | pid, 50 | }; 51 | } catch (error: any) { 52 | return { 53 | success: false, 54 | message: `Failed to kill process ${pid}: ${error.message}`, 55 | pid, 56 | }; 57 | } 58 | } 59 | 60 | /** 61 | * Kills a process by port number 62 | */ 63 | export async function killProcessByPort(port: number, force: boolean = false): Promise { 64 | try { 65 | // Validate port 66 | if (!Number.isInteger(port) || port < 0 || port > 65535) { 67 | return { 68 | success: false, 69 | message: `Invalid port: ${port}`, 70 | port, 71 | }; 72 | } 73 | 74 | // Find process on port 75 | const portInfo = await getPortInfo(port); 76 | 77 | if (!portInfo) { 78 | return { 79 | success: false, 80 | message: `No process found on port ${port}`, 81 | port, 82 | }; 83 | } 84 | 85 | // Kill the process 86 | const result = await killProcessByPid(portInfo.pid, force); 87 | 88 | return { 89 | ...result, 90 | port, 91 | message: result.success 92 | ? `Successfully killed ${portInfo.processName} (PID ${portInfo.pid}) on port ${port}${force ? ' (forced)' : ''}` 93 | : result.message, 94 | }; 95 | } catch (error: any) { 96 | return { 97 | success: false, 98 | message: `Failed to kill process on port ${port}: ${error.message}`, 99 | port, 100 | }; 101 | } 102 | } 103 | 104 | /** 105 | * Checks if a process is running 106 | */ 107 | export async function isProcessRunning(pid: number): Promise { 108 | try { 109 | await execAsync(`ps -p ${pid} -o pid=`); 110 | return true; 111 | } catch { 112 | return false; 113 | } 114 | } 115 | 116 | /** 117 | * Gets process name by PID 118 | */ 119 | export async function getProcessName(pid: number): Promise { 120 | try { 121 | const { stdout } = await execAsync(`ps -p ${pid} -o comm=`); 122 | return stdout.trim() || null; 123 | } catch { 124 | return null; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /packages/app/fix-menubar-icon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { PNG } = require('pngjs'); 6 | 7 | // Create a simple but visible icon programmatically 8 | function createIcon(size) { 9 | const png = new PNG({ width: size, height: size }); 10 | 11 | // Fill with transparent background 12 | for (let y = 0; y < size; y++) { 13 | for (let x = 0; x < size; x++) { 14 | const idx = (size * y + x) << 2; 15 | png.data[idx] = 0; // R 16 | png.data[idx + 1] = 0; // G 17 | png.data[idx + 2] = 0; // B 18 | png.data[idx + 3] = 0; // A (transparent) 19 | } 20 | } 21 | 22 | // Draw a simple "P" shape in solid black 23 | const padding = Math.floor(size * 0.15); 24 | const thickness = Math.max(2, Math.floor(size * 0.12)); 25 | 26 | // Vertical line of P 27 | for (let y = padding; y < size - padding; y++) { 28 | for (let x = padding; x < padding + thickness; x++) { 29 | const idx = (size * y + x) << 2; 30 | png.data[idx] = 0; // R 31 | png.data[idx + 1] = 0; // G 32 | png.data[idx + 2] = 0; // B 33 | png.data[idx + 3] = 255; // A (opaque black) 34 | } 35 | } 36 | 37 | // Top horizontal line of P 38 | const midY = Math.floor(size / 2); 39 | for (let y = padding; y < padding + thickness; y++) { 40 | for (let x = padding; x < size - padding; x++) { 41 | const idx = (size * y + x) << 2; 42 | png.data[idx] = 0; 43 | png.data[idx + 1] = 0; 44 | png.data[idx + 2] = 0; 45 | png.data[idx + 3] = 255; 46 | } 47 | } 48 | 49 | // Right vertical line of P (top half) 50 | for (let y = padding; y < midY; y++) { 51 | for (let x = size - padding - thickness; x < size - padding; x++) { 52 | const idx = (size * y + x) << 2; 53 | png.data[idx] = 0; 54 | png.data[idx + 1] = 0; 55 | png.data[idx + 2] = 0; 56 | png.data[idx + 3] = 255; 57 | } 58 | } 59 | 60 | // Middle horizontal line of P 61 | for (let y = midY - thickness; y < midY; y++) { 62 | for (let x = padding; x < size - padding; x++) { 63 | const idx = (size * y + x) << 2; 64 | png.data[idx] = 0; 65 | png.data[idx + 1] = 0; 66 | png.data[idx + 2] = 0; 67 | png.data[idx + 3] = 255; 68 | } 69 | } 70 | 71 | return png; 72 | } 73 | 74 | async function generateIcons() { 75 | const assetsDir = path.join(__dirname, 'assets'); 76 | 77 | console.log('🎨 Creating menu bar template icons...'); 78 | 79 | // Check if pngjs is available 80 | try { 81 | require.resolve('pngjs'); 82 | } catch (e) { 83 | console.error('❌ pngjs not found. Installing...'); 84 | require('child_process').execSync('npm install pngjs --save-dev', { stdio: 'inherit' }); 85 | } 86 | 87 | const icons = [ 88 | { size: 22, name: 'IconTemplate.png' }, 89 | { size: 44, name: 'IconTemplate@2x.png' } 90 | ]; 91 | 92 | for (const { size, name } of icons) { 93 | const png = createIcon(size); 94 | const filepath = path.join(assetsDir, name); 95 | 96 | const buffer = PNG.sync.write(png); 97 | fs.writeFileSync(filepath, buffer); 98 | 99 | console.log(` ✓ Created ${name} (${size}×${size}, ${buffer.length} bytes)`); 100 | } 101 | 102 | console.log('\n✅ Menu bar template icons created successfully!'); 103 | console.log('Note: These icons use solid black (#000000) on transparent background'); 104 | console.log(' They will render correctly in both light and dark menu bar themes'); 105 | } 106 | 107 | generateIcons().catch(console.error); 108 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process for PortWatch 2 | 3 | This guide explains how to create a new release and make it available via Homebrew. 4 | 5 | ## Prerequisites 6 | 7 | - All changes committed and pushed to GitHub 8 | - Built distribution files in `packages/app/release/` 9 | 10 | ## Step 1: Create GitHub Release 11 | 12 | 1. **Push your commits to GitHub:** 13 | ```bash 14 | git push origin main 15 | ``` 16 | 17 | 2. **Go to GitHub and create a new release:** 18 | - Visit: https://github.com/dingran/portwatch/releases/new 19 | - Tag version: `v1.0.0` 20 | - Release title: `PortWatch v1.0.0` 21 | - Description: Brief summary of features 22 | 23 | 3. **Upload the distribution files:** 24 | - Drag and drop these 4 files from `packages/app/release/`: 25 | - `PortWatch-1.0.0-arm64-mac.zip` 26 | - `PortWatch-1.0.0-arm64.dmg` 27 | - `PortWatch-1.0.0-mac.zip` 28 | - `PortWatch-1.0.0.dmg` 29 | 30 | 4. **Publish the release** 31 | 32 | ## Step 2: Calculate SHA256 Checksums 33 | 34 | After creating the GitHub release, calculate checksums for the ZIP files: 35 | 36 | ```bash 37 | # Apple Silicon (arm64) 38 | shasum -a 256 packages/app/release/PortWatch-1.0.0-arm64-mac.zip 39 | 40 | # Intel (x64) 41 | shasum -a 256 packages/app/release/PortWatch-1.0.0-mac.zip 42 | ``` 43 | 44 | Copy these SHA256 values for the next step. 45 | 46 | ## Step 3: Update Homebrew Cask Formula 47 | 48 | 1. **Edit `homebrew/portwatch.rb`** and replace: 49 | - `REPLACE_WITH_ARM64_SHA256` with the arm64 ZIP checksum 50 | - `REPLACE_WITH_INTEL_SHA256` with the Intel ZIP checksum 51 | 52 | 2. **Test the formula locally:** 53 | ```bash 54 | # Install from local formula 55 | brew install --cask homebrew/portwatch.rb 56 | 57 | # Test it works 58 | # The app should appear in Applications folder 59 | 60 | # Uninstall 61 | brew uninstall --cask portwatch 62 | ``` 63 | 64 | ## Step 4: Submit to Homebrew Cask (Optional) 65 | 66 | To make it available to everyone via `brew install --cask portwatch`: 67 | 68 | 1. **Fork the Homebrew Cask repository:** 69 | - https://github.com/Homebrew/homebrew-cask 70 | 71 | 2. **Add your formula:** 72 | - Copy `homebrew/portwatch.rb` to `Casks/p/portwatch.rb` 73 | 74 | 3. **Create a pull request:** 75 | - Title: "Add PortWatch v1.0.0" 76 | - Description: Brief explanation of what the app does 77 | 78 | 4. **Wait for review:** 79 | - Homebrew maintainers will review and merge 80 | 81 | ## Alternative: Create Your Own Tap 82 | 83 | Instead of submitting to official Homebrew Cask, you can create your own tap: 84 | 85 | 1. **Create a new GitHub repo:** 86 | - Name: `homebrew-tap` 87 | - URL: https://github.com/dingran/homebrew-tap 88 | 89 | 2. **Add the formula:** 90 | - Create folder: `Casks/` 91 | - Copy `homebrew/portwatch.rb` to `Casks/portwatch.rb` 92 | - Commit and push 93 | 94 | 3. **Users can install with:** 95 | ```bash 96 | brew tap dingran/tap 97 | brew install --cask dingran/tap/portwatch 98 | ``` 99 | 100 | ## Security Warning for Users 101 | 102 | Since the app is not notarized, users will see a security warning on first launch. 103 | 104 | **They need to:** 105 | 1. Right-click the app icon in Applications 106 | 2. Select "Open" 107 | 3. Click "Open" on the security dialog 108 | 109 | OR 110 | 111 | 1. Try to open the app normally (will fail) 112 | 2. Go to System Settings > Privacy & Security 113 | 3. Click "Open Anyway" next to the PortWatch security warning 114 | 115 | After the first launch, macOS remembers the app and opens it normally. 116 | 117 | ## Future: Add Code Signing 118 | 119 | To remove the security warning, you would need: 120 | - Apple Developer account ($99/year) 121 | - Developer ID certificate 122 | - Notarization process 123 | 124 | This is covered in Milestone 2 of the original plan. 125 | -------------------------------------------------------------------------------- /packages/app/generate-simple-icon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const { execSync } = require('child_process'); 5 | const path = require('path'); 6 | 7 | // Create a simple "P" icon with background 8 | function createSimpleIcon(size) { 9 | const fontSize = Math.floor(size * 0.65); 10 | const cornerRadius = Math.floor(size * 0.18); 11 | 12 | return ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | P 35 | `; 36 | } 37 | 38 | // Required icon sizes for macOS 39 | const iconSizes = [ 40 | { size: 16, name: 'icon_16x16.png' }, 41 | { size: 32, name: 'icon_16x16@2x.png' }, 42 | { size: 32, name: 'icon_32x32.png' }, 43 | { size: 64, name: 'icon_32x32@2x.png' }, 44 | { size: 128, name: 'icon_128x128.png' }, 45 | { size: 256, name: 'icon_128x128@2x.png' }, 46 | { size: 256, name: 'icon_256x256.png' }, 47 | { size: 512, name: 'icon_256x256@2x.png' }, 48 | { size: 512, name: 'icon_512x512.png' }, 49 | { size: 1024, name: 'icon_512x512@2x.png' } 50 | ]; 51 | 52 | async function generateIcon() { 53 | const assetsDir = path.join(__dirname, 'assets'); 54 | const iconsetDir = path.join(assetsDir, 'icon.iconset'); 55 | 56 | // Create iconset directory 57 | if (fs.existsSync(iconsetDir)) { 58 | execSync(`rm -rf ${iconsetDir}`); 59 | } 60 | fs.mkdirSync(iconsetDir, { recursive: true }); 61 | 62 | console.log('📝 Generating simple "P" icon...'); 63 | 64 | // Generate each required size 65 | for (const { size, name } of iconSizes) { 66 | const svgContent = createSimpleIcon(size); 67 | const svgPath = path.join(iconsetDir, `temp_${size}.svg`); 68 | const pngPath = path.join(iconsetDir, name); 69 | 70 | // Write SVG 71 | fs.writeFileSync(svgPath, svgContent); 72 | 73 | try { 74 | // Convert SVG to PNG using qlmanage 75 | execSync(`qlmanage -t -s ${size} -o ${iconsetDir} ${svgPath} 2>/dev/null`, { stdio: 'pipe' }); 76 | 77 | // qlmanage creates files with .png.png extension, rename them 78 | const qlOutput = path.join(iconsetDir, `temp_${size}.svg.png`); 79 | if (fs.existsSync(qlOutput)) { 80 | fs.renameSync(qlOutput, pngPath); 81 | console.log(` ✓ Created ${name} (${size}×${size})`); 82 | } 83 | } catch (error) { 84 | console.error(` ✗ Failed to create ${name}:`, error.message); 85 | } 86 | 87 | // Clean up temporary SVG 88 | if (fs.existsSync(svgPath)) { 89 | fs.unlinkSync(svgPath); 90 | } 91 | } 92 | 93 | console.log('\n🔨 Converting to .icns format...'); 94 | 95 | try { 96 | // Convert iconset to .icns using iconutil 97 | const icnsPath = path.join(assetsDir, 'icon.icns'); 98 | execSync(`iconutil -c icns ${iconsetDir} -o ${icnsPath}`); 99 | 100 | const stats = fs.statSync(icnsPath); 101 | console.log(` ✓ Created icon.icns (${Math.round(stats.size / 1024)}KB)`); 102 | 103 | // Clean up iconset directory 104 | execSync(`rm -rf ${iconsetDir}`); 105 | console.log('\n✅ Simple "P" icon generated successfully!'); 106 | 107 | } catch (error) { 108 | console.error(' ✗ Failed to create .icns:', error.message); 109 | } 110 | } 111 | 112 | generateIcon().catch(console.error); 113 | -------------------------------------------------------------------------------- /packages/mcp-server/README.md: -------------------------------------------------------------------------------- 1 | # PortWatch MCP Server 2 | 3 | MCP (Model Context Protocol) server for PortWatch - allows Claude and other AI assistants to query port and process information on your macOS system. 4 | 5 | ## Installation 6 | 7 | From the PortWatch monorepo root: 8 | 9 | ```bash 10 | npm install 11 | npm run build 12 | ``` 13 | 14 | Or install globally: 15 | 16 | ```bash 17 | cd packages/mcp-server 18 | npm install -g . 19 | ``` 20 | 21 | ## Setup with Claude Desktop 22 | 23 | Add the PortWatch MCP server to your Claude Desktop configuration. 24 | 25 | **Quick setup:** 26 | 27 | ```bash 28 | # Run this script to get the exact config for your system 29 | ./get-config.sh 30 | ``` 31 | 32 | Then copy the output and add it to: 33 | `~/Library/Application Support/Claude/claude_desktop_config.json` 34 | 35 | **Manual setup:** 36 | 37 | Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: 38 | 39 | ```json 40 | { 41 | "mcpServers": { 42 | "portwatch": { 43 | "command": "node", 44 | "args": [ 45 | "/ABSOLUTE/PATH/TO/portwatch/packages/mcp-server/dist/index.js" 46 | ] 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | Replace `/ABSOLUTE/PATH/TO/portwatch` with the actual path to your PortWatch repository. 53 | 54 | After updating the config, restart Claude Desktop. 55 | 56 | ## Available Tools 57 | 58 | ### 1. `scan_ports` 59 | Scan for processes listening on ports with optional filtering. 60 | 61 | **Parameters:** 62 | - `port` (number, optional): Filter by exact port number 63 | - `portRangeMin` (number, optional): Minimum port in range 64 | - `portRangeMax` (number, optional): Maximum port in range 65 | - `pid` (number, optional): Filter by process ID 66 | - `processName` (string, optional): Filter by process name (substring match) 67 | - `processPrefix` (string, optional): Filter by process name prefix 68 | - `workingDirectory` (string, optional): Filter by working directory 69 | 70 | **Example usage with Claude:** 71 | - "What processes are running on ports 3000-3100?" 72 | - "Show me all Node.js processes listening on ports" 73 | - "What's running on port 5432?" 74 | 75 | ### 2. `get_port_info` 76 | Get detailed information about a specific port. 77 | 78 | **Parameters:** 79 | - `port` (number, required): Port number to query 80 | 81 | **Example usage with Claude:** 82 | - "What process is on port 8080?" 83 | - "Give me details about port 3000" 84 | 85 | ### 3. `kill_by_port` 86 | Kill the process listening on a specific port. 87 | 88 | **Parameters:** 89 | - `port` (number, required): Port number of process to kill 90 | - `force` (boolean, optional): Use SIGKILL instead of SIGTERM (default: false) 91 | 92 | **Example usage with Claude:** 93 | - "Kill the process on port 3000" 94 | - "Force kill whatever is on port 8080" 95 | 96 | ### 4. `kill_by_pid` 97 | Kill a process by its PID. 98 | 99 | **Parameters:** 100 | - `pid` (number, required): Process ID to kill 101 | - `force` (boolean, optional): Use SIGKILL instead of SIGTERM (default: false) 102 | 103 | **Example usage with Claude:** 104 | - "Kill process 12345" 105 | - "Force kill PID 67890" 106 | 107 | ## Response Format 108 | 109 | All tools return JSON data. Example response from `scan_ports`: 110 | 111 | ```json 112 | [ 113 | { 114 | "port": 3000, 115 | "pid": 12345, 116 | "processName": "node", 117 | "address": "0.0.0.0", 118 | "protocol": "TCP", 119 | "workingDirectory": "/Users/username/project", 120 | "command": "node server.js" 121 | } 122 | ] 123 | ``` 124 | 125 | ## Development 126 | 127 | Build the server: 128 | ```bash 129 | npm run build 130 | ``` 131 | 132 | Watch mode for development: 133 | ```bash 134 | npm run dev 135 | ``` 136 | 137 | Test the server manually: 138 | ```bash 139 | npm start 140 | ``` 141 | 142 | ## Requirements 143 | 144 | - macOS (uses `lsof` command) 145 | - Node.js 20+ 146 | - MCP-compatible client (e.g., Claude Desktop) 147 | 148 | ## Security Note 149 | 150 | This MCP server has access to: 151 | - View all processes listening on ports 152 | - Kill processes on your system 153 | 154 | Only use this with trusted MCP clients. The server runs with your user permissions. 155 | -------------------------------------------------------------------------------- /packages/cli/src/utils/formatter.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import Table from 'cli-table3'; 3 | import { PortInfo, DisplayConfig } from '@portwatch/core'; 4 | 5 | /** 6 | * Formats port information as a table 7 | */ 8 | export function formatPortTable(ports: PortInfo[], config?: DisplayConfig): string { 9 | if (ports.length === 0) { 10 | return chalk.yellow('No processes listening on ports found.'); 11 | } 12 | 13 | // Determine which columns to show 14 | const showPort = config?.showPort !== false; 15 | const showPid = config?.showPid !== false; 16 | const showProcessName = config?.showProcessName !== false; 17 | const showWorkingDirectory = config?.showWorkingDirectory !== false; 18 | const showCommand = config?.showCommand === true; 19 | const showAddress = config?.showAddress === true; 20 | const showProtocol = config?.showProtocol === true; 21 | 22 | // Build table headers 23 | const headers: string[] = []; 24 | if (showPort) headers.push(chalk.bold('PORT')); 25 | if (showPid) headers.push(chalk.bold('PID')); 26 | if (showProcessName) headers.push(chalk.bold('PROCESS')); 27 | if (showWorkingDirectory) headers.push(chalk.bold('DIRECTORY')); 28 | if (showCommand) headers.push(chalk.bold('COMMAND')); 29 | if (showAddress) headers.push(chalk.bold('ADDRESS')); 30 | if (showProtocol) headers.push(chalk.bold('PROTOCOL')); 31 | 32 | // Create table 33 | const table = new Table({ 34 | head: headers, 35 | style: { 36 | head: ['cyan'], 37 | }, 38 | wordWrap: true, 39 | wrapOnWordBoundary: true, 40 | colWidths: showCommand ? undefined : [8, 8, 15, 40], 41 | }); 42 | 43 | // Add rows 44 | for (const port of ports) { 45 | const row: string[] = []; 46 | if (showPort) row.push(chalk.green(port.port.toString())); 47 | if (showPid) row.push(port.pid.toString()); 48 | if (showProcessName) row.push(chalk.blue(port.processName)); 49 | if (showWorkingDirectory) { 50 | const dir = port.workingDirectory.replace(process.env.HOME || '', '~'); 51 | row.push(chalk.gray(dir)); 52 | } 53 | if (showCommand) row.push(chalk.gray(truncate(port.command, 50))); 54 | if (showAddress) row.push(port.address); 55 | if (showProtocol) row.push(port.protocol); 56 | 57 | table.push(row); 58 | } 59 | 60 | return table.toString(); 61 | } 62 | 63 | /** 64 | * Formats a single port info as detailed output 65 | */ 66 | export function formatPortDetails(port: PortInfo): string { 67 | const lines = [ 68 | chalk.bold.cyan('Port Details:'), 69 | '', 70 | `${chalk.bold('Port:')} ${chalk.green(port.port.toString())}`, 71 | `${chalk.bold('PID:')} ${port.pid}`, 72 | `${chalk.bold('Process Name:')} ${chalk.blue(port.processName)}`, 73 | `${chalk.bold('Working Directory:')} ${chalk.gray(port.workingDirectory)}`, 74 | `${chalk.bold('Command:')} ${chalk.gray(port.command)}`, 75 | `${chalk.bold('Address:')} ${port.address}`, 76 | `${chalk.bold('Protocol:')} ${port.protocol}`, 77 | ]; 78 | 79 | return lines.join('\n'); 80 | } 81 | 82 | /** 83 | * Formats a success message 84 | */ 85 | export function formatSuccess(message: string): string { 86 | return chalk.green('✓ ') + message; 87 | } 88 | 89 | /** 90 | * Formats an error message 91 | */ 92 | export function formatError(message: string): string { 93 | return chalk.red('✗ ') + message; 94 | } 95 | 96 | /** 97 | * Formats a warning message 98 | */ 99 | export function formatWarning(message: string): string { 100 | return chalk.yellow('⚠ ') + message; 101 | } 102 | 103 | /** 104 | * Formats an info message 105 | */ 106 | export function formatInfo(message: string): string { 107 | return chalk.blue('ℹ ') + message; 108 | } 109 | 110 | /** 111 | * Truncates a string to a maximum length 112 | */ 113 | function truncate(str: string, maxLength: number): string { 114 | if (str.length <= maxLength) { 115 | return str; 116 | } 117 | return str.substring(0, maxLength - 3) + '...'; 118 | } 119 | 120 | /** 121 | * Formats JSON output 122 | */ 123 | export function formatJson(data: any): string { 124 | return JSON.stringify(data, null, 2); 125 | } 126 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | /** 4 | * Represents a process listening on a port 5 | */ 6 | export interface PortInfo { 7 | port: number; 8 | pid: number; 9 | processName: string; 10 | workingDirectory: string; 11 | command: string; 12 | address: string; 13 | protocol: string; 14 | } 15 | 16 | /** 17 | * Configuration for field display 18 | */ 19 | export interface DisplayConfig { 20 | showPort: boolean; 21 | showPid: boolean; 22 | showProcessName: boolean; 23 | showWorkingDirectory: boolean; 24 | showCommand: boolean; 25 | showAddress: boolean; 26 | showProtocol: boolean; 27 | } 28 | 29 | /** 30 | * Filter options for port scanning 31 | */ 32 | export interface FilterOptions { 33 | port?: number; 34 | portRange?: { min: number; max: number }; 35 | pid?: number; 36 | processName?: string; 37 | processPrefix?: string; 38 | workingDirectory?: string; 39 | } 40 | 41 | /** 42 | * Configuration for auto-refresh 43 | */ 44 | export interface RefreshConfig { 45 | enabled: boolean; 46 | intervalMs: number; 47 | } 48 | 49 | /** 50 | * Filter preset for quick access 51 | */ 52 | export interface FilterPreset { 53 | id: string; 54 | name: string; 55 | description?: string; 56 | filters: FilterOptions; 57 | } 58 | 59 | /** 60 | * User preferences 61 | */ 62 | export interface UserConfig { 63 | display: DisplayConfig; 64 | refresh: RefreshConfig; 65 | presets: FilterPreset[]; 66 | } 67 | 68 | // Zod schemas for validation 69 | export const PortInfoSchema = z.object({ 70 | port: z.number().int().min(0).max(65535), 71 | pid: z.number().int().positive(), 72 | processName: z.string(), 73 | workingDirectory: z.string(), 74 | command: z.string(), 75 | address: z.string(), 76 | protocol: z.string(), 77 | }); 78 | 79 | export const FilterOptionsSchema = z.object({ 80 | port: z.number().int().min(0).max(65535).optional(), 81 | portRange: z.object({ 82 | min: z.number().int().min(0).max(65535), 83 | max: z.number().int().min(0).max(65535), 84 | }).optional(), 85 | pid: z.number().int().positive().optional(), 86 | processName: z.string().optional(), 87 | processPrefix: z.string().optional(), 88 | workingDirectory: z.string().optional(), 89 | }); 90 | 91 | export const DisplayConfigSchema = z.object({ 92 | showPort: z.boolean(), 93 | showPid: z.boolean(), 94 | showProcessName: z.boolean(), 95 | showWorkingDirectory: z.boolean(), 96 | showCommand: z.boolean(), 97 | showAddress: z.boolean(), 98 | showProtocol: z.boolean(), 99 | }); 100 | 101 | export const RefreshConfigSchema = z.object({ 102 | enabled: z.boolean(), 103 | intervalMs: z.number().int().positive(), 104 | }); 105 | 106 | export const FilterPresetSchema = z.object({ 107 | id: z.string(), 108 | name: z.string(), 109 | description: z.string().optional(), 110 | filters: FilterOptionsSchema, 111 | }); 112 | 113 | export const UserConfigSchema = z.object({ 114 | display: DisplayConfigSchema, 115 | refresh: RefreshConfigSchema, 116 | presets: z.array(FilterPresetSchema), 117 | }); 118 | 119 | // Default configurations 120 | export const DEFAULT_DISPLAY_CONFIG: DisplayConfig = { 121 | showPort: true, 122 | showPid: true, 123 | showProcessName: true, 124 | showWorkingDirectory: true, 125 | showCommand: false, 126 | showAddress: false, 127 | showProtocol: false, 128 | }; 129 | 130 | export const DEFAULT_REFRESH_CONFIG: RefreshConfig = { 131 | enabled: true, 132 | intervalMs: 5000, 133 | }; 134 | 135 | export const DEFAULT_PRESETS: FilterPreset[] = [ 136 | { 137 | id: 'web-dev', 138 | name: 'Web Dev', 139 | description: 'Common web development ports (3000-3100)', 140 | filters: { 141 | portRange: { min: 3000, max: 3100 }, 142 | }, 143 | }, 144 | { 145 | id: 'vite', 146 | name: 'Vite', 147 | description: 'Vite dev server (port 5173)', 148 | filters: { 149 | port: 5173, 150 | }, 151 | }, 152 | { 153 | id: 'postgres', 154 | name: 'Postgres', 155 | description: 'PostgreSQL database (port 5432)', 156 | filters: { 157 | port: 5432, 158 | }, 159 | }, 160 | ]; 161 | 162 | export const DEFAULT_USER_CONFIG: UserConfig = { 163 | display: DEFAULT_DISPLAY_CONFIG, 164 | refresh: DEFAULT_REFRESH_CONFIG, 165 | presets: DEFAULT_PRESETS, 166 | }; 167 | -------------------------------------------------------------------------------- /packages/app/test/ipc.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { launchElectronApp, closeElectronApp, ElectronTestContext } from './utils'; 3 | 4 | test.describe('IPC Integration Tests', () => { 5 | let context: ElectronTestContext; 6 | 7 | test.beforeEach(async () => { 8 | context = await launchElectronApp(); 9 | // Wait for app to be ready 10 | await context.window.waitForLoadState('domcontentloaded'); 11 | }); 12 | 13 | test.afterEach(async () => { 14 | if (context) { 15 | await closeElectronApp(context); 16 | } 17 | }); 18 | 19 | test('window.portwatchAPI is exposed', async () => { 20 | const { window } = context; 21 | 22 | // Check if the API is exposed via contextBridge 23 | const hasAPI = await window.evaluate(() => { 24 | return typeof (window as any).portwatchAPI !== 'undefined'; 25 | }); 26 | 27 | expect(hasAPI).toBe(true); 28 | }); 29 | 30 | test('scanPorts IPC call works', async () => { 31 | const { window } = context; 32 | 33 | // Call scanPorts via IPC 34 | const result = await window.evaluate(async () => { 35 | return await (window as any).portwatchAPI.scanPorts(); 36 | }); 37 | 38 | // Should return success 39 | expect(result.success).toBe(true); 40 | 41 | // Should have data array (may be empty) 42 | expect(Array.isArray(result.data)).toBe(true); 43 | }); 44 | 45 | test('scanPorts with port range filter', async () => { 46 | const { window } = context; 47 | 48 | // Call scanPorts with filters 49 | const result = await window.evaluate(async () => { 50 | return await (window as any).portwatchAPI.scanPorts({ 51 | portRange: { min: 3000, max: 4000 } 52 | }); 53 | }); 54 | 55 | expect(result.success).toBe(true); 56 | 57 | // All returned ports should be in range 58 | if (result.data && result.data.length > 0) { 59 | for (const port of result.data) { 60 | expect(port.port).toBeGreaterThanOrEqual(3000); 61 | expect(port.port).toBeLessThanOrEqual(4000); 62 | } 63 | } 64 | }); 65 | 66 | test('scanPorts with process prefix filter', async () => { 67 | const { window } = context; 68 | 69 | // Call scanPorts with process prefix filter 70 | const result = await window.evaluate(async () => { 71 | return await (window as any).portwatchAPI.scanPorts({ 72 | processPrefix: 'node' 73 | }); 74 | }); 75 | 76 | expect(result.success).toBe(true); 77 | 78 | // All returned processes should start with 'node' 79 | if (result.data && result.data.length > 0) { 80 | for (const port of result.data) { 81 | expect(port.processName.toLowerCase()).toMatch(/^node/); 82 | } 83 | } 84 | }); 85 | 86 | test('loadConfig IPC call works', async () => { 87 | const { window } = context; 88 | 89 | const result = await window.evaluate(async () => { 90 | return await (window as any).portwatchAPI.loadConfig(); 91 | }); 92 | 93 | expect(result.success).toBe(true); 94 | expect(result.data).toBeTruthy(); 95 | expect(result.data.display).toBeTruthy(); 96 | expect(result.data.refresh).toBeTruthy(); 97 | }); 98 | 99 | test('saveConfig IPC call works', async () => { 100 | const { window } = context; 101 | 102 | // First load the current config 103 | const loadResult = await window.evaluate(async () => { 104 | return await (window as any).portwatchAPI.loadConfig(); 105 | }); 106 | 107 | expect(loadResult.success).toBe(true); 108 | 109 | // Modify and save 110 | const saveResult = await window.evaluate(async (config: any) => { 111 | config.refresh.intervalMs = 3000; 112 | return await (window as any).portwatchAPI.saveConfig(config); 113 | }, loadResult.data); 114 | 115 | expect(saveResult.success).toBe(true); 116 | 117 | // Load again to verify 118 | const verifyResult = await window.evaluate(async () => { 119 | return await (window as any).portwatchAPI.loadConfig(); 120 | }); 121 | 122 | expect(verifyResult.data.refresh.intervalMs).toBe(3000); 123 | }); 124 | 125 | test('error handling for invalid filters', async () => { 126 | const { window } = context; 127 | 128 | // This should still succeed but return empty results 129 | const result = await window.evaluate(async () => { 130 | return await (window as any).portwatchAPI.scanPorts({ 131 | port: 99999 // Invalid port number (should filter everything out) 132 | }); 133 | }); 134 | 135 | expect(result.success).toBe(true); 136 | expect(result.data).toEqual([]); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /packages/app/generate-icon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const { execSync } = require('child_process'); 5 | const path = require('path'); 6 | 7 | // Create a simple SVG icon representing a port/network connection 8 | const createSVGIcon = (size) => { 9 | const padding = size * 0.15; 10 | const strokeWidth = Math.max(2, size * 0.08); 11 | 12 | return ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | 36 | 37 | 39 | 41 | 42 | `; 43 | }; 44 | 45 | // Required icon sizes for macOS 46 | const iconSizes = [ 47 | { size: 16, name: 'icon_16x16.png' }, 48 | { size: 32, name: 'icon_16x16@2x.png' }, 49 | { size: 32, name: 'icon_32x32.png' }, 50 | { size: 64, name: 'icon_32x32@2x.png' }, 51 | { size: 128, name: 'icon_128x128.png' }, 52 | { size: 256, name: 'icon_128x128@2x.png' }, 53 | { size: 256, name: 'icon_256x256.png' }, 54 | { size: 512, name: 'icon_256x256@2x.png' }, 55 | { size: 512, name: 'icon_512x512.png' }, 56 | { size: 1024, name: 'icon_512x512@2x.png' } 57 | ]; 58 | 59 | async function generateIcon() { 60 | const assetsDir = path.join(__dirname, 'assets'); 61 | const iconsetDir = path.join(assetsDir, 'icon.iconset'); 62 | 63 | // Create iconset directory 64 | if (!fs.existsSync(iconsetDir)) { 65 | fs.mkdirSync(iconsetDir, { recursive: true }); 66 | } 67 | 68 | console.log('📝 Generating icon files...'); 69 | 70 | // Generate each required size 71 | for (const { size, name } of iconSizes) { 72 | const svgContent = createSVGIcon(size); 73 | const svgPath = path.join(iconsetDir, `temp_${size}.svg`); 74 | const pngPath = path.join(iconsetDir, name); 75 | 76 | // Write SVG 77 | fs.writeFileSync(svgPath, svgContent); 78 | 79 | try { 80 | // Convert SVG to PNG using qlmanage (built into macOS) 81 | execSync(`qlmanage -t -s ${size} -o ${iconsetDir} ${svgPath} 2>/dev/null`, { stdio: 'pipe' }); 82 | 83 | // qlmanage creates files with .png.png extension, rename them 84 | const qlOutput = path.join(iconsetDir, `temp_${size}.svg.png`); 85 | if (fs.existsSync(qlOutput)) { 86 | fs.renameSync(qlOutput, pngPath); 87 | console.log(` ✓ Created ${name} (${size}×${size})`); 88 | } 89 | } catch (error) { 90 | console.error(` ✗ Failed to create ${name}:`, error.message); 91 | } 92 | 93 | // Clean up temporary SVG 94 | if (fs.existsSync(svgPath)) { 95 | fs.unlinkSync(svgPath); 96 | } 97 | } 98 | 99 | console.log('\n🔨 Converting to .icns format...'); 100 | 101 | try { 102 | // Convert iconset to .icns using iconutil (built into macOS) 103 | const icnsPath = path.join(assetsDir, 'icon.icns'); 104 | execSync(`iconutil -c icns ${iconsetDir} -o ${icnsPath}`); 105 | 106 | const stats = fs.statSync(icnsPath); 107 | console.log(` ✓ Created icon.icns (${Math.round(stats.size / 1024)}KB)`); 108 | 109 | // Clean up iconset directory 110 | execSync(`rm -rf ${iconsetDir}`); 111 | console.log('\n✅ App icon generated successfully!'); 112 | 113 | } catch (error) { 114 | console.error(' ✗ Failed to create .icns:', error.message); 115 | console.error('\nNote: Make sure iconutil is available (built into macOS)'); 116 | } 117 | } 118 | 119 | generateIcon().catch(console.error); 120 | -------------------------------------------------------------------------------- /packages/core/src/port-scanner.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | import { PortInfo, FilterOptions } from './types'; 4 | 5 | const execAsync = promisify(exec); 6 | 7 | /** 8 | * Scans for all processes listening on ports 9 | */ 10 | export async function scanPorts(filters?: FilterOptions): Promise { 11 | try { 12 | // Build lsof command to get listening processes 13 | const { stdout } = await execAsync('lsof -i -P -n | grep LISTEN'); 14 | 15 | const lines = stdout.trim().split('\n'); 16 | const portInfos: PortInfo[] = []; 17 | 18 | for (const line of lines) { 19 | const portInfo = parseLsofLine(line); 20 | if (portInfo && matchesBasicFilters(portInfo, filters)) { 21 | portInfos.push(portInfo); 22 | } 23 | } 24 | 25 | return portInfos; 26 | } catch (error: any) { 27 | // If no processes are listening, lsof returns exit code 1 28 | if (error.code === 1 && !error.stdout) { 29 | return []; 30 | } 31 | throw new Error(`Failed to scan ports: ${error.message}`); 32 | } 33 | } 34 | 35 | /** 36 | * Gets information about a specific port 37 | */ 38 | export async function getPortInfo(port: number): Promise { 39 | const ports = await scanPorts({ port }); 40 | return ports[0] || null; 41 | } 42 | 43 | /** 44 | * Gets the working directory for a process 45 | */ 46 | async function getWorkingDirectory(pid: number): Promise { 47 | try { 48 | const { stdout } = await execAsync(`lsof -p ${pid} 2>/dev/null | grep cwd | head -1`); 49 | const parts = stdout.trim().split(/\s+/); 50 | return parts[parts.length - 1] || 'unknown'; 51 | } catch { 52 | return 'unknown'; 53 | } 54 | } 55 | 56 | /** 57 | * Gets the full command for a process 58 | */ 59 | async function getFullCommand(pid: number): Promise { 60 | try { 61 | const { stdout } = await execAsync(`ps -p ${pid} -o command=`); 62 | return stdout.trim(); 63 | } catch { 64 | return 'unknown'; 65 | } 66 | } 67 | 68 | /** 69 | * Parses a single line from lsof output 70 | */ 71 | function parseLsofLine(line: string): PortInfo | null { 72 | // Example line: node 72218 user 21u IPv6 0x1234 0t0 TCP *:5174 (LISTEN) 73 | const parts = line.trim().split(/\s+/); 74 | 75 | if (parts.length < 9) { 76 | return null; 77 | } 78 | 79 | const processName = parts[0]; 80 | const pid = parseInt(parts[1], 10); 81 | const protocol = parts[7]; // TCP or UDP 82 | const addressPort = parts[8]; 83 | 84 | // Parse address and port 85 | const match = addressPort.match(/(.+):(\d+)/); 86 | if (!match) { 87 | return null; 88 | } 89 | 90 | const address = match[1] === '*' ? '0.0.0.0' : match[1]; 91 | const port = parseInt(match[2], 10); 92 | 93 | if (isNaN(pid) || isNaN(port)) { 94 | return null; 95 | } 96 | 97 | // Return basic info; we'll enrich it later 98 | return { 99 | port, 100 | pid, 101 | processName, 102 | address, 103 | protocol, 104 | workingDirectory: 'unknown', // Will be fetched separately 105 | command: 'unknown', // Will be fetched separately 106 | }; 107 | } 108 | 109 | /** 110 | * Enriches port info with working directory and full command 111 | */ 112 | export async function enrichPortInfo(portInfo: PortInfo): Promise { 113 | const [workingDirectory, command] = await Promise.all([ 114 | getWorkingDirectory(portInfo.pid), 115 | getFullCommand(portInfo.pid), 116 | ]); 117 | 118 | return { 119 | ...portInfo, 120 | workingDirectory, 121 | command, 122 | }; 123 | } 124 | 125 | /** 126 | * Scans ports and enriches all results 127 | */ 128 | export async function scanPortsEnriched(filters?: FilterOptions): Promise { 129 | // Apply only filters that do not require enrichment 130 | const { workingDirectory, ...basicFilters } = filters || {}; 131 | const ports = await scanPorts(basicFilters); 132 | 133 | // Enrich all port infos in parallel 134 | const enriched = await Promise.all( 135 | ports.map(port => enrichPortInfo(port)) 136 | ); 137 | 138 | // Apply working-directory filter after enrichment (now we have the data) 139 | if (workingDirectory) { 140 | return enriched.filter((port) => 141 | port.workingDirectory.toLowerCase().includes(workingDirectory.toLowerCase()) 142 | ); 143 | } 144 | 145 | return enriched; 146 | } 147 | 148 | /** 149 | * Checks if a port info matches the given filters 150 | */ 151 | function matchesBasicFilters(portInfo: PortInfo, filters?: FilterOptions): boolean { 152 | if (!filters) { 153 | return true; 154 | } 155 | 156 | if (filters.port !== undefined && portInfo.port !== filters.port) { 157 | return false; 158 | } 159 | 160 | if (filters.portRange !== undefined) { 161 | const { min, max } = filters.portRange; 162 | if (portInfo.port < min || portInfo.port > max) { 163 | return false; 164 | } 165 | } 166 | 167 | if (filters.pid !== undefined && portInfo.pid !== filters.pid) { 168 | return false; 169 | } 170 | 171 | if (filters.processName !== undefined && 172 | !portInfo.processName.toLowerCase().includes(filters.processName.toLowerCase())) { 173 | return false; 174 | } 175 | 176 | if (filters.processPrefix !== undefined && 177 | !portInfo.processName.toLowerCase().startsWith(filters.processPrefix.toLowerCase())) { 178 | return false; 179 | } 180 | 181 | return true; 182 | } 183 | 184 | /** 185 | * Validates port number 186 | */ 187 | export function isValidPort(port: number): boolean { 188 | return Number.isInteger(port) && port >= 0 && port <= 65535; 189 | } 190 | 191 | /** 192 | * Validates PID 193 | */ 194 | export function isValidPid(pid: number): boolean { 195 | return Number.isInteger(pid) && pid > 0; 196 | } 197 | -------------------------------------------------------------------------------- /packages/app/test/ui.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { launchElectronApp, closeElectronApp, ElectronTestContext } from './utils'; 3 | 4 | test.describe('UI Tests', () => { 5 | let context: ElectronTestContext; 6 | 7 | test.beforeEach(async () => { 8 | context = await launchElectronApp(); 9 | await context.window.waitForLoadState('domcontentloaded'); 10 | // Wait for initial port scan to complete (wait for Loading... to disappear) 11 | await context.window.waitForFunction(() => { 12 | const loadingText = document.body.textContent; 13 | return !loadingText?.includes('Loading...'); 14 | }, { timeout: 10000 }); 15 | }); 16 | 17 | test.afterEach(async () => { 18 | if (context) { 19 | await closeElectronApp(context); 20 | } 21 | }); 22 | 23 | test('search functionality works', async () => { 24 | const { window } = context; 25 | 26 | // Find the search input 27 | const searchInput = window.locator('input[placeholder*="Search"]'); 28 | await expect(searchInput).toBeVisible(); 29 | 30 | // Type a search term 31 | await searchInput.fill('node'); 32 | 33 | // Wait a bit for filtering 34 | await window.waitForTimeout(500); 35 | 36 | // Check if results are filtered (if any ports exist) 37 | const portElements = window.locator('text=/:\\d+/'); 38 | const count = await portElements.count(); 39 | 40 | // If there are results, they should match the filter 41 | if (count > 0) { 42 | const portTexts = await portElements.allTextContents(); 43 | // Should have some port numbers 44 | expect(portTexts.length).toBeGreaterThan(0); 45 | } 46 | }); 47 | 48 | test('search mode toggle works', async () => { 49 | const { window } = context; 50 | 51 | // Find the search mode select 52 | const searchModeSelect = window.locator('select'); 53 | await expect(searchModeSelect).toBeVisible(); 54 | 55 | // Should have two options 56 | const options = await searchModeSelect.locator('option').allTextContents(); 57 | expect(options).toContain('Contains'); 58 | expect(options).toContain('Starts with'); 59 | 60 | // Change to "Starts with" 61 | await searchModeSelect.selectOption('prefix'); 62 | 63 | // Verify it changed 64 | const selectedValue = await searchModeSelect.inputValue(); 65 | expect(selectedValue).toBe('prefix'); 66 | }); 67 | 68 | test('advanced filters panel toggles', async () => { 69 | const { window } = context; 70 | 71 | // Find the settings button (gear icon) 72 | const settingsButton = window.locator('button:has-text("⚙️")'); 73 | await expect(settingsButton).toBeVisible(); 74 | 75 | // Advanced filters should be hidden initially 76 | const advancedFilters = window.locator('text="Port Range"'); 77 | await expect(advancedFilters).not.toBeVisible(); 78 | 79 | // Click to show 80 | await settingsButton.click(); 81 | await expect(advancedFilters).toBeVisible(); 82 | 83 | // Click to hide 84 | await settingsButton.click(); 85 | await expect(advancedFilters).not.toBeVisible(); 86 | }); 87 | 88 | test('port range filter works', async () => { 89 | const { window } = context; 90 | 91 | // Open advanced filters 92 | const settingsButton = window.locator('button:has-text("⚙️")'); 93 | await settingsButton.click(); 94 | 95 | // Find port range inputs 96 | const minInput = window.locator('input[placeholder="Min"]'); 97 | const maxInput = window.locator('input[placeholder="Max"]'); 98 | 99 | await expect(minInput).toBeVisible(); 100 | await expect(maxInput).toBeVisible(); 101 | 102 | // Set a port range 103 | await minInput.fill('3000'); 104 | await maxInput.fill('4000'); 105 | 106 | // Wait for filtering 107 | await window.waitForTimeout(1000); 108 | 109 | // Verify clear button appears 110 | const clearButton = window.locator('button[title="Clear"]'); 111 | await expect(clearButton).toBeVisible(); 112 | 113 | // Click clear 114 | await clearButton.click(); 115 | 116 | // Inputs should be empty 117 | await expect(minInput).toHaveValue(''); 118 | await expect(maxInput).toHaveValue(''); 119 | }); 120 | 121 | test('refresh button works', async () => { 122 | const { window } = context; 123 | 124 | // Find refresh button (loading already completed in beforeEach) 125 | const refreshButton = window.locator('button:has-text("↻")'); 126 | await expect(refreshButton).toBeVisible(); 127 | 128 | // Click it 129 | await refreshButton.click(); 130 | 131 | // Button should show loading state briefly 132 | await window.waitForTimeout(500); 133 | 134 | // Should be back to normal state 135 | await expect(refreshButton).not.toBeDisabled(); 136 | }); 137 | 138 | test('auto-refresh toggle works', async () => { 139 | const { window } = context; 140 | 141 | // Find auto-refresh button 142 | const autoButton = window.locator('button:has-text("Auto")'); 143 | await expect(autoButton).toBeVisible(); 144 | 145 | // Get initial state (should be active/green) 146 | const initialClasses = await autoButton.getAttribute('class'); 147 | expect(initialClasses).toContain('bg-green-500'); 148 | 149 | // Toggle off 150 | await autoButton.click(); 151 | await window.waitForTimeout(100); 152 | 153 | const toggledClasses = await autoButton.getAttribute('class'); 154 | expect(toggledClasses).toContain('bg-gray-200'); 155 | 156 | // Toggle back on 157 | await autoButton.click(); 158 | await window.waitForTimeout(100); 159 | 160 | const finalClasses = await autoButton.getAttribute('class'); 161 | expect(finalClasses).toContain('bg-green-500'); 162 | }); 163 | 164 | test('footer shows correct port count', async () => { 165 | const { window } = context; 166 | 167 | // Find footer 168 | const footer = window.locator('text=/\\d+ port/'); 169 | await expect(footer).toBeVisible(); 170 | 171 | // Should match pattern "N port" or "N ports" 172 | const footerText = await footer.textContent(); 173 | expect(footerText).toMatch(/\d+ ports?/); 174 | }); 175 | 176 | test('port list renders correctly', async () => { 177 | const { window } = context; 178 | 179 | // Wait for potential port data 180 | await window.waitForTimeout(1000); 181 | 182 | // Check if we have any ports 183 | const portNumbers = window.locator('text=/:\\d+/'); 184 | const portCount = await portNumbers.count(); 185 | 186 | if (portCount > 0) { 187 | // First port should be visible 188 | await expect(portNumbers.first()).toBeVisible(); 189 | 190 | // Should have process name 191 | const processNames = window.locator('div').filter({ hasText: /^[a-zA-Z]/ }); 192 | expect(await processNames.count()).toBeGreaterThan(0); 193 | 194 | // Should have kill button 195 | const killButton = window.locator('button:has-text("Kill")').first(); 196 | await expect(killButton).toBeVisible(); 197 | } 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PortWatch 2 | 3 | > macOS port monitoring tool - CLI and menu bar app 4 | 5 | PortWatch helps you quickly see which processes are running on which ports on your Mac, with both a powerful CLI tool and a convenient menu bar app. 6 | 7 | ## Features 8 | 9 | ### CLI Tool 10 | - 📋 **List ports** - See all processes listening on ports 11 | - 👁️ **Watch mode** - Auto-refresh port list 12 | - 🔍 **Filter** - Search by port, PID, process name, or directory 13 | - ⚡ **Kill processes** - Terminate processes by port or PID 14 | - 🔧 **Configurable** - Customize display fields and refresh intervals 15 | - 📤 **JSON output** - Perfect for scripting 16 | 17 | ### Menu Bar App 18 | - 🖥️ **Always accessible** - Lives in your menu bar 19 | - 🔄 **Auto-refresh** - Updates every 5 seconds 20 | - 🔍 **Quick search** - Filter ports instantly 21 | - ⚡ **One-click kill** - Stop processes with a click 22 | - 🎨 **Native UI** - Clean, macOS-style interface 23 | 24 | ### MCP Server 25 | - 🤖 **Claude integration** - Use with Claude Desktop or other MCP clients 26 | - 🔍 **Query ports** - Ask Claude about running processes 27 | - 📊 **Filter results** - Search by port, PID, process name, etc. 28 | - ⚡ **Control processes** - Kill processes through Claude 29 | 30 | ## Installation 31 | 32 | ### CLI Tool 33 | 34 | ```bash 35 | # Install via npm (coming soon) 36 | npm install -g @portwatch/cli 37 | 38 | # Or use npx 39 | npx @portwatch/cli list 40 | ``` 41 | 42 | ### Menu Bar App 43 | 44 | **Download from GitHub Releases:** 45 | 46 | 1. Visit [Releases](https://github.com/dingran/portwatch/releases) 47 | 2. Download the appropriate file for your Mac: 48 | - **Apple Silicon (M1/M2/M3)**: `PortWatch-1.0.0-arm64.dmg` 49 | - **Intel**: `PortWatch-1.0.0.dmg` 50 | 3. Open the DMG and drag PortWatch to Applications 51 | 4. **First launch**: Right-click the app → Open (due to unsigned app warning) 52 | 53 | **Via Homebrew** (after creating a tap): 54 | ```bash 55 | brew tap dingran/tap 56 | brew install --cask dingran/tap/portwatch 57 | ``` 58 | 59 | **Note**: The app is not notarized, so macOS will show a security warning on first launch. See [RELEASE.md](RELEASE.md) for details. 60 | 61 | ### MCP Server 62 | 63 | The MCP server allows Claude and other AI assistants to query port and process information. 64 | 65 | **Setup with Claude Code:** 66 | 67 | 1. Build the MCP server: 68 | ```bash 69 | git clone https://github.com/dingran/portwatch.git 70 | cd portwatch 71 | npm install 72 | npm run build 73 | ``` 74 | 75 | 2. Add the MCP server: 76 | ```bash 77 | cd packages/mcp-server 78 | claude mcp add portwatch node $(pwd)/dist/index.js 79 | ``` 80 | 81 | 3. Verify it's connected: 82 | ```bash 83 | claude mcp list 84 | ``` 85 | 86 | 4. Start a new conversation with Claude Code and ask things like: 87 | - "What processes are running on ports 3000-3100?" 88 | - "What's on port 5432?" 89 | - "Kill the process on port 8080" 90 | 91 | **Setup with Claude Desktop:** 92 | 93 | 1. Build the MCP server (same as above) 94 | 95 | 2. Get your config: 96 | ```bash 97 | cd packages/mcp-server 98 | ./get-config.sh 99 | ``` 100 | 101 | 3. Add the output to `~/Library/Application Support/Claude/claude_desktop_config.json` 102 | 103 | 4. Restart Claude Desktop 104 | 105 | See [packages/mcp-server/README.md](packages/mcp-server/README.md) for detailed usage and available tools. 106 | 107 | ## Usage 108 | 109 | ### CLI Examples 110 | 111 | ```bash 112 | # List all ports 113 | portwatch list 114 | 115 | # Watch ports with auto-refresh 116 | portwatch watch 117 | 118 | # Filter by port number 119 | portwatch list --port 3000 120 | 121 | # Filter by process name 122 | portwatch list --name node 123 | 124 | # Show detailed info for a specific port 125 | portwatch show 3000 126 | 127 | # Kill a process on a port 128 | portwatch kill 3000 129 | 130 | # Kill a process by PID 131 | portwatch kill --pid 12345 132 | 133 | # Force kill 134 | portwatch kill 3000 --force 135 | 136 | # Output as JSON 137 | portwatch list --json 138 | 139 | # Configure display 140 | portwatch config set display.showCommand true 141 | portwatch config show 142 | ``` 143 | 144 | ## Project Structure 145 | 146 | ``` 147 | portwatch/ 148 | ├── packages/ 149 | │ ├── core/ # Shared library 150 | │ ├── cli/ # CLI tool 151 | │ ├── app/ # Electron menu bar app 152 | │ └── mcp-server/ # MCP server for Claude 153 | ├── package.json # Workspace root 154 | └── README.md 155 | ``` 156 | 157 | ## Development 158 | 159 | ### Prerequisites 160 | 161 | - Node.js 18+ 162 | - npm or yarn 163 | - macOS (for menu bar app) 164 | 165 | ### Setup 166 | 167 | ```bash 168 | # Clone the repository 169 | git clone https://github.com/dingran/portwatch.git 170 | cd portwatch 171 | 172 | # Install dependencies 173 | npm install 174 | 175 | # Build all packages 176 | npm run build 177 | ``` 178 | 179 | ### CLI Development 180 | 181 | ```bash 182 | # Watch mode 183 | cd packages/cli 184 | npm run dev 185 | 186 | # Test locally 187 | node dist/index.js list 188 | ``` 189 | 190 | ### App Development 191 | 192 | ```bash 193 | # Start development server 194 | cd packages/app 195 | npm run dev 196 | ``` 197 | 198 | ### Core Library 199 | 200 | ```bash 201 | # Build 202 | cd packages/core 203 | npm run build 204 | 205 | # Watch mode 206 | npm run dev 207 | ``` 208 | 209 | ## Architecture 210 | 211 | ### Core Library (`@portwatch/core`) 212 | 213 | The core library provides shared functionality: 214 | - **Port Scanner** - Wraps `lsof` to scan ports 215 | - **Process Manager** - Kill processes safely 216 | - **Config Manager** - Save/load user preferences 217 | - **Types** - Shared TypeScript types with Zod validation 218 | 219 | ### CLI Tool (`@portwatch/cli`) 220 | 221 | Built with: 222 | - Commander.js - Command framework 223 | - chalk - Terminal colors 224 | - cli-table3 - Formatted tables 225 | - ora - Loading spinners 226 | 227 | ### Menu Bar App (`@portwatch/app`) 228 | 229 | Built with: 230 | - Electron - Native app framework 231 | - menubar - Menu bar integration 232 | - React - UI framework 233 | - Tailwind CSS - Styling 234 | - Vite - Build tool 235 | 236 | ## Security 237 | 238 | The Electron app follows security best practices: 239 | - ✅ Context isolation enabled 240 | - ✅ Node integration disabled 241 | - ✅ Sandboxed renderer 242 | - ✅ IPC via contextBridge 243 | - ✅ Input validation 244 | 245 | ## Configuration 246 | 247 | User preferences are stored in `~/.portwatch/config.json`: 248 | 249 | ```json 250 | { 251 | "display": { 252 | "showPort": true, 253 | "showPid": true, 254 | "showProcessName": true, 255 | "showWorkingDirectory": true, 256 | "showCommand": false, 257 | "showAddress": false, 258 | "showProtocol": false 259 | }, 260 | "refresh": { 261 | "enabled": true, 262 | "intervalMs": 5000 263 | } 264 | } 265 | ``` 266 | 267 | ## TODO 268 | 269 | - [x] Create proper menu bar icon 270 | - [x] Add electron-builder configuration 271 | - [x] Test Electron app build 272 | - [ ] Create Homebrew tap (template ready in `homebrew/portwatch.rb`) 273 | - [ ] Create GitHub release with v1.0.0 (see [RELEASE.md](RELEASE.md)) 274 | - [ ] Publish CLI to npm 275 | - [ ] Add tests 276 | - [ ] CI/CD with GitHub Actions 277 | - [ ] Better error handling 278 | - [ ] Add app settings UI 279 | - [ ] Support more platforms (Linux, Windows) 280 | - [ ] Code signing & notarization (requires Apple Developer account) 281 | 282 | ## Contributing 283 | 284 | Contributions are welcome! Please feel free to submit a Pull Request. 285 | 286 | ## License 287 | 288 | MIT © dingran 289 | 290 | ## Acknowledgments 291 | 292 | Built with [Claude Code](https://claude.com/claude-code) 293 | -------------------------------------------------------------------------------- /packages/app/src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, nativeImage, Menu } from 'electron'; 2 | import { menubar, Menubar } from 'menubar'; 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | import { 6 | scanPortsEnriched, 7 | FilterOptions, 8 | killProcessByPort, 9 | killProcessByPid, 10 | loadConfig, 11 | saveConfig, 12 | } from '@portwatch/core'; 13 | 14 | let mb: Menubar; 15 | 16 | /** 17 | * Update the context menu for the tray icon 18 | */ 19 | function updateContextMenu() { 20 | if (!mb || !mb.tray) return; 21 | 22 | const loginItemSettings = app.getLoginItemSettings(); 23 | const openAtLogin = loginItemSettings.openAtLogin; 24 | 25 | const contextMenu = Menu.buildFromTemplate([ 26 | { 27 | label: 'Launch at Login', 28 | type: 'checkbox', 29 | checked: openAtLogin, 30 | click: () => { 31 | app.setLoginItemSettings({ 32 | openAtLogin: !openAtLogin, 33 | }); 34 | updateContextMenu(); 35 | }, 36 | }, 37 | { type: 'separator' }, 38 | { 39 | label: 'Quit PortWatch', 40 | click: () => { 41 | app.quit(); 42 | }, 43 | }, 44 | ]); 45 | 46 | mb.tray.setContextMenu(contextMenu); 47 | } 48 | 49 | /** 50 | * Load menubar icon with graceful fallback 51 | */ 52 | function getMenubarIcon(): nativeImage { 53 | // In production, assets are in app.getPath('userData')/../Resources/assets 54 | // In development, they're relative to __dirname 55 | let iconPath: string; 56 | 57 | if (app.isPackaged) { 58 | // Production: assets are in Contents/Resources/assets/ 59 | iconPath = path.join(process.resourcesPath, 'assets', 'IconTemplate.png'); 60 | } else { 61 | // Development: relative to dist/main 62 | iconPath = path.join(__dirname, '..', '..', 'assets', 'IconTemplate.png'); 63 | } 64 | 65 | console.log('Looking for icon at:', iconPath); 66 | console.log(' app.isPackaged:', app.isPackaged); 67 | console.log(' process.resourcesPath:', process.resourcesPath); 68 | 69 | if (fs.existsSync(iconPath)) { 70 | console.log('✓ Icon found, loading from disk'); 71 | const img = nativeImage.createFromPath(iconPath); 72 | // Mark as template image for macOS menu bar 73 | img.setTemplateImage(true); 74 | return img; 75 | } 76 | 77 | console.warn('⚠ Menubar icon not found, using text fallback'); 78 | console.warn(' Create icon at:', iconPath); 79 | 80 | // Create a simple text-based icon as fallback 81 | // This creates a 16x16 black square that will be visible in the menu bar 82 | const canvas = ` 83 | 84 | P 85 | 86 | `; 87 | const dataUrl = 'data:image/svg+xml;base64,' + Buffer.from(canvas).toString('base64'); 88 | const img = nativeImage.createFromDataURL(dataUrl); 89 | img.setTemplateImage(true); 90 | return img; 91 | } 92 | 93 | // Create menubar app 94 | app.whenReady().then(() => { 95 | console.log('🚀 PortWatch app starting...'); 96 | console.log(' NODE_ENV:', process.env.NODE_ENV); 97 | console.log(' __dirname:', __dirname); 98 | 99 | const icon = getMenubarIcon(); 100 | 101 | const indexUrl = process.env.NODE_ENV === 'development' 102 | ? 'http://localhost:5173' 103 | : `file://${path.join(__dirname, '..', 'renderer', 'index.html')}`; 104 | 105 | console.log(' Index URL:', indexUrl); 106 | 107 | mb = menubar({ 108 | index: indexUrl, 109 | icon, 110 | browserWindow: { 111 | width: 400, 112 | height: 600, 113 | webPreferences: { 114 | contextIsolation: true, 115 | nodeIntegration: false, 116 | sandbox: true, 117 | preload: path.join(__dirname, '..', 'preload', 'preload.cjs'), 118 | }, 119 | resizable: false, 120 | movable: false, 121 | }, 122 | tooltip: 'PortWatch', 123 | preloadWindow: true, 124 | }); 125 | 126 | mb.on('ready', () => { 127 | console.log('✓ PortWatch menubar is ready'); 128 | 129 | if (mb.tray) { 130 | // Don't set context menu initially - only on right-click 131 | // This prevents it from showing on left-click 132 | 133 | // Right click: show context menu 134 | mb.tray.on('right-click', () => { 135 | updateContextMenu(); 136 | if (mb.tray) { 137 | mb.tray.popUpContextMenu(); 138 | } 139 | }); 140 | } 141 | 142 | // In development mode, show the window once 143 | if (process.env.NODE_ENV === 'development' && mb.window) { 144 | console.log('🔍 Development mode: showing window'); 145 | mb.showWindow(); 146 | 147 | // Make the window not auto-hide on blur in dev mode 148 | mb.window.setAlwaysOnTop(false); 149 | } 150 | }); 151 | 152 | mb.on('show', () => { 153 | console.log('📂 Menubar window shown'); 154 | }); 155 | 156 | mb.on('hide', () => { 157 | console.log('📁 Menubar window hidden'); 158 | // Don't re-show automatically - let it hide normally 159 | }); 160 | 161 | mb.on('after-create-window', () => { 162 | console.log('✓ Window created'); 163 | 164 | // Open DevTools in development mode 165 | if (process.env.NODE_ENV === 'development') { 166 | console.log('🔧 Opening DevTools (development mode)'); 167 | mb.window?.webContents.openDevTools({ mode: 'detach' }); 168 | } 169 | }); 170 | 171 | // Set up IPC handlers 172 | setupIpcHandlers(); 173 | }); 174 | 175 | // Set up IPC handlers for communication with renderer 176 | function setupIpcHandlers() { 177 | console.log('⚡ Setting up IPC handlers'); 178 | 179 | // Scan ports 180 | ipcMain.handle('scan-ports', async (event, filters?: FilterOptions) => { 181 | console.log('IPC: scan-ports called with filters:', filters); 182 | try { 183 | const ports = await scanPortsEnriched(filters); 184 | console.log(`IPC: scan-ports found ${ports.length} ports`); 185 | return { success: true, data: ports }; 186 | } catch (error: any) { 187 | console.error('IPC: scan-ports error:', error); 188 | return { success: false, error: error.message }; 189 | } 190 | }); 191 | 192 | // Kill process by port 193 | ipcMain.handle('kill-by-port', async (event, port: number, force: boolean = false) => { 194 | try { 195 | const result = await killProcessByPort(port, force); 196 | return result; 197 | } catch (error: any) { 198 | return { 199 | success: false, 200 | message: error.message, 201 | }; 202 | } 203 | }); 204 | 205 | // Kill process by PID 206 | ipcMain.handle('kill-by-pid', async (event, pid: number, force: boolean = false) => { 207 | try { 208 | const result = await killProcessByPid(pid, force); 209 | return result; 210 | } catch (error: any) { 211 | return { 212 | success: false, 213 | message: error.message, 214 | }; 215 | } 216 | }); 217 | 218 | // Load config 219 | ipcMain.handle('load-config', async () => { 220 | try { 221 | const config = await loadConfig(); 222 | return { success: true, data: config }; 223 | } catch (error: any) { 224 | return { success: false, error: error.message }; 225 | } 226 | }); 227 | 228 | // Save config 229 | ipcMain.handle('save-config', async (event, config) => { 230 | try { 231 | await saveConfig(config); 232 | return { success: true }; 233 | } catch (error: any) { 234 | return { success: false, error: error.message }; 235 | } 236 | }); 237 | 238 | // Quit app 239 | ipcMain.handle('quit-app', () => { 240 | app.quit(); 241 | }); 242 | } 243 | 244 | // Quit when all windows are closed (except on macOS) 245 | app.on('window-all-closed', () => { 246 | if (process.platform !== 'darwin') { 247 | app.quit(); 248 | } 249 | }); 250 | -------------------------------------------------------------------------------- /packages/mcp-server/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | Tool, 9 | } from '@modelcontextprotocol/sdk/types.js'; 10 | import { 11 | scanPortsEnriched, 12 | getPortInfo, 13 | enrichPortInfo, 14 | } from '@portwatch/core'; 15 | import { 16 | killProcessByPort, 17 | killProcessByPid, 18 | } from '@portwatch/core'; 19 | 20 | const server = new Server( 21 | { 22 | name: 'portwatch-mcp', 23 | version: '1.0.0', 24 | }, 25 | { 26 | capabilities: { 27 | tools: {}, 28 | }, 29 | } 30 | ); 31 | 32 | // Define available tools 33 | const tools: Tool[] = [ 34 | { 35 | name: 'scan_ports', 36 | description: 'Scan for processes listening on ports. Returns port number, PID, process name, working directory, and full command for each listening process. Supports optional filtering by port, port range, PID, process name, or working directory.', 37 | inputSchema: { 38 | type: 'object', 39 | properties: { 40 | port: { 41 | type: 'number', 42 | description: 'Filter by exact port number (0-65535)', 43 | }, 44 | portRangeMin: { 45 | type: 'number', 46 | description: 'Filter by minimum port in range (use with portRangeMax)', 47 | }, 48 | portRangeMax: { 49 | type: 'number', 50 | description: 'Filter by maximum port in range (use with portRangeMin)', 51 | }, 52 | pid: { 53 | type: 'number', 54 | description: 'Filter by process ID', 55 | }, 56 | processName: { 57 | type: 'string', 58 | description: 'Filter by process name (substring match, case-insensitive)', 59 | }, 60 | processPrefix: { 61 | type: 'string', 62 | description: 'Filter by process name prefix (starts with, case-insensitive)', 63 | }, 64 | workingDirectory: { 65 | type: 'string', 66 | description: 'Filter by working directory (substring match, case-insensitive)', 67 | }, 68 | }, 69 | }, 70 | }, 71 | { 72 | name: 'get_port_info', 73 | description: 'Get detailed information about a specific port. Returns the process listening on that port, including PID, process name, working directory, and full command.', 74 | inputSchema: { 75 | type: 'object', 76 | properties: { 77 | port: { 78 | type: 'number', 79 | description: 'Port number to query (0-65535)', 80 | }, 81 | }, 82 | required: ['port'], 83 | }, 84 | }, 85 | { 86 | name: 'kill_by_port', 87 | description: 'Kill the process listening on a specific port. Returns success status and details about the killed process.', 88 | inputSchema: { 89 | type: 'object', 90 | properties: { 91 | port: { 92 | type: 'number', 93 | description: 'Port number of the process to kill (0-65535)', 94 | }, 95 | force: { 96 | type: 'boolean', 97 | description: 'Use SIGKILL (true) instead of SIGTERM (false). Force kill cannot be ignored by the process. Default: false', 98 | default: false, 99 | }, 100 | }, 101 | required: ['port'], 102 | }, 103 | }, 104 | { 105 | name: 'kill_by_pid', 106 | description: 'Kill a process by its PID. Returns success status and details about the operation.', 107 | inputSchema: { 108 | type: 'object', 109 | properties: { 110 | pid: { 111 | type: 'number', 112 | description: 'Process ID to kill', 113 | }, 114 | force: { 115 | type: 'boolean', 116 | description: 'Use SIGKILL (true) instead of SIGTERM (false). Force kill cannot be ignored by the process. Default: false', 117 | default: false, 118 | }, 119 | }, 120 | required: ['pid'], 121 | }, 122 | }, 123 | ]; 124 | 125 | // List available tools 126 | server.setRequestHandler(ListToolsRequestSchema, async () => { 127 | return { tools }; 128 | }); 129 | 130 | // Handle tool calls 131 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 132 | try { 133 | const { name, arguments: args } = request.params; 134 | 135 | switch (name) { 136 | case 'scan_ports': { 137 | const filters: any = {}; 138 | 139 | if (args?.port !== undefined) { 140 | filters.port = args.port as number; 141 | } 142 | 143 | if (args?.portRangeMin !== undefined && args?.portRangeMax !== undefined) { 144 | if (typeof args.portRangeMin !== 'number' || typeof args.portRangeMax !== 'number') { 145 | throw new Error('portRangeMin and portRangeMax must be numbers'); 146 | } 147 | if (args.portRangeMin < 0 || args.portRangeMax > 65535 || args.portRangeMin > args.portRangeMax) { 148 | throw new Error('Invalid port range; ensure 0 <= min <= max <= 65535'); 149 | } 150 | filters.portRange = { 151 | min: args.portRangeMin as number, 152 | max: args.portRangeMax as number, 153 | }; 154 | } 155 | 156 | if (args?.pid !== undefined) { 157 | filters.pid = args.pid as number; 158 | } 159 | 160 | if (args?.processName !== undefined) { 161 | filters.processName = args.processName as string; 162 | } 163 | 164 | if (args?.processPrefix !== undefined) { 165 | filters.processPrefix = args.processPrefix as string; 166 | } 167 | 168 | if (args?.workingDirectory !== undefined) { 169 | filters.workingDirectory = args.workingDirectory as string; 170 | } 171 | 172 | const ports = await scanPortsEnriched(Object.keys(filters).length > 0 ? filters : undefined); 173 | 174 | return { 175 | content: [ 176 | { 177 | type: 'text', 178 | text: JSON.stringify(ports, null, 2), 179 | }, 180 | ], 181 | }; 182 | } 183 | 184 | case 'get_port_info': { 185 | if (args?.port === undefined || args?.port === null) { 186 | throw new Error('Port parameter is required'); 187 | } 188 | 189 | const portInfo = await getPortInfo(args.port as number); 190 | 191 | if (!portInfo) { 192 | return { 193 | content: [ 194 | { 195 | type: 'text', 196 | text: `No process found listening on port ${args.port}`, 197 | }, 198 | ], 199 | }; 200 | } 201 | 202 | const enriched = await enrichPortInfo(portInfo); 203 | 204 | return { 205 | content: [ 206 | { 207 | type: 'text', 208 | text: JSON.stringify(enriched, null, 2), 209 | }, 210 | ], 211 | }; 212 | } 213 | 214 | case 'kill_by_port': { 215 | if (args?.port === undefined || args?.port === null) { 216 | throw new Error('Port parameter is required'); 217 | } 218 | 219 | const force = args.force === true; 220 | const result = await killProcessByPort(args.port as number, force); 221 | 222 | return { 223 | content: [ 224 | { 225 | type: 'text', 226 | text: JSON.stringify(result, null, 2), 227 | }, 228 | ], 229 | }; 230 | } 231 | 232 | case 'kill_by_pid': { 233 | if (args?.pid === undefined || args?.pid === null) { 234 | throw new Error('PID parameter is required'); 235 | } 236 | 237 | const force = args.force === true; 238 | const result = await killProcessByPid(args.pid as number, force); 239 | 240 | return { 241 | content: [ 242 | { 243 | type: 'text', 244 | text: JSON.stringify(result, null, 2), 245 | }, 246 | ], 247 | }; 248 | } 249 | 250 | default: 251 | throw new Error(`Unknown tool: ${name}`); 252 | } 253 | } catch (error: any) { 254 | return { 255 | content: [ 256 | { 257 | type: 'text', 258 | text: `Error: ${error.message}`, 259 | }, 260 | ], 261 | isError: true, 262 | }; 263 | } 264 | }); 265 | 266 | // Start the server 267 | async function main() { 268 | const transport = new StdioServerTransport(); 269 | await server.connect(transport); 270 | console.error('PortWatch MCP server running on stdio'); 271 | } 272 | 273 | main().catch((error) => { 274 | console.error('Fatal error:', error); 275 | process.exit(1); 276 | }); 277 | -------------------------------------------------------------------------------- /packages/app/src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { PortInfo, FilterOptions, FilterPreset } from '@portwatch/core'; 3 | 4 | function App() { 5 | const [ports, setPorts] = useState([]); 6 | const [cachedAllPorts, setCachedAllPorts] = useState([]); // Cache for instant filtering 7 | const [presetResultsCache, setPresetResultsCache] = useState>({}); // Cache results per preset 8 | const [loading, setLoading] = useState(false); 9 | const [searchText, setSearchText] = useState(''); 10 | const [autoRefresh, setAutoRefresh] = useState(true); 11 | const [showFilters, setShowFilters] = useState(false); 12 | 13 | // Advanced filters 14 | const [portRangeMin, setPortRangeMin] = useState(''); 15 | const [portRangeMax, setPortRangeMax] = useState(''); 16 | const [searchMode, setSearchMode] = useState<'substring' | 'prefix'>('substring'); 17 | 18 | // Presets 19 | const [presets, setPresets] = useState([]); 20 | const [activePresetId, setActivePresetId] = useState(null); 21 | const [activeFilters, setActiveFilters] = useState({}); 22 | const [isApplyingPreset, setIsApplyingPreset] = useState(false); 23 | 24 | // Fetch ID tracking to prevent race conditions 25 | const latestFetchId = useRef(0); 26 | 27 | // Client-side filtering for instant results 28 | const filterPortsLocally = (allPorts: PortInfo[], filters: FilterOptions): PortInfo[] => { 29 | return allPorts.filter(port => { 30 | // Port range filter 31 | if (filters.portRange) { 32 | const { min, max } = filters.portRange; 33 | if (port.port < min || port.port > max) return false; 34 | } 35 | 36 | // Exact port filter 37 | if (filters.port !== undefined && port.port !== filters.port) { 38 | return false; 39 | } 40 | 41 | // Process name filters 42 | if (filters.processPrefix) { 43 | if (!port.processName.toLowerCase().startsWith(filters.processPrefix.toLowerCase())) { 44 | return false; 45 | } 46 | } else if (filters.processName) { 47 | if (!port.processName.toLowerCase().includes(filters.processName.toLowerCase())) { 48 | return false; 49 | } 50 | } 51 | 52 | return true; 53 | }); 54 | }; 55 | 56 | // Get current active filters - single source of truth 57 | const getCurrentFilters = (): FilterOptions => { 58 | // If preset is active, return preset filters directly 59 | if (activePresetId && presets.length > 0) { 60 | const preset = presets.find(p => p.id === activePresetId); 61 | if (preset) { 62 | return preset.filters; 63 | } 64 | } 65 | 66 | // Otherwise build from state 67 | const filters: FilterOptions = {}; 68 | 69 | if (portRangeMin && portRangeMax) { 70 | const min = parseInt(portRangeMin, 10); 71 | const max = parseInt(portRangeMax, 10); 72 | if (!isNaN(min) && !isNaN(max) && min <= max) { 73 | filters.portRange = { min, max }; 74 | } 75 | } 76 | 77 | if (searchText) { 78 | if (searchMode === 'prefix') { 79 | filters.processPrefix = searchText; 80 | } else { 81 | filters.processName = searchText; 82 | } 83 | } 84 | 85 | return filters; 86 | }; 87 | 88 | // Fetch ports with filters (can pass explicit filters or use state) 89 | const fetchPorts = async (explicitFilters?: FilterOptions, cachePresetId?: string) => { 90 | // Track this fetch with an ID to prevent race conditions 91 | const fetchId = ++latestFetchId.current; 92 | 93 | setLoading(true); 94 | try { 95 | let filters: FilterOptions = {}; 96 | 97 | if (explicitFilters !== undefined) { 98 | // Use explicitly passed filters 99 | filters = explicitFilters; 100 | } else { 101 | // Build filters from state 102 | // Port range filter 103 | if (portRangeMin && portRangeMax) { 104 | const min = parseInt(portRangeMin, 10); 105 | const max = parseInt(portRangeMax, 10); 106 | if (!isNaN(min) && !isNaN(max) && min <= max) { 107 | filters.portRange = { min, max }; 108 | } 109 | } 110 | 111 | // Process name filter 112 | if (searchText) { 113 | if (searchMode === 'prefix') { 114 | filters.processPrefix = searchText; 115 | } else { 116 | filters.processName = searchText; 117 | } 118 | } 119 | } 120 | 121 | const result = await window.portwatchAPI.scanPorts(filters); 122 | 123 | // Only update UI if this is still the latest fetch 124 | if (fetchId === latestFetchId.current) { 125 | if (result.success && result.data) { 126 | setPorts(result.data); 127 | 128 | // Update cache when fetching all ports (no filters) 129 | if (Object.keys(filters).length === 0) { 130 | setCachedAllPorts(result.data); 131 | } 132 | 133 | // Update preset cache if this was a preset fetch 134 | if (cachePresetId) { 135 | setPresetResultsCache(prev => ({ 136 | ...prev, 137 | [cachePresetId]: result.data! 138 | })); 139 | } 140 | } else { 141 | console.error('Failed to fetch ports:', result.message || 'Unknown error'); 142 | setPorts([]); // Set empty array on error so UI updates 143 | } 144 | } 145 | } catch (error) { 146 | console.error('Failed to fetch ports:', error); 147 | // Only update on error if this is still the latest fetch 148 | if (fetchId === latestFetchId.current) { 149 | setPorts([]); // Set empty array on error so UI updates 150 | } 151 | } finally { 152 | // Only clear loading if this is still the latest fetch 153 | if (fetchId === latestFetchId.current) { 154 | setLoading(false); 155 | } 156 | } 157 | }; 158 | 159 | // Load presets on mount 160 | useEffect(() => { 161 | const loadPresets = async () => { 162 | const result = await window.portwatchAPI.loadConfig(); 163 | if (result.success && result.data?.presets) { 164 | setPresets(result.data.presets); 165 | } 166 | }; 167 | loadPresets(); 168 | }, []); 169 | 170 | // Initial fetch and auto-refresh 171 | useEffect(() => { 172 | // Skip if we're in the middle of applying a preset 173 | if (isApplyingPreset) { 174 | return; 175 | } 176 | 177 | // Get current filters (single source of truth) 178 | const currentFilters = getCurrentFilters(); 179 | 180 | // Update activeFilters if not already set by applyPreset 181 | if (!activePresetId) { 182 | setActiveFilters(currentFilters); 183 | } 184 | 185 | // Fetch with current filters 186 | fetchPorts(currentFilters); 187 | 188 | if (autoRefresh) { 189 | const interval = setInterval(async () => { 190 | // Fetch ALL ports (no filters) to keep cache fresh 191 | const result = await window.portwatchAPI.scanPorts({}); 192 | 193 | if (result.success && result.data) { 194 | // Update cache with all ports 195 | setCachedAllPorts(result.data); 196 | 197 | // Apply current filters client-side for instant update 198 | const filters = getCurrentFilters(); 199 | const filtered = filterPortsLocally(result.data, filters); 200 | setPorts(filtered); 201 | 202 | // Update preset cache if a preset is active 203 | if (activePresetId) { 204 | setPresetResultsCache(prev => ({ 205 | ...prev, 206 | [activePresetId]: filtered 207 | })); 208 | } 209 | } 210 | }, 5000); 211 | return () => clearInterval(interval); 212 | } 213 | }, [autoRefresh, searchText, portRangeMin, portRangeMax, searchMode, activePresetId, isApplyingPreset]); 214 | 215 | // Apply a preset (or toggle it off if already active) 216 | const applyPreset = async (preset: FilterPreset) => { 217 | // Prevent useEffect from firing during preset application 218 | setIsApplyingPreset(true); 219 | 220 | // If clicking the same preset, toggle it off 221 | if (activePresetId === preset.id) { 222 | // Show cached all ports instantly 223 | if (cachedAllPorts.length > 0) { 224 | setPorts(cachedAllPorts); 225 | } 226 | 227 | // Clear state 228 | setSearchText(''); 229 | setPortRangeMin(''); 230 | setPortRangeMax(''); 231 | setActivePresetId(null); 232 | setActiveFilters({}); 233 | 234 | // Fetch fresh all ports 235 | await fetchPorts({}); 236 | setIsApplyingPreset(false); 237 | return; 238 | } 239 | 240 | const { filters } = preset; 241 | 242 | // INSTANT: Show cached results for this preset if available 243 | if (presetResultsCache[preset.id] !== undefined) { 244 | setPorts(presetResultsCache[preset.id]); 245 | } else { 246 | // No cache for this preset yet, clear display 247 | setPorts([]); 248 | } 249 | 250 | // Update activeFilters and activePresetId FIRST (single source of truth) 251 | setActiveFilters(filters); 252 | setActivePresetId(preset.id); 253 | 254 | // Then update UI state to match (for display purposes only) 255 | setSearchText(''); 256 | setPortRangeMin(''); 257 | setPortRangeMax(''); 258 | 259 | // Apply port range to UI 260 | if (filters.portRange) { 261 | setPortRangeMin(filters.portRange.min.toString()); 262 | setPortRangeMax(filters.portRange.max.toString()); 263 | } 264 | 265 | // Apply exact port to UI 266 | if (filters.port !== undefined) { 267 | setPortRangeMin(filters.port.toString()); 268 | setPortRangeMax(filters.port.toString()); 269 | } 270 | 271 | // Apply process filter to UI 272 | if (filters.processPrefix) { 273 | setSearchText(filters.processPrefix); 274 | setSearchMode('prefix'); 275 | } else if (filters.processName) { 276 | setSearchText(filters.processName); 277 | setSearchMode('substring'); 278 | } 279 | 280 | // Fetch with preset filters and cache the results 281 | await fetchPorts(filters, preset.id); 282 | 283 | setIsApplyingPreset(false); 284 | }; 285 | 286 | // Save current filters as preset 287 | const saveCurrentAsPreset = async () => { 288 | const name = prompt('Enter preset name:'); 289 | if (!name) return; 290 | 291 | const description = prompt('Enter description (optional):'); 292 | 293 | const newPreset: FilterPreset = { 294 | id: `custom-${Date.now()}`, 295 | name, 296 | description: description || undefined, 297 | filters: {}, 298 | }; 299 | 300 | // Build filters from current state 301 | if (portRangeMin && portRangeMax) { 302 | const min = parseInt(portRangeMin, 10); 303 | const max = parseInt(portRangeMax, 10); 304 | if (!isNaN(min) && !isNaN(max)) { 305 | if (min === max) { 306 | newPreset.filters.port = min; 307 | } else { 308 | newPreset.filters.portRange = { min, max }; 309 | } 310 | } 311 | } 312 | 313 | if (searchText) { 314 | if (searchMode === 'prefix') { 315 | newPreset.filters.processPrefix = searchText; 316 | } else { 317 | newPreset.filters.processName = searchText; 318 | } 319 | } 320 | 321 | // Save to config 322 | const updatedPresets = [...presets, newPreset]; 323 | setPresets(updatedPresets); 324 | 325 | const configResult = await window.portwatchAPI.loadConfig(); 326 | if (configResult.success && configResult.data) { 327 | await window.portwatchAPI.saveConfig({ 328 | ...configResult.data, 329 | presets: updatedPresets, 330 | }); 331 | } 332 | 333 | alert(`Preset "${name}" saved!`); 334 | }; 335 | 336 | // Delete a preset 337 | const deletePreset = async (presetId: string) => { 338 | if (!confirm('Delete this preset?')) return; 339 | 340 | const updatedPresets = presets.filter((p) => p.id !== presetId); 341 | setPresets(updatedPresets); 342 | 343 | const configResult = await window.portwatchAPI.loadConfig(); 344 | if (configResult.success && configResult.data) { 345 | await window.portwatchAPI.saveConfig({ 346 | ...configResult.data, 347 | presets: updatedPresets, 348 | }); 349 | } 350 | 351 | if (activePresetId === presetId) { 352 | setActivePresetId(null); 353 | } 354 | }; 355 | 356 | // Clear all filters 357 | const clearFilters = () => { 358 | setSearchText(''); 359 | setPortRangeMin(''); 360 | setPortRangeMax(''); 361 | setActivePresetId(null); 362 | setActiveFilters({}); 363 | 364 | // Show cached all ports instantly 365 | if (cachedAllPorts.length > 0) { 366 | setPorts(cachedAllPorts); 367 | } 368 | 369 | // useEffect will handle fetching with empty filters 370 | }; 371 | 372 | // Kill process (graceful SIGTERM) 373 | const handleKill = async (port: number, force: boolean = false) => { 374 | const action = force ? 'Force kill' : 'Kill'; 375 | if (confirm(`${action} process on port ${port}?${force ? '\n\n(Force kill will not allow the process to clean up gracefully)' : ''}`)) { 376 | // Optimistically remove from UI for instant feedback 377 | setPorts(prevPorts => prevPorts.filter(p => p.port !== port)); 378 | 379 | const result = await window.portwatchAPI.killByPort(port, force); 380 | 381 | if (result.success) { 382 | // Wait for process to die 383 | await new Promise(resolve => setTimeout(resolve, force ? 300 : 500)); 384 | // Refresh to verify 385 | await fetchPorts(); 386 | } else { 387 | // Kill failed, restore UI 388 | alert(`Failed to ${action.toLowerCase()} process: ${result.message}`); 389 | await fetchPorts(); 390 | } 391 | } 392 | }; 393 | 394 | return ( 395 |
396 | {/* Header */} 397 |
398 |
399 |

PortWatch

400 |
401 | 412 | 419 | 430 |
431 |
432 | 433 | {/* Preset Bar */} 434 | {presets.length > 0 && ( 435 |
436 |
Presets
437 |
438 | {presets.map((preset) => ( 439 |
440 | 451 | {!['web-dev', 'inngest', 'postgres'].includes(preset.id) && ( 452 | 462 | )} 463 |
464 | ))} 465 | {(portRangeMin || portRangeMax || searchText) && !activePresetId && ( 466 | 473 | )} 474 | {(portRangeMin || portRangeMax || searchText || activePresetId) && ( 475 | 482 | )} 483 |
484 |
485 | )} 486 | 487 | {/* Search */} 488 |
489 | setSearchText(e.target.value)} 494 | className="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" 495 | /> 496 | 504 |
505 | 506 | {/* Advanced Filters */} 507 | {showFilters && ( 508 |
509 |
510 | 513 |
514 | setPortRangeMin(e.target.value)} 519 | className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" 520 | min="0" 521 | max="65535" 522 | /> 523 | - 524 | setPortRangeMax(e.target.value)} 529 | className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" 530 | min="0" 531 | max="65535" 532 | /> 533 | {(portRangeMin || portRangeMax) && ( 534 | 546 | )} 547 |
548 |
549 |
550 | )} 551 |
552 | 553 | {/* Port List */} 554 |
555 | {ports.length === 0 ? ( 556 |
557 | {loading ? 'Loading...' : 'No ports found'} 558 |
559 | ) : ( 560 |
561 | {ports.map((port, index) => ( 562 |
566 |
567 |
568 |
569 | 570 | :{port.port} 571 | 572 | PID {port.pid} 573 |
574 |
575 | {port.processName} 576 |
577 |
578 | {port.workingDirectory.replace(/^\/Users\/[^/]+/, '~')} 579 |
580 |
581 |
582 | 589 | 596 |
597 |
598 |
599 | ))} 600 |
601 | )} 602 |
603 | 604 | {/* Footer */} 605 |
606 | {ports.length} port{ports.length !== 1 ? 's' : ''} • PortWatch v1.0.0 607 |
608 |
609 | ); 610 | } 611 | 612 | export default App; 613 | --------------------------------------------------------------------------------