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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
308
plugins/compound-engineering/skills/ce-polish-beta/scripts/resolve-port.sh
Executable file
308
plugins/compound-engineering/skills/ce-polish-beta/scripts/resolve-port.sh
Executable 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
|
||||
Reference in New Issue
Block a user