typst-pkg-sync 0.1.0__py3-none-any.whl
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.
- typst_pkg_sync/__init__.py +1 -0
- typst_pkg_sync/__main__.py +3 -0
- typst_pkg_sync/cli.py +564 -0
- typst_pkg_sync-0.1.0.dist-info/METADATA +64 -0
- typst_pkg_sync-0.1.0.dist-info/RECORD +8 -0
- typst_pkg_sync-0.1.0.dist-info/WHEEL +4 -0
- typst_pkg_sync-0.1.0.dist-info/entry_points.txt +3 -0
- typst_pkg_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local package manager for Typst."""
|
typst_pkg_sync/cli.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""
|
|
2
|
+
typst-pkg-sync — Local Package Manager for Typst
|
|
3
|
+
|
|
4
|
+
Downloads tagged source files from GitHub, GitLab, and Codeberg
|
|
5
|
+
and installs them into the local Typst package directory structure:
|
|
6
|
+
|
|
7
|
+
<base_dir>/<namespace>/<package-name>/<version>/
|
|
8
|
+
|
|
9
|
+
The package name is read from the typst.toml file inside the
|
|
10
|
+
downloaded archive. Version tags may carry a 'v' prefix (e.g. "v1.0.0"),
|
|
11
|
+
which is stripped for the installation directory.
|
|
12
|
+
|
|
13
|
+
Requires Python 3.14+ (uses tomllib from the standard library).
|
|
14
|
+
No third-party dependencies.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import http.client
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
import tarfile
|
|
25
|
+
import tempfile
|
|
26
|
+
import tomllib
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
import zipfile
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Constants
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
CONFIG_DIR = Path.home() / ".config" / "lpmt"
|
|
38
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
39
|
+
DEFAULT_BASE_DIR = Path.home() / ".local/share/typst/packages"
|
|
40
|
+
|
|
41
|
+
# Version strings: optional 'v' prefix, then digits and dots,
|
|
42
|
+
# at least major.minor. Capture group 1 = version without prefix.
|
|
43
|
+
SEMVER_RE = re.compile(r"^v?(\d+\.\d+(?:\.\d+)?)$")
|
|
44
|
+
|
|
45
|
+
PLATFORMS = {
|
|
46
|
+
"github": {
|
|
47
|
+
"tags_api": "https://api.github.com/repos/{owner}/{repo}/tags",
|
|
48
|
+
"archive": "https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz",
|
|
49
|
+
"archive_format": "tar",
|
|
50
|
+
"auth_env": "GITHUB_TOKEN",
|
|
51
|
+
"auth_header": "Authorization",
|
|
52
|
+
"auth_prefix": "token ",
|
|
53
|
+
},
|
|
54
|
+
"gitlab": {
|
|
55
|
+
"base_url": "https://gitlab.com",
|
|
56
|
+
"tags_api": "{base_url}/api/v4/projects/{project_path}/repository/tags",
|
|
57
|
+
"archive": "{base_url}/api/v4/projects/{project_path}/repository/archive.tar.gz?sha={tag}",
|
|
58
|
+
"archive_format": "tar",
|
|
59
|
+
"auth_env": "GITLAB_TOKEN",
|
|
60
|
+
"auth_header": "PRIVATE-TOKEN",
|
|
61
|
+
"auth_prefix": "",
|
|
62
|
+
},
|
|
63
|
+
"codeberg": {
|
|
64
|
+
"tags_api": "https://codeberg.org/api/v1/repos/{owner}/{repo}/tags",
|
|
65
|
+
"archive": "https://codeberg.org/{owner}/{repo}/archive/{tag}.tar.gz",
|
|
66
|
+
"archive_format": "tar",
|
|
67
|
+
"auth_env": "CODEBERG_TOKEN",
|
|
68
|
+
"auth_header": "Authorization",
|
|
69
|
+
"auth_prefix": "token ",
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
DEFAULT_CONFIG = """\
|
|
74
|
+
# typst-pkg-sync — Local Package Manager for Typst
|
|
75
|
+
#
|
|
76
|
+
# Packages are installed to:
|
|
77
|
+
# <base_dir>/<namespace>/<package-name>/<version>/
|
|
78
|
+
#
|
|
79
|
+
# The <package-name> is read from the typst.toml file inside the
|
|
80
|
+
# downloaded archive, NOT from the repository name.
|
|
81
|
+
#
|
|
82
|
+
# Tags may be written with or without a 'v' prefix (e.g. "v1.0.0"
|
|
83
|
+
# or "1.0.0"). The prefix is stripped for the install directory.
|
|
84
|
+
#
|
|
85
|
+
# GitLab repositories may set base_url and token_env per repository.
|
|
86
|
+
# base_url defaults to https://gitlab.com; token_env defaults to GITLAB_TOKEN.
|
|
87
|
+
|
|
88
|
+
[settings]
|
|
89
|
+
base_dir = "~/.local/share/typst/packages"
|
|
90
|
+
namespace = "jp-packages"
|
|
91
|
+
skip_existing = true
|
|
92
|
+
verbose = true
|
|
93
|
+
|
|
94
|
+
[[repositories]]
|
|
95
|
+
platform = "gitlab"
|
|
96
|
+
base_url = "https://gitlab.gwdg.de"
|
|
97
|
+
owner = "ressler"
|
|
98
|
+
repo = "jps-university-assignments"
|
|
99
|
+
tags = ["0.2.0"]
|
|
100
|
+
|
|
101
|
+
[[repositories]]
|
|
102
|
+
platform = "codeberg"
|
|
103
|
+
owner = "ressler"
|
|
104
|
+
repo = "protokoll-template"
|
|
105
|
+
tags = "all"
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Logging
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def log_info(msg: str) -> None:
|
|
114
|
+
print(f"\033[32m[INFO]\033[0m {msg}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def log_warn(msg: str) -> None:
|
|
118
|
+
print(f"\033[33m[WARN]\033[0m {msg}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def log_error(msg: str) -> None:
|
|
122
|
+
print(f"\033[31m[ERROR]\033[0m {msg}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def log_debug(msg: str) -> None:
|
|
126
|
+
print(f"\033[34m[DEBUG]\033[0m {msg}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Tag handling
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_tag(tag: str) -> str | None:
|
|
135
|
+
"""
|
|
136
|
+
Normalize a tag string.
|
|
137
|
+
|
|
138
|
+
Accepts an optional 'v' prefix followed by digits and dots
|
|
139
|
+
(at least major.minor). Returns the version without the prefix,
|
|
140
|
+
or None if the tag does not match the pattern.
|
|
141
|
+
"""
|
|
142
|
+
m = SEMVER_RE.match(tag)
|
|
143
|
+
return m.group(1) if m else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_tags(tags: object, repo: str) -> list[str] | str:
|
|
147
|
+
"""
|
|
148
|
+
Validate the tags field from the config.
|
|
149
|
+
|
|
150
|
+
Allowed:
|
|
151
|
+
- the string "all"
|
|
152
|
+
- an array of version strings, with optional 'v' prefix
|
|
153
|
+
(e.g. "0.2.0", "v1.0.0", "1.0")
|
|
154
|
+
|
|
155
|
+
Returns either "all" or a list of normalized versions
|
|
156
|
+
(prefix stripped). Exits on invalid input.
|
|
157
|
+
"""
|
|
158
|
+
if isinstance(tags, str):
|
|
159
|
+
if tags == "all":
|
|
160
|
+
return "all"
|
|
161
|
+
log_error(f'[{repo}] tags must be an array of versions or the string "all", got: "{tags}"')
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
|
|
164
|
+
if isinstance(tags, list):
|
|
165
|
+
if not tags:
|
|
166
|
+
log_error(f"[{repo}] tags array is empty")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
normalized = []
|
|
169
|
+
for t in tags:
|
|
170
|
+
version = normalize_tag(t) if isinstance(t, str) else None
|
|
171
|
+
if version is None:
|
|
172
|
+
log_error(
|
|
173
|
+
f"[{repo}] invalid tag {t!r}: tags must consist of an "
|
|
174
|
+
f"optional 'v' prefix followed by digits and dots "
|
|
175
|
+
f'(e.g. "0.2.0" or "v1.0.0")'
|
|
176
|
+
)
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
normalized.append(version)
|
|
179
|
+
return normalized
|
|
180
|
+
|
|
181
|
+
log_error(f'[{repo}] tags must be an array of versions or "all"')
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def filter_remote_tags(raw_tags: list[str], owner: str, repo: str) -> list[str]:
|
|
186
|
+
"""
|
|
187
|
+
Filter remote tag names against the version pattern.
|
|
188
|
+
|
|
189
|
+
Tags that don't match (e.g. "release-candidate", "nightly")
|
|
190
|
+
are skipped with a warning. Matching tags are normalized
|
|
191
|
+
('v' prefix stripped) and deduplicated while preserving order.
|
|
192
|
+
"""
|
|
193
|
+
versions: list[str] = []
|
|
194
|
+
seen: set[str] = set()
|
|
195
|
+
|
|
196
|
+
for raw in raw_tags:
|
|
197
|
+
version = normalize_tag(raw)
|
|
198
|
+
if version is None:
|
|
199
|
+
log_warn(f"[{owner}/{repo}] skipping non-version tag: {raw!r}")
|
|
200
|
+
continue
|
|
201
|
+
if version in seen:
|
|
202
|
+
log_warn(
|
|
203
|
+
f"[{owner}/{repo}] tag {raw!r} maps to version {version!r}, "
|
|
204
|
+
f"which was already seen — skipping duplicate"
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
seen.add(version)
|
|
208
|
+
versions.append(version)
|
|
209
|
+
|
|
210
|
+
return versions
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Config handling
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_default_config(config_file: Path) -> None:
|
|
219
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
config_file.write_text(DEFAULT_CONFIG)
|
|
221
|
+
log_info(f"Created default config at {config_file}")
|
|
222
|
+
log_info("Edit it to add your repositories, then re-run.")
|
|
223
|
+
sys.exit(0)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_config(config_file: Path = CONFIG_FILE) -> dict:
|
|
227
|
+
if not config_file.exists():
|
|
228
|
+
create_default_config(config_file)
|
|
229
|
+
try:
|
|
230
|
+
with open(config_file, "rb") as f:
|
|
231
|
+
return tomllib.load(f)
|
|
232
|
+
except tomllib.TOMLDecodeError as e:
|
|
233
|
+
log_error(f"Invalid TOML in {config_file}: {e}")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_platform_config(platform: str, repo_cfg: dict) -> dict | None:
|
|
238
|
+
"""Apply per-repository overrides to a platform's request settings."""
|
|
239
|
+
plat = dict(PLATFORMS[platform])
|
|
240
|
+
plat["name"] = platform
|
|
241
|
+
|
|
242
|
+
token_env = repo_cfg.get("token_env", plat["auth_env"])
|
|
243
|
+
if not isinstance(token_env, str) or not token_env:
|
|
244
|
+
log_error(f"Invalid token_env for repository '{repo_cfg.get('repo', '')}'")
|
|
245
|
+
return None
|
|
246
|
+
plat["auth_env"] = token_env
|
|
247
|
+
|
|
248
|
+
if "base_url" in repo_cfg:
|
|
249
|
+
if platform != "gitlab":
|
|
250
|
+
log_error("The 'base_url' option is only supported for GitLab repositories")
|
|
251
|
+
return None
|
|
252
|
+
base_url = repo_cfg["base_url"]
|
|
253
|
+
if not isinstance(base_url, str) or not base_url or base_url != base_url.strip():
|
|
254
|
+
log_error(f"Invalid GitLab base_url: {base_url!r}")
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
258
|
+
_ = parsed.port
|
|
259
|
+
except ValueError:
|
|
260
|
+
log_error(f"Invalid GitLab base_url: {base_url!r}")
|
|
261
|
+
return None
|
|
262
|
+
if (
|
|
263
|
+
parsed.scheme not in {"http", "https"}
|
|
264
|
+
or not parsed.hostname
|
|
265
|
+
or parsed.username is not None
|
|
266
|
+
or parsed.password is not None
|
|
267
|
+
or parsed.query
|
|
268
|
+
or parsed.fragment
|
|
269
|
+
):
|
|
270
|
+
log_error(f"Invalid GitLab base_url: {base_url!r}")
|
|
271
|
+
return None
|
|
272
|
+
plat["base_url"] = base_url.rstrip("/")
|
|
273
|
+
|
|
274
|
+
return plat
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# HTTP helpers (stdlib only)
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def make_request(url: str, plat: dict) -> bytes:
|
|
283
|
+
headers = {"User-Agent": "typst-pkg-sync/1.0"}
|
|
284
|
+
|
|
285
|
+
token = os.environ.get(plat["auth_env"], "")
|
|
286
|
+
if token:
|
|
287
|
+
headers[plat["auth_header"]] = plat["auth_prefix"] + token
|
|
288
|
+
|
|
289
|
+
req = urllib.request.Request(url, headers=headers)
|
|
290
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
291
|
+
return resp.read()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def fetch_tags(plat: dict, owner: str, repo: str) -> list[str]:
|
|
295
|
+
"""Fetch all tag names from the remote repository."""
|
|
296
|
+
url = plat["tags_api"].format(
|
|
297
|
+
base_url=plat.get("base_url", ""),
|
|
298
|
+
owner=owner,
|
|
299
|
+
repo=repo,
|
|
300
|
+
project_path=urllib.parse.quote(f"{owner}/{repo}", safe=""),
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
data = json.loads(make_request(url, plat))
|
|
304
|
+
return [tag["name"] for tag in data]
|
|
305
|
+
except (http.client.HTTPException, OSError, ValueError, KeyError, TypeError) as e:
|
|
306
|
+
log_warn(f"Could not fetch tags for {owner}/{repo}: {e}")
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Archive handling
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def extract_archive(archive_bytes: bytes, dest: Path, fmt: str) -> None:
|
|
316
|
+
"""Extract a tar.gz or zip archive into dest, flattening the top dir."""
|
|
317
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
318
|
+
tmp_path = Path(tmp) / ("archive.tar.gz" if fmt == "tar" else "archive.zip")
|
|
319
|
+
tmp_path.write_bytes(archive_bytes)
|
|
320
|
+
|
|
321
|
+
extract_dir = Path(tmp) / "extracted"
|
|
322
|
+
extract_dir.mkdir()
|
|
323
|
+
|
|
324
|
+
if fmt == "tar":
|
|
325
|
+
with tarfile.open(tmp_path, "r:gz") as tf:
|
|
326
|
+
tf.extractall(extract_dir, filter="data")
|
|
327
|
+
else:
|
|
328
|
+
with zipfile.ZipFile(tmp_path) as zf:
|
|
329
|
+
zf.extractall(extract_dir)
|
|
330
|
+
|
|
331
|
+
entries = list(extract_dir.iterdir())
|
|
332
|
+
source = entries[0] if (len(entries) == 1 and entries[0].is_dir()) else extract_dir
|
|
333
|
+
|
|
334
|
+
for item in source.iterdir():
|
|
335
|
+
shutil.move(str(item), str(dest / item.name))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def read_package_name(package_dir: Path) -> str | None:
|
|
339
|
+
"""
|
|
340
|
+
Read the package name from typst.toml in the extracted package.
|
|
341
|
+
|
|
342
|
+
Expected structure in typst.toml:
|
|
343
|
+
[package]
|
|
344
|
+
name = "..."
|
|
345
|
+
"""
|
|
346
|
+
typst_toml = package_dir / "typst.toml"
|
|
347
|
+
if not typst_toml.exists():
|
|
348
|
+
return None
|
|
349
|
+
try:
|
|
350
|
+
with open(typst_toml, "rb") as f:
|
|
351
|
+
data = tomllib.load(f)
|
|
352
|
+
name = data.get("package", {}).get("name")
|
|
353
|
+
return name if isinstance(name, str) and name else None
|
|
354
|
+
except (tomllib.TOMLDecodeError, OSError) as e:
|
|
355
|
+
log_warn(f"Could not parse typst.toml: {e}")
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Download & install
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def download_and_install(
|
|
365
|
+
plat: dict,
|
|
366
|
+
owner: str,
|
|
367
|
+
repo: str,
|
|
368
|
+
tag: str,
|
|
369
|
+
base_dir: Path,
|
|
370
|
+
namespace: str,
|
|
371
|
+
skip_existing: bool,
|
|
372
|
+
) -> bool:
|
|
373
|
+
"""
|
|
374
|
+
Download one tag of one repository and install it.
|
|
375
|
+
|
|
376
|
+
'tag' must already be normalized (no 'v' prefix) — it is used both
|
|
377
|
+
to build the archive URL (with prefix re-added if the remote uses
|
|
378
|
+
one) and as the version directory name.
|
|
379
|
+
|
|
380
|
+
The package name is read from typst.toml inside the archive.
|
|
381
|
+
Returns True on success (including skip), False on failure.
|
|
382
|
+
"""
|
|
383
|
+
platform = plat["name"]
|
|
384
|
+
|
|
385
|
+
# Try the plain tag first; if it 404s, retry with a 'v' prefix.
|
|
386
|
+
# This handles remotes that tag as "v1.0.0" while the config
|
|
387
|
+
# (or the filtered remote list) uses the normalized form.
|
|
388
|
+
candidates = [tag]
|
|
389
|
+
if not tag.startswith("v"):
|
|
390
|
+
candidates.append(f"v{tag}")
|
|
391
|
+
|
|
392
|
+
archive_bytes: bytes | None = None
|
|
393
|
+
used_tag: str | None = None
|
|
394
|
+
|
|
395
|
+
for candidate in candidates:
|
|
396
|
+
url = plat["archive"].format(
|
|
397
|
+
base_url=plat.get("base_url", ""),
|
|
398
|
+
owner=owner,
|
|
399
|
+
project_path=urllib.parse.quote(f"{owner}/{repo}", safe=""),
|
|
400
|
+
repo=urllib.parse.quote(repo, safe=""),
|
|
401
|
+
tag=urllib.parse.quote(candidate, safe=""),
|
|
402
|
+
)
|
|
403
|
+
try:
|
|
404
|
+
log_info(f"⬇ Downloading {owner}/{repo}@{candidate} from {platform}...")
|
|
405
|
+
archive_bytes = make_request(url, plat)
|
|
406
|
+
used_tag = candidate
|
|
407
|
+
break
|
|
408
|
+
except urllib.error.HTTPError as e:
|
|
409
|
+
if e.code == 404 and candidate != candidates[-1]:
|
|
410
|
+
log_debug(f"Tag '{candidate}' not found, trying next candidate...")
|
|
411
|
+
continue
|
|
412
|
+
log_error(f"HTTP {e.code} for {url}")
|
|
413
|
+
return False
|
|
414
|
+
except (http.client.HTTPException, OSError, ValueError) as e:
|
|
415
|
+
log_error(f"Download failed for {owner}/{repo}@{candidate}: {e}")
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
if archive_bytes is None or used_tag is None:
|
|
419
|
+
log_error(f"Could not download {owner}/{repo}@{tag}")
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
# Extract to a staging dir first so we can read typst.toml
|
|
423
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
424
|
+
staging = Path(tmp) / "staging"
|
|
425
|
+
staging.mkdir()
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
extract_archive(archive_bytes, staging, plat["archive_format"])
|
|
429
|
+
except (
|
|
430
|
+
tarfile.TarError,
|
|
431
|
+
zipfile.BadZipFile,
|
|
432
|
+
OSError,
|
|
433
|
+
EOFError,
|
|
434
|
+
) as e:
|
|
435
|
+
log_error(f"Extraction failed for {owner}/{repo}@{used_tag}: {e}")
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
# Read the package name from typst.toml
|
|
439
|
+
package_name = read_package_name(staging)
|
|
440
|
+
if package_name is None:
|
|
441
|
+
log_error(
|
|
442
|
+
f"No valid typst.toml with [package] name found in "
|
|
443
|
+
f"{owner}/{repo}@{used_tag} — cannot determine package name"
|
|
444
|
+
)
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
if package_name != repo:
|
|
448
|
+
log_debug(f"Package name '{package_name}' differs from repo '{repo}'")
|
|
449
|
+
|
|
450
|
+
# Final destination: normalized tag (no 'v' prefix) as version dir
|
|
451
|
+
dest = base_dir / namespace / package_name / tag
|
|
452
|
+
|
|
453
|
+
if dest.exists():
|
|
454
|
+
if skip_existing:
|
|
455
|
+
log_warn(f"⊘ Already exists: {dest}")
|
|
456
|
+
return True
|
|
457
|
+
log_warn(f"Overwriting existing: {dest}")
|
|
458
|
+
shutil.rmtree(dest)
|
|
459
|
+
|
|
460
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
for item in staging.iterdir():
|
|
464
|
+
shutil.move(str(item), str(dest / item.name))
|
|
465
|
+
except OSError as e:
|
|
466
|
+
log_error(f"Install failed: {e}")
|
|
467
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
log_info(f"✅ Installed {package_name}@{tag} → {dest}")
|
|
471
|
+
return True
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Sync logic
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def sync_repository(repo_cfg: dict, settings: dict) -> bool:
|
|
480
|
+
platform = repo_cfg.get("platform", "").lower()
|
|
481
|
+
owner = repo_cfg.get("owner", "")
|
|
482
|
+
repo = repo_cfg.get("repo", "")
|
|
483
|
+
raw_tags = repo_cfg.get("tags")
|
|
484
|
+
|
|
485
|
+
if platform not in PLATFORMS:
|
|
486
|
+
log_error(f"Unknown platform '{platform}' for repo '{repo}'")
|
|
487
|
+
return False
|
|
488
|
+
if not owner or not repo:
|
|
489
|
+
log_error(f"Missing 'owner' or 'repo' in config entry: {repo_cfg}")
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
plat = resolve_platform_config(platform, repo_cfg)
|
|
493
|
+
if plat is None:
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
tags = validate_tags(raw_tags, repo)
|
|
497
|
+
|
|
498
|
+
# "all": query remote tags and filter them for the version pattern
|
|
499
|
+
if tags == "all":
|
|
500
|
+
raw_remote = fetch_tags(plat, owner, repo)
|
|
501
|
+
if not raw_remote:
|
|
502
|
+
log_error(f"No tags found for {owner}/{repo}")
|
|
503
|
+
return False
|
|
504
|
+
tags = filter_remote_tags(raw_remote, owner, repo)
|
|
505
|
+
if not tags:
|
|
506
|
+
log_error(f"No tags matching the version pattern found for {owner}/{repo}")
|
|
507
|
+
return False
|
|
508
|
+
log_info(f'[{owner}/{repo}] tags = "all" → {len(tags)} version(s): {tags}')
|
|
509
|
+
|
|
510
|
+
base_dir = Path(settings.get("base_dir", str(DEFAULT_BASE_DIR))).expanduser()
|
|
511
|
+
namespace = settings.get("namespace", "preview")
|
|
512
|
+
skip_existing = settings.get("skip_existing", True)
|
|
513
|
+
|
|
514
|
+
ok = 0
|
|
515
|
+
for tag in tags:
|
|
516
|
+
if download_and_install(plat, owner, repo, tag, base_dir, namespace, skip_existing):
|
|
517
|
+
ok += 1
|
|
518
|
+
|
|
519
|
+
log_info(f"── {owner}/{repo}: {ok}/{len(tags)} version(s) synced")
|
|
520
|
+
return ok == len(tags)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def main(argv: list[str] | None = None) -> None:
|
|
524
|
+
parser = argparse.ArgumentParser(description="Sync Typst packages from Git repositories")
|
|
525
|
+
parser.add_argument(
|
|
526
|
+
"--config",
|
|
527
|
+
type=Path,
|
|
528
|
+
default=CONFIG_FILE,
|
|
529
|
+
help=f"config file path (default: {CONFIG_FILE})",
|
|
530
|
+
)
|
|
531
|
+
args = parser.parse_args(argv)
|
|
532
|
+
config_file = args.config.expanduser()
|
|
533
|
+
|
|
534
|
+
config = load_config(config_file)
|
|
535
|
+
settings = config.get("settings", {})
|
|
536
|
+
repos = config.get("repositories", [])
|
|
537
|
+
|
|
538
|
+
if not repos:
|
|
539
|
+
log_warn("No [[repositories]] defined in config.")
|
|
540
|
+
log_info(f"Edit {config_file} to add some.")
|
|
541
|
+
sys.exit(0)
|
|
542
|
+
|
|
543
|
+
namespace = settings.get("namespace", "preview")
|
|
544
|
+
base_dir = Path(settings.get("base_dir", str(DEFAULT_BASE_DIR))).expanduser()
|
|
545
|
+
|
|
546
|
+
log_info("📦 typst-pkg-sync — Typst Package Sync")
|
|
547
|
+
log_info(f"📁 Base: {base_dir}")
|
|
548
|
+
log_info(f"🏷 Namespace: {namespace}")
|
|
549
|
+
print()
|
|
550
|
+
|
|
551
|
+
failures = 0
|
|
552
|
+
for repo_cfg in repos:
|
|
553
|
+
if not sync_repository(repo_cfg, settings):
|
|
554
|
+
failures += 1
|
|
555
|
+
|
|
556
|
+
print()
|
|
557
|
+
if failures:
|
|
558
|
+
log_error(f"{failures} repository(ies) had errors")
|
|
559
|
+
sys.exit(1)
|
|
560
|
+
log_info("🎉 All done!")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
if __name__ == "__main__":
|
|
564
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typst-pkg-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync tagged Typst packages from GitHub, GitLab, and Codeberg
|
|
5
|
+
Author: Jan-Philipp Reßler
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Project-URL: Repository, https://codeberg.org/ressler/lpmt
|
|
10
|
+
Project-URL: Issues, https://codeberg.org/ressler/lpmt/issues
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# typst-pkg-sync
|
|
14
|
+
|
|
15
|
+
typst-pkg-sync downloads tagged Typst packages from GitHub, GitLab (including
|
|
16
|
+
self-hosted instances), and Codeberg, then installs them in the local Typst
|
|
17
|
+
package directory.
|
|
18
|
+
|
|
19
|
+
Requires Python 3.14 or newer. typst-pkg-sync has no runtime third-party dependencies.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
After typst-pkg-sync is published to PyPI, install it as a user tool with:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv tool install typst-pkg-sync
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To install from a local checkout while developing typst-pkg-sync:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
uv tool install .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configure
|
|
36
|
+
|
|
37
|
+
On its first run, typst-pkg-sync creates `~/.config/lpmt/config.toml` and exits.
|
|
38
|
+
The `lpmt` config directory is retained for compatibility. Edit the file to add
|
|
39
|
+
repositories, then run `typst-pkg-sync` again. Alternatively, select a config
|
|
40
|
+
file explicitly:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
typst-pkg-sync --config /path/to/config.toml
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See the [example configuration](https://codeberg.org/ressler/lpmt/src/branch/main/config_example.toml)
|
|
47
|
+
for examples, including a self-hosted GitLab repository. GitLab entries can
|
|
48
|
+
set `base_url` and an optional `token_env` per repository.
|
|
49
|
+
|
|
50
|
+
Packages are installed under:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
<base_dir>/<namespace>/<package-name>/<version>/
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The package name is read from `typst.toml` in the downloaded archive. A `v`
|
|
57
|
+
prefix on a tag is removed from the installed version directory.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
Run the sample configuration with `just run` and the code-quality checks with
|
|
62
|
+
`just check`.
|
|
63
|
+
|
|
64
|
+
typst-pkg-sync is licensed under MIT; see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
typst_pkg_sync/__init__.py,sha256=ELTFeRb4DDajqZyzxzkPGXFMqwHpO_xc0yFXdW_6lC4,39
|
|
2
|
+
typst_pkg_sync/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
typst_pkg_sync/cli.py,sha256=CeK39OZ3Ynt3tBuJxXoeRPlYs6cAt66x8E--c0h8gyM,18566
|
|
4
|
+
typst_pkg_sync-0.1.0.dist-info/licenses/LICENSE,sha256=Og_MGJ76gr6KQjWSzcS6AVwFM2suJj6syvXrlYd-ytc,1076
|
|
5
|
+
typst_pkg_sync-0.1.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
|
|
6
|
+
typst_pkg_sync-0.1.0.dist-info/entry_points.txt,sha256=khquF574jW8TPJyO0xjed1MZVPYkVjxgLHL0AjFQxAc,60
|
|
7
|
+
typst_pkg_sync-0.1.0.dist-info/METADATA,sha256=6jAnsW7lzpnGbw3VDmsG501SeWEDyns8x6WCnI-ZQTM,1866
|
|
8
|
+
typst_pkg_sync-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jan-Philipp Reßler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|