chopstick
All widgets

Ports

Comes with Chopstick

The dev servers listening on your Mac's ports (node, python, ssh tunnels), to open or stop; kill whatever is on any port by number.

Already installed? It's in your widget library.

Type it in the notch's search

  • Kill port port
  • Show ports

What it runs

A widget is a folder of plain files. Here is all of its code.

widget.json
{
  "id": "ports",
  "name": "Ports",
  "icon": "icon.png",
  "description": "The dev servers listening on your Mac's ports (node, python, ssh tunnels), to open or stop; kill whatever is on any port by number.",
  "anytime": true,
  "initial": "list",
  "watch": [
    {
      "script": "ports.sh",
      "every": 5,
      "as": "ports"
    }
  ],
  "commands": {
    "kill": {
      "description": "Stop whatever is listening on a port.",
      "phrase": "Kill port {port}",
      "inputs": {
        "port": {
          "type": "number",
          "description": "The port number, e.g. 3000"
        }
      },
      "preview": {
        "state": "lookup",
        "run": "who.sh"
      },
      "do": [
        {
          "set": {
            "port": "{{input.port}}"
          }
        },
        {
          "set": {
            "error": ""
          }
        },
        {
          "run": "kill.sh",
          "then": "killed",
          "else": "failed"
        },
        {
          "refresh": "ports"
        }
      ]
    },
    "show": {
      "description": "Show the dev servers listening on ports.",
      "phrase": "Show ports",
      "do": [
        {
          "go": "list"
        }
      ]
    }
  },
  "states": {
    "list": {
      "compact": {
        "leading": {
          "symbol": "network",
          "tint": "cyan"
        },
        "trailing": {
          "text": "{{ports.label | …}}"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "network",
          "tint": "cyan",
          "title": "Ports"
        },
        "blocks": [
          {
            "list": {
              "from": "ports.ports",
              "limit": 8,
              "symbol": "server.rack",
              "title": "localhost:{{item.port}}",
              "subtitle": "{{item.where}}",
              "do": [
                {
                  "set": {
                    "port": "{{item.port}}",
                    "where": "{{item.where}}",
                    "url": "{{item.url}}",
                    "error": ""
                  }
                },
                {
                  "go": "port"
                }
              ]
            }
          },
          {
            "note": "{{ports.none}}"
          },
          {
            "input": {
              "placeholder": "Kill a port by number, e.g. 3000",
              "do": [
                {
                  "command": "kill",
                  "with": {
                    "port": "{{item.text}}"
                  }
                }
              ]
            }
          }
        ]
      }
    },
    "port": {
      "task": true,
      "open": true,
      "compact": {
        "leading": {
          "symbol": "network",
          "tint": "cyan"
        },
        "trailing": {
          "text": ":{{port}}"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "server.rack",
          "tint": "cyan",
          "title": "localhost:{{port}}"
        },
        "blocks": [
          {
            "text": "{{where}}",
            "style": "caption",
            "truncate": "middle"
          },
          {
            "row": [
              {
                "button": {
                  "symbol": "safari",
                  "label": "Open",
                  "do": [
                    {
                      "open_url": "{{url}}"
                    },
                    "dismiss"
                  ]
                }
              },
              {
                "button": {
                  "symbol": "xmark.octagon",
                  "label": "Kill",
                  "tint": "red",
                  "do": [
                    {
                      "set": {
                        "error": ""
                      }
                    },
                    {
                      "run": "kill.sh",
                      "then": "killed",
                      "else": "failed"
                    },
                    {
                      "refresh": "ports"
                    }
                  ]
                }
              },
              "spacer",
              {
                "button": {
                  "label": "Back",
                  "style": "plain",
                  "do": [
                    {
                      "go": "list"
                    }
                  ]
                }
              }
            ],
            "spacing": "tight",
            "align": "center"
          }
        ]
      },
      "keys": [
        {
          "key": "return",
          "label": "Open",
          "do": [
            {
              "open_url": "{{url}}"
            },
            "dismiss"
          ]
        },
        {
          "key": "cmd+delete",
          "label": "Kill",
          "do": [
            {
              "set": {
                "error": ""
              }
            },
            {
              "run": "kill.sh",
              "then": "killed",
              "else": "failed"
            },
            {
              "refresh": "ports"
            }
          ]
        },
        {
          "key": "esc",
          "do": [
            {
              "go": "list"
            }
          ]
        }
      ]
    },
    "lookup": {
      "task": true,
      "open": true,
      "compact": {
        "leading": {
          "symbol": "xmark.octagon",
          "tint": "red"
        },
        "trailing": {
          "text": ":{{input.port}}"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "xmark.octagon",
          "tint": "red",
          "title": "Kill port {{input.port}}"
        },
        "blocks": [
          {
            "text": "{{result.where}}",
            "style": "body"
          },
          {
            "note": "{{error}}"
          },
          {
            "note": "Return stops it."
          }
        ]
      }
    },
    "killed": {
      "task": true,
      "dismiss_after": 3,
      "compact": {
        "leading": {
          "symbol": "checkmark.circle.fill",
          "tint": "green"
        },
        "trailing": {
          "text": ":{{result.port}} stopped"
        }
      },
      "expanded": {
        "size": "compact",
        "header": {
          "symbol": "checkmark.circle.fill",
          "tint": "green",
          "title": "Stopped port {{result.port}}"
        },
        "blocks": [
          {
            "text": "{{result.where}}",
            "style": "caption"
          }
        ]
      }
    },
    "failed": {
      "task": true,
      "open": true,
      "dismiss_after": 5,
      "compact": {
        "leading": {
          "symbol": "exclamationmark.triangle",
          "tint": "orange"
        }
      },
      "expanded": {
        "size": "compact",
        "header": {
          "symbol": "exclamationmark.triangle",
          "tint": "orange",
          "title": "Port {{port}}"
        },
        "blocks": [
          {
            "text": "{{error | Couldn't stop it}}",
            "style": "body",
            "lines": 3
          }
        ]
      }
    }
  },
  "preview": {
    "states": [
      "list",
      "port",
      "lookup",
      "killed",
      "failed"
    ],
    "data": {
      "ports": {
        "count": 3,
        "label": "3 ports",
        "ports": [
          {
            "port": 3000,
            "pid": 4120,
            "name": "node",
            "where": "node · ~/code/shop",
            "url": "http://localhost:3000"
          },
          {
            "port": 5432,
            "pid": 812,
            "name": "postgres",
            "where": "postgres · /opt/homebrew/var/postgresql@16",
            "url": "http://localhost:5432"
          },
          {
            "port": 8000,
            "pid": 5210,
            "name": "Python",
            "where": "Python · ~/code/api",
            "url": "http://localhost:8000"
          }
        ],
        "none": ""
      },
      "port": 3000,
      "where": "node · ~/code/shop",
      "url": "http://localhost:3000",
      "input": {
        "port": 3000
      },
      "result": {
        "port": 3000,
        "pid": 4120,
        "name": "node",
        "where": "node · ~/code/shop"
      },
      "error": "Nothing is listening on port 3000"
    }
  }
}
kill.sh
#!/bin/sh
# Stops whatever listens on $CHOPSTICK_PORT: asks it to quit, and forces it after 3 seconds.
# Prints who.sh's description of what was stopped.
PORT=$CHOPSTICK_PORT
[ "$PORT" -gt 0 ] 2>/dev/null || { echo "\"$PORT\" isn't a port" >&2; exit 1; }
WHO=$(sh who.sh) || exit 1
PIDS=$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null | sort -u)
kill $PIDS 2>/dev/null || { echo "Couldn't stop port $PORT's process (it may belong to another user)" >&2; exit 1; }
for _ in 1 2 3 4 5 6; do
  lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1 || { echo "$WHO"; exit 0; }
  sleep 0.5
