├── 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 | `;
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 | `;
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 | `;
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 |
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 |
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 |
--------------------------------------------------------------------------------