Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion scripts/deliver-artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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())
184 changes: 184 additions & 0 deletions scripts/deploy-share.py
Original file line number Diff line number Diff line change
@@ -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())
71 changes: 71 additions & 0 deletions tests/test_share_deploy.py
Original file line number Diff line number Diff line change
@@ -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(
"<!doctype html>\n<html lang='en'><head>"
"<meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width'>"
"<title>Test Deploy</title>"
"</head><body><h1>Test Artifact</h1></body></html>"
)

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.")
Loading