done
kill -9 $PIDS 2>/dev/null
sleep 0.3
lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1 && { echo "Port $PORT is still in use" >&2; exit 1; }
echo "$WHO"
ports.sh
#!/bin/sh
# Your listening TCP ports that look like dev servers (node, python, an ssh tunnel, a Homebrew
# service), as one JSON object. Apps (in /Applications, their helpers in Library) and macOS's own
# services are left out: they listen on ports of their own all the time.
LSOF=$(lsof -nP -iTCP -sTCP:LISTEN -F pn 2>/dev/null)
PIDS=$(printf '%s\n' "$LSOF" | sed -n 's/^p//p' | paste -sd, -)
[ -n "$PIDS" ] || { echo '{"count":0,"label":"","none":"No dev servers are listening.","ports":[]}'; exit 0; }
PATHS=$(ps -o pid=,comm= -p "$PIDS" 2>/dev/null)
DIRS=$(lsof -a -d cwd -p "$PIDS" -Fn 2>/dev/null)
{ printf '%s\n' "$PATHS" | sed 's/^ */path /'; printf '%s\n' "$DIRS" | sed 's/^/cwd /'; printf '%s\n' "$LSOF" | sed 's/^/port /'; } |
awk -v home="$HOME" '
  function esc(s) { gsub(/["\\]/, "", s); return s }
  $1 == "path" { pid = $2; sub(/^path [0-9]+ /, ""); path[pid] = $0; next }
  $1 == "cwd" { s = substr($0, 5); if (s ~ /^p/) pid = substr(s, 2); else if (s ~ /^n/) { d = substr(s, 2); if (index(d, home) == 1) d = "~" substr(d, length(home) + 1); dir[pid] = d }; next }
  $1 == "port" {
    s = substr($0, 6)
    if (s ~ /^p/) { pid = substr(s, 2); next }
    if (s !~ /^n/) next
    p = path[pid]
    if (p == "" || index(p, "/Applications/") == 1 || index(p, home "/Applications/") == 1 || index(p, home "/Library/") == 1 ||
        index(p, "/Library/Application Support/") == 1 || p ~ /^\/(System|usr\/libexec|usr\/sbin|sbin)\//) next
    n = substr(s, 2); port = n; sub(/.*:/, "", port)
    if (seen[pid ":" port]++) next
    name = p; sub(/.*\//, "", name)
    d = (pid in dir && dir[pid] != "/") ? " · " dir[pid] : ""
    out = out sprintf("%s{\"port\":%d,\"pid\":%d,\"name\":\"%s\",\"where\":\"%s%s\",\"url\":\"http://localhost:%d\"}", (count++ ? "," : ""), port, pid, esc(name), esc(name), esc(d), port)
  }
  END { printf "{\"count\":%d,\"label\":\"%s\",\"none\":\"%s\",\"ports\":[%s]}\n", count, count ? count " " (count == 1 ? "port" : "ports") : "", count ? "" : "No dev servers are listening.", out }'
who.sh
#!/bin/sh
# What listens on $CHOPSTICK_PORT: {"port", "pid", "name", "where"}, or fails with nothing there.
PORT=$CHOPSTICK_PORT
PID=$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null | head -1)
[ -n "$PID" ] || { echo "Nothing is listening on port $PORT" >&2; exit 1; }
NAME=$(ps -o comm= -p "$PID" | sed 's|.*/||; s/["\\]//g')
DIR=$(lsof -a -d cwd -p "$PID" -Fn 2>/dev/null | sed -n 's/^n//p' | sed "s|^$HOME|~|; s/[\"\\\\]//g")
WHERE=$NAME; [ -n "$DIR" ] && [ "$DIR" != "/" ] && WHERE="$NAME · $DIR"
printf '{"port":%d,"pid":%d,"name":"%s","where":"%s"}\n' "$PORT" "$PID" "$NAME" "$WHERE"