From 75f0a57821e5316dd11cb82533c56f22c256e8ad Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 3 Aug 2026 11:17:28 -0400 Subject: [PATCH] build(site): share the plugin closure resolver, stage only web.config Two fixes to the lean-site build. The closure resolution moves out of an inline heredoc into scripts/resolve_plugin_closure.py. The Windows builder needs the same answer, and a PowerShell reimplementation would have been a second copy of the rules, free to drift and produce a bundle whose plugin set did not match its profile. The backend staging step copied all of deploy/ into the output tree. The Windows installer stages its bundle at deploy/windows/installer/bundle, so that copy recursed into its own destination and cp aborted with 'cannot copy a directory into itself' - the documented build could not complete. Only deploy/windows/web.config is read at install time, so only that is staged; the rest of deploy/ is installer source and does not belong on an application server. --- scripts/build-site.sh | 43 +++++++--------------- scripts/resolve_plugin_closure.py | 61 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 29 deletions(-) create mode 100644 scripts/resolve_plugin_closure.py diff --git a/scripts/build-site.sh b/scripts/build-site.sh index f40b7ba..4d80689 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -17,34 +17,9 @@ OUT="${2:-$REPO/build/site}" [ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; } # Resolve the chosen plugins + their hard-dependency closure from the manifests. -CLOSURE=$(python3 - "$PROFILE" "$REPO" <<'PY' -import json, sys, os -profile_path, repo = sys.argv[1], sys.argv[2] -chosen = json.load(open(profile_path)).get('plugins', []) -plugins_dir = os.path.join(repo, 'plugins') - -def deps(name): - mpath = os.path.join(plugins_dir, name, 'manifest.json') - if not os.path.exists(mpath): - sys.exit(f'profile plugin not found on disk: {name}') - out = [] - for dep in json.load(open(mpath)).get('dependencies', []): - # name-only (strip any PEP440 range) - for sep in '><=!~ ': - dep = dep.split(sep)[0] - out.append(dep.strip()) - return out - -closure, seen = [], set() -def add(name): - if name in seen: return - seen.add(name) - for d in deps(name): add(d) - closure.append(name) -for p in chosen: add(p) -print(','.join(closure)) -PY -) +# Shared with the Windows builder (deploy/windows/installer/build-installer.ps1) +# so both stage the same set from the same profile. +CLOSURE=$(python3 "$REPO/scripts/resolve_plugin_closure.py" "$PROFILE" "$REPO") echo "Site profile: $PROFILE" echo "Plugin closure: $CLOSURE" @@ -90,7 +65,17 @@ cp -r "$SUBPATH_TMP/dist-subpath" "$OUT/frontend-dist-subpath" # match the profile it was built from. cp "$REPO/wsgi.py" "$REPO/requirements.txt" "$OUT/" cp -a "$REPO/migrations" "$OUT/" -[ -d "$REPO/deploy" ] && cp -a "$REPO/deploy" "$OUT/" +# ONLY web.config, not all of deploy/. Two reasons, and the first is fatal: +# the Windows builder stages its output at deploy/windows/installer/bundle, so +# copying deploy/ wholesale recursed into the destination and cp aborted with +# "cannot copy a directory into itself" - the documented build could not finish. +# Second, the rest of deploy/ is installer SOURCE (scripts, artwork, MSIs); none +# of it belongs in an application tree that gets copied onto a server. +# shopdb-install.ps1 reads it from exactly this path. +if [ -f "$REPO/deploy/windows/web.config" ]; then + mkdir -p "$OUT/deploy/windows" + cp -a "$REPO/deploy/windows/web.config" "$OUT/deploy/windows/" +fi # Stage the profile INTO the tree. This is what makes the set self-describing: # `flask plugin apply-profile` at provisioning time reads the same profile the diff --git a/scripts/resolve_plugin_closure.py b/scripts/resolve_plugin_closure.py new file mode 100644 index 0000000..5c090ac --- /dev/null +++ b/scripts/resolve_plugin_closure.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Resolve a site profile's chosen plugins plus their hard-dependency closure. + +Printed as a comma-separated list, dependencies before dependants, so the caller +can stage them in order. + +This lives in its own file because TWO builders need the same answer: +scripts/build-site.sh (Linux) and deploy/windows/installer/build-installer.ps1 +(Windows). It was inline in build-site.sh; a PowerShell reimplementation would +have been a second copy of the closure rules, free to drift from this one and +produce a bundle whose plugin set did not match the profile it was built from. + +Usage: resolve_plugin_closure.py +""" +import json +import os +import sys + + +def load_dependencies(plugins_dir, name): + manifest = os.path.join(plugins_dir, name, 'manifest.json') + if not os.path.exists(manifest): + sys.exit('profile plugin not found on disk: %s' % name) + with open(manifest) as fh: + declared = json.load(fh).get('dependencies', []) + names = [] + for dep in declared: + # name-only (strip any PEP440 range) + for sep in '><=!~ ': + dep = dep.split(sep)[0] + names.append(dep.strip()) + return names + + +def resolve(profile_path, repo): + with open(profile_path) as fh: + chosen = json.load(fh).get('plugins', []) + plugins_dir = os.path.join(repo, 'plugins') + closure, seen = [], set() + + def add(name): + if name in seen: + return + seen.add(name) + for dep in load_dependencies(plugins_dir, name): + add(dep) + closure.append(name) + + for name in chosen: + add(name) + return closure + + +def main(): + if len(sys.argv) != 3: + sys.exit('usage: resolve_plugin_closure.py ') + print(','.join(resolve(sys.argv[1], sys.argv[2]))) + + +if __name__ == '__main__': + main()