fix(deploy-app-sd): make app upload replacement reliable
This commit is contained in:
@@ -22,7 +22,7 @@ The CrossPoint Reader web server exposes:
|
||||
3. Upload the app directory to `/.apps/<AppId>`:
|
||||
|
||||
```sh
|
||||
.pi/skills/deploy-app-sd/upload-app.sh <device-ip> <AppId>
|
||||
.agents/skills/deploy-app-sd/upload-app.sh <device-ip> <AppId>
|
||||
```
|
||||
|
||||
The script creates `/.apps`, replaces `/.apps/<AppId>`, recreates any subdirectories, and uploads every file under `apps/<AppId>/` except `README.md`.
|
||||
The script creates `/.apps`, clears `/.apps/<AppId>` recursively, recreates any subdirectories, and uploads every file under `apps/<AppId>/` except `README.md`.
|
||||
|
||||
@@ -1,59 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: upload-app.sh <device-ip> <AppId>
|
||||
set -euo pipefail
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Usage: upload-app.sh <device-ip> <AppId>"""
|
||||
|
||||
IP="${1:?usage: upload-app.sh <device-ip> <AppId>}"
|
||||
APP="${2:?usage: upload-app.sh <device-ip> <AppId>}"
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user