@thebassclef/lite 1.9.4 → 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # scripts/interpret-input/extract-repo.sh — Phase C repo extractor.
4
+ #
5
+ # Walks a local repo directory and extracts:
6
+ # - README excerpt (first 30 lines of README.md or equivalent)
7
+ # - Title (first H1 in README, or repo dirname)
8
+ # - Domain entity candidates (Prisma models + directory-pattern heuristic)
9
+ # - Boundary objects (Next.js routes, pages, API endpoints)
10
+ # - Actor candidates (role/auth keywords in source)
11
+ #
12
+ # Outputs JSON to stdout. Wrapper composes into the InputArtifact.
13
+ #
14
+ # Usage:
15
+ # bash scripts/interpret-input/extract-repo.sh <local-repo-path>
16
+ #
17
+ # Exit codes:
18
+ # 0 = success (even partial extraction is success — empty arrays for missing signals)
19
+ # 2 = bad input (missing path or not a directory)
20
+ #
21
+ # Pairs with: scripts/interpret-input.sh + standards/input-handler-interface.md
22
+
23
+ set -u
24
+
25
+ REPO_PATH="${1:-}"
26
+
27
+ if [ -z "$REPO_PATH" ]; then
28
+ echo "extract-repo: requires <repo-path>" >&2
29
+ exit 2
30
+ fi
31
+
32
+ if [ ! -d "$REPO_PATH" ]; then
33
+ echo "extract-repo: $REPO_PATH is not a directory" >&2
34
+ exit 2
35
+ fi
36
+
37
+ # Use absolute path so cd doesn't break relative output
38
+ REPO_PATH="$(cd "$REPO_PATH" && pwd)"
39
+ cd "$REPO_PATH" || { echo "extract-repo: cannot cd to $REPO_PATH" >&2; exit 2; }
40
+
41
+ # === README excerpt + title ===
42
+ README_EXCERPT=""
43
+ README_PATH=""
44
+ for candidate in README.md README.MD readme.md README.rst README.txt; do
45
+ if [ -f "$candidate" ]; then
46
+ README_PATH="$candidate"
47
+ README_EXCERPT=$(head -30 "$candidate")
48
+ break
49
+ fi
50
+ done
51
+
52
+ TITLE=""
53
+ if [ -n "$README_PATH" ]; then
54
+ TITLE=$(grep -m1 '^# ' "$README_PATH" 2>/dev/null | sed 's/^# *//')
55
+ fi
56
+ if [ -z "$TITLE" ]; then
57
+ TITLE=$(basename "$REPO_PATH")
58
+ fi
59
+
60
+ # === Entity candidates ===
61
+
62
+ # Strategy 1: Prisma models
63
+ ENTITIES_PRISMA=()
64
+ for schema in prisma/schema.prisma prisma/*.prisma; do
65
+ if [ -f "$schema" ]; then
66
+ while IFS= read -r model_name; do
67
+ [ -z "$model_name" ] && continue
68
+ ENTITIES_PRISMA+=("$model_name")
69
+ done < <(grep -E '^model [A-Z][a-zA-Z]+' "$schema" 2>/dev/null | awk '{print $2}')
70
+ fi
71
+ done
72
+
73
+ # Strategy 2: directory-pattern entities
74
+ # A directory containing >=3 files/subdirs of consistent shape suggests a collection.
75
+ # The singularized dirname is a candidate entity.
76
+ ENTITIES_DIR=()
77
+
78
+ # Collect candidate dirs at depth <=4, skipping known noise
79
+ CANDIDATE_DIRS=$(find . -maxdepth 4 -mindepth 1 -type d \
80
+ -not -path '*/node_modules*' \
81
+ -not -path '*/.git*' \
82
+ -not -path '*/.next*' \
83
+ -not -path '*/dist*' \
84
+ -not -path '*/build*' \
85
+ -not -path '*/coverage*' \
86
+ -not -path '*/__pycache__*' \
87
+ 2>/dev/null)
88
+
89
+ while IFS= read -r dir; do
90
+ [ -z "$dir" ] && continue
91
+ DIRNAME=$(basename "$dir")
92
+ case "$DIRNAME" in
93
+ .|..|node_modules|.git|.next|dist|build|coverage|__tests__|tests|test|src|lib|components|utils|public|static|scripts|bin|hooks) continue ;;
94
+ esac
95
+ # Only consider directories whose name suggests a collection (plural-ish)
96
+ case "$DIRNAME" in
97
+ *s|*ies)
98
+ # Count direct children
99
+ COUNT=$(find "$dir" -maxdepth 1 -mindepth 1 \
100
+ \( -type d -o -name '*.md' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' \) \
101
+ 2>/dev/null | wc -l | tr -d ' ')
102
+ if [ "$COUNT" -ge 3 ]; then
103
+ # Singularize: ies → y; ses/xes/zes/ches/shes → strip "es"; else strip "s"
104
+ case "$DIRNAME" in
105
+ *ies) SINGULAR="${DIRNAME%ies}y" ;;
106
+ *ses|*xes|*zes|*ches|*shes) SINGULAR="${DIRNAME%es}" ;;
107
+ *s) SINGULAR="${DIRNAME%s}" ;;
108
+ esac
109
+ # Skip too-short or too-noisy singulars
110
+ if [ "${#SINGULAR}" -ge 3 ]; then
111
+ ENTITIES_DIR+=("$SINGULAR")
112
+ fi
113
+ fi
114
+ ;;
115
+ esac
116
+ done <<<"$CANDIDATE_DIRS"
117
+
118
+ # Combine + dedupe entities
119
+ ALL_ENTITIES=()
120
+ SEEN=""
121
+ for e in "${ENTITIES_PRISMA[@]:-}" "${ENTITIES_DIR[@]:-}"; do
122
+ [ -z "$e" ] && continue
123
+ case " $SEEN " in
124
+ *" $e "*) ;;
125
+ *) ALL_ENTITIES+=("$e"); SEEN="$SEEN $e" ;;
126
+ esac
127
+ done
128
+
129
+ # === Boundary objects (routes + pages + API endpoints) ===
130
+ BOUNDARY_OBJECTS=()
131
+
132
+ # Next.js App Router routes (app/**/page.{ts,tsx,js,jsx})
133
+ for app_dir in app src/app; do
134
+ if [ -d "$app_dir" ]; then
135
+ while IFS= read -r f; do
136
+ [ -z "$f" ] && continue
137
+ # Convert path to route: app/deals/page.tsx → /deals
138
+ ROUTE=$(echo "$f" | sed -E "s|^./${app_dir}||" | sed 's|/page\.[a-zA-Z]*$||')
139
+ [ -z "$ROUTE" ] && ROUTE="/"
140
+ BOUNDARY_OBJECTS+=("route:$ROUTE")
141
+ done < <(find "./$app_dir" -maxdepth 6 \( -name 'page.tsx' -o -name 'page.ts' -o -name 'page.js' -o -name 'page.jsx' \) 2>/dev/null | head -50)
142
+ fi
143
+ done
144
+
145
+ # Next.js Pages Router routes (pages/**/*.{ts,tsx,js,jsx})
146
+ for pages_dir in pages src/pages; do
147
+ if [ -d "$pages_dir" ]; then
148
+ while IFS= read -r f; do
149
+ [ -z "$f" ] && continue
150
+ ROUTE=$(echo "$f" | sed -E "s|^./${pages_dir}||" | sed 's/\.[a-zA-Z]*$//')
151
+ [ -z "$ROUTE" ] && ROUTE="/"
152
+ BOUNDARY_OBJECTS+=("page:$ROUTE")
153
+ done < <(find "./$pages_dir" -maxdepth 6 -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.js' -o -name '*.jsx' \) -not -name '_*' -not -name 'api' 2>/dev/null | head -30)
154
+ fi
155
+ done
156
+
157
+ # API routes
158
+ for api_dir in app/api src/app/api pages/api src/pages/api; do
159
+ if [ -d "$api_dir" ]; then
160
+ while IFS= read -r f; do
161
+ [ -z "$f" ] && continue
162
+ ROUTE=$(echo "$f" | sed -E "s|^./${api_dir}||" | sed 's|/route\.[a-zA-Z]*$||' | sed 's/\.[a-zA-Z]*$//')
163
+ [ -z "$ROUTE" ] && ROUTE="/"
164
+ BOUNDARY_OBJECTS+=("api:$ROUTE")
165
+ done < <(find "./$api_dir" -maxdepth 6 -type f \( -name 'route.ts' -o -name 'route.tsx' -o -name 'route.js' -o -name '*.ts' \) 2>/dev/null | head -20)
166
+ fi
167
+ done
168
+
169
+ # === Actor candidates ===
170
+ # Heuristic: grep role/auth keywords in source code
171
+ ACTOR_CANDIDATES=()
172
+ ACTOR_RAW=$(grep -rEho \
173
+ '\b(admin|owner|operator|viewer|editor|guest|member|customer|client|reviewer|approver|manager)\b' \
174
+ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.prisma' --include='*.md' \
175
+ --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.next --exclude-dir=dist --exclude-dir=build \
176
+ . 2>/dev/null | tr '[:upper:]' '[:lower:]' | sort -u | head -10)
177
+
178
+ while IFS= read -r kw; do
179
+ [ -z "$kw" ] && continue
180
+ ACTOR_CANDIDATES+=("$kw")
181
+ done <<<"$ACTOR_RAW"
182
+
183
+ # === Compose extracted_text prose ===
184
+ ENTITY_LIST=$(IFS=,; echo "${ALL_ENTITIES[*]:-}")
185
+ ACTOR_LIST=$(IFS=,; echo "${ACTOR_CANDIDATES[*]:-}")
186
+ BOUNDARY_COUNT=${#BOUNDARY_OBJECTS[@]}
187
+
188
+ EXTRACTED_TEXT="Repository: $TITLE
189
+ Path: $REPO_PATH
190
+
191
+ Detected entities (${#ALL_ENTITIES[@]}): ${ENTITY_LIST:-none}
192
+ Detected actors (${#ACTOR_CANDIDATES[@]}): ${ACTOR_LIST:-none}
193
+ Detected boundary objects: ${BOUNDARY_COUNT} routes/pages/api endpoints"
194
+
195
+ if [ -n "$README_EXCERPT" ]; then
196
+ EXTRACTED_TEXT="${EXTRACTED_TEXT}
197
+
198
+ README excerpt:
199
+ ${README_EXCERPT}"
200
+ fi
201
+
202
+ # === Build JSON output ===
203
+ # Convert arrays to JSON; handle empty arrays explicitly (bash 3.x compat — no namerefs).
204
+ if [ ${#ALL_ENTITIES[@]} -eq 0 ]; then
205
+ ENTITIES_JSON='[]'
206
+ else
207
+ ENTITIES_JSON=$(printf '%s\n' "${ALL_ENTITIES[@]}" | jq -R . | jq -s -c '. | unique')
208
+ fi
209
+
210
+ if [ ${#ACTOR_CANDIDATES[@]} -eq 0 ]; then
211
+ ACTORS_JSON='[]'
212
+ else
213
+ ACTORS_JSON=$(printf '%s\n' "${ACTOR_CANDIDATES[@]}" | jq -R . | jq -s -c '. | unique')
214
+ fi
215
+
216
+ if [ ${#BOUNDARY_OBJECTS[@]} -eq 0 ]; then
217
+ BOUNDARY_JSON='[]'
218
+ else
219
+ BOUNDARY_JSON=$(printf '%s\n' "${BOUNDARY_OBJECTS[@]}" | jq -R . | jq -s -c '. | unique')
220
+ fi
221
+
222
+ # Output
223
+ jq -n \
224
+ --arg extracted_text "$EXTRACTED_TEXT" \
225
+ --arg title "$TITLE" \
226
+ --arg readme_excerpt "$README_EXCERPT" \
227
+ --argjson entities_seen "$ENTITIES_JSON" \
228
+ --argjson actor_candidates "$ACTORS_JSON" \
229
+ --argjson boundary_objects "$BOUNDARY_JSON" \
230
+ '{
231
+ extracted_text: $extracted_text,
232
+ structural_hints: {
233
+ entities_seen: $entities_seen,
234
+ actor_candidates: $actor_candidates,
235
+ boundary_objects: $boundary_objects,
236
+ readme_excerpt: $readme_excerpt,
237
+ title: $title
238
+ }
239
+ }'
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # scripts/interpret-input.sh — input-handler CLI wrapper.
4
+ #
5
+ # Phase B: text-mode fully; other modes return stub InputArtifacts
6
+ # (schema-valid; extracted_text="deferred to Phase C").
7
+ #
8
+ # Usage:
9
+ # bash scripts/interpret-input.sh "<source>" [--type=auto|text|url|image|repo|transcript|napkin|mixed] [--persist] [--slug=<slug>]
10
+ #
11
+ # Output:
12
+ # --persist OFF (default): InputArtifact JSON to stdout
13
+ # --persist ON: JSON written to docs/input-artifacts/<slug>.json; stdout = path
14
+ #
15
+ # Exit codes:
16
+ # 0 = success
17
+ # 2 = invalid input
18
+ # 3 = jq construction failed
19
+ # 4 = filesystem error (overwrite or mkdir)
20
+ # 5 = repo extraction setup failed (clone error, mktemp error)
21
+ #
22
+ # Pairs with: standards/input-handler-interface.md + .claude/skills/interpret-input/SKILL.md
23
+
24
+ set -u
25
+
26
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
27
+
28
+ usage() {
29
+ cat <<'EOF'
30
+ /interpret-input — produce schema-validated InputArtifact from any input
31
+
32
+ Usage:
33
+ bash scripts/interpret-input.sh "<source>" [flags]
34
+
35
+ Flags:
36
+ --type=<type> text|url|image|repo|transcript|napkin|mixed (default: auto)
37
+ --persist write to docs/input-artifacts/<slug>.json
38
+ --slug=<slug> override auto-generated slug
39
+ --help show this message
40
+
41
+ Auto-detection:
42
+ ^https?:// → url
43
+ \.(png|jpg|jpeg|gif|webp|svg)$ → image
44
+ \.git$ or contains github.com → repo
45
+ (else) → text
46
+
47
+ Phase B: text fully; other modes stub (extracted_text="deferred to Phase C").
48
+ EOF
49
+ }
50
+
51
+ SOURCE=""
52
+ TYPE="auto"
53
+ PERSIST=0
54
+ SLUG=""
55
+
56
+ while [ $# -gt 0 ]; do
57
+ case "$1" in
58
+ --type=*) TYPE="${1#--type=}"; shift ;;
59
+ --persist) PERSIST=1; shift ;;
60
+ --slug=*) SLUG="${1#--slug=}"; shift ;;
61
+ --help|-h) usage; exit 0 ;;
62
+ --*) echo "interpret-input: unknown flag '$1'" >&2; usage >&2; exit 2 ;;
63
+ *)
64
+ if [ -z "$SOURCE" ]; then SOURCE="$1"; shift
65
+ else echo "interpret-input: multiple positional sources not supported in v0" >&2; exit 2
66
+ fi
67
+ ;;
68
+ esac
69
+ done
70
+
71
+ if [ -z "$SOURCE" ]; then
72
+ echo "interpret-input: requires <source>" >&2
73
+ usage >&2
74
+ exit 2
75
+ fi
76
+
77
+ # Auto-detect type
78
+ if [ "$TYPE" = "auto" ]; then
79
+ if echo "$SOURCE" | grep -qE '^https?://'; then
80
+ TYPE="url"
81
+ elif echo "$SOURCE" | grep -qiE '\.(png|jpg|jpeg|gif|webp|svg)$'; then
82
+ TYPE="image"
83
+ elif echo "$SOURCE" | grep -qE '\.git$'; then
84
+ TYPE="repo"
85
+ elif [ -d "$SOURCE" ] && [ -d "$SOURCE/.git" ]; then
86
+ # Local directory that's a git repo
87
+ TYPE="repo"
88
+ else
89
+ TYPE="text"
90
+ fi
91
+ fi
92
+
93
+ # Validate type against schema enum
94
+ case "$TYPE" in
95
+ text|url|image|repo|transcript|napkin|mixed) ;;
96
+ *)
97
+ echo "interpret-input: unknown type '$TYPE' (must be: text|url|image|repo|transcript|napkin|mixed)" >&2
98
+ exit 2
99
+ ;;
100
+ esac
101
+
102
+ # Generate slug if not provided
103
+ if [ -z "$SLUG" ]; then
104
+ TS=$(date -u +%Y-%m-%d)
105
+ HASH=$(printf '%s' "$SOURCE" | md5 -q 2>/dev/null \
106
+ || printf '%s' "$SOURCE" | md5sum 2>/dev/null | cut -c1-6 \
107
+ || echo "abc123")
108
+ HASH="${HASH:0:6}"
109
+ SLUG="${TS}-${TYPE}-${HASH}"
110
+ fi
111
+
112
+ # Validate slug pattern (matches schema's id pattern)
113
+ if ! echo "$SLUG" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then
114
+ echo "interpret-input: slug '$SLUG' does not match required pattern ^[a-z0-9][a-z0-9-]*$" >&2
115
+ exit 2
116
+ fi
117
+
118
+ NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
119
+
120
+ STRUCTURAL_HINTS='{}'
121
+
122
+ case "$TYPE" in
123
+ text)
124
+ EXTRACTED_TEXT="$SOURCE"
125
+ EXTERNAL_URLS=$(printf '%s' "$SOURCE" | grep -oE 'https?://[^[:space:]<>"`]+' \
126
+ | sed 's/[.,;:)]*$//' \
127
+ | jq -R . | jq -s -c . 2>/dev/null || echo '[]')
128
+ ;;
129
+ repo)
130
+ # Phase C: walk a local repo or clone a URL, extract structural signals.
131
+ # Resolve source to a local path. If the source isn't a URL or an existing
132
+ # directory, fall back to stub mode (preserves Phase B contract for
133
+ # not-yet-extractable sources).
134
+ CLEANUP_TMP=0
135
+ REPO_LOCAL=""
136
+ if echo "$SOURCE" | grep -qE '^https?://'; then
137
+ TMP_DIR=$(mktemp -d 2>/dev/null) || { echo "interpret-input: mktemp failed" >&2; exit 5; }
138
+ if ! git clone --depth=1 --quiet "$SOURCE" "$TMP_DIR" 2>/dev/null; then
139
+ rm -rf "$TMP_DIR"
140
+ echo "interpret-input: git clone failed for $SOURCE" >&2
141
+ exit 5
142
+ fi
143
+ REPO_LOCAL="$TMP_DIR"
144
+ CLEANUP_TMP=1
145
+ elif [ -d "$SOURCE" ]; then
146
+ REPO_LOCAL="$SOURCE"
147
+ fi
148
+
149
+ if [ -z "$REPO_LOCAL" ]; then
150
+ # Source can't be resolved; return Phase B-style stub (still valid InputArtifact)
151
+ EXTRACTED_TEXT="deferred to Phase C"
152
+ STRUCTURAL_HINTS='{}'
153
+ else
154
+ EXTRACT_OUT=$(bash "$REPO_ROOT/scripts/interpret-input/extract-repo.sh" "$REPO_LOCAL" 2>/dev/null)
155
+ EXTRACT_RC=$?
156
+
157
+ if [ "$CLEANUP_TMP" = "1" ]; then
158
+ rm -rf "$TMP_DIR"
159
+ fi
160
+
161
+ if [ "$EXTRACT_RC" != "0" ] || [ -z "$EXTRACT_OUT" ]; then
162
+ EXTRACTED_TEXT="repo extraction returned no signal for $SOURCE"
163
+ STRUCTURAL_HINTS='{}'
164
+ else
165
+ EXTRACTED_TEXT=$(echo "$EXTRACT_OUT" | jq -r '.extracted_text')
166
+ STRUCTURAL_HINTS=$(echo "$EXTRACT_OUT" | jq -c '.structural_hints')
167
+ fi
168
+ fi
169
+ EXTERNAL_URLS='[]'
170
+ ;;
171
+ *)
172
+ EXTRACTED_TEXT="deferred to Phase C"
173
+ EXTERNAL_URLS='[]'
174
+ ;;
175
+ esac
176
+
177
+ ARTIFACT=$(jq -n \
178
+ --arg id "$SLUG" \
179
+ --arg type "$TYPE" \
180
+ --arg source "$SOURCE" \
181
+ --arg extracted_text "$EXTRACTED_TEXT" \
182
+ --arg created_at "$NOW" \
183
+ --argjson external_urls "$EXTERNAL_URLS" \
184
+ --argjson structural_hints "$STRUCTURAL_HINTS" \
185
+ '{
186
+ id: $id,
187
+ type: $type,
188
+ source: $source,
189
+ extracted_text: $extracted_text,
190
+ structural_hints: $structural_hints,
191
+ external_urls: $external_urls,
192
+ created_at: $created_at
193
+ }')
194
+
195
+ if [ -z "$ARTIFACT" ]; then
196
+ echo "interpret-input: jq failed to construct InputArtifact JSON" >&2
197
+ exit 3
198
+ fi
199
+
200
+ if [ "$PERSIST" = "1" ]; then
201
+ TARGET_DIR="docs/input-artifacts"
202
+ TARGET_PATH="${TARGET_DIR}/${SLUG}.json"
203
+ if [ -f "$TARGET_PATH" ]; then
204
+ echo "interpret-input: $TARGET_PATH already exists; --slug=<new> to write a different one" >&2
205
+ exit 4
206
+ fi
207
+ mkdir -p "$TARGET_DIR" || { echo "interpret-input: mkdir $TARGET_DIR failed" >&2; exit 4; }
208
+ echo "$ARTIFACT" | jq . > "$TARGET_PATH" || { echo "interpret-input: write $TARGET_PATH failed" >&2; exit 4; }
209
+ echo "$TARGET_PATH"
210
+ else
211
+ echo "$ARTIFACT" | jq .
212
+ fi
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # local-serve-stop.sh — stop a local-serve.sh server by slug
4
+ #
5
+ # Per bassclef#923 A2. Companion to scripts/launch/local-serve.sh.
6
+ #
7
+ # Usage:
8
+ # bash scripts/launch/local-serve-stop.sh <gallery-slug>
9
+ #
10
+ # If a server is running for the slug, kills the PID and removes the
11
+ # state file. If no server is running, exits 0 silently.
12
+
13
+ set -e
14
+
15
+ SLUG="${1:-}"
16
+ if [ -z "$SLUG" ]; then
17
+ echo "ERROR: gallery-slug required" >&2
18
+ echo "usage: $0 <gallery-slug>" >&2
19
+ exit 2
20
+ fi
21
+
22
+ STATE_DIR="${REPO_ROOT:-$(pwd)}/state/markers/local-serve"
23
+ STATE_FILE="$STATE_DIR/${SLUG}.state"
24
+
25
+ if [ ! -f "$STATE_FILE" ]; then
26
+ echo "local-serve-stop: no state file for $SLUG — nothing to stop" >&2
27
+ exit 0
28
+ fi
29
+
30
+ PID=$(awk -F= '/^pid=/{print $2}' "$STATE_FILE" 2>/dev/null)
31
+ URL=$(awk -F= '/^url=/{print $2}' "$STATE_FILE" 2>/dev/null)
32
+
33
+ if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
34
+ kill "$PID" 2>/dev/null || true
35
+ echo "local-serve-stop: stopped server at $URL (pid=$PID)" >&2
36
+ else
37
+ echo "local-serve-stop: state file present but PID $PID not alive — cleaning up" >&2
38
+ fi
39
+
40
+ rm -f "$STATE_FILE" "${STATE_DIR}/${SLUG}.log"
41
+ exit 0
@@ -0,0 +1,122 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # local-serve.sh — serve a /launch local-mode prototype gallery via python http.server
4
+ #
5
+ # Per bassclef#923 A2: when /launch runs with --local flag, the Phase 12
6
+ # bind-subdomain step is replaced with this helper. Gallery served on
7
+ # localhost; no vendor accounts required.
8
+ #
9
+ # Usage:
10
+ # bash scripts/launch/local-serve.sh <gallery-dir> [port-hint]
11
+ #
12
+ # Args:
13
+ # gallery-dir — path to the directory containing the gallery (e.g.
14
+ # docs/prototypes/<sprint-slug>/)
15
+ # port-hint — optional starting port; defaults to 8000. Helper walks
16
+ # up from this port until it finds an open one.
17
+ #
18
+ # What this does:
19
+ # 1. Verifies python3 is on the path
20
+ # 2. Verifies gallery-dir exists and contains at least an index.html
21
+ # 3. Finds an open port starting from port-hint (default 8000)
22
+ # 4. Starts python -m http.server in background, bound to 127.0.0.1
23
+ # 5. Writes the PID + URL to state/markers/local-serve/<slug>.state
24
+ # 6. Prints the URL to stdout (one line) for the caller to capture
25
+ #
26
+ # Idempotent: safe to re-run. If a server is already running for the
27
+ # same gallery (state file exists, PID still alive), prints the
28
+ # existing URL and exits 0.
29
+ #
30
+ # Cleanup: rely on the operator running scripts/launch/local-serve-stop.sh
31
+ # or just killing the PID. State file lists the PID for reference.
32
+
33
+ set -e
34
+
35
+ GALLERY_DIR="${1:-}"
36
+ PORT_HINT="${2:-8000}"
37
+
38
+ if [ -z "$GALLERY_DIR" ]; then
39
+ echo "ERROR: gallery-dir required" >&2
40
+ echo "usage: $0 <gallery-dir> [port-hint]" >&2
41
+ exit 2
42
+ fi
43
+
44
+ if ! command -v python3 >/dev/null 2>&1; then
45
+ echo "ERROR: python3 not on PATH. Install python3 first." >&2
46
+ exit 1
47
+ fi
48
+
49
+ if [ ! -d "$GALLERY_DIR" ]; then
50
+ echo "ERROR: gallery directory does not exist: $GALLERY_DIR" >&2
51
+ exit 1
52
+ fi
53
+
54
+ if [ ! -f "$GALLERY_DIR/index.html" ]; then
55
+ echo "WARN: $GALLERY_DIR has no index.html — serving anyway, but the gallery may not render cleanly" >&2
56
+ fi
57
+
58
+ # Derive a slug for the state file from the gallery dir's basename
59
+ GALLERY_SLUG=$(basename "$GALLERY_DIR")
60
+ STATE_DIR="${REPO_ROOT:-$(pwd)}/state/markers/local-serve"
61
+ STATE_FILE="$STATE_DIR/${GALLERY_SLUG}.state"
62
+ mkdir -p "$STATE_DIR"
63
+
64
+ # Idempotency check: if state file exists and PID is alive, return existing URL
65
+ if [ -f "$STATE_FILE" ]; then
66
+ EXISTING_PID=$(awk -F= '/^pid=/{print $2}' "$STATE_FILE" 2>/dev/null || true)
67
+ EXISTING_URL=$(awk -F= '/^url=/{print $2}' "$STATE_FILE" 2>/dev/null || true)
68
+ if [ -n "$EXISTING_PID" ] && kill -0 "$EXISTING_PID" 2>/dev/null; then
69
+ echo "local-serve: already running for $GALLERY_SLUG (pid=$EXISTING_PID)" >&2
70
+ echo "$EXISTING_URL"
71
+ exit 0
72
+ fi
73
+ # Stale state file — remove
74
+ rm -f "$STATE_FILE"
75
+ fi
76
+
77
+ # Find an open port starting from PORT_HINT
78
+ PORT="$PORT_HINT"
79
+ MAX_PORT=$((PORT_HINT + 100))
80
+
81
+ while [ "$PORT" -lt "$MAX_PORT" ]; do
82
+ if ! nc -z 127.0.0.1 "$PORT" 2>/dev/null; then
83
+ break
84
+ fi
85
+ PORT=$((PORT + 1))
86
+ done
87
+
88
+ if [ "$PORT" -ge "$MAX_PORT" ]; then
89
+ echo "ERROR: could not find open port in range $PORT_HINT..$MAX_PORT" >&2
90
+ exit 1
91
+ fi
92
+
93
+ # Start http.server in background, bound to 127.0.0.1 only (no external exposure)
94
+ LOG_FILE="$STATE_DIR/${GALLERY_SLUG}.log"
95
+ nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$GALLERY_DIR" \
96
+ > "$LOG_FILE" 2>&1 &
97
+ SERVER_PID=$!
98
+
99
+ # Brief sleep to let server bind; verify by checking PID
100
+ sleep 1
101
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
102
+ echo "ERROR: http.server failed to start. Log: $LOG_FILE" >&2
103
+ exit 1
104
+ fi
105
+
106
+ URL="http://127.0.0.1:${PORT}/"
107
+
108
+ # Write state file
109
+ cat > "$STATE_FILE" <<EOF
110
+ slug=${GALLERY_SLUG}
111
+ pid=${SERVER_PID}
112
+ port=${PORT}
113
+ url=${URL}
114
+ gallery_dir=${GALLERY_DIR}
115
+ started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
116
+ EOF
117
+
118
+ echo "local-serve: gallery serving at $URL (pid=$SERVER_PID)" >&2
119
+ echo "local-serve: stop with: kill $SERVER_PID OR bash scripts/launch/local-serve-stop.sh $GALLERY_SLUG" >&2
120
+
121
+ # Print URL to stdout for the caller
122
+ echo "$URL"