dev-bubble 0.7.17__tar.gz → 0.7.19__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.gitignore +1 -0
  2. {dev_bubble-0.7.17/dev_bubble.egg-info → dev_bubble-0.7.19}/PKG-INFO +1 -1
  3. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/__init__.py +1 -1
  4. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/finalization.py +2 -0
  5. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/hooks/__init__.py +8 -0
  6. dev_bubble-0.7.19/bubble/hooks/lean.py +459 -0
  7. {dev_bubble-0.7.17 → dev_bubble-0.7.19/dev_bubble.egg-info}/PKG-INFO +1 -1
  8. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_hooks.py +239 -1
  9. dev_bubble-0.7.17/bubble/hooks/lean.py +0 -275
  10. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.claude/CLAUDE.md +0 -0
  11. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.github/workflows/ci.yml +0 -0
  12. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.github/workflows/publish.yml +0 -0
  13. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/CHANGELOG.md +0 -0
  14. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/LICENSE +0 -0
  15. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/README.md +0 -0
  16. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/SPEC.md +0 -0
  17. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/__main__.py +0 -0
  18. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/ai.py +0 -0
  19. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/auth_proxy.py +0 -0
  20. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/automation.py +0 -0
  21. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/clean.py +0 -0
  22. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/cli.py +0 -0
  23. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/clone.py +0 -0
  24. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/cloud.py +0 -0
  25. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/cloud_types.py +0 -0
  26. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/__init__.py +0 -0
  27. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/cloud_cmd.py +0 -0
  28. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/completion.py +0 -0
  29. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/doctor.py +0 -0
  30. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/images.py +0 -0
  31. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/infrastructure.py +0 -0
  32. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/internal.py +0 -0
  33. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/lifecycle.py +0 -0
  34. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/list_cmd.py +0 -0
  35. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/relay_cmd.py +0 -0
  36. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/remote_cmd.py +0 -0
  37. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/security_cmd.py +0 -0
  38. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/settings.py +0 -0
  39. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/status_cmd.py +0 -0
  40. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/config.py +0 -0
  41. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/container_helpers.py +0 -0
  42. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/data/skill.md +0 -0
  43. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/default_repos.json +0 -0
  44. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/git_store.py +0 -0
  45. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/github_token.py +0 -0
  46. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/graphql_validator.py +0 -0
  47. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/hooks/python.py +0 -0
  48. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/image_management.py +0 -0
  49. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/__init__.py +0 -0
  50. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/builder.py +0 -0
  51. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/base.sh +0 -0
  52. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/cloud-init.sh +0 -0
  53. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/lean-toolchain.sh +0 -0
  54. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/lean.sh +0 -0
  55. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/python.sh +0 -0
  56. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/claude.sh +0 -0
  57. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/codex.sh +0 -0
  58. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/elan.sh +0 -0
  59. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/emacs.sh +0 -0
  60. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/gh.sh +0 -0
  61. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/neovim.sh +0 -0
  62. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/pins.json +0 -0
  63. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/uv.sh +0 -0
  64. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/vscode.sh +0 -0
  65. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/lean.py +0 -0
  66. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/lifecycle.py +0 -0
  67. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/naming.py +0 -0
  68. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/network.py +0 -0
  69. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/notices.py +0 -0
  70. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/output.py +0 -0
  71. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/provisioning.py +0 -0
  72. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/relay.py +0 -0
  73. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/remote.py +0 -0
  74. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/repo_registry.py +0 -0
  75. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/__init__.py +0 -0
  76. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/base.py +0 -0
  77. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/colima.py +0 -0
  78. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/incus.py +0 -0
  79. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/security.py +0 -0
  80. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/setup.py +0 -0
  81. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/skill.py +0 -0
  82. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/spinner.py +0 -0
  83. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/target.py +0 -0
  84. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/token_store.py +0 -0
  85. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/tools.py +0 -0
  86. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/tunnel.py +0 -0
  87. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/vscode.py +0 -0
  88. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/config/com.bubble.git-update.plist +0 -0
  89. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/config/com.bubble.image-refresh.plist +0 -0
  90. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/config/com.bubble.relay-daemon.plist +0 -0
  91. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/conftest.py +0 -0
  92. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/SOURCES.txt +0 -0
  93. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/dependency_links.txt +0 -0
  94. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/entry_points.txt +0 -0
  95. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/requires.txt +0 -0
  96. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/top_level.txt +0 -0
  97. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/pyproject.toml +0 -0
  98. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/setup.cfg +0 -0
  99. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/conftest.py +0 -0
  100. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_ai.py +0 -0
  101. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_auth_proxy.py +0 -0
  102. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_authorized_keys.py +0 -0
  103. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_branch_no_target.py +0 -0
  104. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_build_lock.py +0 -0
  105. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_claude_projects_symlink.py +0 -0
  106. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_cloud.py +0 -0
  107. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_colima.py +0 -0
  108. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_completion.py +0 -0
  109. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_config.py +0 -0
  110. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_customize.py +0 -0
  111. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_editor.py +0 -0
  112. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_ephemeral.py +0 -0
  113. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_git_store.py +0 -0
  114. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_github_security_override.py +0 -0
  115. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_github_token.py +0 -0
  116. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_graphql_validator.py +0 -0
  117. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_integration.py +0 -0
  118. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_internal.py +0 -0
  119. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_lifecycle.py +0 -0
  120. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_list_columns.py +0 -0
  121. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_list_remote.py +0 -0
  122. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_mounts.py +0 -0
  123. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_multi_target.py +0 -0
  124. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_naming.py +0 -0
  125. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_network.py +0 -0
  126. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_notices.py +0 -0
  127. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_reattach_network.py +0 -0
  128. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_relay.py +0 -0
  129. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_remote.py +0 -0
  130. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_repo_registry.py +0 -0
  131. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_security.py +0 -0
  132. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_skill.py +0 -0
  133. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_spinner.py +0 -0
  134. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_status.py +0 -0
  135. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_systemd_path.py +0 -0
  136. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_target.py +0 -0
  137. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_token_no_argv_leak.py +0 -0
  138. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_tools.py +0 -0
  139. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_tunnel.py +0 -0
  140. {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_vscode.py +0 -0
@@ -11,3 +11,4 @@ uv.lock
11
11
  .vscode/claude-prompt.txt
12
12
  .vscode/tasks.json
13
13
  .vscode/settings.json
14
+ .claude/scheduled_tasks.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.17
3
+ Version: 0.7.19
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.17"
3
+ __version__ = "0.7.19"
@@ -41,6 +41,8 @@ def finalize_bubble(
41
41
  project_dir = f"/home/user/{short}"
42
42
  if hook:
43
43
  hook.post_clone(runtime, name, project_dir)
44
+ for note in hook.notices():
45
+ click.echo(note, err=True)
44
46
 
45
47
  # Add a "github" remote with SSH-format URL for gh CLI host discovery.
46
48
  # The global url.insteadOf rewrites HTTPS github.com URLs to the proxy,
@@ -69,6 +69,14 @@ class Hook(ABC):
69
69
  """Return absolute path to a .code-workspace file to open, or None."""
70
70
  return None
71
71
 
72
+ def notices(self) -> list[str]:
73
+ """One-shot human-readable notes to print after detection.
74
+
75
+ Used for warnings the user should see when a bubble is created (but
76
+ not on every reattach). Only populated after detect() returns True.
77
+ """
78
+ return []
79
+
72
80
 
73
81
  def discover_hooks() -> list[Hook]:
74
82
  """Return all registered hooks in priority order."""
@@ -0,0 +1,459 @@
1
+ """Lean 4 language hook."""
2
+
3
+ import json
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from ..git_store import parse_github_url
12
+ from ..lean import LEAN_VERSION_RE
13
+ from ..runtime.base import ContainerRuntime
14
+ from . import GitDependency, Hook
15
+
16
+ # Allowlist for Lake package names and repo names (prevents path traversal)
17
+ _SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
18
+
19
+
20
+ def _is_safe_subdir(subdir: str) -> bool:
21
+ """Validate a detected project subdirectory.
22
+
23
+ The string flows into shell commands and into paths under /home/user/, so
24
+ each path component must match the same conservative policy used for Lake
25
+ package names. Reject absolute paths, leading/trailing slashes, empty
26
+ components, and any '.' / '..' segments.
27
+ """
28
+ if not subdir or subdir.startswith("/") or subdir.endswith("/"):
29
+ return False
30
+ for part in subdir.split("/"):
31
+ if not part or part in (".", "..") or not _SAFE_NAME_RE.match(part):
32
+ return False
33
+ return True
34
+
35
+
36
+ def _has_lakefile(bare_repo_path: Path, ref: str, subdir: str) -> bool:
37
+ """Check for a sibling lakefile alongside a discovered lean-toolchain.
38
+
39
+ Filters out vendored/example/doc directories that happen to ship a
40
+ `lean-toolchain` without being a buildable Lean project — e.g. a Python
41
+ repo with `vendor/lean-toolchain` shouldn't be mistaken for a Lean repo.
42
+ """
43
+ prefix = f"{subdir}/" if subdir else ""
44
+ for name in ("lakefile.toml", "lakefile.lean"):
45
+ try:
46
+ subprocess.run(
47
+ ["git", "-C", str(bare_repo_path), "cat-file", "-e", f"{ref}:{prefix}{name}"],
48
+ capture_output=True,
49
+ check=True,
50
+ )
51
+ return True
52
+ except (subprocess.CalledProcessError, FileNotFoundError):
53
+ continue
54
+ return False
55
+
56
+
57
+ def _read_lean_toolchain(bare_repo_path: Path, ref: str, subdir: str = "") -> str | None:
58
+ """Read the lean-toolchain file content from a bare repo at a given ref.
59
+
60
+ ``subdir`` is "" for the repo root, or a relative path like "foo" or "foo/bar".
61
+ """
62
+ path = f"{subdir}/lean-toolchain" if subdir else "lean-toolchain"
63
+ try:
64
+ result = subprocess.run(
65
+ ["git", "-C", str(bare_repo_path), "show", f"{ref}:{path}"],
66
+ capture_output=True,
67
+ text=True,
68
+ check=True,
69
+ )
70
+ return result.stdout.strip()
71
+ except (subprocess.CalledProcessError, FileNotFoundError):
72
+ return None
73
+
74
+
75
+ def _find_lean_toolchain_subdirs(bare_repo_path: Path, ref: str) -> list[str]:
76
+ """Return every directory containing a `lean-toolchain` file at `ref`.
77
+
78
+ Empty string represents the repo root. Uses `git ls-tree -rz` so paths
79
+ with unusual bytes aren't quoted, and streams output through a single
80
+ decode/split pass. Unsafe path components (traversal, control chars,
81
+ non-ASCII) are silently dropped — they'd be unsafe to plumb into shell
82
+ commands later.
83
+ """
84
+ try:
85
+ proc = subprocess.Popen(
86
+ ["git", "-C", str(bare_repo_path), "ls-tree", "-rz", "--name-only", ref],
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.DEVNULL,
89
+ )
90
+ except (OSError, FileNotFoundError):
91
+ return []
92
+
93
+ subdirs: list[str] = []
94
+ suffix = b"/lean-toolchain"
95
+ try:
96
+ assert proc.stdout is not None
97
+ data = proc.stdout.read()
98
+ finally:
99
+ try:
100
+ proc.stdout.close() # type: ignore[union-attr]
101
+ except Exception:
102
+ pass
103
+ try:
104
+ proc.wait(timeout=2)
105
+ except Exception:
106
+ try:
107
+ proc.terminate()
108
+ except Exception:
109
+ pass
110
+
111
+ for entry in data.split(b"\0"):
112
+ if entry == b"lean-toolchain":
113
+ subdirs.append("")
114
+ elif entry.endswith(suffix):
115
+ try:
116
+ subdir = entry[: -len(suffix)].decode("utf-8")
117
+ except UnicodeDecodeError:
118
+ continue
119
+ if _is_safe_subdir(subdir):
120
+ subdirs.append(subdir)
121
+ return subdirs
122
+
123
+
124
+ def _parse_lean_version(toolchain_str: str) -> str | None:
125
+ """Extract the version tag from a lean-toolchain string.
126
+
127
+ Handles formats like:
128
+ leanprover/lean4:v4.16.0
129
+ leanprover/lean4:v4.16.0-rc2
130
+ leanprover/lean4:nightly-2024-01-01 (returns None)
131
+
132
+ Returns the version (e.g. 'v4.16.0') if it's a stable or RC release, else None.
133
+ """
134
+ # Strip the repository prefix if present
135
+ if ":" in toolchain_str:
136
+ version = toolchain_str.split(":", 1)[1]
137
+ else:
138
+ version = toolchain_str
139
+
140
+ if LEAN_VERSION_RE.fullmatch(version):
141
+ return version
142
+ return None
143
+
144
+
145
+ def _parse_git_dependencies(
146
+ bare_repo_path: Path, ref: str, subdir: str = ""
147
+ ) -> list[GitDependency]:
148
+ """Parse git dependencies from lake-manifest.json in the bare repo."""
149
+ path = f"{subdir}/lake-manifest.json" if subdir else "lake-manifest.json"
150
+ try:
151
+ result = subprocess.run(
152
+ ["git", "-C", str(bare_repo_path), "show", f"{ref}:{path}"],
153
+ capture_output=True,
154
+ text=True,
155
+ check=True,
156
+ )
157
+ manifest = json.loads(result.stdout)
158
+ except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
159
+ return []
160
+
161
+ deps = []
162
+ for pkg in manifest.get("packages", []):
163
+ if pkg.get("type") != "git":
164
+ continue
165
+ url = pkg.get("url", "")
166
+ name = pkg.get("name", "")
167
+ rev = pkg.get("rev", "")
168
+ org_repo = parse_github_url(url)
169
+ if not org_repo:
170
+ continue # Skip non-GitHub deps
171
+ # Validate name and rev to prevent path traversal and option injection
172
+ if not name or not _SAFE_NAME_RE.match(name):
173
+ continue
174
+ if not rev or not re.match(r"^[0-9a-f]{40}$", rev):
175
+ continue
176
+ deps.append(
177
+ GitDependency(
178
+ name=name,
179
+ url=url,
180
+ rev=rev,
181
+ sub_dir=pkg.get("subDir"),
182
+ org_repo=org_repo,
183
+ )
184
+ )
185
+ return deps
186
+
187
+
188
+ class LeanHook(Hook):
189
+ """Hook for Lean 4 projects (detected by lean-toolchain file)."""
190
+
191
+ def __init__(self):
192
+ self._toolchain: str | None = None
193
+ self._needs_cache: bool = False
194
+ self._is_lean4: bool = False
195
+ self._git_deps: list[GitDependency] = []
196
+ self._subdir: str = ""
197
+ self._multi_project: bool = False
198
+ self._notices: list[str] = []
199
+
200
+ def name(self) -> str:
201
+ return "Lean 4"
202
+
203
+ def _reset_state(self) -> None:
204
+ self._toolchain = None
205
+ self._subdir = ""
206
+ self._needs_cache = False
207
+ self._is_lean4 = False
208
+ self._git_deps = []
209
+ self._multi_project = False
210
+ self._notices = []
211
+
212
+ def detect(self, bare_repo_path: Path, ref: str) -> bool:
213
+ """Fire if there is any `lean-toolchain` file at the given ref.
214
+
215
+ The repo root is preferred when present. For repos with one or more
216
+ `lean-toolchain` files in subdirectories, the hook still fires (so
217
+ elan + the VS Code extension end up in the bubble), but VS Code
218
+ always opens at the repo root. When multiple files exist the
219
+ auto-build is skipped and a notice is emitted; when their contents
220
+ disagree the plain `lean` image is used and elan installs toolchains
221
+ on demand.
222
+ """
223
+ self._reset_state()
224
+ root_content = _read_lean_toolchain(bare_repo_path, ref)
225
+
226
+ if root_content is not None:
227
+ # Root lean-toolchain wins — treat the repo as a single-root
228
+ # project even if additional lean-toolchain files exist deeper
229
+ # in the tree. Subdir copies in real repos are almost always
230
+ # vendored/test fixtures (e.g. lean4 itself ships them under
231
+ # tests/), so demoting to multi-project would be wrong.
232
+ self._toolchain = root_content
233
+ self._subdir = ""
234
+ self._configure_for_single_project(bare_repo_path, ref, "")
235
+ return True
236
+
237
+ # Only count subdirs that look like real Lean projects (sibling
238
+ # lakefile). This filters out e.g. a Python repo with a vendored
239
+ # `lean-toolchain` that has no lakefile next to it.
240
+ candidates = [
241
+ s
242
+ for s in _find_lean_toolchain_subdirs(bare_repo_path, ref)
243
+ if _has_lakefile(bare_repo_path, ref, s)
244
+ ]
245
+ if not candidates:
246
+ return False
247
+
248
+ if len(candidates) == 1:
249
+ subdir = candidates[0]
250
+ content = _read_lean_toolchain(bare_repo_path, ref, subdir)
251
+ if content is None:
252
+ return False
253
+ self._toolchain = content
254
+ self._subdir = subdir
255
+ self._configure_for_single_project(bare_repo_path, ref, subdir)
256
+ return True
257
+
258
+ # Multiple buildable Lean projects across subdirectories.
259
+ self._multi_project = True
260
+ self._subdir = "" # we don't pick one for the auto-build
261
+ contents: list[str] = []
262
+ for s in candidates:
263
+ c = _read_lean_toolchain(bare_repo_path, ref, s)
264
+ if c is not None:
265
+ contents.append(c)
266
+ unique = sorted(set(contents))
267
+ dirs_label = ", ".join(sorted(candidates))
268
+ if len(unique) == 1:
269
+ self._toolchain = contents[0]
270
+ self._notices.append(
271
+ f"Multiple Lean projects detected ({dirs_label}); skipping auto-build."
272
+ " Run `lake build` in your project's subdirectory."
273
+ )
274
+ else:
275
+ self._toolchain = None # forces image_name() to plain `lean`
276
+ versions_label = ", ".join(unique)
277
+ self._notices.append(
278
+ f"Multiple Lean toolchains detected across {dirs_label}: {versions_label}."
279
+ " Using the base `lean` image; elan will install each toolchain"
280
+ " on demand the first time you run `lake build` in a subdirectory."
281
+ )
282
+ self._is_lean4 = False
283
+ self._git_deps = []
284
+ self._needs_cache = False
285
+ return True
286
+
287
+ def _configure_for_single_project(self, bare_repo_path: Path, ref: str, subdir: str) -> None:
288
+ """Populate is_lean4 / git_deps / needs_cache for a single-project repo."""
289
+ self._is_lean4 = bare_repo_path.name == "lean4.git"
290
+ if self._is_lean4:
291
+ self._git_deps = []
292
+ self._needs_cache = False
293
+ else:
294
+ self._git_deps = _parse_git_dependencies(bare_repo_path, ref, subdir)
295
+ self._needs_cache = bare_repo_path.name == "mathlib4.git" or any(
296
+ d.name == "mathlib" for d in self._git_deps
297
+ )
298
+
299
+ def notices(self) -> list[str]:
300
+ return list(self._notices)
301
+
302
+ def image_name(self) -> str:
303
+ """Return the image name based on the lean-toolchain version.
304
+
305
+ For stable/RC versions (v4.X.Y, v4.X.Y-rcK): returns 'lean-v4.X.Y' or 'lean-v4.X.Y-rcK'.
306
+ For nightlies or unrecognized: returns 'lean' (base image with elan only).
307
+ """
308
+ if self._toolchain:
309
+ version = _parse_lean_version(self._toolchain)
310
+ if version:
311
+ return f"lean-{version}"
312
+ return "lean"
313
+
314
+ def shared_mounts(self) -> list[tuple[str, str, str]]:
315
+ if self._needs_cache:
316
+ return [("mathlib-cache", "/shared/mathlib-cache", "MATHLIB_CACHE_DIR")]
317
+ return []
318
+
319
+ def git_dependencies(self) -> list[GitDependency]:
320
+ return self._git_deps
321
+
322
+ def workspace_file(self, project_dir: str) -> str | None:
323
+ if self._is_lean4:
324
+ return f"{project_dir}/lean.code-workspace"
325
+ return None
326
+
327
+ def post_clone(self, runtime: ContainerRuntime, container: str, project_dir: str):
328
+ """Pre-populate Lake dependencies, then set up auto build command.
329
+
330
+ ``project_dir`` is always the repo root. The hook's stored ``_subdir``
331
+ controls where ``lake build`` actually runs. For multi-project repos
332
+ the auto-build is skipped entirely.
333
+ """
334
+ if self._multi_project:
335
+ return
336
+
337
+ if self._is_lean4:
338
+ self._setup_lean4_build(runtime, container, project_dir)
339
+ return
340
+
341
+ build_dir = f"{project_dir}/{self._subdir}" if self._subdir else project_dir
342
+
343
+ if self._git_deps:
344
+ self._populate_lake_packages(runtime, container, build_dir)
345
+
346
+ q_dir = shlex.quote(build_dir)
347
+ if self._needs_cache:
348
+ cmd = f"cd {q_dir} && lake exe cache get && lake build"
349
+ msg = "Mathlib cache download and build will start automatically."
350
+ else:
351
+ cmd = f"cd {q_dir} && lake build"
352
+ msg = "Build will start automatically."
353
+ # Write command for the VS Code extension or shell login hook to pick up
354
+ runtime.exec(
355
+ container,
356
+ [
357
+ "su",
358
+ "-",
359
+ "user",
360
+ "-c",
361
+ f"printf '%s' {shlex.quote(cmd)} > ~/.bubble-fetch-cache",
362
+ ],
363
+ )
364
+ click.echo(msg)
365
+
366
+ def _setup_lean4_build(self, runtime: ContainerRuntime, container: str, project_dir: str):
367
+ """Set up auto-build for the lean4 repo itself (cmake is in base image)."""
368
+ q_dir = shlex.quote(project_dir)
369
+ cmd = f"cd {q_dir} && cmake --preset release && make -C build/release -j$(nproc)"
370
+ runtime.exec(
371
+ container,
372
+ [
373
+ "su",
374
+ "-",
375
+ "user",
376
+ "-c",
377
+ f"printf '%s' {shlex.quote(cmd)} > ~/.bubble-fetch-cache",
378
+ ],
379
+ )
380
+ click.echo("Lean 4 build will start automatically.")
381
+
382
+ def _populate_lake_packages(self, runtime: ContainerRuntime, container: str, project_dir: str):
383
+ """Clone each dependency into .lake/packages/<name>/ using alternates."""
384
+ q_dir = shlex.quote(project_dir)
385
+
386
+ # Create .lake/packages/ directory
387
+ runtime.exec(
388
+ container,
389
+ ["su", "-", "user", "-c", f"mkdir -p {q_dir}/.lake/packages"],
390
+ )
391
+
392
+ populated = 0
393
+ for dep in self._git_deps:
394
+ repo_name = dep.org_repo.split("/")[-1]
395
+ # All values are validated (_SAFE_NAME_RE, hex SHA, parse_github_url)
396
+ # but quote everything for defense in depth
397
+ q_bare = shlex.quote(f"/shared/git/{repo_name}.git")
398
+ q_url = shlex.quote(dep.url)
399
+ q_rev = shlex.quote(dep.rev)
400
+ q_pkg = shlex.quote(f"{project_dir}/.lake/packages/{dep.name}")
401
+
402
+ try:
403
+ # Clone from mounted bare repo with --reference for alternates.
404
+ # Since source and reference are the same, zero objects are copied.
405
+ # Use -c safe.directory to allow reading the root-owned mounted bare repo
406
+ # (scoped to this command only, not persisted in global gitconfig).
407
+ runtime.exec(
408
+ container,
409
+ [
410
+ "su",
411
+ "-",
412
+ "user",
413
+ "-c",
414
+ f"git -c safe.directory={q_bare} clone"
415
+ f" --reference {q_bare} file://{q_bare} {q_pkg}",
416
+ ],
417
+ )
418
+
419
+ # Fix remote URL to match what the manifest expects
420
+ runtime.exec(
421
+ container,
422
+ [
423
+ "su",
424
+ "-",
425
+ "user",
426
+ "-c",
427
+ f"git -C {q_pkg} remote set-url origin {q_url}",
428
+ ],
429
+ )
430
+
431
+ # Checkout the exact revision from the manifest
432
+ # rev is validated as a 40-char hex SHA, so no option injection risk
433
+ runtime.exec(
434
+ container,
435
+ [
436
+ "su",
437
+ "-",
438
+ "user",
439
+ "-c",
440
+ f"git -C {q_pkg} checkout {q_rev}",
441
+ ],
442
+ )
443
+
444
+ populated += 1
445
+ except RuntimeError as e:
446
+ # Non-fatal: Lake will clone this dep normally when needed
447
+ click.echo(f" Warning: could not pre-populate {dep.name}: {e}")
448
+
449
+ if populated:
450
+ click.echo(f" Pre-populated {populated}/{len(self._git_deps)} Lake dependencies.")
451
+
452
+ def network_domains(self) -> list[str]:
453
+ return [
454
+ "releases.lean-lang.org",
455
+ "reservoir.lean-lang.org",
456
+ "reservoir.lean-cache.cloud",
457
+ "mathlib4.lean-cache.cloud",
458
+ "lakecache.blob.core.windows.net",
459
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.17
3
+ Version: 0.7.19
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0