#!/usr/bin/env bash # Package a plugin directory and publish it to the sync server. # # bash tools/publish-plugin.sh team-defaults # bash tools/publish-plugin.sh team-defaults --url http://host:8020 --token # bash tools/publish-plugin.sh team-defaults --dry-run # # Reads the id and version out of the plugin's manifest.json, builds a .tar.gz # whose members sit at the archive root (manifest.json + files/...), verifies # the result the same way the server will, then POSTs it. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PLUGIN_ID="${1:-}" shift || true URL="${DSH_URL:-http://192.168.5.2:8020}" TOKEN="${DSH_ADMIN_TOKEN:-74020d5458c05a43e6abca0ddea020b5}" DRY_RUN=0 while [ $# -gt 0 ]; do case "$1" in --url) URL="$2"; shift 2 ;; --token) TOKEN="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done if [ -z "$PLUGIN_ID" ]; then echo "usage: $0 [--url U] [--token T] [--dry-run]" >&2 echo "available:" >&2 ls -1 "$ROOT/plugins" 2>/dev/null | sed 's/^/ /' >&2 exit 2 fi SRC="$ROOT/plugins/$PLUGIN_ID" [ -d "$SRC" ] || { echo "no such plugin dir: $SRC" >&2; exit 1; } [ -f "$SRC/manifest.json" ] || { echo "missing $SRC/manifest.json" >&2; exit 1; } VERSION="$(python -c "import json,sys; print(json.load(open(sys.argv[1]))['version'])" \ "$SRC/manifest.json")" MANIFEST_ID="$(python -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" \ "$SRC/manifest.json")" if [ "$MANIFEST_ID" != "$PLUGIN_ID" ]; then echo "manifest.id ($MANIFEST_ID) != directory name ($PLUGIN_ID)" >&2 exit 1 fi # The archive must contain manifest.json at its root: the server looks for that # exact path and rejects anything it cannot find. Packing the directory itself # would put everything under / and fail validation. # # Staged inside build/ rather than the system temp dir: curl on Windows is a # native binary and cannot open an MSYS-style /tmp/... path for -F @file. STAGE="$ROOT/build/publish-$PLUGIN_ID" rm -rf "$STAGE" mkdir -p "$ROOT/build" "$STAGE/pkg" trap 'rm -rf "$STAGE"' EXIT cp -r "$SRC/." "$STAGE/pkg/" rm -rf "$STAGE/pkg/.git" "$STAGE/pkg/__pycache__" 2>/dev/null || true find "$STAGE/pkg" -name '*.pyc' -delete 2>/dev/null || true ARCHIVE="$STAGE/$PLUGIN_ID-$VERSION.tar.gz" # Build the tar with Python: GNU tar's `-C dir .` emits a bare `./` member and # listing entries explicitly duplicates whatever find returns for directories. # Python's TarFile.add writes exactly the relative paths we choose. python - "$STAGE/pkg" "$ARCHIVE" <<'PY' import os, sys, tarfile from pathlib import Path src, dst = Path(sys.argv[1]), Path(sys.argv[2]) SKIP_DIRS = {".git", "__pycache__", ".pytest_cache"} with tarfile.open(dst, "w:gz") as tf: for path in sorted(src.rglob("*")): rel = path.relative_to(src) if any(part in SKIP_DIRS for part in rel.parts): continue if path.suffix == ".pyc": continue # recursive=False: each path is added once, by its own rel name. tf.add(path, arcname=rel.as_posix(), recursive=False) PY echo "plugin : $PLUGIN_ID" echo "version : $VERSION" echo "archive : $(stat -c %s "$ARCHIVE") bytes" echo "sha256 : $(sha256sum "$ARCHIVE" | cut -d' ' -f1)" echo "members :" tar -tzf "$ARCHIVE" | sed 's/^/ /' if [ "$DRY_RUN" = "1" ]; then echo echo "dry run — not uploading. archive kept at $ARCHIVE" trap - EXIT exit 0 fi echo echo ">> uploading to $URL" # Python rather than curl -F: the multipart body has to be built by hand to # avoid path-format disagreements between MSYS paths and the native curl binary. python - "$URL" "$TOKEN" "$PLUGIN_ID" "$ARCHIVE" <<'PY' import json, sys, urllib.error, urllib.request, uuid from pathlib import Path url, token, plugin_id, archive = sys.argv[1:5] blob = Path(archive).read_bytes() boundary = uuid.uuid4().hex body = b"".join([ f"--{boundary}\r\n".encode(), f'Content-Disposition: form-data; name="file"; filename="{Path(archive).name}"\r\n'.encode(), b"Content-Type: application/gzip\r\n\r\n", blob, f"\r\n--{boundary}--\r\n".encode(), ]) req = urllib.request.Request( f"{url}/v1/plugins/{plugin_id}", data=body, method="POST", headers={"Authorization": f"Bearer {token}", "Content-Type": f"multipart/form-data; boundary={boundary}"}) try: with urllib.request.urlopen(req, timeout=60) as r: print(json.dumps(json.loads(r.read()), indent=2, ensure_ascii=False)) except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", "replace") print(f"publish failed (HTTP {e.code}): {detail}", file=sys.stderr) sys.exit(1) PY STATUS=$? [ "$STATUS" = "0" ] || exit "$STATUS" echo echo "published. install with:" echo " python client/dsh_sync_client.py install $PLUGIN_ID"