scripts/bootstrap.py resolves --group against <repo-root>/pyproject.toml, but package_plugin.py's INCLUDE tuple never shipped that file. An archive built and unpacked elsewhere could never bootstrap (F1). Verify: built an archive with package_plugin.py, unpacked it to a tmp dir, ran scripts/bootstrap.py against the unpacked copy -- "bootstrap: guard 1.3.0" / "bootstrap: ok", exit 0. Also fixed two stale pyproject.toml comments still naming the pre-port scripts/bootstrap.sh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
161 lines
5.7 KiB
Python
161 lines
5.7 KiB
Python
"""Build jobbsok.plugin from an explicit include list.
|
|
|
|
Python rather than bash, and standard library only: `zip` is not on stock
|
|
Windows any more than `bash` is, and `zipfile` does the same job everywhere.
|
|
|
|
The include list is the whole point, and it is not a convenience. An archiver
|
|
does not consult .gitignore, so archiving the repository root would ship .git,
|
|
the virtualenv from Step 2, the local-only STATE.md and everything under
|
|
.claude/ -- which holds the operator's decisions and absolute home paths. This
|
|
archive is uploaded to Cowork once per milestone and its command is printed in
|
|
a public README. An include list is the only form that is safe to publish.
|
|
|
|
The virtualenv is excluded for a second reason on top of that one: a venv's
|
|
absolute paths do not survive relocation, so an archived one would be broken
|
|
wherever it landed. The supported route in an installed copy is to run
|
|
scripts/bootstrap.py once against it, which builds the environment under
|
|
$CLAUDE_PLUGIN_DATA. README.md says so.
|
|
|
|
Entry names are written with forward slashes and no Unix permission bits,
|
|
which is what the shell version's `-X` was after: two builds of the same commit
|
|
differ only in timestamps, and nothing in the archive needs an execute bit now
|
|
that every entry point is started by naming an interpreter.
|
|
|
|
Usage:
|
|
python3 scripts/package_plugin.py # -> <repo root>/jobbsok.plugin
|
|
python3 scripts/package_plugin.py --ut <sti> # -> somewhere else
|
|
|
|
On Windows the interpreter is normally called `python`, not `python3`.
|
|
"""
|
|
|
|
import fnmatch
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
|
|
|
|
#: Directories are archived whole, minus the exclusions below. Entries that do
|
|
#: not exist yet are reported and skipped -- hooks/ and templates/ land in
|
|
#: later steps -- but the two the archive is worthless without are required.
|
|
INCLUDE = (
|
|
".claude-plugin",
|
|
"skills",
|
|
"scripts",
|
|
"hooks",
|
|
"templates",
|
|
".mcp.json",
|
|
"README.md",
|
|
"SECURITY.md",
|
|
"LICENSE",
|
|
"BUILD_STAMP",
|
|
"pyproject.toml",
|
|
)
|
|
REQUIRED = (".claude-plugin/plugin.json", "BUILD_STAMP")
|
|
|
|
#: Belt to the include list's braces: nothing under scripts/ or skills/ that is
|
|
#: a build artefact of a local test run may ride along. The patterns are the
|
|
#: ones the shell version passed to `zip -x`, matched against the archive entry
|
|
#: name, where `*` crosses directory separators exactly as it did there.
|
|
UTELAT = (
|
|
"*/__pycache__/*",
|
|
"*.pyc",
|
|
"*/.pytest_cache/*",
|
|
"*/.venv/*",
|
|
"*.local.md",
|
|
"*/.DS_Store",
|
|
)
|
|
|
|
|
|
def parse_args(argv):
|
|
"""Return the archive path, or exit the way the shell version exited."""
|
|
ut = os.path.join(REPO_ROOT, "jobbsok.plugin")
|
|
i = 0
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg == "--ut":
|
|
if i + 1 >= len(argv):
|
|
sys.stderr.write("package_plugin: --ut needs a path\n")
|
|
raise SystemExit(2)
|
|
ut = argv[i + 1]
|
|
i += 2
|
|
elif arg in ("-h", "--help"):
|
|
sys.stdout.write(__doc__)
|
|
raise SystemExit(0)
|
|
else:
|
|
sys.stderr.write("package_plugin: unknown argument: %s\n" % arg)
|
|
raise SystemExit(2)
|
|
return os.path.abspath(ut)
|
|
|
|
|
|
def skal_utelates(navn):
|
|
"""True when ``navn`` -- an archive entry name -- matches an exclusion."""
|
|
for monster in UTELAT:
|
|
if fnmatch.fnmatch(navn, monster):
|
|
return True
|
|
return False
|
|
|
|
|
|
def oppforinger(rot, include):
|
|
"""Return [(absolute path, archive entry name)] for ``include`` under ``rot``.
|
|
|
|
Sorted, so two builds of the same tree lay the archive out identically.
|
|
"""
|
|
funnet = []
|
|
for sti in include:
|
|
full = os.path.join(rot, sti)
|
|
if os.path.isfile(full):
|
|
funnet.append((full, sti.replace(os.sep, "/")))
|
|
continue
|
|
for dirpath, dirnames, filenames in os.walk(full):
|
|
dirnames.sort()
|
|
for filnavn in sorted(filenames):
|
|
filsti = os.path.join(dirpath, filnavn)
|
|
navn = os.path.relpath(filsti, rot).replace(os.sep, "/")
|
|
funnet.append((filsti, navn))
|
|
return [(f, n) for f, n in funnet if not skal_utelates(n)]
|
|
|
|
|
|
def main(argv):
|
|
ut = parse_args(argv)
|
|
|
|
# Regenerate first: an archive carrying a stale stamp is the exact failure
|
|
# the stamp exists to catch, and building one would be worse than not
|
|
# stamping. Started with this interpreter, so the packaging run and the
|
|
# stamp it ships cannot end up on two different Pythons.
|
|
stempling = subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPT_DIR, "build_stamp.py")]
|
|
)
|
|
if stempling.returncode != 0:
|
|
return stempling.returncode
|
|
|
|
for sti in REQUIRED:
|
|
if not os.path.exists(os.path.join(REPO_ROOT, sti.replace("/", os.sep))):
|
|
sys.stderr.write("package_plugin: required entry missing: %s\n" % sti)
|
|
return 1
|
|
|
|
present = [p for p in INCLUDE if os.path.exists(os.path.join(REPO_ROOT, p))]
|
|
skipped = [p for p in INCLUDE if not os.path.exists(os.path.join(REPO_ROOT, p))]
|
|
if skipped:
|
|
sys.stdout.write("package_plugin: not present yet, skipped: %s\n" % " ".join(skipped))
|
|
|
|
if os.path.exists(ut):
|
|
os.remove(ut)
|
|
|
|
with zipfile.ZipFile(ut, "w", zipfile.ZIP_DEFLATED) as pakke:
|
|
for filsti, navn in oppforinger(REPO_ROOT, present):
|
|
pakke.write(filsti, navn)
|
|
|
|
with open(os.path.join(REPO_ROOT, "BUILD_STAMP"), "r", encoding="utf-8") as handle:
|
|
stempel = handle.read().strip()
|
|
sys.stdout.write(
|
|
"package_plugin: %s built from %s\n" % (os.path.basename(ut), stempel)
|
|
)
|
|
sys.stdout.write("package_plugin: %s\n" % ut)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|