← back to Dw Vendor Microsites
build-vendor.sh
223 lines
#!/usr/bin/env bash
# build-vendor.sh <vendor_code>
#
# Idempotent / resumable per-vendor build harness. Takes a vendor to local-verified
# BUILT status (NOT deployed). One iTerm Claude session runs this end-to-end.
#
# Steps (per the approved plan §2):
# 0. private-label / exclusion gate (lib/resolve-vendor.sh)
# 1. export <vendor>_catalog -> staging/<vendor>.jsonl (lib/export-catalog.sh, scrubbed)
# 2. image health (HEAD-sample + relink) (lib/image-health.sh)
# 3. scaffold template -> vendors/<vendor>/viewer + write fieldmap.json
# 4. allocate free port >= 10000 (lib/port-alloc.sh)
# 5. local smoke (200 + count + facets) (lib/smoke-local.sh)
# 6. update manifest.json + git commit
#
# LOCAL ONLY: no deploy, no DNS, no Shopify, no dw_unified writes, no spend.
set -euo pipefail
VENDOR="${1:?usage: build-vendor.sh <vendor_code>}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$REPO"
source "$REPO/lib/manifest-lib.sh"
log() { printf '\033[1;36m[build:%s]\033[0m %s\n' "$VENDOR" "$*"; }
die() { printf '\033[1;31m[build:%s] FAIL\033[0m %s\n' "$VENDOR" "$*" >&2; exit 1; }
# ---------- step 0: resolve / gate ----------
log "step 0 — private-label / exclusion gate"
RES="$(bash "$REPO/lib/resolve-vendor.sh" "$VENDOR")" || {
st=$?
if [ "$st" = "3" ]; then
log "EXCLUDED — $RES"
manifest_upsert "$VENDOR" "{\"build_status\":\"EXCLUDED\",\"status\":\"EXCLUDED\"}"
exit 0
fi
die "resolve failed ($st): $RES"
}
HOUSE="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["house_name"])')"
SUB="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["subdomain"])')"
CTABLE="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["catalog_table"])')"
IS_PL="$(echo "$RES" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("is_private_label",False)).lower())')"
SAMPLES_ONLY="$(echo "$RES" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("samples_only",True)).lower())')"
BANNED_JSON="$(echo "$RES" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("banned_names",[])))')"
# bash 3.2 (macOS) has no mapfile — read newline-delimited names into an array
BANNED=()
while IFS= read -r _bn; do [ -n "$_bn" ] && BANNED+=("$_bn"); done < <(echo "$BANNED_JSON" | python3 -c 'import sys,json;[print(x) for x in json.load(sys.stdin)]')
log "house=$HOUSE subdomain=$SUB table=$CTABLE private_label=$IS_PL samples_only=$SAMPLES_ONLY banned=[${BANNED[*]+${BANNED[*]}}]"
# ---------- step 1: export + scrub ----------
JSONL="$REPO/staging/${VENDOR}.jsonl"
log "step 1 — export ${CTABLE} -> staging/${VENDOR}.jsonl (scrubbing ${#BANNED[@]} banned names)"
TABLE_COUNT="$(ssh my-server "sudo -u postgres psql -d dw_unified -tAc \"select count(*) from ${CTABLE};\"" 2>/dev/null | tr -d ' ')"
[ -z "$TABLE_COUNT" ] && die "could not count ${CTABLE}"
EXPORTED="$(HOUSE="$HOUSE" bash "$REPO/lib/export-catalog.sh" "$CTABLE" "$JSONL" "${BANNED[@]+"${BANNED[@]}"}")" || die "export failed"
log "exported ${EXPORTED} rows (table count ${TABLE_COUNT})"
# leak verification for private-label vendors: assert no banned name survives.
# WORD-BOUNDARY aware so legit substrings (e.g. "twill" weave) do NOT false-
# positive on a "TWIL" ban; also catches glued alnum tokens in URLs/slugs/keys
# (e.g. "momentumtextilesandwalls") which carry the brand with no word boundary.
LEAK="ok"
if [ "${#BANNED[@]}" -gt 0 ]; then
LEAK="$(BANNED_JSON="$BANNED_JSON" python3 - "$JSONL" <<'PY'
import sys, os, json, re
path = sys.argv[1]
banned = [b for b in json.loads(os.environ.get('BANNED_JSON') or '[]') if b.strip()]
# word-boundary patterns (real standalone-brand leaks)
wpats = [(b, re.compile(r'\b' + re.escape(b) + r'\b', re.IGNORECASE)) for b in banned]
# alnum token forms for glued-substring leaks in urls/slugs/keys
toks = [(b, re.sub(r'[^a-z0-9]', '', b.lower())) for b in banned]
toks = [(b, t) for (b, t) in toks if t]
def scan(line):
# parse the JSON line so we can inspect KEYS (a key like "momentum_sku" is a
# leak the value-only scan would miss) and the string values precisely.
try:
rec = json.loads(line)
except Exception:
rec = None
def check_text(s):
# standalone-brand word match (authoritative "real leak" definition).
# word-boundary => "twill" is NOT flagged by a "TWIL" ban.
for b, p in wpats:
if p.search(s):
return b
return None
def check_urlish(s):
# url/slug/path-shaped strings: glued alnum tokens have no word boundary
# (e.g. "momentumtextilesandwalls"), so substring-match the alnum token.
low = re.sub(r'[^a-z0-9]', '', s.lower())
for b, t in toks:
if t in low:
return b
return None
def is_slug_or_url(s):
# url/path chars, OR a glued slug/handle (hyphen/underscore-joined, no
# spaces) where a brand token can hide without a word boundary.
if re.search(r'(https?:)?//|[./\\]', s):
return True
if ' ' not in s and re.search(r'[-_]', s):
return True
return False
if rec is not None:
stack = [rec]
while stack:
o = stack.pop()
if isinstance(o, dict):
for k, v in o.items():
# KEY name itself must not embed a brand token
kt = re.sub(r'[^a-z0-9]', '', str(k).lower())
for b, t in toks:
if t and t in kt:
return b
stack.append(v)
elif isinstance(o, list):
stack.extend(o)
elif isinstance(o, str):
hit = check_urlish(o) if is_slug_or_url(o) else check_text(o)
if hit:
return hit
return None
# fallback (unparseable line): conservative word-boundary scan
return check_text(line)
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
hit = scan(line)
if hit:
print("LEAK:" + hit)
break
else:
print("ok")
PY
)"
if [ "$LEAK" != "ok" ]; then
die "private-label LEAK detected in JSONL: $LEAK"
fi
log "leak check — ZERO banned upstream names in JSONL ✓"
fi
# ---------- step 2: image health ----------
log "step 2 — image health"
HEALTH_JSON="$(bash "$REPO/lib/image-health.sh" "$JSONL" 40)" || HEALTH_JSON='{"image_health":0,"status":"UNKNOWN"}'
HEALTH="$(echo "$HEALTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("image_health",0))')"
HEALTH_STATUS="$(echo "$HEALTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("status","UNKNOWN"))')"
log "image_health=$HEALTH status=$HEALTH_STATUS — $HEALTH_JSON"
# NEEDS_RESCRAPE is a flag, not a blocker (per plan)
[ "$HEALTH_STATUS" = "NEEDS_RESCRAPE" ] || [ "$HEALTH_STATUS" = "EMPTY" ] && log "FLAGGED NEEDS_RESCRAPE (not blocking build)"
# ---------- step 3: scaffold ----------
VDIR="$REPO/vendors/${VENDOR}/viewer"
log "step 3 — scaffold template -> vendors/${VENDOR}/viewer + fieldmap.json"
mkdir -p "$VDIR" "$REPO/vendors/${VENDOR}"
cp "$REPO/template/server.js" "$VDIR/server.js"
rm -rf "$VDIR/public"; cp -R "$REPO/template/public" "$VDIR/public"
cat > "$VDIR/fieldmap.json" <<JSON
{
"title": "$(echo "$HOUSE" | sed 's/"/\\"/g')",
"samples_only": ${SAMPLES_ONLY},
"vendor_code": "${VENDOR}",
"subdomain": "${SUB}"
}
JSON
# ---------- step 4: port ----------
log "step 4 — allocate port"
PORT="$(bash "$REPO/lib/port-alloc.sh" "$VENDOR")" || die "port-alloc failed"
log "port=$PORT"
# ---------- step 5: local smoke ----------
log "step 5 — local smoke"
SMOKE_JSON="$(bash "$REPO/lib/smoke-local.sh" "$VDIR" "$JSONL" "$VDIR/fieldmap.json" "$EXPORTED")" || {
echo "$SMOKE_JSON"
manifest_upsert "$VENDOR" "{\"build_status\":\"SMOKE_FAIL\",\"port\":$PORT,\"sku_count\":$EXPORTED}"
die "smoke FAILED: $SMOKE_JSON"
}
log "smoke PASS — $SMOKE_JSON"
# ---------- step 6: manifest + git ----------
log "step 6 — manifest + git"
# compute build_status in shell to keep the python interpolation trivial
BUILD_STATUS="BUILT"
if [ "$HEALTH_STATUS" = "NEEDS_RESCRAPE" ] || [ "$HEALTH_STATUS" = "EMPTY" ]; then
BUILD_STATUS="BUILT_NEEDS_RESCRAPE"
fi
# build the manifest fragment via python args (no fragile inline interpolation)
FRAG="$(HOUSE="$HOUSE" SUB="$SUB" CTABLE="$CTABLE" IS_PL="$IS_PL" \
BANNED_JSON="$BANNED_JSON" PORT="$PORT" EXPORTED="$EXPORTED" TABLE_COUNT="$TABLE_COUNT" \
HEALTH="$HEALTH" HEALTH_STATUS="$HEALTH_STATUS" SAMPLES_ONLY="$SAMPLES_ONLY" \
BUILD_STATUS="$BUILD_STATUS" python3 -c '
import os, json
print(json.dumps({
"house_name": os.environ["HOUSE"],
"subdomain": os.environ["SUB"],
"catalog_table": os.environ["CTABLE"],
"is_private_label": os.environ["IS_PL"] == "true",
"banned_names": json.loads(os.environ["BANNED_JSON"] or "[]"),
"port": int(os.environ["PORT"]),
"sku_count": int(os.environ["EXPORTED"]),
"table_count": int(os.environ["TABLE_COUNT"]),
"image_health": float(os.environ["HEALTH"]),
"image_status": os.environ["HEALTH_STATUS"],
"samples_only": os.environ["SAMPLES_ONLY"] == "true",
"build_status": os.environ["BUILD_STATUS"],
"deploy_status": "PENDING",
}))
')"
manifest_upsert "$VENDOR" "$FRAG"
git -C "$REPO" add -A
git -C "$REPO" commit -q -m "build ${VENDOR} (${HOUSE}) -> BUILT, port ${PORT}, ${EXPORTED} skus, img ${HEALTH}" || true
log "DONE — ${VENDOR} -> ${HOUSE} @ ${SUB}.designerwallcoverings.com port ${PORT} ${EXPORTED} skus img ${HEALTH} status BUILT"
echo "$RES" | python3 -c 'import sys,json;d=json.load(sys.stdin);print()' 2>/dev/null || true