diff --git a/.agents/skills/deploy-app-sd/SKILL.md b/.agents/skills/deploy-app-sd/SKILL.md index a9eecf3..0789f2f 100644 --- a/.agents/skills/deploy-app-sd/SKILL.md +++ b/.agents/skills/deploy-app-sd/SKILL.md @@ -22,7 +22,7 @@ The CrossPoint Reader web server exposes: 3. Upload the app directory to `/.apps/`: ```sh - .pi/skills/deploy-app-sd/upload-app.sh + .agents/skills/deploy-app-sd/upload-app.sh ``` -The script creates `/.apps`, replaces `/.apps/`, recreates any subdirectories, and uploads every file under `apps//` except `README.md`. +The script creates `/.apps`, clears `/.apps/` recursively, recreates any subdirectories, and uploads every file under `apps//` except `README.md`. diff --git a/.agents/skills/deploy-app-sd/upload-app.sh b/.agents/skills/deploy-app-sd/upload-app.sh index e74db6f..ff9eacd 100755 --- a/.agents/skills/deploy-app-sd/upload-app.sh +++ b/.agents/skills/deploy-app-sd/upload-app.sh @@ -1,59 +1,145 @@ -#!/usr/bin/env bash -# Usage: upload-app.sh -set -euo pipefail +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Usage: upload-app.sh """ -IP="${1:?usage: upload-app.sh }" -APP="${2:?usage: upload-app.sh }" +import json +import mimetypes +import re +import subprocess +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen -[[ "$APP" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "bad AppId: $APP" >&2; exit 1; } -APP_DIR="apps/$APP" -[ -d "$APP_DIR" ] || { echo "not found: $APP_DIR" >&2; exit 1; } -[ -f "$APP_DIR/main.lua" ] || { echo "not found: $APP_DIR/main.lua" >&2; exit 1; } +TIMEOUT = 20 -if [ -x scripts/update-manifests.py ]; then - scripts/update-manifests.py -else - python3 scripts/update-manifests.py -fi -urlenc() { - python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1" -} +def fail(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) -mkdir_remote() { - local parent="$1" name="$2" - curl -sS -X POST "http://$IP/mkdir" \ - --data-urlencode "path=$parent" \ - --data-urlencode "name=$name" \ - -o /dev/null || true -} -upload_file() { - local src="$1" rel="${1#"$APP_DIR"/}" dir remote_dir - dir="$(dirname "$rel")" - remote_dir="/.apps/$APP" - [ "$dir" = "." ] || remote_dir="$remote_dir/$dir" +def request(ip: str, method: str, path: str, *, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, bytes]: + req = Request(f"http://{ip}{path}", data=data, headers=headers or {}, method=method) + try: + with urlopen(req, timeout=TIMEOUT) as res: + return res.status, res.read() + except HTTPError as e: + return e.code, e.read() + except URLError as e: + fail(f"request failed: {e.reason}") - curl -fsS \ - -F "file=@$src;filename=$(basename "$src")" \ - "http://$IP/upload?path=$(urlenc "$remote_dir")" >/dev/null - echo "uploaded: $rel" -} -mkdir_remote "/" ".apps" -curl -sS -X POST "http://$IP/delete" --data-urlencode "path=/.apps/$APP" -o /dev/null || true -mkdir_remote "/.apps" "$APP" +def post_form(ip: str, path: str, fields: dict[str, str]) -> tuple[int, bytes]: + return request( + ip, + "POST", + path, + data=urlencode(fields).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) -while IFS= read -r -d '' dir; do - rel="${dir#"$APP_DIR"/}" - parent="/.apps/$APP" - [ "$(dirname "$rel")" = "." ] || parent="$parent/$(dirname "$rel")" - mkdir_remote "$parent" "$(basename "$rel")" -done < <(find "$APP_DIR" -mindepth 1 -type d -print0 | sort -z) -while IFS= read -r -d '' file; do - [ -s "$file" ] || { echo "empty file rejected: $file" >&2; exit 1; } - upload_file "$file" -done < <(find "$APP_DIR" -type f ! -name README.md -print0 | sort -z) +def mkdir_remote(ip: str, parent: str, name: str) -> None: + status, body = post_form(ip, "/mkdir", {"path": parent, "name": name}) + if status not in (200, 400): + fail(f"mkdir failed for {parent}/{name}: HTTP {status} {body.decode(errors='replace')}") -echo "App uploaded successfully: $APP" + +def delete_remote(ip: str, path: str, *, missing_ok: bool = True) -> None: + status, body = post_form(ip, "/delete", {"path": path}) + text = body.decode(errors="replace") + if status == 200 or (missing_ok and "not found" in text.lower()): + return + fail(f"delete failed for {path}: HTTP {status} {text}") + + +def list_remote(ip: str, path: str) -> list[dict]: + status, body = request(ip, "GET", f"/api/files?path={quote(path, safe='')}") + if status == 404: + return [] + if status != 200: + fail(f"list failed for {path}: HTTP {status} {body.decode(errors='replace')}") + return json.loads(body) + + +def delete_tree_contents(ip: str, path: str) -> None: + dirs: list[str] = [] + for entry in list_remote(ip, path): + child = f"{path.rstrip('/')}/{entry['name']}" + if entry.get("isDirectory"): + delete_tree_contents(ip, child) + dirs.append(child) + else: + delete_remote(ip, child) + for child in dirs: + delete_remote(ip, child) + + +def upload_file(ip: str, remote_dir: str, src: Path, rel: Path) -> None: + boundary = "----xteink-app-upload" + filename = src.name + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + body = b"".join( + [ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(), + f"Content-Type: {content_type}\r\n\r\n".encode(), + src.read_bytes(), + f"\r\n--{boundary}--\r\n".encode(), + ] + ) + status, response = request( + ip, + "POST", + f"/upload?path={quote(remote_dir, safe='')}", + data=body, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + ) + if not 200 <= status < 300: + fail(f"upload failed for {rel}: HTTP {status} {response.decode(errors='replace')}") + print(f"uploaded: {rel}") + + +def main() -> None: + if len(sys.argv) != 3: + fail(__doc__ or "usage error") + + ip, app = sys.argv[1:] + if not re.fullmatch(r"[A-Za-z0-9_-]+", app): + fail(f"bad AppId: {app}") + + app_dir = Path("apps") / app + if not app_dir.is_dir(): + fail(f"not found: {app_dir}") + if not (app_dir / "main.lua").is_file(): + fail(f"not found: {app_dir / 'main.lua'}") + + subprocess.run(["scripts/update-manifests.py"], check=True) + + mkdir_remote(ip, "/", ".apps") + remote_app = f"/.apps/{app}" + mkdir_remote(ip, "/.apps", app) + delete_tree_contents(ip, remote_app) + + for local_dir in sorted(p for p in app_dir.rglob("*") if p.is_dir()): + rel = local_dir.relative_to(app_dir) + parent = remote_app if rel.parent == Path(".") else f"{remote_app}/{rel.parent.as_posix()}" + mkdir_remote(ip, parent, rel.name) + + for src in sorted(p for p in app_dir.rglob("*") if p.is_file() and p.name != "README.md"): + if src.stat().st_size == 0: + fail(f"empty file rejected: {src}") + rel = src.relative_to(app_dir) + remote_dir = remote_app if rel.parent == Path(".") else f"{remote_app}/{rel.parent.as_posix()}" + upload_file(ip, remote_dir, src, rel) + + print(f"App uploaded successfully: {app}") + + +if __name__ == "__main__": + main()