Files
dsh/tools/publish-plugin.sh

135 lines
4.7 KiB
Bash

#!/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 <admin>
# 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 <plugin-id> [--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 <id>/ 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"