xberg-cli 1.0.0__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.
@@ -0,0 +1,334 @@
1
+ # Build artifacts
2
+ target/
3
+ build/
4
+ dist/
5
+ .polylint/
6
+ # Ad-hoc PDF/image debug binaries
7
+ /check_pdf_oxide
8
+ /check_*
9
+ # Hand-authored WASM JS distribution files (intentionally committed)
10
+ !crates/xberg-wasm/dist/index.js
11
+ !crates/xberg-wasm/dist/extraction/files.js
12
+ !crates/xberg-wasm/dist/ocr/enabler.js
13
+ .lycheecache
14
+ dist-musl/
15
+ *.so
16
+ *.dylib
17
+ *.dll
18
+ *.pdb
19
+ *.node
20
+ *.whl
21
+ *.tar.gz
22
+ *.gem
23
+ *.jar
24
+ wheels/
25
+ **/bin/
26
+ !**/src/bin/
27
+ **/obj/
28
+
29
+ # Rust
30
+ *.rs.bk
31
+ *.profraw
32
+ rust-coverage.lcov
33
+ rust_out
34
+
35
+ # Python
36
+ *.py[cod]
37
+ *$py.class
38
+ *.egg-info/
39
+ __pycache__/
40
+ .venv/
41
+ .mypy_cache/
42
+ .ruff_cache/
43
+ .pytest_cache/
44
+ .hypothesis/
45
+ .coverage
46
+ .coverage*
47
+ .e2e-logs/
48
+ htmlcov/
49
+ share/python-wheels/
50
+ prompt_template.egg-info/
51
+ packages/python/xberg/xberg-cli
52
+
53
+ # Node.js / WASM
54
+ node_modules/
55
+ *.tsbuildinfo
56
+ npm-debug.log*
57
+ pnpm-debug.log*
58
+ yarn-debug.log*
59
+ yarn-error.log*
60
+ # NAPI-RS generated files (index.js and index.d.ts are committed for TypeScript type resolution)
61
+ crates/xberg-node/*.tgz
62
+ # ORT dylibs are copied to crates/xberg-node/ at build time and included in npm publish
63
+ # despite the global *.dylib/*.so/*.dll rules below
64
+ !crates/xberg-node/*.dylib
65
+ !crates/xberg-node/*.so
66
+ !crates/xberg-node/*.dll
67
+ .wrangler/
68
+ .pnpm-store/
69
+
70
+ # Ruby
71
+ packages/ruby/.bundle/
72
+ packages/ruby/vendor/
73
+ !packages/ruby/vendor/rb-sys/
74
+ !vendor/rb-sys/build/
75
+ !vendor/rb-sys/build/**
76
+ *.Cache
77
+ .yardoc/
78
+
79
+ # PHP
80
+ vendor/
81
+ !crates/xberg-candle-ocr/src/vendor/
82
+ !crates/xberg-libwpd/vendor/
83
+ # The vendored WordPerfect source archives are committed on purpose; re-include
84
+ # them past the global `*.tar.gz` rule above (a dir negation alone won't).
85
+ !crates/xberg-libwpd/vendor/*.tar.gz
86
+ php-xberg.ini
87
+ packages/php/php-xberg.ini
88
+ packages/php/tests.xml
89
+ packages/php/var/
90
+ .php-cs-fixer.cache
91
+
92
+ # Swift / SwiftPM / Xcode
93
+ .build/
94
+ .swiftpm/
95
+ # Legacy SwiftPM resolved-packages dir — anchored to packages/swift/ so it
96
+ # does NOT match our top-level packages/ directory on macOS case-insensitive
97
+ # filesystems (where the bare `Packages/` pattern previously matched and
98
+ # silently dropped packages/dart/pubspec.yaml + 9 other scaffold files from
99
+ # the index during regen).
100
+ packages/swift/**/Packages/
101
+ Package.resolved
102
+ *.xcodeproj/
103
+ *.xcworkspace/
104
+ DerivedData/
105
+
106
+ # Java / C# / .NET
107
+ *.suo
108
+ *.user
109
+ *.cscfg
110
+ *.cobertura.xml
111
+ TestResults/
112
+
113
+ # Generated test apps (e2e-generator --mode published)
114
+ test_apps/
115
+
116
+ # Elixir
117
+ .nox/
118
+
119
+ # Go
120
+ packages/go/v*/install
121
+
122
+ # C FFI
123
+ crates/xberg-ffi/tests/c/test_*
124
+ !crates/xberg-ffi/tests/c/test_*.c
125
+ crates/xberg-ffi/tests/c/*.o
126
+ crates/xberg-ffi/tests/c/*.dSYM/
127
+ e2e/c/test_*
128
+ !e2e/c/test_*.c
129
+ e2e/c/*.o
130
+ e2e/c/*.dSYM/
131
+
132
+ # Generated pkg-config
133
+ *.pc
134
+
135
+ # Coverage
136
+ coverage/
137
+ coverage.info
138
+ coverage.json
139
+ coverage.lcov
140
+ coverage.out
141
+ coverage.xml
142
+ packages/*/.coverage
143
+ packages/*/coverage.json
144
+ packages/*/coverage.out
145
+ packages/*/htmlcov/
146
+ packages/*/test-results/
147
+ packages/*/TestResults/
148
+
149
+ # IDE / Editor
150
+ .idea/
151
+ *.iml
152
+ .vscode/
153
+ .run/
154
+ .DS_store
155
+
156
+ # CI / Tools
157
+ .cache/
158
+ .tmp/
159
+ *temp/
160
+ *.temp
161
+ *.tmp
162
+ *.log
163
+ **/logs/
164
+ **/test_output.log
165
+ .mvn/
166
+ .tox/
167
+ .artifacts/
168
+ .remote-cache/
169
+ .rumdl_cache/
170
+ docker-logs/
171
+
172
+ # Benchmarks
173
+ benchmark-results/
174
+ benchmark_results.json
175
+ benchmarks/results/
176
+ !benchmarks/baselines/
177
+ aggregated-results/
178
+ results/
179
+ reports/
180
+ tests/benchmarks/results/
181
+ profiling-results/
182
+ profiling-results-*/
183
+
184
+ # Local perf-iteration artifacts (flamegraphs/ is committed; intermediate outputs are not)
185
+ profile.json.gz
186
+ profile.pb.gz
187
+ *.heap
188
+ bench/
189
+
190
+ # Docs
191
+ docs/_build/
192
+ docs/build/
193
+ docs/benchmarks/charts/
194
+ site/
195
+
196
+ # Elixir build artifacts (lock files MUST stay tracked — see CLAUDE.md
197
+ # task-runner / dependency-awareness rules and the brotli/alloc-no-stdlib
198
+ # regression that bit CI run 27492590406 because the NIF lock wasn't
199
+ # committed and the resolver picked a fresh broken combo).
200
+ packages/elixir/_build/
201
+ packages/elixir/deps/
202
+ e2e/elixir/_build/
203
+ e2e/elixir/deps/
204
+ e2e/elixir/config/
205
+ test_documents/_build/
206
+ test_documents/deps/
207
+
208
+ # Maven plugin artifacts
209
+ *.versionsBackup
210
+
211
+ # Gradle (Kotlin e2e)
212
+ e2e/kotlin/.gradle/
213
+ e2e/kotlin/build/
214
+ e2e/kotlin_android/.gradle/
215
+ e2e/kotlin_android/build/
216
+
217
+ # Ruby native build artifacts
218
+ packages/ruby/lib/*.bundle
219
+ packages/ruby/tmp/
220
+
221
+ # Tests
222
+ test_report.json
223
+ tests/e2e/logs/
224
+ tests/e2e/test_report.json
225
+ temp-test-*
226
+ packages/csharp/test_html_debug.cs
227
+ # CI renders this from runtime.json.template at publish time; template is the source of truth.
228
+ packages/csharp/Xberg/runtime.json
229
+
230
+ # AI / MCP / Agent config
231
+ .claude/
232
+ .codex/
233
+ .gemini/
234
+ .xberg/
235
+ .mcp.json
236
+ # Alef-generated agent skills (mirrors of .ai-rulez/skills; .claude/.codex covered above)
237
+ .agents/skills/
238
+ .github/skills/
239
+ .playwright-mcp/
240
+ .fastembed_cache/
241
+ */.fastembed_cache/
242
+ AGENTS.md
243
+ CLAUDE.md
244
+ GEMINI.md
245
+ .cursorrules
246
+ .windsurfrules
247
+ [Tt][Oo][Dd][Oo]*
248
+
249
+ # Git worktrees
250
+ .worktrees/
251
+
252
+ # Misc
253
+ .env
254
+ output.txt
255
+ examples.txt
256
+ requirements.txt
257
+ docker-compose.yaml
258
+ /tmp/xberg-docker-test-results.json
259
+ tools/benchmark-harness/datasets/
260
+ tools/benchmark-harness/.fixtures-pdf-subset/
261
+ tools/benchmark-harness/vendored/docling/md/iso_21111_10.md
262
+ visualizations/
263
+
264
+
265
+ # Additional generated artifacts
266
+ .alef/
267
+ *.pyd
268
+ .gems/
269
+ vendor/bundle/
270
+ *.h.bak
271
+ *.test
272
+ *.class
273
+ bin/
274
+ obj/
275
+ *.nupkg
276
+ pkg/
277
+
278
+ # Dart's alef-generated install-time downloader must ship in the pub.dev package,
279
+ # so it is exempt from the blanket bin/ ignore above.
280
+ !packages/dart/bin/
281
+ !packages/dart/bin/download_libs.dart
282
+
283
+
284
+ # Local dev artifacts
285
+ docs-site/public/demo-dev.html
286
+ docs-site/dist/
287
+ docs-site/.astro/
288
+ demo-loaded.png
289
+ scratch/
290
+
291
+
292
+ .php-cs-fixer.*
293
+
294
+ e2e/zig/.zig-cache/
295
+
296
+ # Swift e2e build artifacts (Swift Package Manager)
297
+ e2e/swift/.build/
298
+ e2e/swift/.swiftpm/
299
+ e2e/swift/Package.resolved
300
+ e2e/swift_e2e/.build/
301
+ e2e/swift_e2e/.swiftpm/
302
+ e2e/swift_e2e/Package.resolved
303
+
304
+ # Dart e2e build artifacts (lock file tracked for reproducibility)
305
+ e2e/dart/.dart_tool/
306
+ e2e/dart/build/
307
+
308
+ # Dart FRB codegen cache (incremental build optimization)
309
+ packages/dart/rust/.frb_codegen_hash
310
+
311
+ # Go e2e build artifacts
312
+ e2e/go/vendor/
313
+
314
+ erl_crash.dump
315
+
316
+
317
+ .gradle/
318
+
319
+ packages/go/.lib/
320
+
321
+ .build/
322
+
323
+ # basemind code-map index (local tooling)
324
+ .basemind/
325
+
326
+ .bench-rc32/
327
+ .private-bench/
328
+ build-output-musl-node/
329
+
330
+ poly.local.toml
331
+
332
+ # BEGIN ai-rulez (DO NOT EDIT - managed by ai-rulez)
333
+ .ai-rulez/.generated-manifest.json
334
+ # END ai-rulez
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: xberg-cli
3
+ Version: 1.0.0
4
+ Summary: CLI proxy for xberg — downloads and runs the native xberg binary from GitHub releases.
5
+ Project-URL: Homepage, https://github.com/xberg-io/xberg
6
+ Project-URL: Issues, https://github.com/xberg-io/xberg/issues
7
+ Project-URL: Repository, https://github.com/xberg-io/xberg.git
8
+ Author-email: Na'aman Hirschfeld <naaman@xberg.io>
9
+ License: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # xberg-cli
24
+
25
+ CLI proxy for [`xberg`](https://github.com/xberg-io/xberg). Installing
26
+ this package provides a `xberg` command that downloads the matching native
27
+ binary from GitHub releases and runs it.
28
+
29
+ Pin a specific release tag with the `XBERG_CLI_VERSION` environment variable.
@@ -0,0 +1,7 @@
1
+ # xberg-cli
2
+
3
+ CLI proxy for [`xberg`](https://github.com/xberg-io/xberg). Installing
4
+ this package provides a `xberg` command that downloads the matching native
5
+ binary from GitHub releases and runs it.
6
+
7
+ Pin a specific release tag with the `XBERG_CLI_VERSION` environment variable.
@@ -0,0 +1 @@
1
+ """Build package marker for the CLI proxy project."""
@@ -0,0 +1,140 @@
1
+ """Custom hatchling build hook that bundles the native xberg binary into a wheel.
2
+
3
+ When building a platform-specific wheel in CI, the target triple is supplied via
4
+ the ``XBERG_CLI_TARGET`` env var (the build host is always linux/amd64, so the
5
+ triple cannot be inferred from ``platform.*``). The matching
6
+ ``xberg-cli-<target>.tar.gz`` / ``.zip`` is located (repo root or ``dist/``),
7
+ the binary is extracted into ``xberg_cli/bin/<target>/``, force-included in the
8
+ wheel, and the wheel is tagged for that platform so PyPI serves the right artifact.
9
+
10
+ If no target/binary is found (e.g. the sdist build, or an unknown platform), the
11
+ hook is a no-op and the package falls back to the runtime downloader in
12
+ ``xberg_cli/downloader.py`` (see ``cli.py``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import tarfile
20
+ import zipfile
21
+ from pathlib import Path
22
+
23
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
24
+
25
+ _TAG_MAP = {
26
+ "x86_64-pc-windows-msvc": "win_amd64",
27
+ "x86_64-unknown-linux-gnu": "manylinux_2_28_x86_64",
28
+ "aarch64-unknown-linux-gnu": "manylinux_2_28_aarch64",
29
+ "x86_64-unknown-linux-musl": "musllinux_1_2_x86_64",
30
+ "aarch64-unknown-linux-musl": "musllinux_1_2_aarch64",
31
+ "aarch64-apple-darwin": "macosx_11_0_arm64",
32
+ "x86_64-apple-darwin": "macosx_11_0_x86_64",
33
+ }
34
+
35
+
36
+ def _safe_destination(root: Path, member_name: str) -> Path:
37
+ """Resolve an archive member path and require it to stay under root."""
38
+ root = root.resolve()
39
+ target = (root / member_name.replace("\\", "/")).resolve(strict=False)
40
+ if target != root and not target.is_relative_to(root):
41
+ raise RuntimeError(f"archive member escapes extraction directory: {member_name}")
42
+ return target
43
+
44
+
45
+ def _extract_zip_bounded(archive: Path, extract_dir: Path) -> None:
46
+ """Extract zip entries after bounding each destination path."""
47
+ with zipfile.ZipFile(archive) as zf:
48
+ for member in zf.infolist():
49
+ target = _safe_destination(extract_dir, member.filename)
50
+ if member.is_dir():
51
+ target.mkdir(parents=True, exist_ok=True)
52
+ continue
53
+
54
+ target.parent.mkdir(parents=True, exist_ok=True)
55
+ with zf.open(member) as source, target.open("wb") as destination:
56
+ shutil.copyfileobj(source, destination)
57
+
58
+
59
+ def _extract_tar_bounded(archive: Path, extract_dir: Path) -> None:
60
+ """Extract regular tar entries after bounding each destination path."""
61
+ with tarfile.open(archive, "r:gz") as tf:
62
+ for member in tf.getmembers():
63
+ target = _safe_destination(extract_dir, member.name)
64
+ if member.isdir():
65
+ target.mkdir(parents=True, exist_ok=True)
66
+ continue
67
+ if not member.isfile():
68
+ raise RuntimeError(f"unsupported archive member type: {member.name}")
69
+
70
+ target.parent.mkdir(parents=True, exist_ok=True)
71
+ source = tf.extractfile(member)
72
+ if source is None:
73
+ raise RuntimeError(f"could not read archive member: {member.name}")
74
+ with source, target.open("wb") as destination:
75
+ shutil.copyfileobj(source, destination)
76
+
77
+
78
+ class CustomBuildHook(BuildHookInterface):
79
+ """Inject the matching native binary into a platform-tagged wheel."""
80
+
81
+ PLUGIN_NAME = "custom"
82
+
83
+ def initialize(self, version: str, build_data: dict) -> None: # noqa: ARG002
84
+ """Bundle a staged native binary when building a targeted wheel."""
85
+ target = os.environ.get("XBERG_CLI_TARGET", "").strip()
86
+ if not target:
87
+ return
88
+
89
+ archive = self._find_archive(target)
90
+ if archive is None:
91
+ raise RuntimeError(
92
+ f"XBERG_CLI_TARGET={target} but no xberg-cli-{target}.(tar.gz|zip) "
93
+ f"found in repo root or dist/; refusing to build an empty platform wheel."
94
+ )
95
+
96
+ wheel_tag = _TAG_MAP.get(target)
97
+ if wheel_tag is None:
98
+ raise RuntimeError(f"no wheel platform tag mapped for target {target}")
99
+
100
+ binary = self._extract_binary(archive, target)
101
+
102
+ relative = f"xberg_cli/bin/{target}/{binary.name}"
103
+ force_include = build_data.setdefault("force_include", {})
104
+ force_include[str(binary)] = relative
105
+
106
+ for lib in sorted(binary.parent.glob("*.dylib")):
107
+ force_include[str(lib)] = f"xberg_cli/bin/{target}/{lib.name}"
108
+
109
+ build_data["pure_python"] = False
110
+ build_data["infer_tag"] = False
111
+ build_data["tag"] = f"py3-none-{wheel_tag}"
112
+
113
+ def _find_archive(self, target: str) -> Path | None:
114
+ root = Path(self.root)
115
+ repo_root = root.parent.parent
116
+ for base in (repo_root, repo_root / "dist", root, root / "dist"):
117
+ for ext in ("tar.gz", "zip"):
118
+ candidate = base / f"xberg-cli-{target}.{ext}"
119
+ if candidate.is_file():
120
+ return candidate
121
+ return None
122
+
123
+ def _extract_binary(self, archive: Path, target: str) -> Path:
124
+ is_windows = target.endswith("windows-msvc")
125
+ binary_name = "xberg.exe" if is_windows else "xberg"
126
+ extract_dir = Path(self.root) / ".build-extract" / target
127
+ if extract_dir.exists():
128
+ shutil.rmtree(extract_dir, ignore_errors=True)
129
+ extract_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ if str(archive).lower().endswith(".zip"):
132
+ _extract_zip_bounded(archive, extract_dir)
133
+ else:
134
+ _extract_tar_bounded(archive, extract_dir)
135
+
136
+ for candidate in extract_dir.rglob(binary_name):
137
+ if candidate.is_file():
138
+ candidate.chmod(0o755)
139
+ return candidate
140
+ raise RuntimeError(f"binary {binary_name} not found inside {archive.name}")
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = ["hatchling"]
4
+
5
+ [project]
6
+ name = "xberg-cli"
7
+ version = "1.0.0"
8
+ description = "CLI proxy for xberg — downloads and runs the native xberg binary from GitHub releases."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Na'aman Hirschfeld", email = "naaman@xberg.io" }]
12
+ requires-python = ">=3.10"
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ ]
25
+ urls.Homepage = "https://github.com/xberg-io/xberg"
26
+ urls.Issues = "https://github.com/xberg-io/xberg/issues"
27
+ urls.Repository = "https://github.com/xberg-io/xberg.git"
28
+ scripts.xberg = "xberg_cli.cli:main"
29
+
30
+ [tool.hatch]
31
+ build.hooks.custom.path = "hatch_build.py"
32
+ build.targets.wheel.packages = ["xberg_cli"]
@@ -0,0 +1,3 @@
1
+ """CLI proxy package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,45 @@
1
+ """CLI entry point for the xberg proxy.
2
+
3
+ A platform-specific wheel bundles the native binary under
4
+ ``xberg_cli/bin/<target>/``; the sdist (and unknown platforms) ship no binary
5
+ and fall back to the runtime downloader. Each wheel contains exactly one ``bin/<target>``
6
+ directory, so we locate the binary by globbing rather than recomputing the target
7
+ triple (which cannot distinguish glibc from musl at runtime).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import platform
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .downloader import run
19
+
20
+
21
+ def _find_bundled_binary() -> str | None:
22
+ """Return the path to the bundled native binary if this wheel shipped one."""
23
+ bin_root = Path(__file__).parent / "bin"
24
+ if not bin_root.is_dir():
25
+ return None
26
+
27
+ binary_name = "xberg.exe" if platform.system().lower() == "windows" else "xberg"
28
+ for candidate in bin_root.glob(f"*/{binary_name}"):
29
+ if candidate.is_file() and os.access(candidate, os.X_OK):
30
+ return str(candidate)
31
+ return None
32
+
33
+
34
+ def main() -> None:
35
+ """Resolve the native binary (bundled or downloaded) and exec it with forwarded argv."""
36
+ bundled = _find_bundled_binary()
37
+ if bundled:
38
+ completed = subprocess.run([bundled, *sys.argv[1:]], check=False)
39
+ sys.exit(completed.returncode)
40
+
41
+ sys.exit(run(sys.argv[1:]))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1,264 @@
1
+ """Resolve, download, verify, and run the native xberg binary.
2
+
3
+ Self-healing asset discovery: query the GitHub releases API and pick the asset
4
+ whose name contains the platform target triple, instead of hardcoding one exact
5
+ asset name. This keeps working as release asset naming standardizes over time.
6
+ All diagnostics go to stderr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tarfile
19
+ import tempfile
20
+ import zipfile
21
+ from pathlib import Path, PurePosixPath
22
+ from urllib.error import URLError
23
+ from urllib.parse import quote, urlsplit
24
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
25
+
26
+ REPO = "xberg-io/xberg"
27
+ BIN_NAME = "xberg"
28
+ PKG_NAME = "xberg-cli"
29
+ VERSION_ENV = "XBERG_CLI_VERSION"
30
+ _USER_AGENT = "xberg-cli-python-proxy"
31
+
32
+
33
+ def _target_triple() -> str:
34
+ system = platform.system().lower()
35
+ machine = platform.machine().lower()
36
+
37
+ if system == "windows":
38
+ if machine in {"amd64", "x86_64"}:
39
+ return "x86_64-pc-windows-msvc"
40
+ raise RuntimeError(f"unsupported Windows arch: {machine}")
41
+ if system == "linux":
42
+ if machine in {"amd64", "x86_64"}:
43
+ return "x86_64-unknown-linux-gnu"
44
+ if machine in {"aarch64", "arm64"}:
45
+ return "aarch64-unknown-linux-gnu"
46
+ raise RuntimeError(f"unsupported Linux arch: {machine}")
47
+ if system == "darwin":
48
+ if machine in {"aarch64", "arm64"}:
49
+ return "aarch64-apple-darwin"
50
+ if machine in {"amd64", "x86_64"}:
51
+ return "x86_64-apple-darwin"
52
+ raise RuntimeError(f"unsupported macOS arch: {machine}")
53
+ raise RuntimeError(f"unsupported platform: {system} {machine}")
54
+
55
+
56
+ def _binary_name() -> str:
57
+ return f"{BIN_NAME}.exe" if platform.system().lower() == "windows" else BIN_NAME
58
+
59
+
60
+ class _HttpsOnlyRedirectHandler(HTTPRedirectHandler):
61
+ """Reject any redirect whose target is not https (downgrade/SSRF guard)."""
62
+
63
+ def redirect_request( # noqa: PLR0917 -- overrides HTTPRedirectHandler's fixed positional signature
64
+ self, req, fp, code, msg, headers, newurl
65
+ ): # type: ignore[no-untyped-def]
66
+ if urlsplit(newurl).scheme.lower() != "https":
67
+ raise URLError(f"refusing non-https redirect to: {newurl}")
68
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
69
+
70
+
71
+ _opener = build_opener(_HttpsOnlyRedirectHandler())
72
+
73
+
74
+ def _http_get(url: str, accept: str | None = None) -> bytes:
75
+ if urlsplit(url).scheme.lower() != "https":
76
+ raise RuntimeError(f"refusing non-https URL: {url}")
77
+ headers = {"User-Agent": _USER_AGENT}
78
+ if accept:
79
+ headers["Accept"] = accept
80
+ request = Request(url, headers=headers)
81
+ try:
82
+ with _opener.open(request, timeout=60) as response:
83
+ if response.status != 200:
84
+ raise RuntimeError(f"HTTP {response.status} for {url}")
85
+ return response.read()
86
+ except URLError as exc:
87
+ raise RuntimeError(f"failed to fetch {url}: {exc}") from exc
88
+
89
+
90
+ def _asset_score(name: str) -> int:
91
+ lowered = name.lower()
92
+ score = 0
93
+ if BIN_NAME.lower() in lowered:
94
+ score += 2
95
+ if "cli" in lowered:
96
+ score += 1
97
+ return score
98
+
99
+
100
+ def _resolve_release() -> tuple[str, dict, dict | None]:
101
+ """Return (tag, archive_asset, checksums_asset_or_none) for this platform."""
102
+ triple = _target_triple()
103
+ pinned = os.getenv(VERSION_ENV)
104
+ if pinned:
105
+ api_url = f"https://api.github.com/repos/{REPO}/releases/tags/{quote(pinned, safe='')}"
106
+ else:
107
+ api_url = f"https://api.github.com/repos/{REPO}/releases/latest"
108
+
109
+ release = json.loads(_http_get(api_url, accept="application/vnd.github+json"))
110
+ assets = release.get("assets") or []
111
+ tag = release.get("tag_name") or pinned or "latest"
112
+
113
+ archives = [
114
+ a
115
+ for a in assets
116
+ if triple in (a.get("name") or "").lower()
117
+ and ((a.get("name") or "").lower().endswith(".tar.gz") or (a.get("name") or "").lower().endswith(".zip"))
118
+ ]
119
+ if not archives:
120
+ raise RuntimeError(f'no release asset matching target triple "{triple}" in {REPO} release {tag}')
121
+ archives.sort(key=lambda a: _asset_score(a.get("name") or ""), reverse=True)
122
+ archive = archives[0]
123
+
124
+ checksums = next((a for a in assets if "SHA256SUMS" in (a.get("name") or "").upper()), None)
125
+ return tag, archive, checksums
126
+
127
+
128
+ def _expected_digest(text: str, asset_name: str) -> str | None:
129
+ for line in text.splitlines():
130
+ stripped = line.strip()
131
+ if not stripped:
132
+ continue
133
+ parts = stripped.split()
134
+ if len(parts) < 2:
135
+ continue
136
+ name = parts[-1].lstrip("*")
137
+ if name == asset_name:
138
+ return parts[0].lower()
139
+ return None
140
+
141
+
142
+ def _verify_or_warn(archive_path: Path, asset_name: str, checksums: dict | None) -> None:
143
+ if not checksums:
144
+ print(
145
+ f"WARNING: no SHA256SUMS asset found for {asset_name}; "
146
+ "installing over HTTPS without checksum verification.",
147
+ file=sys.stderr,
148
+ )
149
+ return
150
+ sums_text = _http_get(checksums["browser_download_url"]).decode("utf-8")
151
+ expected = _expected_digest(sums_text, asset_name)
152
+ if not expected:
153
+ raise RuntimeError(
154
+ f"no checksum entry for {asset_name} in {checksums['name']} — refusing to install unverified binary"
155
+ )
156
+ digest = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower()
157
+ if digest != expected:
158
+ raise RuntimeError(f"checksum mismatch for {asset_name} (expected {expected}, got {digest})")
159
+ print(f"Checksum verified for {asset_name}.", file=sys.stderr)
160
+
161
+
162
+ def _is_within(base: Path, target: Path) -> bool:
163
+ """True if `target`'s resolved path stays inside `base`."""
164
+ base_resolved = base.resolve()
165
+ try:
166
+ target.resolve().relative_to(base_resolved)
167
+ except ValueError:
168
+ return False
169
+ return True
170
+
171
+
172
+ def _reject_unsafe_member(name: str) -> None:
173
+ """Reject absolute paths and any `..` component (zip-slip / tar-slip)."""
174
+ normalized = name.replace("\\", "/")
175
+ pure = PurePosixPath(normalized)
176
+ if pure.is_absolute() or normalized.startswith("/"):
177
+ raise RuntimeError(f"refusing absolute path in archive: {name}")
178
+ if len(normalized) >= 2 and normalized[1] == ":":
179
+ raise RuntimeError(f"refusing absolute path in archive: {name}")
180
+ if any(part == ".." for part in pure.parts):
181
+ raise RuntimeError(f"refusing parent-directory escape in archive: {name}")
182
+
183
+
184
+ def _safe_extract(archive_path: Path, asset_name: str, dest: Path) -> None:
185
+ """Extract a tar/zip, validating every member stays within `dest`.
186
+
187
+ Does not rely on extractall() defaults or the 3.12-only ``filter='data'``:
188
+ each member name is rejected up front if it is absolute or contains ``..``,
189
+ and the resolved destination path is re-checked against ``dest``.
190
+ """
191
+ dest = dest.resolve()
192
+ dest.mkdir(parents=True, exist_ok=True)
193
+
194
+ if asset_name.lower().endswith(".zip"):
195
+ with zipfile.ZipFile(archive_path) as zf:
196
+ for member in zf.namelist():
197
+ _reject_unsafe_member(member)
198
+ if not _is_within(dest, dest / member):
199
+ raise RuntimeError(f"refusing archive entry escaping dest: {member}")
200
+ for member in zf.namelist():
201
+ zf.extract(member, dest)
202
+ else:
203
+ with tarfile.open(archive_path, "r:gz") as tf:
204
+ for member in tf.getmembers():
205
+ _reject_unsafe_member(member.name)
206
+ if not _is_within(dest, dest / member.name):
207
+ raise RuntimeError(f"refusing archive entry escaping dest: {member.name}")
208
+ if member.islnk() or member.issym():
209
+ link_target = dest / member.name
210
+ if not _is_within(dest, link_target.parent / member.linkname):
211
+ raise RuntimeError(f"refusing link escaping dest: {member.name}")
212
+ tf.extractall(dest) # noqa: S202 (members validated above)
213
+
214
+
215
+ def _find_binary(root: Path, name: str) -> Path | None:
216
+ for candidate in root.rglob(name):
217
+ if candidate.is_file():
218
+ return candidate
219
+ return None
220
+
221
+
222
+ def _cache_dir(tag: str) -> Path:
223
+ cache = Path.home() / ".cache" / PKG_NAME / tag
224
+ cache.mkdir(parents=True, exist_ok=True, mode=0o700)
225
+ return cache
226
+
227
+
228
+ def ensure_binary() -> str:
229
+ """Ensure the native binary exists locally, downloading if necessary."""
230
+ override = os.getenv("XBERG_BINARY")
231
+ if override:
232
+ return override
233
+
234
+ tag, archive, checksums = _resolve_release()
235
+ cache = _cache_dir(tag)
236
+ binary_path = cache / _binary_name()
237
+ if binary_path.exists() and os.access(binary_path, os.X_OK):
238
+ return str(binary_path)
239
+
240
+ print(f"Downloading {BIN_NAME} {tag} asset {archive['name']}...", file=sys.stderr)
241
+ with tempfile.TemporaryDirectory() as tmpdir:
242
+ tmp = Path(tmpdir)
243
+ archive_path = tmp / Path(archive["name"]).name
244
+ archive_path.write_bytes(_http_get(archive["browser_download_url"]))
245
+ _verify_or_warn(archive_path, archive["name"], checksums)
246
+ extract_dir = tmp / "extract"
247
+ extract_dir.mkdir()
248
+ _safe_extract(archive_path, archive["name"], extract_dir)
249
+ found = _find_binary(extract_dir, _binary_name())
250
+ if not found:
251
+ raise RuntimeError(f"binary {_binary_name()} not found after extracting {archive['name']}")
252
+ shutil.move(str(found), str(binary_path))
253
+
254
+ if platform.system().lower() != "windows":
255
+ binary_path.chmod(0o755)
256
+ print(f"{BIN_NAME} installed.", file=sys.stderr)
257
+ return str(binary_path)
258
+
259
+
260
+ def run(args: list[str]) -> int:
261
+ """Run the native binary with the given args, returning its exit code."""
262
+ binary_path = ensure_binary()
263
+ completed = subprocess.run([binary_path, *args], check=False)
264
+ return completed.returncode