#!/usr/bin/env python3 """Create and deploy the img2threejs Docker Space with the local ``hf`` CLI. The uploader is deliberately allowlist-based. It stages only the files in ``deployment_manifest()`` and performs exactly one ``hf upload`` from that sanitized directory. Authentication comes from the active ``hf auth`` store; ``HF_TOKEN`` is optional. Examples: python3 scripts/deploy_space.py --dry-run python3 scripts/deploy_space.py python3 scripts/deploy_space.py --no-secrets """ from __future__ import annotations import argparse import json import os import shlex import shutil import subprocess import sys import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_SPACE_ID = "Mike0021/img2threejs" # Root files and source trees intentionally published to the Space repository. # The Docker runtime has a narrower COPY allowlist in Dockerfile. MANIFEST_ROOT_FILES = ( ".dockerignore", ".gitignore", "CHANGELOG.md", "CONTRIBUTING.md", "Dockerfile", "LICENSE", "README.md", "ROADMAP.md", "SKILL.md", "UPSTREAM_REVISION", "package-lock.json", "package.json", "pytest.ini", "requirements.txt", ) MANIFEST_TREES = ( "app", "assets", "docs", "forge", "grimoire", "scripts", "tests", ) REQUIRED_PATHS = ( "README.md", "Dockerfile", "requirements.txt", "package.json", "package-lock.json", "app/main.py", "app/static/index.html", "UPSTREAM_REVISION", "forge/stage2_spec/validate_sculpt_spec.py", "forge/stage3_build/generate_threejs_factory.py", "scripts/build_fixture_factory.py", "scripts/node_smoke.mjs", "tests/fixtures/canned_spec.json", ) EXCLUDED_DIR_NAMES = { ".git", ".pytest_cache", ".venv", ".workflow", "__pycache__", "htmlcov", "node_modules", "upstream-src", "verification", } EXCLUDED_EXACT = {"tests/fixtures/factory_fixture.ts"} # Destination secret -> accepted environment aliases, in priority order. SECRET_SOURCES = { "LLM_API_KEY": ("LLM_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"), "LLM_BASE_URL": ("LLM_BASE_URL", "ANTHROPIC_BASE_URL"), "LLM_MODEL": ("LLM_MODEL", "ANTHROPIC_MODEL"), "LLM_API_STYLE": ("LLM_API_STYLE",), "LLM_MAX_TOKENS": ("LLM_MAX_TOKENS",), "LLM_TIMEOUT_S": ("LLM_TIMEOUT_S",), "LLM_MAX_RETRIES": ("LLM_MAX_RETRIES",), "LLM_REFERER": ("LLM_REFERER",), "LLM_TITLE": ("LLM_TITLE",), } class ManifestError(RuntimeError): """The local source tree cannot be safely staged for deployment.""" def _excluded(relative: Path) -> bool: posix = relative.as_posix() name = relative.name if any(part in EXCLUDED_DIR_NAMES for part in relative.parts): return True if posix in EXCLUDED_EXACT: return True if name in {".DS_Store", ".coverage", ".env"} or name.startswith(".env."): return True if name.startswith("rollout") and name.endswith(".jsonl"): return True if name.endswith((".pyc", ".pyo", ".log")): return True return False def deployment_manifest(root: Path = REPO_ROOT) -> tuple[str, ...]: """Return the sorted, repo-relative deployment file allowlist. Symlinks are rejected rather than dereferenced so a local workspace link cannot copy data from outside the repository into a public Space. """ root = root.resolve() missing = [path for path in REQUIRED_PATHS if not (root / path).is_file()] if missing: raise ManifestError("required deployment files missing: " + ", ".join(missing)) selected: set[str] = set() for relative_text in MANIFEST_ROOT_FILES: path = root / relative_text if not path.exists(): continue if path.is_symlink(): raise ManifestError(f"deployment file is a symlink: {relative_text}") if not path.is_file(): raise ManifestError(f"deployment path is not a file: {relative_text}") selected.add(relative_text) for tree_name in MANIFEST_TREES: tree = root / tree_name if not tree.is_dir(): raise ManifestError(f"deployment tree missing: {tree_name}") for path in tree.rglob("*"): relative = path.relative_to(root) if path.is_symlink(): raise ManifestError(f"deployment tree contains symlink: {relative.as_posix()}") if path.is_file() and not _excluded(relative): selected.add(relative.as_posix()) return tuple(sorted(selected)) def _stage_manifest(root: Path, manifest: tuple[str, ...], destination: Path) -> None: for relative_text in manifest: source = root / relative_text target = destination / relative_text target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) def _run(argv: list[str]) -> None: # Commands contain paths and secret *names* only; secret values are sent # through a mode-0600 temporary file and never appear in logs or argv. print("->", shlex.join(argv), flush=True) subprocess.run(argv, check=True) def _environment_secrets() -> dict[str, str]: values: dict[str, str] = {} for destination, candidates in SECRET_SOURCES.items(): value = next( (os.environ[name] for name in candidates if os.environ.get(name, "").strip()), None, ) if value is None: continue if "\n" in value or "\r" in value or "\0" in value: raise ManifestError(f"secret {destination} contains a forbidden control character") values[destination] = value return values def _set_secrets(space_id: str, values: dict[str, str]) -> None: if not values: print( "-> no supported LLM secret variables found; deployment will use " "the honest llm_not_configured path", file=sys.stderr, ) return file_descriptor, raw_path = tempfile.mkstemp( prefix="img2threejs-secrets-", suffix=".env" ) secret_path = Path(raw_path) try: os.fchmod(file_descriptor, 0o600) with os.fdopen(file_descriptor, "w", encoding="utf-8") as stream: for name in sorted(values): # JSON double-quoted strings are accepted dotenv values and # preserve spaces/# characters without exposing the value. stream.write(f"{name}={json.dumps(values[name])}\n") print("-> setting Space Secrets: " + ", ".join(sorted(values)), flush=True) _run(["hf", "spaces", "secrets", "add", space_id, "--secrets-file", str(secret_path)]) finally: secret_path.unlink(missing_ok=True) def _print_manifest(root: Path, manifest: tuple[str, ...]) -> None: total = sum((root / item).stat().st_size for item in manifest) print(f"deployment manifest: {len(manifest)} files, {total} bytes") for item in manifest: print(item) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--space-id", default=DEFAULT_SPACE_ID) parser.add_argument("--dry-run", action="store_true", help="print the exact allowlist; do not authenticate or mutate the Hub") parser.add_argument("--no-secrets", action="store_true", help="do not set any environment-derived Space Secrets") parser.add_argument("--private", action="store_true", help="make a newly-created Space private") parser.add_argument( "--commit-message", default="Deploy verified img2threejs Docker Space", ) args = parser.parse_args(argv) try: manifest = deployment_manifest(REPO_ROOT) _print_manifest(REPO_ROOT, manifest) if args.dry_run: return 0 # Uses the current hf CLI credential store. No HF_TOKEN is required. _run(["hf", "auth", "whoami", "--format", "json"]) create = [ "hf", "repos", "create", args.space_id, "--type", "space", "--sdk", "docker", "--flavor", "cpu-basic", "--exist-ok", "--private" if args.private else "--public", ] _run(create) if not args.no_secrets: _set_secrets(args.space_id, _environment_secrets()) with tempfile.TemporaryDirectory(prefix="img2threejs-upload-") as temp_dir: stage = Path(temp_dir) _stage_manifest(REPO_ROOT, manifest, stage) _run([ "hf", "upload", args.space_id, str(stage), ".", "--type", "space", "--commit-message", args.commit_message, ]) slug = args.space_id.lower().replace("/", "-").replace("_", "-") print(f"-> Space page: https://huggingface.co/spaces/{args.space_id}") print(f"-> App URL: https://{slug}.hf.space") return 0 except (ManifestError, OSError, subprocess.CalledProcessError) as exc: print(f"deployment failed: {exc}", file=sys.stderr) if isinstance(exc, subprocess.CalledProcessError) and exc.returncode: return int(exc.returncode) return 2 if __name__ == "__main__": raise SystemExit(main())