typst-forge 0.1.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,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: typst-forge
3
+ Version: 0.1.0
4
+ Summary: A local manager for Typst templates and packages
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Typst Forge
9
+
10
+ Typst Forge is a small local manager for Typst packages and templates.
11
+
12
+ It can:
13
+
14
+ - scaffold a new Typst repo with `git`, `typst.toml`, `src`, and `template/` when needed
15
+ - install local development repos into Typst's package directory
16
+ - install release repos from GitHub or any git URL
17
+ - manage installed packages/templates with versioned registry entries
18
+ - fall back between symlink and copy modes for platform-friendly installs
19
+
20
+ ## Install
21
+
22
+ This repo is meant to be run with `uv`:
23
+
24
+ ```bash
25
+ uv run typst-forge --help
26
+ ```
27
+
28
+ To run it without installing from a checkout, use `uvx`:
29
+
30
+ ```bash
31
+ uvx --from . typst-forge --help
32
+ ```
33
+
34
+ You can also install directly from GitHub:
35
+
36
+ ```bash
37
+ uvx --from git+https://github.com/<owner>/typst-forge typst-forge --help
38
+ ```
39
+
40
+ ## Commands
41
+
42
+ ```bash
43
+ typst-forge init ./my-template --kind template --name my-template --version 0.1.0
44
+ typst-forge init ./my-package --kind package --name my-package --version 0.1.0
45
+ typst-forge install ./my-package --link-mode copy
46
+ typst-forge install github:owner/repo@v1.2.3
47
+ typst-forge list
48
+ typst-forge status my-package
49
+ typst-forge update my-package
50
+ typst-forge remove my-package
51
+ ```
52
+
53
+ ## Versioning
54
+
55
+ - local repos use the `typst.toml` package version unless a matching git tag is present at `HEAD`
56
+ - GitHub and other git URLs install from release tags
57
+ - tags can be written as `v1.2.3` or `1.2.3`; the stored Typst version is normalized to `1.2.3`
58
+ - installed entries live under `~/.local/share/typst/packages/<namespace>/<package>/<version>`
59
+
60
+ ## Tests
61
+
62
+ Run the in-repo test suite with:
63
+
64
+ ```bash
65
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
66
+ ```
67
+
68
+ ## Publishing
69
+
70
+ The GitHub Actions workflow in [.github/workflows/publish.yml](./.github/workflows/publish.yml)
71
+ publishes tagged releases to PyPI with trusted publishing.
72
+
73
+ Create and push a version tag like `v0.1.0` to trigger it.
@@ -0,0 +1,66 @@
1
+ # Typst Forge
2
+
3
+ Typst Forge is a small local manager for Typst packages and templates.
4
+
5
+ It can:
6
+
7
+ - scaffold a new Typst repo with `git`, `typst.toml`, `src`, and `template/` when needed
8
+ - install local development repos into Typst's package directory
9
+ - install release repos from GitHub or any git URL
10
+ - manage installed packages/templates with versioned registry entries
11
+ - fall back between symlink and copy modes for platform-friendly installs
12
+
13
+ ## Install
14
+
15
+ This repo is meant to be run with `uv`:
16
+
17
+ ```bash
18
+ uv run typst-forge --help
19
+ ```
20
+
21
+ To run it without installing from a checkout, use `uvx`:
22
+
23
+ ```bash
24
+ uvx --from . typst-forge --help
25
+ ```
26
+
27
+ You can also install directly from GitHub:
28
+
29
+ ```bash
30
+ uvx --from git+https://github.com/<owner>/typst-forge typst-forge --help
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ ```bash
36
+ typst-forge init ./my-template --kind template --name my-template --version 0.1.0
37
+ typst-forge init ./my-package --kind package --name my-package --version 0.1.0
38
+ typst-forge install ./my-package --link-mode copy
39
+ typst-forge install github:owner/repo@v1.2.3
40
+ typst-forge list
41
+ typst-forge status my-package
42
+ typst-forge update my-package
43
+ typst-forge remove my-package
44
+ ```
45
+
46
+ ## Versioning
47
+
48
+ - local repos use the `typst.toml` package version unless a matching git tag is present at `HEAD`
49
+ - GitHub and other git URLs install from release tags
50
+ - tags can be written as `v1.2.3` or `1.2.3`; the stored Typst version is normalized to `1.2.3`
51
+ - installed entries live under `~/.local/share/typst/packages/<namespace>/<package>/<version>`
52
+
53
+ ## Tests
54
+
55
+ Run the in-repo test suite with:
56
+
57
+ ```bash
58
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
59
+ ```
60
+
61
+ ## Publishing
62
+
63
+ The GitHub Actions workflow in [.github/workflows/publish.yml](./.github/workflows/publish.yml)
64
+ publishes tagged releases to PyPI with trusted publishing.
65
+
66
+ Create and push a version tag like `v0.1.0` to trigger it.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "typst-forge"
7
+ version = "0.1.0"
8
+ description = "A local manager for Typst templates and packages"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ typst-forge = "typst_forge.cli:main"
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+ include = ["typst_forge", "typst_forge.*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Typst Forge: a local manager for Typst packages and templates."""
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .registry import (
8
+ install_source,
9
+ installation_status,
10
+ list_installed_packages,
11
+ remove_installation,
12
+ update_installation,
13
+ )
14
+ from .scaffold import scaffold_project
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(prog="typst-forge")
19
+ subparsers = parser.add_subparsers(dest="command", required=True)
20
+
21
+ init_parser = subparsers.add_parser("init", help="Create a new Typst project")
22
+ init_parser.add_argument("path", nargs="?", default=".")
23
+ init_parser.add_argument("--kind", choices=["template", "package"], default="package")
24
+ init_parser.add_argument("--name")
25
+ init_parser.add_argument("--version", default="0.1.0")
26
+ init_parser.set_defaults(handler=_cmd_init)
27
+
28
+ install_parser = subparsers.add_parser(
29
+ "install", help="Install a local path or GitHub source"
30
+ )
31
+ install_parser.add_argument("source")
32
+ install_parser.add_argument("--namespace", default="local")
33
+ install_parser.add_argument("--version")
34
+ install_parser.add_argument("--registry-root", help="Override the Typst packages root")
35
+ install_parser.add_argument("--cache-root", help="Override the Typst Forge cache root")
36
+ install_parser.add_argument(
37
+ "--link-mode",
38
+ choices=["auto", "symlink", "copy"],
39
+ default="auto",
40
+ help="How installed projects should be materialized",
41
+ )
42
+ install_parser.add_argument("--force", action="store_true")
43
+ install_parser.set_defaults(handler=_cmd_install)
44
+
45
+ update_parser = subparsers.add_parser(
46
+ "update", help="Reinstall an installed package or source"
47
+ )
48
+ update_parser.add_argument("target")
49
+ update_parser.add_argument("--namespace", default="local")
50
+ update_parser.add_argument("--version")
51
+ update_parser.add_argument("--registry-root", help="Override the Typst packages root")
52
+ update_parser.add_argument("--cache-root", help="Override the Typst Forge cache root")
53
+ update_parser.add_argument(
54
+ "--link-mode",
55
+ choices=["auto", "symlink", "copy"],
56
+ default=None,
57
+ help="How installed projects should be materialized",
58
+ )
59
+ update_parser.set_defaults(handler=_cmd_update)
60
+
61
+ remove_parser = subparsers.add_parser(
62
+ "remove", help="Remove an installed package or source"
63
+ )
64
+ remove_parser.add_argument("target")
65
+ remove_parser.add_argument("--namespace", default="local")
66
+ remove_parser.add_argument("--version")
67
+ remove_parser.add_argument("--registry-root", help="Override the Typst packages root")
68
+ remove_parser.set_defaults(handler=_cmd_remove)
69
+
70
+ list_parser = subparsers.add_parser("list", help="List installed packages and templates")
71
+ list_parser.add_argument("--json", action="store_true")
72
+ list_parser.add_argument("--registry-root", help="Override the Typst packages root")
73
+ list_parser.set_defaults(handler=_cmd_list)
74
+
75
+ status_parser = subparsers.add_parser(
76
+ "status", help="Show status for an installed package or source"
77
+ )
78
+ status_parser.add_argument("target")
79
+ status_parser.add_argument("--namespace", default="local")
80
+ status_parser.add_argument("--version")
81
+ status_parser.add_argument("--registry-root", help="Override the Typst packages root")
82
+ status_parser.set_defaults(handler=_cmd_status)
83
+
84
+ return parser
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> int:
88
+ args = build_parser().parse_args(argv)
89
+ return args.handler(args)
90
+
91
+
92
+ def _cmd_init(args: argparse.Namespace) -> int:
93
+ root = Path(args.path)
94
+ name = args.name or root.resolve().name.replace("_", "-")
95
+ scaffold_project(root, args.kind, name, args.version)
96
+ print(f"initialized {args.kind} project at {root.resolve()}")
97
+ return 0
98
+
99
+
100
+ def _cmd_install(args: argparse.Namespace) -> int:
101
+ record = install_source(
102
+ args.source,
103
+ namespace=args.namespace,
104
+ version=args.version,
105
+ link_mode=args.link_mode,
106
+ registry_root_override=args.registry_root,
107
+ cache_root_override=args.cache_root,
108
+ force=args.force,
109
+ )
110
+ print(f"installed {record.package} {record.version} -> {record.installed_root}")
111
+ return 0
112
+
113
+
114
+ def _cmd_update(args: argparse.Namespace) -> int:
115
+ records = update_installation(
116
+ args.target,
117
+ namespace=args.namespace,
118
+ version=args.version,
119
+ link_mode=args.link_mode,
120
+ registry_root_override=args.registry_root,
121
+ cache_root_override=args.cache_root,
122
+ )
123
+ for record in records:
124
+ print(f"updated {record.package} {record.version} -> {record.installed_root}")
125
+ return 0
126
+
127
+
128
+ def _cmd_remove(args: argparse.Namespace) -> int:
129
+ removed = remove_installation(
130
+ args.target,
131
+ namespace=args.namespace,
132
+ version=args.version,
133
+ registry_root_override=args.registry_root,
134
+ )
135
+ for path in removed:
136
+ print(f"removed {path}")
137
+ return 0
138
+
139
+
140
+ def _cmd_list(args: argparse.Namespace) -> int:
141
+ items = list_installed_packages(args.registry_root)
142
+ if args.json:
143
+ print(json.dumps(items, indent=2, sort_keys=True))
144
+ return 0
145
+ for item in items:
146
+ print(
147
+ f'{item["namespace"]}/{item["package"]}/{item["version"]} '
148
+ f'[{item["link_mode"]}] <- {item["source"]}'
149
+ )
150
+ return 0
151
+
152
+
153
+ def _cmd_status(args: argparse.Namespace) -> int:
154
+ status = installation_status(
155
+ args.target,
156
+ namespace=args.namespace,
157
+ version=args.version,
158
+ registry_root_override=args.registry_root,
159
+ )
160
+ for key in sorted(status):
161
+ print(f"{key}: {status[key]}")
162
+ return 0
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def default_registry_root() -> Path:
8
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
9
+ if xdg_data_home:
10
+ return Path(xdg_data_home).expanduser() / "typst" / "packages"
11
+ return Path.home() / ".local" / "share" / "typst" / "packages"
12
+
13
+
14
+ def default_cache_root() -> Path:
15
+ xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
16
+ if xdg_cache_home:
17
+ return Path(xdg_cache_home).expanduser() / "typst-forge"
18
+ return Path.home() / ".cache" / "typst-forge"
19
+
20
+
21
+ def registry_root(override: str | None = None) -> Path:
22
+ if override:
23
+ return Path(override).expanduser()
24
+ return default_registry_root()
25
+
26
+
27
+ def metadata_root(override: str | None = None) -> Path:
28
+ return registry_root(override) / ".forge"
29
+
30
+
31
+ def cache_root(override: str | None = None) -> Path:
32
+ if override:
33
+ return Path(override).expanduser()
34
+ return default_cache_root()
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ VERSION_TAG = re.compile(r"^v?(?P<version>\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?)$")
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class GitState:
15
+ root: Path
16
+ commit: str | None
17
+ tags: tuple[str, ...]
18
+
19
+
20
+ def run_git(args: list[str], cwd: Path | None = None) -> str:
21
+ result = subprocess.run(
22
+ ["git", *args],
23
+ cwd=cwd,
24
+ check=True,
25
+ text=True,
26
+ capture_output=True,
27
+ )
28
+ return result.stdout.strip()
29
+
30
+
31
+ def git_available() -> bool:
32
+ return shutil.which("git") is not None
33
+
34
+
35
+ def repo_root(start: Path) -> Path:
36
+ output = run_git(["rev-parse", "--show-toplevel"], cwd=start)
37
+ return Path(output)
38
+
39
+
40
+ def git_state(project_root: Path) -> GitState:
41
+ root = repo_root(project_root)
42
+ try:
43
+ commit = run_git(["rev-parse", "HEAD"], cwd=root)
44
+ except subprocess.CalledProcessError:
45
+ commit = None
46
+ tags_output = ""
47
+ if commit is not None:
48
+ tags_output = run_git(["tag", "--points-at", "HEAD"], cwd=root)
49
+ tags = tuple(sorted(filter(None, tags_output.splitlines())))
50
+ return GitState(root=root, commit=commit, tags=tags)
51
+
52
+
53
+ def normalize_version(tag: str) -> str:
54
+ match = VERSION_TAG.fullmatch(tag)
55
+ if not match:
56
+ raise ValueError(f"tag {tag!r} is not a version-like tag")
57
+ return match.group("version")
58
+
59
+
60
+ def version_key(version: str) -> tuple[object, ...]:
61
+ normalized = normalize_version(version)
62
+ core, *suffix = normalized.split("-", 1)
63
+ parts = tuple(int(part) for part in core.split("."))
64
+ prerelease = suffix[0] if suffix else ""
65
+ return (*parts, 1 if not prerelease else 0, prerelease)
66
+
67
+
68
+ def highest_version(tags: list[str]) -> str | None:
69
+ normalized = []
70
+ for tag in tags:
71
+ try:
72
+ normalized.append((version_key(tag), normalize_version(tag)))
73
+ except ValueError:
74
+ continue
75
+ if not normalized:
76
+ return None
77
+ return max(normalized, key=lambda item: item[0])[1]
78
+
79
+
80
+ def local_version(project_root: Path, requested: str | None = None) -> str:
81
+ manifest_version = None
82
+ if requested is not None:
83
+ return normalize_version(requested)
84
+
85
+ try:
86
+ state = git_state(project_root)
87
+ except subprocess.CalledProcessError:
88
+ state = GitState(root=project_root, commit=None, tags=())
89
+
90
+ resolved = highest_version(list(state.tags))
91
+ if resolved is not None:
92
+ return resolved
93
+
94
+ from .manifest import load_manifest
95
+
96
+ manifest_version = load_manifest(project_root).version
97
+ return normalize_version(manifest_version)
98
+
99
+
100
+ def remote_tags(remote_url: str) -> list[str]:
101
+ try:
102
+ output = run_git(["ls-remote", "--tags", "--refs", remote_url])
103
+ except subprocess.CalledProcessError as exc:
104
+ raise ValueError(f"failed to inspect remote tags for {remote_url!r}") from exc
105
+ tags: list[str] = []
106
+ for line in output.splitlines():
107
+ _, ref = line.split("\t", 1)
108
+ if ref.startswith("refs/tags/"):
109
+ tags.append(ref.removeprefix("refs/tags/"))
110
+ return tags
111
+
112
+
113
+ def remote_version(remote_url: str, requested: str | None = None) -> str:
114
+ if requested is not None:
115
+ return normalize_version(requested)
116
+ tag = highest_version(remote_tags(remote_url))
117
+ if tag is None:
118
+ raise ValueError(f"no version-like tags found for {remote_url!r}")
119
+ return tag
120
+
121
+
122
+ def clone_release(remote_url: str, ref: str, destination: Path) -> Path:
123
+ if destination.exists():
124
+ shutil.rmtree(destination)
125
+ subprocess.run(
126
+ ["git", "clone", "--quiet", "--depth", "1", "--branch", ref, remote_url, str(destination)],
127
+ check=True,
128
+ text=True,
129
+ capture_output=True,
130
+ )
131
+ return destination
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .git import normalize_version
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ProjectManifest:
12
+ name: str
13
+ version: str
14
+ entrypoint: str
15
+ kind: str
16
+ raw: dict[str, object]
17
+
18
+
19
+ def load_manifest(project_root: Path) -> ProjectManifest:
20
+ path = project_root / "typst.toml"
21
+ if not path.is_file():
22
+ raise FileNotFoundError(f"missing manifest: {path}")
23
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
24
+ package = data.get("package")
25
+ if not isinstance(package, dict):
26
+ raise ValueError("typst.toml is missing a [package] table")
27
+
28
+ name = _require_string(package, "name")
29
+ version = normalize_version(_require_string(package, "version"))
30
+ entrypoint = _require_string(package, "entrypoint", default="src/lib.typ")
31
+
32
+ tool = data.get("tool")
33
+ kind = "package"
34
+ if isinstance(tool, dict):
35
+ forge = tool.get("typst_forge")
36
+ if isinstance(forge, dict):
37
+ maybe_kind = forge.get("kind", "package")
38
+ if isinstance(maybe_kind, str) and maybe_kind:
39
+ kind = maybe_kind
40
+ if entrypoint.startswith("template/"):
41
+ kind = "template"
42
+
43
+ return ProjectManifest(name=name, version=version, entrypoint=entrypoint, kind=kind, raw=data)
44
+
45
+
46
+ def _require_string(values: dict[str, object], key: str, default: str | None = None) -> str:
47
+ value = values.get(key, default)
48
+ if not isinstance(value, str) or not value:
49
+ raise ValueError(f"typst.toml [{key}] must be a non-empty string")
50
+ return value
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from .config import cache_root, metadata_root, registry_root
11
+ from .git import clone_release, local_version, remote_version
12
+ from .manifest import ProjectManifest, load_manifest
13
+ from .source import SourceSpec, parse_source, source_label
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class InstallRecord:
18
+ namespace: str
19
+ package: str
20
+ version: str
21
+ source_raw: str
22
+ source_kind: str
23
+ source_location: str
24
+ link_mode: str
25
+ installed_root: str
26
+ installed_at: str
27
+
28
+ @property
29
+ def install_path(self) -> Path:
30
+ return Path(self.installed_root)
31
+
32
+
33
+ def package_registry_path(root: str | None, namespace: str, package_name: str, version: str) -> Path:
34
+ return registry_root(root) / namespace / package_name / version
35
+
36
+
37
+ def metadata_path(root: str | None, namespace: str, package_name: str, version: str) -> Path:
38
+ return metadata_root(root) / namespace / package_name / f"{version}.json"
39
+
40
+
41
+ def install_source(
42
+ source: str | Path,
43
+ namespace: str = "local",
44
+ version: str | None = None,
45
+ link_mode: str = "auto",
46
+ registry_root_override: str | None = None,
47
+ cache_root_override: str | None = None,
48
+ force: bool = False,
49
+ ) -> InstallRecord:
50
+ spec = _normalize_source(source)
51
+ source_id = source_label(spec)
52
+ install_root, manifest, resolved_version, source_location = _resolve_source(
53
+ spec,
54
+ requested_version=version,
55
+ cache_root_override=cache_root_override,
56
+ )
57
+ target = package_registry_path(registry_root_override, namespace, manifest.name, resolved_version)
58
+ _install_tree(install_root, target, link_mode=link_mode, force=force)
59
+ record = InstallRecord(
60
+ namespace=namespace,
61
+ package=manifest.name,
62
+ version=resolved_version,
63
+ source_raw=source_id,
64
+ source_kind=spec.kind,
65
+ source_location=source_location,
66
+ link_mode=link_mode,
67
+ installed_root=str(target),
68
+ installed_at=datetime.now(timezone.utc).isoformat(),
69
+ )
70
+ _write_record(record, registry_root_override)
71
+ return record
72
+
73
+
74
+ def update_installation(
75
+ target: str | Path,
76
+ namespace: str = "local",
77
+ version: str | None = None,
78
+ link_mode: str | None = None,
79
+ registry_root_override: str | None = None,
80
+ cache_root_override: str | None = None,
81
+ ) -> list[InstallRecord]:
82
+ records = resolve_records(target, namespace=namespace, version=version, registry_root_override=registry_root_override)
83
+ updated: list[InstallRecord] = []
84
+ for record in records:
85
+ source = _source_from_record(record)
86
+ updated.append(
87
+ install_source(
88
+ source,
89
+ namespace=record.namespace,
90
+ version=record.version,
91
+ link_mode=link_mode or record.link_mode,
92
+ registry_root_override=registry_root_override,
93
+ cache_root_override=cache_root_override,
94
+ force=True,
95
+ )
96
+ )
97
+ return updated
98
+
99
+
100
+ def remove_installation(
101
+ target: str | Path,
102
+ namespace: str = "local",
103
+ version: str | None = None,
104
+ registry_root_override: str | None = None,
105
+ ) -> list[Path]:
106
+ removed: list[Path] = []
107
+ for record in resolve_records(target, namespace=namespace, version=version, registry_root_override=registry_root_override):
108
+ install_path = record.install_path
109
+ if install_path.is_symlink() or install_path.is_file():
110
+ install_path.unlink()
111
+ elif install_path.exists():
112
+ shutil.rmtree(install_path)
113
+ _remove_record(record, registry_root_override)
114
+ _prune_empty_dirs(install_path.parent)
115
+ removed.append(install_path)
116
+ return removed
117
+
118
+
119
+ def list_installed_packages(registry_root_override: str | None = None) -> list[dict[str, str]]:
120
+ records = _load_records(registry_root_override)
121
+ if not records:
122
+ records = _scan_registry(registry_root_override)
123
+ return [
124
+ {
125
+ "namespace": record.namespace,
126
+ "package": record.package,
127
+ "version": record.version,
128
+ "source": record.source_raw,
129
+ "source_kind": record.source_kind,
130
+ "link_mode": record.link_mode,
131
+ "target": record.installed_root,
132
+ }
133
+ for record in sorted(records, key=lambda item: (item.namespace, item.package, item.version))
134
+ ]
135
+
136
+
137
+ def installation_status(
138
+ target: str | Path,
139
+ namespace: str = "local",
140
+ version: str | None = None,
141
+ registry_root_override: str | None = None,
142
+ ) -> dict[str, str]:
143
+ records = resolve_records(target, namespace=namespace, version=version, registry_root_override=registry_root_override)
144
+ if not records:
145
+ return {"installed": "no"}
146
+ if len(records) == 1:
147
+ record = records[0]
148
+ return {
149
+ "installed": "yes",
150
+ "namespace": record.namespace,
151
+ "package": record.package,
152
+ "version": record.version,
153
+ "source": record.source_raw,
154
+ "source_kind": record.source_kind,
155
+ "link_mode": record.link_mode,
156
+ "target": record.installed_root,
157
+ }
158
+ return {
159
+ "installed": "yes",
160
+ "count": str(len(records)),
161
+ "packages": ",".join(f"{record.package}:{record.version}" for record in records),
162
+ }
163
+
164
+
165
+ def resolve_records(
166
+ target: str | Path,
167
+ namespace: str = "local",
168
+ version: str | None = None,
169
+ registry_root_override: str | None = None,
170
+ ) -> list[InstallRecord]:
171
+ try:
172
+ spec = _normalize_source(target)
173
+ except (ValueError, TypeError):
174
+ spec = None
175
+
176
+ records = _load_records(registry_root_override)
177
+ if spec is not None:
178
+ source_id = source_label(spec)
179
+ matching = [record for record in records if record.source_raw == source_id]
180
+ if version is not None:
181
+ matching = [record for record in matching if record.version == version]
182
+ return matching
183
+
184
+ package_name = str(target)
185
+ matching = [record for record in records if record.namespace == namespace and record.package == package_name]
186
+ if version is not None:
187
+ matching = [record for record in matching if record.version == version]
188
+ if matching:
189
+ return matching
190
+
191
+ package_dir = registry_root(registry_root_override) / namespace / package_name
192
+ if package_dir.exists():
193
+ if version is not None:
194
+ child = package_dir / version
195
+ if child.exists() or child.is_symlink():
196
+ return [_record_from_path(child, namespace, package_name)]
197
+ else:
198
+ return [_record_from_path(child, namespace, package_name) for child in package_dir.iterdir() if child.name and not child.name.startswith(".")]
199
+ return []
200
+
201
+
202
+ def _normalize_source(source: str | Path) -> SourceSpec:
203
+ if isinstance(source, Path):
204
+ return parse_source(str(source))
205
+ return parse_source(str(source))
206
+
207
+
208
+ def _resolve_source(
209
+ spec: SourceSpec,
210
+ requested_version: str | None,
211
+ cache_root_override: str | None,
212
+ ) -> tuple[Path, ProjectManifest, str, str]:
213
+ if spec.kind == "local":
214
+ if spec.path is None:
215
+ raise ValueError("local source is missing a path")
216
+ manifest = load_manifest(spec.path)
217
+ resolved_version = local_version(spec.path, requested_version)
218
+ if requested_version is not None and manifest.version != resolved_version:
219
+ raise ValueError(
220
+ f"manifest version {manifest.version!r} does not match requested version {resolved_version!r}"
221
+ )
222
+ if manifest.version != resolved_version:
223
+ raise ValueError(
224
+ f"manifest version {manifest.version!r} does not match resolved version {resolved_version!r}"
225
+ )
226
+ return spec.path, manifest, resolved_version, str(spec.path)
227
+
228
+ if spec.kind == "github":
229
+ remote_url = _github_url(spec)
230
+ release = _resolve_remote_release(remote_url, spec.ref or requested_version)
231
+ checkout = _checkout_cache_path(cache_root_override, spec, release.version)
232
+ clone_release(remote_url, release.ref, checkout)
233
+ manifest = load_manifest(checkout)
234
+ if manifest.version != release.version:
235
+ raise ValueError(
236
+ f"manifest version {manifest.version!r} does not match release {release.version!r}"
237
+ )
238
+ return checkout, manifest, release.version, remote_url
239
+
240
+ if spec.kind == "remote":
241
+ if spec.url is None:
242
+ raise ValueError("remote source is missing a url")
243
+ release = _resolve_remote_release(spec.url, requested_version)
244
+ checkout = _checkout_cache_path(cache_root_override, spec, release.version)
245
+ clone_release(spec.url, release.ref, checkout)
246
+ manifest = load_manifest(checkout)
247
+ if manifest.version != release.version:
248
+ raise ValueError(
249
+ f"manifest version {manifest.version!r} does not match release {release.version!r}"
250
+ )
251
+ return checkout, manifest, release.version, spec.url
252
+
253
+ raise ValueError(f"unsupported source kind: {spec.kind}")
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class _RemoteRelease:
258
+ ref: str
259
+ version: str
260
+
261
+
262
+ def _resolve_remote_release(remote_url: str, requested_version: str | None) -> _RemoteRelease:
263
+ version = remote_version(remote_url, requested_version)
264
+ return _RemoteRelease(ref=_version_ref(remote_url, version), version=version)
265
+
266
+
267
+ def _version_ref(remote_url: str, version: str) -> str:
268
+ tags = _remote_tag_map(remote_url)
269
+ if version in tags:
270
+ return tags[version]
271
+ prefixed = f"v{version}"
272
+ if prefixed in tags:
273
+ return tags[prefixed]
274
+ raise ValueError(f"version {version!r} is not available on {remote_url!r}")
275
+
276
+
277
+ def _remote_tag_map(remote_url: str) -> dict[str, str]:
278
+ from .git import remote_tags
279
+
280
+ tags = remote_tags(remote_url)
281
+ mapping: dict[str, str] = {}
282
+ for tag in tags:
283
+ try:
284
+ mapping.setdefault(tag, tag)
285
+ mapping.setdefault(tag.removeprefix("v"), tag)
286
+ except Exception:
287
+ continue
288
+ return mapping
289
+
290
+
291
+ def _github_url(spec: SourceSpec) -> str:
292
+ if not spec.owner or not spec.repo:
293
+ raise ValueError("github source is missing owner or repo")
294
+ return f"https://github.com/{spec.owner}/{spec.repo}.git"
295
+
296
+
297
+ def _checkout_cache_path(cache_root_override: str | None, spec: SourceSpec, version: str) -> Path:
298
+ root = cache_root(cache_root_override) / "checkouts"
299
+ source_id = spec.owner or spec.url or spec.raw
300
+ safe = source_id.replace(":", "_").replace("/", "_")
301
+ return root / safe / version
302
+
303
+
304
+ def _install_tree(source_root: Path, target: Path, link_mode: str, force: bool) -> None:
305
+ if target.exists() or target.is_symlink():
306
+ if not force:
307
+ if target.is_symlink() and target.resolve(strict=False) == source_root.resolve(strict=False):
308
+ return
309
+ raise FileExistsError(f"registry entry already exists: {target}")
310
+ if target.is_symlink() or target.is_file():
311
+ target.unlink()
312
+ else:
313
+ shutil.rmtree(target)
314
+ target.parent.mkdir(parents=True, exist_ok=True)
315
+ if link_mode == "symlink":
316
+ target.symlink_to(source_root, target_is_directory=True)
317
+ return
318
+ if link_mode == "copy":
319
+ shutil.copytree(source_root, target, symlinks=True, ignore=_copy_ignore)
320
+ return
321
+ try:
322
+ target.symlink_to(source_root, target_is_directory=True)
323
+ except OSError:
324
+ shutil.copytree(source_root, target, symlinks=True, ignore=_copy_ignore)
325
+
326
+
327
+ def _copy_ignore(_: str, names: list[str]) -> set[str]:
328
+ ignored = {".git", "__pycache__", ".typst-forge"}
329
+ return {name for name in names if name in ignored}
330
+
331
+
332
+ def _write_record(record: InstallRecord, registry_root_override: str | None) -> None:
333
+ path = metadata_path(registry_root_override, record.namespace, record.package, record.version)
334
+ path.parent.mkdir(parents=True, exist_ok=True)
335
+ payload = asdict(record)
336
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
337
+
338
+
339
+ def _remove_record(record: InstallRecord, registry_root_override: str | None) -> None:
340
+ path = metadata_path(registry_root_override, record.namespace, record.package, record.version)
341
+ if path.exists():
342
+ path.unlink()
343
+ _prune_metadata_dirs(path.parent)
344
+
345
+
346
+ def _load_records(registry_root_override: str | None) -> list[InstallRecord]:
347
+ root = metadata_root(registry_root_override)
348
+ if not root.exists():
349
+ return []
350
+ records: list[InstallRecord] = []
351
+ for meta in root.rglob("*.json"):
352
+ try:
353
+ payload = json.loads(meta.read_text(encoding="utf-8"))
354
+ records.append(InstallRecord(**payload))
355
+ except Exception:
356
+ continue
357
+ return records
358
+
359
+
360
+ def _scan_registry(registry_root_override: str | None) -> list[InstallRecord]:
361
+ root = registry_root(registry_root_override)
362
+ if not root.exists():
363
+ return []
364
+ records: list[InstallRecord] = []
365
+ for namespace_dir in root.iterdir():
366
+ if not namespace_dir.is_dir() or namespace_dir.name.startswith("."):
367
+ continue
368
+ for package_dir in namespace_dir.iterdir():
369
+ if not package_dir.is_dir() or package_dir.name.startswith("."):
370
+ continue
371
+ for version_dir in package_dir.iterdir():
372
+ if version_dir.name.startswith("."):
373
+ continue
374
+ records.append(_record_from_path(version_dir, namespace_dir.name, package_dir.name))
375
+ return records
376
+
377
+
378
+ def _record_from_path(path: Path, namespace: str, package: str) -> InstallRecord:
379
+ if path.is_symlink():
380
+ source_location = str(path.resolve(strict=False))
381
+ source_kind = "local"
382
+ else:
383
+ source_location = str(path)
384
+ source_kind = "copy"
385
+ return InstallRecord(
386
+ namespace=namespace,
387
+ package=package,
388
+ version=path.name,
389
+ source_raw=source_location,
390
+ source_kind=source_kind,
391
+ source_location=source_location,
392
+ link_mode="copy" if source_kind == "copy" else "symlink",
393
+ installed_root=str(path),
394
+ installed_at="",
395
+ )
396
+
397
+
398
+ def _source_from_record(record: InstallRecord) -> str | Path:
399
+ if record.source_kind == "local":
400
+ return Path(record.source_location)
401
+ if record.source_kind in {"github", "remote"}:
402
+ return record.source_raw
403
+ return record.source_location
404
+
405
+
406
+ def _prune_empty_dirs(path: Path) -> None:
407
+ current = path
408
+ while True:
409
+ if current.name in {"packages", ".forge"}:
410
+ break
411
+ try:
412
+ current.rmdir()
413
+ except OSError:
414
+ break
415
+ current = current.parent
416
+
417
+
418
+ def _prune_metadata_dirs(path: Path) -> None:
419
+ current = path
420
+ while True:
421
+ if current.name == ".forge":
422
+ break
423
+ try:
424
+ current.rmdir()
425
+ except OSError:
426
+ break
427
+ current = current.parent
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .git import git_available
7
+
8
+
9
+ def scaffold_project(root: Path, kind: str, name: str, version: str) -> None:
10
+ root = root.resolve()
11
+ root.mkdir(parents=True, exist_ok=True)
12
+
13
+ (root / "src").mkdir(exist_ok=True)
14
+ if kind == "template":
15
+ (root / "template").mkdir(exist_ok=True)
16
+
17
+ (root / "typst.toml").write_text(_typst_toml(kind, name, version), encoding="utf-8")
18
+ (root / "src" / "lib.typ").write_text(_lib_typ(kind), encoding="utf-8")
19
+ if kind == "template":
20
+ (root / "template" / "main.typ").write_text(_template_main_typ(), encoding="utf-8")
21
+
22
+ (root / ".gitignore").write_text(
23
+ "\n".join(
24
+ [
25
+ ".typst-forge",
26
+ "build",
27
+ "__pycache__",
28
+ "",
29
+ ]
30
+ ),
31
+ encoding="utf-8",
32
+ )
33
+
34
+ if not git_available():
35
+ raise RuntimeError("git is required to initialize Typst Forge projects")
36
+ subprocess.run(["git", "init"], cwd=root, check=True, text=True, capture_output=True)
37
+
38
+
39
+ def _typst_toml(kind: str, name: str, version: str) -> str:
40
+ entrypoint = "template/main.typ" if kind == "template" else "src/lib.typ"
41
+ return "\n".join(
42
+ [
43
+ "[package]",
44
+ f'name = "{name}"',
45
+ f'version = "{version}"',
46
+ f'entrypoint = "{entrypoint}"',
47
+ "",
48
+ "[tool.typst_forge]",
49
+ f'kind = "{kind}"',
50
+ "",
51
+ ]
52
+ )
53
+
54
+
55
+ def _lib_typ(kind: str) -> str:
56
+ if kind == "template":
57
+ return (
58
+ "#let render(title, body) = [\n"
59
+ "= #title\n\n"
60
+ "#body\n"
61
+ "]\n"
62
+ )
63
+ return '#let hello(name) = "Hello, " + name\n'
64
+
65
+
66
+ def _template_main_typ() -> str:
67
+ return (
68
+ '#import "../src/lib.typ": render\n\n'
69
+ '#render("Typst Forge", "template scaffold")\n'
70
+ )
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SourceSpec:
10
+ raw: str
11
+ kind: str
12
+ path: Path | None = None
13
+ url: str | None = None
14
+ owner: str | None = None
15
+ repo: str | None = None
16
+ ref: str | None = None
17
+
18
+
19
+ def parse_source(raw: str, cwd: Path | None = None) -> SourceSpec:
20
+ if not raw:
21
+ raise ValueError("source cannot be empty")
22
+
23
+ cwd = cwd or Path.cwd()
24
+ candidate = Path(raw).expanduser()
25
+ if candidate.exists():
26
+ return SourceSpec(raw=raw, kind="local", path=candidate.resolve())
27
+
28
+ if raw.startswith("github:"):
29
+ return _parse_github(raw[len("github:") :], raw)
30
+
31
+ parsed = urlparse(raw)
32
+ if parsed.scheme:
33
+ if parsed.scheme in {"http", "https", "ssh", "git", "file"}:
34
+ return SourceSpec(raw=raw, kind="remote", url=raw)
35
+ raise ValueError(f"unsupported source url scheme: {parsed.scheme}")
36
+
37
+ if raw.startswith("git@") or raw.endswith(".git"):
38
+ return SourceSpec(raw=raw, kind="remote", url=raw)
39
+
40
+ if "/" in raw:
41
+ return _parse_github(raw, raw)
42
+
43
+ maybe_path = (cwd / raw).expanduser()
44
+ if maybe_path.exists():
45
+ return SourceSpec(raw=raw, kind="local", path=maybe_path.resolve())
46
+
47
+ raise ValueError(f"cannot parse source: {raw!r}")
48
+
49
+
50
+ def _parse_github(value: str, raw: str) -> SourceSpec:
51
+ ref: str | None = None
52
+ repo_part = value
53
+ if "@" in value:
54
+ repo_part, ref = value.rsplit("@", 1)
55
+ if repo_part.startswith("https://github.com/"):
56
+ repo_part = repo_part.removeprefix("https://github.com/")
57
+ if repo_part.startswith("http://github.com/"):
58
+ repo_part = repo_part.removeprefix("http://github.com/")
59
+ if repo_part.endswith(".git"):
60
+ repo_part = repo_part[:-4]
61
+ owner, _, repo = repo_part.partition("/")
62
+ if not owner or not repo:
63
+ raise ValueError(f"invalid github source: {raw!r}")
64
+ return SourceSpec(raw=raw, kind="github", owner=owner, repo=repo, ref=ref)
65
+
66
+
67
+ def source_label(spec: SourceSpec) -> str:
68
+ if spec.kind == "local" and spec.path is not None:
69
+ return str(spec.path)
70
+ if spec.kind == "github" and spec.owner and spec.repo:
71
+ label = f"github:{spec.owner}/{spec.repo}"
72
+ if spec.ref:
73
+ label += f"@{spec.ref}"
74
+ return label
75
+ if spec.url:
76
+ return spec.url
77
+ return spec.raw
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: typst-forge
3
+ Version: 0.1.0
4
+ Summary: A local manager for Typst templates and packages
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Typst Forge
9
+
10
+ Typst Forge is a small local manager for Typst packages and templates.
11
+
12
+ It can:
13
+
14
+ - scaffold a new Typst repo with `git`, `typst.toml`, `src`, and `template/` when needed
15
+ - install local development repos into Typst's package directory
16
+ - install release repos from GitHub or any git URL
17
+ - manage installed packages/templates with versioned registry entries
18
+ - fall back between symlink and copy modes for platform-friendly installs
19
+
20
+ ## Install
21
+
22
+ This repo is meant to be run with `uv`:
23
+
24
+ ```bash
25
+ uv run typst-forge --help
26
+ ```
27
+
28
+ To run it without installing from a checkout, use `uvx`:
29
+
30
+ ```bash
31
+ uvx --from . typst-forge --help
32
+ ```
33
+
34
+ You can also install directly from GitHub:
35
+
36
+ ```bash
37
+ uvx --from git+https://github.com/<owner>/typst-forge typst-forge --help
38
+ ```
39
+
40
+ ## Commands
41
+
42
+ ```bash
43
+ typst-forge init ./my-template --kind template --name my-template --version 0.1.0
44
+ typst-forge init ./my-package --kind package --name my-package --version 0.1.0
45
+ typst-forge install ./my-package --link-mode copy
46
+ typst-forge install github:owner/repo@v1.2.3
47
+ typst-forge list
48
+ typst-forge status my-package
49
+ typst-forge update my-package
50
+ typst-forge remove my-package
51
+ ```
52
+
53
+ ## Versioning
54
+
55
+ - local repos use the `typst.toml` package version unless a matching git tag is present at `HEAD`
56
+ - GitHub and other git URLs install from release tags
57
+ - tags can be written as `v1.2.3` or `1.2.3`; the stored Typst version is normalized to `1.2.3`
58
+ - installed entries live under `~/.local/share/typst/packages/<namespace>/<package>/<version>`
59
+
60
+ ## Tests
61
+
62
+ Run the in-repo test suite with:
63
+
64
+ ```bash
65
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
66
+ ```
67
+
68
+ ## Publishing
69
+
70
+ The GitHub Actions workflow in [.github/workflows/publish.yml](./.github/workflows/publish.yml)
71
+ publishes tagged releases to PyPI with trusted publishing.
72
+
73
+ Create and push a version tag like `v0.1.0` to trigger it.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/typst_forge/__init__.py
4
+ src/typst_forge/__main__.py
5
+ src/typst_forge/cli.py
6
+ src/typst_forge/config.py
7
+ src/typst_forge/git.py
8
+ src/typst_forge/manifest.py
9
+ src/typst_forge/registry.py
10
+ src/typst_forge/scaffold.py
11
+ src/typst_forge/source.py
12
+ src/typst_forge.egg-info/PKG-INFO
13
+ src/typst_forge.egg-info/SOURCES.txt
14
+ src/typst_forge.egg-info/dependency_links.txt
15
+ src/typst_forge.egg-info/entry_points.txt
16
+ src/typst_forge.egg-info/top_level.txt
17
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ typst-forge = typst_forge.cli:main
@@ -0,0 +1 @@
1
+ typst_forge
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def git(*args: str, cwd: Path) -> None:
14
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
15
+
16
+
17
+ def forge(*args: str, home: Path, capture_output: bool = False) -> subprocess.CompletedProcess[str]:
18
+ env = os.environ.copy()
19
+ env["HOME"] = str(home)
20
+ env["XDG_DATA_HOME"] = str(home / ".local" / "share")
21
+ env["XDG_CACHE_HOME"] = str(home / ".cache")
22
+ env["PYTHONPATH"] = str(ROOT / "src")
23
+ kwargs: dict[str, object] = {"cwd": ROOT, "check": True, "text": True, "env": env}
24
+ if capture_output:
25
+ kwargs["capture_output"] = True
26
+ return subprocess.run(["python3", "-m", "typst_forge", *args], **kwargs)
27
+
28
+
29
+ def init_repo(root: Path, kind: str, name: str, version: str) -> Path:
30
+ forge("init", str(root), "--kind", kind, "--name", name, "--version", version, home=root.parent)
31
+ git("config", "user.email", "test@example.com", cwd=root)
32
+ git("config", "user.name", "Test User", cwd=root)
33
+ git("add", ".", cwd=root)
34
+ git("commit", "-m", "initial", cwd=root)
35
+ git("tag", f"v{version}", cwd=root)
36
+ return root
37
+
38
+
39
+ class TypstForgeCliTest(unittest.TestCase):
40
+ def test_init_creates_expected_layouts(self) -> None:
41
+ with tempfile.TemporaryDirectory() as tempdir:
42
+ temp = Path(tempdir)
43
+ package_repo = temp / "dummy-package"
44
+ template_repo = temp / "dummy-template"
45
+
46
+ init_repo(package_repo, "package", "dummy-package", "0.2.0")
47
+ init_repo(template_repo, "template", "dummy-template", "0.1.0")
48
+
49
+ self.assertTrue((package_repo / "typst.toml").is_file())
50
+ self.assertTrue((package_repo / "src" / "lib.typ").is_file())
51
+ self.assertFalse((package_repo / "template").exists())
52
+
53
+ self.assertTrue((template_repo / "typst.toml").is_file())
54
+ self.assertTrue((template_repo / "src" / "lib.typ").is_file())
55
+ self.assertTrue((template_repo / "template" / "main.typ").is_file())
56
+
57
+ def test_local_install_update_list_and_remove(self) -> None:
58
+ with tempfile.TemporaryDirectory() as tempdir:
59
+ temp = Path(tempdir)
60
+ home = temp / "home"
61
+ home.mkdir()
62
+ repo = init_repo(temp / "dummy-package", "package", "dummy-package", "0.2.0")
63
+ install_path = home / ".local" / "share" / "typst" / "packages" / "local" / "dummy-package" / "0.2.0"
64
+
65
+ forge("install", str(repo), "--link-mode", "copy", home=home)
66
+ self.assertTrue(install_path.exists())
67
+ self.assertTrue((install_path / "src" / "lib.typ").is_file())
68
+
69
+ repo.joinpath("src", "lib.typ").write_text('#let hello(name) = "Hi, " + name\n', encoding="utf-8")
70
+ git("add", "src/lib.typ", cwd=repo)
71
+ git("commit", "-m", "update lib", cwd=repo)
72
+
73
+ forge("update", "dummy-package", home=home)
74
+ self.assertIn("Hi,", (install_path / "src" / "lib.typ").read_text(encoding="utf-8"))
75
+
76
+ list_output = forge("list", home=home, capture_output=True).stdout
77
+ self.assertIn("local/dummy-package/0.2.0", list_output)
78
+ self.assertIn("[copy]", list_output)
79
+
80
+ status_output = forge("status", "dummy-package", home=home, capture_output=True).stdout
81
+ self.assertIn("installed: yes", status_output)
82
+ self.assertIn("package: dummy-package", status_output)
83
+ self.assertIn("version: 0.2.0", status_output)
84
+
85
+ forge("remove", "dummy-package", home=home)
86
+ self.assertFalse(install_path.exists())
87
+
88
+ def test_remote_install_from_git_url_uses_release_tag(self) -> None:
89
+ with tempfile.TemporaryDirectory() as tempdir:
90
+ temp = Path(tempdir)
91
+ home = temp / "home"
92
+ home.mkdir()
93
+ repo = init_repo(temp / "remote-template", "template", "remote-template", "1.0.0")
94
+ remote = temp / "remote-template.git"
95
+ subprocess.run(["git", "clone", "--bare", str(repo), str(remote)], check=True, capture_output=True, text=True)
96
+
97
+ forge("install", f"file://{remote}", "--link-mode", "symlink", home=home)
98
+ install_path = home / ".local" / "share" / "typst" / "packages" / "local" / "remote-template" / "1.0.0"
99
+ self.assertTrue(install_path.is_symlink())
100
+ self.assertIn("remote-template", forge("list", home=home, capture_output=True).stdout)
101
+
102
+ status_output = forge("status", f"file://{remote}", home=home, capture_output=True).stdout
103
+ self.assertIn("source_kind: remote", status_output)
104
+ self.assertIn("version: 1.0.0", status_output)
105
+
106
+ def test_github_style_source_parser_accepts_tagged_refs(self) -> None:
107
+ from typst_forge.source import parse_source
108
+
109
+ spec = parse_source("github:example/hello-world@v1.2.3")
110
+ self.assertEqual(spec.kind, "github")
111
+ self.assertEqual(spec.owner, "example")
112
+ self.assertEqual(spec.repo, "hello-world")
113
+ self.assertEqual(spec.ref, "v1.2.3")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()