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 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 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" + "" + "" + "Test Deploy" + "

Test Artifact

" + ) + + result = subprocess.run( + [sys.executable, str(DEPLOY_SCRIPT), str(test_artifact), "--json"], + capture_output=True, text=True, timeout=120 + ) + # Expect either success JSON or graceful error (surge may not be available in CI) + assert result.returncode in (0, 1), f"Unexpected exit code: {result.returncode}" + if result.returncode == 0: + import json + data = json.loads(result.stdout) + assert "url" in data, f"No URL in response: {result.stdout}" + assert data["url"].startswith("https://"), f"Invalid URL: {data['url']}" + print(f"PASS: deploy-share.py returned URL {data['url']}") + else: + # Acceptable: surge.sh not configured in test environment + print(f"INFO: deploy-share.py did not deploy (expected in CI without surge token): {result.stderr.strip()}") + + +def test_deliver_artifact_share_flag_exists(): + """deliver-artifact.py accepts --share flag.""" + result = subprocess.run( + [sys.executable, str(ARTIFACT_SCRIPT), "--help"], + capture_output=True, text=True, timeout=10 + ) + assert "--share" in result.stdout, f"--share flag missing from help: {result.stdout}" + assert "--share-url-output" in result.stdout, f"--share-url-output flag missing: {result.stdout}" + print("PASS: deliver-artifact.py has --share and --share-url-output flags") + + +if __name__ == "__main__": + test_deploy_share_help() + test_deliver_artifact_share_flag_exists() + test_deploy_share_json_flag() + print("\nAll tests passed.") \ No newline at end of file