From 968dbe44efe6a1d3771e5f5cef316d0ea3f6b43b Mon Sep 17 00:00:00 2001 From: ramsani <97571563+ramsani@users.noreply.github.com> Date: Sat, 16 May 2026 16:24:50 -0700 Subject: [PATCH 1/3] feat(share): add deploy-share.py using surge.sh for artifact sharing --- scripts/deploy-share.py | 184 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 scripts/deploy-share.py diff --git a/scripts/deploy-share.py b/scripts/deploy-share.py new file mode 100644 index 0000000..39636c0 --- /dev/null +++ b/scripts/deploy-share.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Deploy an HTML artifact to surge.sh and return the public URL.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timedelta +from pathlib import Path +from shutil import copytree, rmtree + +ROOT = Path.home() / ".claude/html-explainer" +DEPLOY_LOG = ROOT / "deploy-log.json" + + +def load_deploy_log() -> dict: + if DEPLOY_LOG.exists(): + try: + return json.loads(DEPLOY_LOG.read_text()) + except Exception: + pass + return {"deploys": []} + + +def save_deploy_log(log: dict) -> None: + DEPLOY_LOG.parent.mkdir(parents=True, exist_ok=True) + DEPLOY_LOG.write_text(json.dumps(log, indent=2)) + + +def deploy_to_surge(artifact_path: Path) -> tuple[str, datetime]: + """Deploy artifact to surge.sh using a temporary directory.""" + artifact_path = artifact_path.resolve() + + if not artifact_path.exists(): + raise SystemExit(f"Artifact not found: {artifact_path}") + + # Create a temporary directory for surge deployment + tmpdir = Path(tempfile.mkdtemp(prefix="html-explainer-deploy-")) + deploy_dir = tmpdir / "artifact" + deploy_dir.mkdir(parents=True) + + # Copy artifact with index.html name if it's a standalone file + dest = deploy_dir / "index.html" + copytree(artifact_path, dest, dirs_exist_ok=True) + + # Check if source has files (is a directory with multiple files) + if artifact_path.is_dir(): + for item in artifact_path.iterdir(): + if item.is_file(): + copytree(artifact_path, deploy_dir, dirs_exist_ok=True) + break + + # Run surge + result = subprocess.run( + ["npx", "surge", str(deploy_dir), "--token", os.environ.get("SURGE_TOKEN", "")], + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + # Try without token (anonymous deploy) + result = subprocess.run( + ["npx", "surge", str(deploy_dir)], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "SURGE_REGISTRATION_MODE": "anonymous"}, + ) + + rmtree(tmpdir) + + if result.returncode != 0: + raise SystemExit(f"Deploy failed: {result.stderr}") + + # Parse URL from surge output + for line in result.stdout.splitlines(): + if "https://" in line: + url = line.strip().split()[0] + return url.rstrip("/") + + raise SystemExit("Could not parse surge URL from output") + + +def deploy_single_file(artifact_path: Path) -> tuple[str, datetime]: + """Deploy a single HTML file to surge.sh.""" + artifact_path = artifact_path.resolve() + + if not artifact_path.exists(): + raise SystemExit(f"Artifact not found: {artifact_path}") + + tmpdir = Path(tempfile.mkdtemp(prefix="html-explainer-deploy-")) + dest = tmpdir / "index.html" + dest.write_bytes(artifact_path.read_bytes()) + + # Run surge (anonymous) + result = subprocess.run( + ["npx", "surge", str(tmpdir), "--token", os.environ.get("SURGE_TOKEN", "")], + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + result = subprocess.run( + ["npx", "surge", str(tmpdir)], + capture_output=True, + text=True, + timeout=60, + ) + + rmtree(tmpdir) + + if result.returncode != 0: + raise SystemExit(f"Deploy failed: {result.stderr}") + + for line in result.stdout.splitlines(): + if "https://" in line: + url = line.strip().split()[0] + return url.rstrip("/"), datetime.utcnow() + timedelta(days=30) + + raise SystemExit("Could not parse surge URL from output") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Deploy an HTML artifact to a public URL via surge.sh." + ) + parser.add_argument("artifact", type=Path, help="Path to the HTML artifact file or directory") + parser.add_argument("--output-root", type=Path, default=Path.home() / ".claude/html-explainer/outputs") + parser.add_argument("--json", action="store_true", help="Output JSON") + args = parser.parse_args() + + try: + url, expires_at = deploy_single_file(args.artifact) + except SystemExit: + # Fallback: try as directory + try: + url, expires_at = deploy_to_surge(args.artifact) + except SystemExit as e: + if args.json: + print(json.dumps({"success": False, "error": str(e)})) + return 1 + raise + + deployed_at = datetime.utcnow() + + # Log the deploy + log = load_deploy_log() + entry = { + "artifact": str(args.artifact), + "url": url, + "deployed_at": deployed_at.isoformat(), + "expires_at": expires_at.isoformat(), + } + log["deploys"].insert(0, entry) + # Keep last 50 deploys + log["deploys"] = log["deploys"][:50] + save_deploy_log(log) + + if args.json: + print(json.dumps({ + "success": True, + "url": url, + "artifact": str(args.artifact), + "deployed_at": deployed_at.isoformat(), + "expires_at": expires_at.isoformat(), + }, indent=2)) + else: + print(f"\n\U0001f517 Public URL: {url}") + print(f"\U0001f4c5 Artifact: {args.artifact}") + print(f"\U0001f551 Deployed at: {deployed_at.isoformat()}") + print(f"\u23f3 Expires: ~{expires_at.strftime('%Y-%m-%d')} (surge.sh 30-day limit)") + print(f"\nDeploy logged to: {DEPLOY_LOG}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file From 210c733c51bc2c52eef654c25f59332d682fa9e0 Mon Sep 17 00:00:00 2001 From: ramsani <97571563+ramsani@users.noreply.github.com> Date: Sat, 16 May 2026 16:24:56 -0700 Subject: [PATCH 2/3] feat(share): add --share and --share-url-output flags to deliver-artifact.py --- scripts/deliver-artifact.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/deliver-artifact.py b/scripts/deliver-artifact.py index 36ebab8..e431535 100755 --- a/scripts/deliver-artifact.py +++ b/scripts/deliver-artifact.py @@ -45,6 +45,8 @@ def main() -> int: parser.add_argument("--recommended-action", default="") parser.add_argument("--confidence", choices=["low", "medium", "high", "unknown"], default="unknown") parser.add_argument("--tag", action="append", default=[]) + parser.add_argument("--share", action="store_true", help="Deploy artifact to public URL after approval") + parser.add_argument("--share-url-output", type=Path, default=None, help="Write deployed URL to this file") args = parser.parse_args() result = run_json([sys.executable, str(AUDIT), str(args.artifact), "--min-score", str(args.min_score), "--json"]) @@ -119,8 +121,39 @@ def main() -> int: if completed.returncode != 0: return completed.returncode + if args.share and completed.returncode == 0: + import urllib.parse + deploy_cmd = [ + sys.executable, + str(ROOT / "scripts/deploy-share.py"), + str(args.artifact), + "--json", + ] + deploy_result = subprocess.run(deploy_cmd, capture_output=True, text=True, timeout=120) + if deploy_result.returncode == 0: + try: + deploy_data = json.loads(deploy_result.stdout) + public_url = deploy_data.get("url", "") + print(f"\n\U0001f517 Shared: {public_url}") + if args.share_url_output: + args.share_url_output.parent.mkdir(parents=True, exist_ok=True) + args.share_url_output.write_text(public_url) + print(f"URL written to: {args.share_url_output}") + # Append share URL to metadata + meta_path = args.output_root / "metadata" / f"{Path(args.artifact).stem}.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text()) + meta["share_url"] = public_url + meta["deployed_at"] = deploy_data.get("deployed_at", "") + meta["share_expires_at"] = deploy_data.get("expires_at", "") + meta_path.write_text(json.dumps(meta, indent=2)) + except Exception as e: + print(f"warning: could not log share URL: {e}") + else: + print(f"warning: share failed: {deploy_result.stderr.strip()}") + return 0 if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 425f4fa1c3a7c5c4d60728b2f89bb8ad5bf188d9 Mon Sep 17 00:00:00 2001 From: ramsani <97571563+ramsani@users.noreply.github.com> Date: Sat, 16 May 2026 16:25:05 -0700 Subject: [PATCH 3/3] feat(share): add test_share_deploy.py --- tests/test_share_deploy.py | 71 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_share_deploy.py diff --git a/tests/test_share_deploy.py b/tests/test_share_deploy.py new file mode 100644 index 0000000..b65873c --- /dev/null +++ b/tests/test_share_deploy.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Test deploy-share.py functionality.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEPLOY_SCRIPT = REPO_ROOT / "scripts" / "deploy-share.py" +ARTIFACT_SCRIPT = REPO_ROOT / "scripts" / "deliver-artifact.py" + + +def test_deploy_share_help(): + """deploy-share.py --help works.""" + result = subprocess.run( + [sys.executable, str(DEPLOY_SCRIPT), "--help"], + capture_output=True, text=True, timeout=10 + ) + assert result.returncode == 0, f"--help failed: {result.stderr}" + assert "--share" in result.stdout or "share" in result.stdout.lower() + print("PASS: deploy-share.py --help works") + + +def test_deploy_share_json_flag(): + """deploy-share.py --json on a test artifact returns JSON.""" + # Create a minimal valid HTML artifact + test_artifact = REPO_ROOT / "outputs" / "test-deploy.html" + test_artifact.parent.mkdir(parents=True, exist_ok=True) + test_artifact.write_text( + "\n
" + "" + "" + "