Files
claude-engineering-plugin/plugins/compound-engineering/skills/ce-polish-beta/scripts/resolve-port.sh
2026-04-16 17:55:10 -05:00

309 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# resolve-port.sh -- resolve the dev-server port for a project.
#
# Usage:
# resolve-port.sh [path] [--type <type>] [--port <n>]
#
# Arguments:
# path (optional) -- project root directory. Defaults to the git repo root.
# --type (optional) -- framework type to scope probes (rails|next|vite|nuxt|
# astro|remix|sveltekit|procfile). Unset runs all probes.
# --port (optional) -- explicit port override. Emitted immediately when present.
#
# Output:
# Single line on stdout: the resolved port number.
# stderr is reserved for ERROR: messages only.
#
# Probe order (FIRST HIT WINS):
#
# 1. Explicit --port flag
# 2. Framework config files (next.config.*, vite.config.*, nuxt.config.*,
# astro.config.*) -- conservative regex matching only numeric literal
# port values. Variable references like process.env.PORT or getPort()
# are deliberately not matched; the probe falls through.
# 3. Rails: config/puma.rb for `port <n>`
# 4. Procfile.dev: web line scanned for -p/-p=<n>/--port/--port=<n>
# 5. docker-compose.yml: line-anchored grep for "- "<n>:<n>"" port mapping
# 6. package.json: dev/start script for --port/-p flags
# 7. .env files in override order: .env.local -> .env.development -> .env
# (first hit wins). Values are parsed with quote stripping (" and ')
# and comment truncation (at #, after trimming whitespace).
# 8. Framework default lookup table
#
# Why config-before-prose: framework config files are the most reliable source
# of truth for the intended port; instruction files and env files are often
# stale or overridden. Prose files (AGENTS.md, CLAUDE.md) are deliberately NOT
# scanned -- they carry natural language that may mention ports in contexts
# unrelated to the dev server (documentation, examples, troubleshooting).
# Scanning them produces false positives that are hard to debug.
#
# .env parsing contract: surrounding double or single quotes are stripped.
# Inline comments (# ...) are truncated after trimming whitespace. This is
# intentionally more aggressive than the test-browser skill's inline cascade,
# which does neither. See dev-server-detection.md for the divergence notes.
set -u
# ── Argument parsing ─────────────────────────────────────────────────────────
PROJECT_ROOT=""
PROJ_TYPE=""
EXPLICIT_PORT=""
while [ $# -gt 0 ]; do
case "$1" in
--type)
PROJ_TYPE="${2:-}"
shift 2
;;
--port)
EXPLICIT_PORT="${2:-}"
shift 2
;;
*)
if [ -z "$PROJECT_ROOT" ]; then
PROJECT_ROOT="$1"
fi
shift
;;
esac
done
# Default to git repo root when no positional path is given.
if [ -z "$PROJECT_ROOT" ]; then
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$PROJECT_ROOT" ]; then
echo "ERROR: not in a git repository and no path provided" >&2
exit 1
fi
fi
if [ ! -d "$PROJECT_ROOT" ]; then
echo "ERROR: path does not exist: $PROJECT_ROOT" >&2
exit 1
fi
# ── Helpers ──────────────────────────────────────────────────────────────────
# should_probe TYPE PROBE_NAME
# Returns 0 (true) if the probe should run for the given --type.
should_probe() {
local ptype="$1"
local probe="$2"
if [ -z "$ptype" ]; then
return 0 # no type filter -- run all probes
fi
case "$ptype" in
rails)
case "$probe" in
puma|procfile|docker-compose|env|default) return 0 ;;
*) return 1 ;;
esac
;;
next|nuxt|astro|remix|vite|sveltekit)
case "$probe" in
framework-config|package-json|env|default) return 0 ;;
*) return 1 ;;
esac
;;
procfile)
case "$probe" in
procfile|docker-compose|env|default) return 0 ;;
*) return 1 ;;
esac
;;
*)
return 0 # unknown type -- run all probes
;;
esac
}
# parse_env_port FILE
# Parses PORT=<n> from the given file. Strips surrounding quotes and inline
# comments. Prints the port on stdout or nothing.
parse_env_port() {
local envfile="$1"
if [ ! -f "$envfile" ]; then
return
fi
local line
line=$(grep -E '^PORT=' "$envfile" 2>/dev/null | tail -1)
if [ -z "$line" ]; then
return
fi
# Extract value after PORT=
local value
value="${line#PORT=}"
# Trim whitespace, then truncate at # (inline comment) -- comment stripping
# must happen BEFORE quote stripping so PORT="3001" # comment -> "3001" -> 3001
value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*#.*$//;s/[[:space:]]*$//')
# Strip surrounding double quotes
value="${value%\"}"
value="${value#\"}"
# Strip surrounding single quotes
value="${value%\'}"
value="${value#\'}"
# Trim any remaining whitespace
value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$value" ]; then
printf '%s' "$value"
fi
}
# ── Probe 1: Explicit --port flag ────────────────────────────────────────────
if [ -n "$EXPLICIT_PORT" ]; then
echo "$EXPLICIT_PORT"
exit 0
fi
# ── Probe 2: Framework config files ─────────────────────────────────────────
if should_probe "$PROJ_TYPE" "framework-config"; then
for cfg in \
"$PROJECT_ROOT"/next.config.js \
"$PROJECT_ROOT"/next.config.ts \
"$PROJECT_ROOT"/next.config.mjs \
"$PROJECT_ROOT"/next.config.cjs \
"$PROJECT_ROOT"/vite.config.js \
"$PROJECT_ROOT"/vite.config.ts \
"$PROJECT_ROOT"/vite.config.mjs \
"$PROJECT_ROOT"/vite.config.cjs \
"$PROJECT_ROOT"/nuxt.config.js \
"$PROJECT_ROOT"/nuxt.config.ts \
"$PROJECT_ROOT"/nuxt.config.mjs \
"$PROJECT_ROOT"/nuxt.config.cjs \
"$PROJECT_ROOT"/astro.config.js \
"$PROJECT_ROOT"/astro.config.ts \
"$PROJECT_ROOT"/astro.config.mjs \
"$PROJECT_ROOT"/astro.config.cjs \
; do
if [ ! -f "$cfg" ]; then
continue
fi
# Conservative regex: match "port:" + digits, then verify nothing non-numeric
# follows (rejects variable references like "port: process.env.PORT || 3000").
local_line=$(grep -E 'port:[[:space:]]*["'"'"']?[0-9]+' "$cfg" 2>/dev/null | head -1)
if [ -z "$local_line" ]; then continue; fi
local_port=$(printf '%s' "$local_line" | grep -Eo 'port:[[:space:]]*["'"'"']?[0-9]+["'"'"']?' | head -1 | grep -Eo '[0-9]+')
if [ -n "$local_port" ]; then
local_after=$(printf '%s' "$local_line" | sed "s/.*port:[[:space:]]*[\"']*${local_port}[\"']*//" )
if [ -z "$local_after" ] || printf '%s' "$local_after" | grep -qE '^[[:space:],})]*$'; then
echo "$local_port"
exit 0
fi
fi
done
fi
# ── Probe 3: Rails config/puma.rb ───────────────────────────────────────────
if should_probe "$PROJ_TYPE" "puma"; then
puma_file="$PROJECT_ROOT/config/puma.rb"
if [ -f "$puma_file" ]; then
puma_port=$(grep -Eo 'port[[:space:]]+[0-9]+' "$puma_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+')
if [ -n "$puma_port" ]; then
echo "$puma_port"
exit 0
fi
fi
fi
# ── Probe 4: Procfile.dev ───────────────────────────────────────────────────
if should_probe "$PROJ_TYPE" "procfile"; then
procfile="$PROJECT_ROOT/Procfile.dev"
if [ -f "$procfile" ]; then
# Extract the web line
web_line=$(grep -E '^web:' "$procfile" 2>/dev/null | head -1)
if [ -n "$web_line" ]; then
# Match -p <n>, -p<n>, --port <n>, -p=<n>, --port=<n>
proc_port=$(printf '%s' "$web_line" | grep -Eo '(-p[= ]*|--port[= ]+)[0-9]+' | head -1 | grep -Eo '[0-9]+')
if [ -n "$proc_port" ]; then
echo "$proc_port"
exit 0
fi
fi
fi
fi
# ── Probe 5: docker-compose.yml ─────────────────────────────────────────────
if should_probe "$PROJ_TYPE" "docker-compose"; then
compose_file="$PROJECT_ROOT/docker-compose.yml"
if [ -f "$compose_file" ]; then
# Simple line-anchored grep for port mappings: - "NNNN:NNNN" or - NNNN:NNNN
compose_port=$(grep -Eo '"[0-9]+:[0-9]+"' "$compose_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+' | head -1)
if [ -n "$compose_port" ]; then
echo "$compose_port"
exit 0
fi
fi
fi
# ── Probe 6: package.json scripts ───────────────────────────────────────────
if should_probe "$PROJ_TYPE" "package-json"; then
pkg_file="$PROJECT_ROOT/package.json"
if [ -f "$pkg_file" ]; then
# Look for --port or -p in dev/start scripts
pkg_port=$(grep -Eo '(-p[= ]+|--port[= ]+)[0-9]+' "$pkg_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+')
if [ -n "$pkg_port" ]; then
echo "$pkg_port"
exit 0
fi
fi
fi
# ── Probe 7: .env files ─────────────────────────────────────────────────────
if should_probe "$PROJ_TYPE" "env"; then
for envfile in \
"$PROJECT_ROOT/.env.local" \
"$PROJECT_ROOT/.env.development" \
"$PROJECT_ROOT/.env" \
; do
env_port=$(parse_env_port "$envfile")
if [ -n "$env_port" ]; then
echo "$env_port"
exit 0
fi
done
fi
# ── Probe 8: Framework default lookup table ──────────────────────────────────
if should_probe "$PROJ_TYPE" "default"; then
case "$PROJ_TYPE" in
rails|next|nuxt|remix|procfile|"")
echo "3000"
;;
vite|sveltekit)
echo "5173"
;;
astro)
echo "4321"
;;
*)
echo "3000"
;;
esac
exit 0
fi
# Final fallback (should not normally be reached)
echo "3000"
exit 0