feat(ce-polish-beta): human-in-the-loop polish phase between /ce:review and merge (#568)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kieran Klaassen
2026-04-16 17:55:10 -05:00
committed by GitHub
parent 3d96c0f074
commit 070092d997
25 changed files with 3800 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env bash
#
# detect-project-type.sh — inspect signature files at the repo root (and, if
# no root match is found, probe shallow subdirectories) to emit a project-type
# identifier on stdout.
#
# Usage:
# detect-project-type.sh
#
# Output grammar (one line on stdout):
#
# <type> — single signature match at root
# e.g. "next", "rails", "vite"
#
# <type>@<relative-dir> — single monorepo hit (no root match)
# e.g. "next@apps/web"
#
# multiple — two or more disjoint root signatures
# (caller must prompt for disambiguation)
#
# multiple:<type>@<dir>,<type>@<dir> — multiple monorepo hits (no root match)
# e.g. "multiple:next@apps/web,rails@apps/api"
#
# unknown — no signatures found at root or in probe
#
# Supported root types: rails, next, vite, nuxt, astro, remix, sveltekit, procfile
#
# Monorepo probe:
# Runs only when root detection finds ZERO matches. Searches subdirectories
# up to depth 3 (e.g. services/api/server/vite.config.ts) for framework
# signature files. Deeper nesting is ignored to avoid false positives.
#
# Excluded directories (not real project roots):
# node_modules .git vendor dist build coverage .next .nuxt
# .svelte-kit .turbo tmp fixtures
#
# `multiple` vs `rails`: Rails apps commonly ship a Procfile.dev alongside
# bin/dev. To avoid treating every Rails app as a monorepo, the `rails`
# signature takes precedence over a bare `procfile` match. `multiple` is
# reserved for genuine disambiguation cases (e.g., Rails + Next, Next + Vite).
set -u
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "ERROR: not in a git repository" >&2
exit 1
fi
cd "$REPO_ROOT" || { echo "ERROR: cannot cd to repo root" >&2; exit 1; }
MATCHES=()
# Rails: bin/dev AND Gemfile together. A Gemfile alone (or bin/dev alone) is
# insufficient -- plenty of gems have Gemfiles without bin/dev, and bin/dev
# may exist in non-Rails projects.
if [ -f "bin/dev" ] && [ -f "Gemfile" ]; then
MATCHES+=("rails")
fi
# Next.js
if [ -f "next.config.js" ] || [ -f "next.config.mjs" ] || [ -f "next.config.ts" ] || [ -f "next.config.cjs" ]; then
MATCHES+=("next")
fi
# Vite
if [ -f "vite.config.js" ] || [ -f "vite.config.ts" ] || [ -f "vite.config.mjs" ] || [ -f "vite.config.cjs" ]; then
MATCHES+=("vite")
fi
# Nuxt
if [ -f "nuxt.config.js" ] || [ -f "nuxt.config.mjs" ] || [ -f "nuxt.config.ts" ]; then
MATCHES+=("nuxt")
fi
# Astro
if [ -f "astro.config.js" ] || [ -f "astro.config.mjs" ] || [ -f "astro.config.ts" ]; then
MATCHES+=("astro")
fi
# Remix (classic — Remix on Vite uses vite.config.ts, detected as vite)
if [ -f "remix.config.js" ] || [ -f "remix.config.ts" ]; then
MATCHES+=("remix")
fi
# SvelteKit
if [ -f "svelte.config.js" ] || [ -f "svelte.config.mjs" ] || [ -f "svelte.config.ts" ]; then
MATCHES+=("sveltekit")
fi
# Procfile / Overmind / Foreman — only if we didn't already detect rails
if [ ${#MATCHES[@]} -eq 0 ] || [ "${MATCHES[0]}" != "rails" ]; then
if [ -f "Procfile" ] || [ -f "Procfile.dev" ]; then
MATCHES+=("procfile")
fi
fi
# ── Root result ──────────────────────────────────────────────────────────────
case ${#MATCHES[@]} in
0)
# No root match — run monorepo probe (shallow find, depth <= 3).
;;
1)
echo "${MATCHES[0]}"
exit 0
;;
*)
echo "multiple"
exit 0
;;
esac
# ── Monorepo probe ─────────────────────────────────────────────────────────
# When root detection returns zero matches, descend up to depth 3 looking for
# framework signatures in workspace directories. Common layouts:
# apps/web/next.config.js (depth 2)
# packages/frontend/vite.config.ts (depth 2)
# services/api/server/vite.config.ts (depth 3)
#
# Exclusion list: directories that ship framework configs as fixtures or build
# output, not as real project roots.
EXCLUDE_DIRS="node_modules .git vendor dist build coverage .next .nuxt .svelte-kit .turbo tmp fixtures"
EXCLUDE_ARGS=""
for d in $EXCLUDE_DIRS; do
EXCLUDE_ARGS="$EXCLUDE_ARGS -path './$d' -prune -o -path '*/$d' -prune -o"
done
# Signature file patterns to look for
SIGNATURE_PATTERNS=(
"next.config.js" "next.config.mjs" "next.config.ts" "next.config.cjs"
"vite.config.js" "vite.config.ts" "vite.config.mjs" "vite.config.cjs"
"nuxt.config.js" "nuxt.config.mjs" "nuxt.config.ts"
"astro.config.js" "astro.config.mjs" "astro.config.ts"
"remix.config.js" "remix.config.ts"
"svelte.config.js" "svelte.config.mjs" "svelte.config.ts"
)
# Build the find -name arguments
NAME_ARGS=""
for i in "${!SIGNATURE_PATTERNS[@]}"; do
if [ "$i" -gt 0 ]; then
NAME_ARGS="$NAME_ARGS -o"
fi
NAME_ARGS="$NAME_ARGS -name '${SIGNATURE_PATTERNS[$i]}'"
done
# Run find. Use eval because the dynamically built arguments contain quoted
# strings that must be expanded by the shell.
FOUND_FILES=$(eval "find . -maxdepth 4 $EXCLUDE_ARGS \\( $NAME_ARGS \\) -print" 2>/dev/null | sort)
# Also check for Rails signature (bin/dev + Gemfile in the same subdir)
RAILS_HITS=""
# Find all Gemfiles at depth <= 3, check each dir for bin/dev
while IFS= read -r gemfile; do
[ -z "$gemfile" ] && continue
gdir=$(dirname "$gemfile")
if [ -f "$gdir/bin/dev" ]; then
RAILS_HITS="$RAILS_HITS
$gdir"
fi
done < <(eval "find . -maxdepth 4 $EXCLUDE_ARGS -name 'Gemfile' -print" 2>/dev/null)
# Parse found files into (type, relative-dir) pairs
declare -A MONO_HITS=() # key = "type@dir", value = 1 (dedup)
if [ -n "$FOUND_FILES" ]; then
for f in $FOUND_FILES; do
[ -z "$f" ] && continue
fname=$(basename "$f")
fdir=$(dirname "$f")
# Normalize dir: strip leading ./
fdir="${fdir#./}"
# Enforce depth cap of 3: count slashes in the relative path of the file.
# A file at apps/web/next.config.js has dir apps/web (1 slash = depth 2).
# A file at a/b/c/d/next.config.js has dir a/b/c/d (3 slashes = depth 4 = too deep).
# We want maxdepth 3 for the directory, meaning at most 2 slashes in fdir.
slash_count=$(echo "$fdir" | tr -cd '/' | wc -c | tr -d ' ')
if [ "$slash_count" -gt 2 ]; then
continue
fi
case "$fname" in
next.config.*) ftype="next" ;;
vite.config.*) ftype="vite" ;;
nuxt.config.*) ftype="nuxt" ;;
astro.config.*) ftype="astro" ;;
remix.config.*) ftype="remix" ;;
svelte.config.*) ftype="sveltekit" ;;
*) continue ;;
esac
# Skip root hits (those would have been caught by root detection)
if [ "$fdir" = "." ]; then continue; fi
MONO_HITS["${ftype}@${fdir}"]=1
done
fi
# Add Rails monorepo hits
if [ -n "$RAILS_HITS" ]; then
for rdir in $RAILS_HITS; do
[ -z "$rdir" ] && continue
rdir="${rdir#./}"
if [ "$rdir" != "." ] && [ -n "$rdir" ]; then
# Enforce depth cap for Rails hits too
slash_count=$(echo "$rdir" | tr -cd '/' | wc -c | tr -d ' ')
if [ "$slash_count" -le 2 ]; then
MONO_HITS["rails@${rdir}"]=1
fi
fi
done
fi
# ${#MONO_HITS[@]} triggers "unbound variable" under set -u on macOS bash 3.2
# when the array is empty. Use the ${var+expr} expansion to guard it.
MONO_COUNT=${MONO_HITS[@]+${#MONO_HITS[@]}}
MONO_COUNT=${MONO_COUNT:-0}
case $MONO_COUNT in
0)
echo "unknown"
;;
1)
# Single monorepo hit: emit type@cwd
for key in "${!MONO_HITS[@]}"; do
echo "$key"
done
;;
*)
# Multiple hits: emit multiple:type1@cwd1,type2@cwd2,...
result=""
for key in "${!MONO_HITS[@]}"; do
if [ -n "$result" ]; then
result="${result},${key}"
else
result="$key"
fi
done
echo "multiple:$result"
;;
esac

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# read-launch-json.sh — read .claude/launch.json from the repo root and emit
# the selected configuration as JSON on stdout, or a sentinel on failure.
#
# Usage:
# read-launch-json.sh [config-name]
#
# Arguments:
# config-name (optional) — if multiple configurations exist and this arg
# matches a configuration's `name`, emit that one.
# If omitted and there are multiple configurations,
# emit a __MULTIPLE_CONFIGS__ sentinel followed by a
# JSON array of configuration names on the next line.
#
# Output contract:
# Success: single-line JSON object on stdout representing the chosen
# configuration. Shape mirrors VS Code's launch.json entry:
# {name, runtimeExecutable, runtimeArgs, port, cwd, env}.
# Sentinels (printed to stdout, one per line):
# __NO_LAUNCH_JSON__ - file not found
# __INVALID_LAUNCH_JSON__ - file exists but fails JSON parsing
# __MISSING_CONFIGURATIONS__ - valid JSON but no `configurations` array
# __MULTIPLE_CONFIGS__ - ambiguity, needs caller disambiguation.
# Followed by a JSON array of names on line 2.
# __CONFIG_NOT_FOUND__ - caller-provided name doesn't match any entry
#
# The script never exits non-zero for a missing or malformed file -- callers
# parse the sentinel and decide how to proceed. Exit code 1 is reserved for
# genuine operational failures (missing `jq`, git root not found).
set -u
REQUESTED_NAME="${1:-}"
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "ERROR: not in a git repository" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required but not installed" >&2
exit 1
fi
LAUNCH_PATH="$REPO_ROOT/.claude/launch.json"
if [ ! -f "$LAUNCH_PATH" ]; then
echo "__NO_LAUNCH_JSON__"
exit 0
fi
# Validate JSON. We parse with `jq empty` so malformed JSON is caught
# before any downstream query runs.
if ! jq empty "$LAUNCH_PATH" >/dev/null 2>&1; then
echo "__INVALID_LAUNCH_JSON__"
exit 0
fi
CONFIG_COUNT=$(jq '(.configurations // []) | length' "$LAUNCH_PATH")
if [ "$CONFIG_COUNT" = "0" ]; then
echo "__MISSING_CONFIGURATIONS__"
exit 0
fi
if [ "$CONFIG_COUNT" = "1" ]; then
jq -c '.configurations[0]' "$LAUNCH_PATH"
exit 0
fi
# Multiple configurations. If the caller named one, emit it. Otherwise, emit
# the sentinel + name list so the caller can prompt the user.
if [ -n "$REQUESTED_NAME" ]; then
MATCH=$(jq -c --arg name "$REQUESTED_NAME" '.configurations[] | select(.name == $name)' "$LAUNCH_PATH")
if [ -z "$MATCH" ]; then
echo "__CONFIG_NOT_FOUND__"
exit 0
fi
echo "$MATCH"
exit 0
fi
echo "__MULTIPLE_CONFIGS__"
jq -c '[.configurations[].name]' "$LAUNCH_PATH"
exit 0

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# resolve-package-manager.sh — detect which JS package manager a project uses
# by inspecting lockfiles, and emit the binary name plus canonical command tail.
#
# Usage:
# resolve-package-manager.sh [path]
#
# Arguments:
# path (optional) — directory to inspect. When omitted, defaults to the
# repo root via `git rev-parse --show-toplevel`.
#
# Output contract (two lines on stdout):
# Line 1: package-manager binary token (`npm` | `pnpm` | `yarn` | `bun`)
# Line 2: canonical argv tail for running a dev script
# - npm: "run dev" (npm requires the `run` verb)
# - pnpm: "dev" (pnpm allows bare script names)
# - yarn: "dev" (yarn allows bare script names)
# - bun: "run dev" (bun requires the `run` verb)
#
# Lockfile priority order (first match wins):
# 1. pnpm-lock.yaml -> pnpm
# 2. yarn.lock -> yarn
# 3. bun.lock -> bun (text format, preferred — newer canonical)
# 4. bun.lockb -> bun (binary format, legacy)
# 5. package-lock.json -> npm
# When both bun.lock and bun.lockb are present, bun.lock (text) is checked
# first and wins because it is the newer canonical format.
#
# Sentinel (stdout, exit 0):
# __NO_PACKAGE_JSON__ — the target directory has no package.json
#
# Errors (stderr, exit 1):
# ERROR: <message> — path does not exist, is not a directory, or
# no positional arg and not inside a git repo
set -u
TARGET_PATH="${1:-}"
# Resolve target directory: positional arg or git repo root.
if [ -n "$TARGET_PATH" ]; then
if [ ! -d "$TARGET_PATH" ]; then
echo "ERROR: path does not exist or is not a directory: $TARGET_PATH" >&2
exit 1
fi
else
TARGET_PATH=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$TARGET_PATH" ]; then
echo "ERROR: not in a git repository and no path argument provided" >&2
exit 1
fi
fi
# Sentinel: no package.json means this is not a JS/TS project.
if [ ! -f "$TARGET_PATH/package.json" ]; then
echo "__NO_PACKAGE_JSON__"
exit 0
fi
# Check lockfiles in priority order.
if [ -f "$TARGET_PATH/pnpm-lock.yaml" ]; then
echo "pnpm"
echo "dev"
exit 0
fi
if [ -f "$TARGET_PATH/yarn.lock" ]; then
echo "yarn"
echo "dev"
exit 0
fi
if [ -f "$TARGET_PATH/bun.lock" ]; then
echo "bun"
echo "run dev"
exit 0
fi
if [ -f "$TARGET_PATH/bun.lockb" ]; then
echo "bun"
echo "run dev"
exit 0
fi
if [ -f "$TARGET_PATH/package-lock.json" ]; then
echo "npm"
echo "run dev"
exit 0
fi
# Fallback: package.json present but no recognized lockfile.
echo "npm"
echo "run dev"
exit 0

View File

@@ -0,0 +1,308 @@
#!/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