Skills › Compound Engineering › ce-polish › detect-project-type.sh
detect-project-type.sh
277 lines · skills/ce-polish/scripts/detect-project-type.sh at 3449230
1#!/usr/bin/env bash
2#
3# detect-project-type.sh — inspect signature files at a project root (and, if
4# no root match is found, probe shallow subdirectories) to emit a project-type
5# identifier on stdout.
6#
7# Usage:
8# detect-project-type.sh [path]
9#
10# Arguments:
11# path (optional) — project root to inspect. Relative paths resolve from the
12# repository root. Defaults to the repository root. The
13# resolved path must remain inside the repository.
14#
15# Output grammar (one line on stdout):
16#
17# <type> — single signature match at root
18# e.g. "next", "rails", "vite"
19#
20# <type>@<relative-dir> — single monorepo hit (no root match)
21# e.g. "next@apps/web"
22#
23# multiple — two or more disjoint root signatures
24# (caller must prompt for disambiguation)
25#
26# multiple:<type>@<dir>,<type>@<dir> — multiple monorepo hits (no root match)
27# e.g. "multiple:next@apps/web,rails@apps/api"
28#
29# unknown — no signatures found at root or in probe
30#
31# Supported root types: rails, next, vite, nuxt, astro, remix, sveltekit, procfile
32#
33# Monorepo probe:
34# Runs only when root detection finds ZERO matches. Searches subdirectories
35# up to depth 3 (e.g. services/api/server/vite.config.ts) for framework
36# signature files. Deeper nesting is ignored to avoid false positives.
37#
38# Excluded directories (not real project roots):
39# node_modules .git vendor dist build coverage .next .nuxt
40# .svelte-kit .turbo tmp fixtures
41#
42# `multiple` vs `rails`: Rails apps commonly ship a Procfile.dev alongside
43# bin/dev. To avoid treating every Rails app as a monorepo, the `rails`
44# signature takes precedence over a bare `procfile` match. `multiple` is
45# reserved for genuine disambiguation cases (e.g., Rails + Next, Next + Vite).
47set -u
49REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
50if [ -z "$REPO_ROOT" ]; then
51 echo "ERROR: not in a git repository" >&2
52 exit 1
53fi
55REPO_ROOT=$(cd "$REPO_ROOT" 2>/dev/null && pwd -P)
56if [ -z "$REPO_ROOT" ]; then
57 echo "ERROR: cannot resolve repo root" >&2
58 exit 1
59fi
61PROJECT_ROOT="${1:-$REPO_ROOT}"
62case "$PROJECT_ROOT" in
63 /*) ;;
64 *) PROJECT_ROOT="$REPO_ROOT/$PROJECT_ROOT" ;;
65esac
67if [ ! -d "$PROJECT_ROOT" ]; then
68 echo "ERROR: path does not exist: $PROJECT_ROOT" >&2
69 exit 1
70fi
72if ! PROJECT_ROOT=$(cd "$PROJECT_ROOT" 2>/dev/null && pwd -P); then
73 echo "ERROR: cannot resolve project root" >&2
74 exit 1
75fi
76case "$PROJECT_ROOT" in
77 "$REPO_ROOT"|"$REPO_ROOT"/*) ;;
78 *)
79 echo "ERROR: path must stay inside repo root: $PROJECT_ROOT" >&2
80 exit 1
81 ;;
82esac
84cd "$PROJECT_ROOT" || { echo "ERROR: cannot cd to project root" >&2; exit 1; }
86MATCHES=()
88# Rails: bin/dev AND Gemfile together. A Gemfile alone (or bin/dev alone) is
89# insufficient -- plenty of gems have Gemfiles without bin/dev, and bin/dev
90# may exist in non-Rails projects.
91if [ -f "bin/dev" ] && [ -f "Gemfile" ]; then
92 MATCHES+=("rails")
93fi
95# Next.js
96if [ -f "next.config.js" ] || [ -f "next.config.mjs" ] || [ -f "next.config.ts" ] || [ -f "next.config.cjs" ]; then
97 MATCHES+=("next")
98fi
100# Vite
101if [ -f "vite.config.js" ] || [ -f "vite.config.ts" ] || [ -f "vite.config.mjs" ] || [ -f "vite.config.cjs" ]; then
102 MATCHES+=("vite")
103fi
105# Nuxt
106if [ -f "nuxt.config.js" ] || [ -f "nuxt.config.mjs" ] || [ -f "nuxt.config.ts" ]; then
107 MATCHES+=("nuxt")
108fi
110# Astro
111if [ -f "astro.config.js" ] || [ -f "astro.config.mjs" ] || [ -f "astro.config.ts" ]; then
112 MATCHES+=("astro")
113fi
115# Remix (classic — Remix on Vite uses vite.config.ts, detected as vite)
116if [ -f "remix.config.js" ] || [ -f "remix.config.ts" ]; then
117 MATCHES+=("remix")
118fi
120# SvelteKit
121if [ -f "svelte.config.js" ] || [ -f "svelte.config.mjs" ] || [ -f "svelte.config.ts" ]; then
122 MATCHES+=("sveltekit")
123fi
125# Procfile / Overmind / Foreman — only if we didn't already detect rails
126if [ ${#MATCHES[@]} -eq 0 ] || [ "${MATCHES[0]}" != "rails" ]; then
127 if [ -f "Procfile" ] || [ -f "Procfile.dev" ]; then
128 MATCHES+=("procfile")
129 fi
130fi
132# ── Root result ──────────────────────────────────────────────────────────────
133case ${#MATCHES[@]} in
134 0)
135 # No root match — run monorepo probe (shallow find, depth <= 3).
136 ;;
137 1)
138 echo "${MATCHES[0]}"
139 exit 0
140 ;;
141 *)
142 echo "multiple"
143 exit 0
144 ;;
145esac
147# ── Monorepo probe ─────────────────────────────────────────────────────────
148# When root detection returns zero matches, descend up to depth 3 looking for
149# framework signatures in workspace directories. Common layouts:
150# apps/web/next.config.js (depth 2)
151# packages/frontend/vite.config.ts (depth 2)
152# services/api/server/vite.config.ts (depth 3)
153#
154# Exclusion list: directories that ship framework configs as fixtures or build
155# output, not as real project roots.
157EXCLUDE_DIRS="node_modules .git vendor dist build coverage .next .nuxt .svelte-kit .turbo tmp fixtures"
158EXCLUDE_ARGS=""
159for d in $EXCLUDE_DIRS; do
160 EXCLUDE_ARGS="$EXCLUDE_ARGS -path './$d' -prune -o -path '*/$d' -prune -o"
161done
163# Signature file patterns to look for
164SIGNATURE_PATTERNS=(
165 "next.config.js" "next.config.mjs" "next.config.ts" "next.config.cjs"
166 "vite.config.js" "vite.config.ts" "vite.config.mjs" "vite.config.cjs"
167 "nuxt.config.js" "nuxt.config.mjs" "nuxt.config.ts"
168 "astro.config.js" "astro.config.mjs" "astro.config.ts"
169 "remix.config.js" "remix.config.ts"
170 "svelte.config.js" "svelte.config.mjs" "svelte.config.ts"
171)
173# Build the find -name arguments
174NAME_ARGS=""
175for i in "${!SIGNATURE_PATTERNS[@]}"; do
176 if [ "$i" -gt 0 ]; then
177 NAME_ARGS="$NAME_ARGS -o"
178 fi
179 NAME_ARGS="$NAME_ARGS -name '${SIGNATURE_PATTERNS[$i]}'"
180done
182# Run find. Use eval because the dynamically built arguments contain quoted
183# strings that must be expanded by the shell.
184FOUND_FILES=$(eval "find . -maxdepth 4 $EXCLUDE_ARGS \\( $NAME_ARGS \\) -print" 2>/dev/null | sort)
186# Also check for Rails signature (bin/dev + Gemfile in the same subdir)
187RAILS_HITS=""
188# Find all Gemfiles at depth <= 3, check each dir for bin/dev
189while IFS= read -r gemfile; do
190 [ -z "$gemfile" ] && continue
191 gdir=$(dirname "$gemfile")
192 if [ -f "$gdir/bin/dev" ]; then
193 RAILS_HITS="$RAILS_HITS
194$gdir"
195 fi
196done < <(eval "find . -maxdepth 4 $EXCLUDE_ARGS -name 'Gemfile' -print" 2>/dev/null)
198# Parse found files into (type, relative-dir) pairs. Use a newline-delimited
199# string instead of an associative array so the script works on macOS's default
200# Bash 3.2 as well as newer Bash versions.
201MONO_HITS=""
203add_mono_hit() {
204 hit="$1"
205 if printf '%s\n' "$MONO_HITS" | grep -Fxq "$hit"; then
206 return 0
207 fi
208 MONO_HITS="${MONO_HITS}
209${hit}"
210}
212if [ -n "$FOUND_FILES" ]; then
213 for f in $FOUND_FILES; do
214 [ -z "$f" ] && continue
215 fname=$(basename "$f")
216 fdir=$(dirname "$f")
217 # Normalize dir: strip leading ./
218 fdir="${fdir#./}"
220 # Enforce depth cap of 3: count slashes in the relative path of the file.
221 # A file at apps/web/next.config.js has dir apps/web (1 slash = depth 2).
222 # A file at a/b/c/d/next.config.js has dir a/b/c/d (3 slashes = depth 4 = too deep).
223 # We want maxdepth 3 for the directory, meaning at most 2 slashes in fdir.
224 slash_count=$(echo "$fdir" | tr -cd '/' | wc -c | tr -d ' ')
225 if [ "$slash_count" -gt 2 ]; then
226 continue
227 fi
229 case "$fname" in
230 next.config.*) ftype="next" ;;
231 vite.config.*) ftype="vite" ;;
232 nuxt.config.*) ftype="nuxt" ;;
233 astro.config.*) ftype="astro" ;;
234 remix.config.*) ftype="remix" ;;
235 svelte.config.*) ftype="sveltekit" ;;
236 *) continue ;;
237 esac
239 # Skip root hits (those would have been caught by root detection)
240 if [ "$fdir" = "." ]; then continue; fi
242 add_mono_hit "${ftype}@${fdir}"
243 done
244fi
246# Add Rails monorepo hits
247if [ -n "$RAILS_HITS" ]; then
248 for rdir in $RAILS_HITS; do
249 [ -z "$rdir" ] && continue
250 rdir="${rdir#./}"
251 if [ "$rdir" != "." ] && [ -n "$rdir" ]; then
252 # Enforce depth cap for Rails hits too
253 slash_count=$(echo "$rdir" | tr -cd '/' | wc -c | tr -d ' ')
254 if [ "$slash_count" -le 2 ]; then
255 add_mono_hit "rails@${rdir}"
256 fi
257 fi
258 done
259fi
261MONO_HITS=$(printf '%s\n' "$MONO_HITS" | sed '/^$/d' | sort)
262MONO_COUNT=$(printf '%s\n' "$MONO_HITS" | sed '/^$/d' | wc -l | tr -d ' ')
264case $MONO_COUNT in
265 0)
266 echo "unknown"
267 ;;
268 1)
269 # Single monorepo hit: emit type@cwd
270 printf '%s\n' "$MONO_HITS"
271 ;;
272 *)
273 # Multiple hits: emit multiple:type1@cwd1,type2@cwd2,...
274 result=$(printf '%s\n' "$MONO_HITS" | paste -sd, -)
275 echo "multiple:$result"
276 ;;
277esac