Skills › Compound Engineering › ce-polish › resolve-port.sh
resolve-port.sh
307 lines · skills/ce-polish/scripts/resolve-port.sh at 3449230
1#!/usr/bin/env bash
2#
3# resolve-port.sh -- resolve the dev-server port for a project.
4#
5# Usage:
6# resolve-port.sh [path] [--type <type>] [--port <n>]
7#
8# Arguments:
9# path (optional) -- project root directory. Defaults to the git repo root.
10# --type (optional) -- framework type to scope probes (rails|next|vite|nuxt|
11# astro|remix|sveltekit|procfile). Unset runs all probes.
12# --port (optional) -- explicit port override. Emitted immediately when present.
13#
14# Output:
15# Single line on stdout: the resolved port number.
16# stderr is reserved for ERROR: messages only.
17#
18# Probe order (FIRST HIT WINS):
19#
20# 1. Explicit --port flag
21# 2. Framework config files (next.config.*, vite.config.*, nuxt.config.*,
22# astro.config.*) -- conservative regex matching only numeric literal
23# port values. Variable references like process.env.PORT or getPort()
24# are deliberately not matched; the probe falls through.
25# 3. Rails: config/puma.rb for `port <n>`
26# 4. Procfile.dev: web line scanned for -p/-p=<n>/--port/--port=<n>
27# 5. docker-compose.yml: line-anchored grep for "- "<n>:<n>"" port mapping
28# 6. package.json: dev/start script for --port/-p flags
29# 7. .env files in override order: .env.local -> .env.development -> .env
30# (first hit wins). Values are parsed with quote stripping (" and ')
31# and comment truncation (at #, after trimming whitespace).
32# 8. Framework default lookup table
33#
34# Why config-before-prose: framework config files are the most reliable source
35# of truth for the intended port; instruction files and env files are often
36# stale or overridden. Prose files (AGENTS.md, CLAUDE.md) are deliberately NOT
37# scanned -- they carry natural language that may mention ports in contexts
38# unrelated to the dev server (documentation, examples, troubleshooting).
39# Scanning them produces false positives that are hard to debug.
40#
41# .env parsing contract: surrounding double or single quotes are stripped.
42# Inline comments (# ...) are truncated after trimming whitespace. Both are
43# intentional for real-world .env files. See dev-server-detection.md.
45set -u
47# ── Argument parsing ─────────────────────────────────────────────────────────
49PROJECT_ROOT=""
50PROJ_TYPE=""
51EXPLICIT_PORT=""
53while [ $# -gt 0 ]; do
54 case "$1" in
55 --type)
56 PROJ_TYPE="${2:-}"
57 shift 2
58 ;;
59 --port)
60 EXPLICIT_PORT="${2:-}"
61 shift 2
62 ;;
63 *)
64 if [ -z "$PROJECT_ROOT" ]; then
65 PROJECT_ROOT="$1"
66 fi
67 shift
68 ;;
69 esac
70done
72# Default to git repo root when no positional path is given.
73if [ -z "$PROJECT_ROOT" ]; then
74 PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
75 if [ -z "$PROJECT_ROOT" ]; then
76 echo "ERROR: not in a git repository and no path provided" >&2
77 exit 1
78 fi
79fi
81if [ ! -d "$PROJECT_ROOT" ]; then
82 echo "ERROR: path does not exist: $PROJECT_ROOT" >&2
83 exit 1
84fi
86# ── Helpers ──────────────────────────────────────────────────────────────────
88# should_probe TYPE PROBE_NAME
89# Returns 0 (true) if the probe should run for the given --type.
90should_probe() {
91 local ptype="$1"
92 local probe="$2"
94 if [ -z "$ptype" ]; then
95 return 0 # no type filter -- run all probes
96 fi
98 case "$ptype" in
99 rails)
100 case "$probe" in
101 puma|procfile|docker-compose|env|default) return 0 ;;
102 *) return 1 ;;
103 esac
104 ;;
105 next|nuxt|astro|remix|vite|sveltekit)
106 case "$probe" in
107 framework-config|package-json|env|default) return 0 ;;
108 *) return 1 ;;
109 esac
110 ;;
111 procfile)
112 case "$probe" in
113 procfile|docker-compose|env|default) return 0 ;;
114 *) return 1 ;;
115 esac
116 ;;
117 *)
118 return 0 # unknown type -- run all probes
119 ;;
120 esac
121}
123# parse_env_port FILE
124# Parses PORT=<n> from the given file. Strips surrounding quotes and inline
125# comments. Prints the port on stdout or nothing.
126parse_env_port() {
127 local envfile="$1"
128 if [ ! -f "$envfile" ]; then
129 return
130 fi
132 local line
133 line=$(grep -E '^PORT=' "$envfile" 2>/dev/null | tail -1)
134 if [ -z "$line" ]; then
135 return
136 fi
138 # Extract value after PORT=
139 local value
140 value="${line#PORT=}"
142 # Trim whitespace, then truncate at # (inline comment) -- comment stripping
143 # must happen BEFORE quote stripping so PORT="3001" # comment -> "3001" -> 3001
144 value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*#.*$//;s/[[:space:]]*$//')
146 # Strip surrounding double quotes
147 value="${value%\"}"
148 value="${value#\"}"
150 # Strip surrounding single quotes
151 value="${value%\'}"
152 value="${value#\'}"
154 # Trim any remaining whitespace
155 value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
157 if [ -n "$value" ]; then
158 printf '%s' "$value"
159 fi
160}
162# ── Probe 1: Explicit --port flag ────────────────────────────────────────────
164if [ -n "$EXPLICIT_PORT" ]; then
165 echo "$EXPLICIT_PORT"
166 exit 0
167fi
169# ── Probe 2: Framework config files ─────────────────────────────────────────
171if should_probe "$PROJ_TYPE" "framework-config"; then
172 for cfg in \
173 "$PROJECT_ROOT"/next.config.js \
174 "$PROJECT_ROOT"/next.config.ts \
175 "$PROJECT_ROOT"/next.config.mjs \
176 "$PROJECT_ROOT"/next.config.cjs \
177 "$PROJECT_ROOT"/vite.config.js \
178 "$PROJECT_ROOT"/vite.config.ts \
179 "$PROJECT_ROOT"/vite.config.mjs \
180 "$PROJECT_ROOT"/vite.config.cjs \
181 "$PROJECT_ROOT"/nuxt.config.js \
182 "$PROJECT_ROOT"/nuxt.config.ts \
183 "$PROJECT_ROOT"/nuxt.config.mjs \
184 "$PROJECT_ROOT"/nuxt.config.cjs \
185 "$PROJECT_ROOT"/astro.config.js \
186 "$PROJECT_ROOT"/astro.config.ts \
187 "$PROJECT_ROOT"/astro.config.mjs \
188 "$PROJECT_ROOT"/astro.config.cjs \
189 ; do
190 if [ ! -f "$cfg" ]; then
191 continue
192 fi
194 # Conservative regex: match "port:" + digits, then verify nothing non-numeric
195 # follows (rejects variable references like "port: process.env.PORT || 3000").
196 local_line=$(grep -E 'port:[[:space:]]*["'"'"']?[0-9]+' "$cfg" 2>/dev/null | head -1)
197 if [ -z "$local_line" ]; then continue; fi
199 local_port=$(printf '%s' "$local_line" | grep -Eo 'port:[[:space:]]*["'"'"']?[0-9]+["'"'"']?' | head -1 | grep -Eo '[0-9]+')
200 if [ -n "$local_port" ]; then
201 local_after=$(printf '%s' "$local_line" | sed "s/.*port:[[:space:]]*[\"']*${local_port}[\"']*//" )
202 if [ -z "$local_after" ] || printf '%s' "$local_after" | grep -qE '^[[:space:],})]*$'; then
203 echo "$local_port"
204 exit 0
205 fi
206 fi
207 done
208fi
210# ── Probe 3: Rails config/puma.rb ───────────────────────────────────────────
212if should_probe "$PROJ_TYPE" "puma"; then
213 puma_file="$PROJECT_ROOT/config/puma.rb"
214 if [ -f "$puma_file" ]; then
215 puma_port=$(grep -Eo 'port[[:space:]]+[0-9]+' "$puma_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+')
216 if [ -n "$puma_port" ]; then
217 echo "$puma_port"
218 exit 0
219 fi
220 fi
221fi
223# ── Probe 4: Procfile.dev ───────────────────────────────────────────────────
225if should_probe "$PROJ_TYPE" "procfile"; then
226 procfile="$PROJECT_ROOT/Procfile.dev"
227 if [ -f "$procfile" ]; then
228 # Extract the web line
229 web_line=$(grep -E '^web:' "$procfile" 2>/dev/null | head -1)
230 if [ -n "$web_line" ]; then
231 # Match -p <n>, -p<n>, --port <n>, -p=<n>, --port=<n>
232 proc_port=$(printf '%s' "$web_line" | grep -Eo '(-p[= ]*|--port[= ]+)[0-9]+' | head -1 | grep -Eo '[0-9]+')
233 if [ -n "$proc_port" ]; then
234 echo "$proc_port"
235 exit 0
236 fi
237 fi
238 fi
239fi
241# ── Probe 5: docker-compose.yml ─────────────────────────────────────────────
243if should_probe "$PROJ_TYPE" "docker-compose"; then
244 compose_file="$PROJECT_ROOT/docker-compose.yml"
245 if [ -f "$compose_file" ]; then
246 # Simple line-anchored grep for port mappings: - "NNNN:NNNN" or - NNNN:NNNN
247 compose_port=$(grep -Eo '"[0-9]+:[0-9]+"' "$compose_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+' | head -1)
248 if [ -n "$compose_port" ]; then
249 echo "$compose_port"
250 exit 0
251 fi
252 fi
253fi
255# ── Probe 6: package.json scripts ───────────────────────────────────────────
257if should_probe "$PROJ_TYPE" "package-json"; then
258 pkg_file="$PROJECT_ROOT/package.json"
259 if [ -f "$pkg_file" ]; then
260 # Look for --port or -p in dev/start scripts
261 pkg_port=$(grep -Eo '(-p[= ]+|--port[= ]+)[0-9]+' "$pkg_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+')
262 if [ -n "$pkg_port" ]; then
263 echo "$pkg_port"
264 exit 0
265 fi
266 fi
267fi
269# ── Probe 7: .env files ─────────────────────────────────────────────────────
271if should_probe "$PROJ_TYPE" "env"; then
272 for envfile in \
273 "$PROJECT_ROOT/.env.local" \
274 "$PROJECT_ROOT/.env.development" \
275 "$PROJECT_ROOT/.env" \
276 ; do
277 env_port=$(parse_env_port "$envfile")
278 if [ -n "$env_port" ]; then
279 echo "$env_port"
280 exit 0
281 fi
282 done
283fi
285# ── Probe 8: Framework default lookup table ──────────────────────────────────
287if should_probe "$PROJ_TYPE" "default"; then
288 case "$PROJ_TYPE" in
289 rails|next|nuxt|remix|procfile|"")
290 echo "3000"
291 ;;
292 vite|sveltekit)
293 echo "5173"
294 ;;
295 astro)
296 echo "4321"
297 ;;
298 *)
299 echo "3000"
300 ;;
301 esac
302 exit 0
303fi
305# Final fallback (should not normally be reached)
306echo "3000"
307exit 0