fix(deploy-app-sd): make app upload replacement reliable

This commit is contained in:
2026-07-27 18:13:03 -04:00
parent 27ae4b1523
commit 6aeea4be4a
2 changed files with 136 additions and 50 deletions
+2 -2
View File
@@ -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`.
+134 -48
View File
@@ -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()