Jarvis-Forge 1.0.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.
@@ -0,0 +1,4 @@
1
+ """Independent command orchestration and acceptance evidence for Jarvis."""
2
+
3
+ __version__ = "1.0.0"
4
+
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
4
+
@@ -0,0 +1,234 @@
1
+ """APT-shaped commands backed by Forge recipes, never the system apt binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from pathlib import Path
7
+ import shlex
8
+ from typing import Optional
9
+
10
+ from packaging.version import Version
11
+ import typer
12
+
13
+ from .catalog import Catalog, requirement, source_config
14
+ from .packages import (edit_sources, environment_path, install, installed, plan_install,
15
+ plan_remove, remove, update)
16
+ from .storage import ForgeError
17
+
18
+
19
+ app = typer.Typer(invoke_without_command=True, no_args_is_help=False,
20
+ help="Manage source-built packages in your Forge home without sudo.\n\n"
21
+ "Command help: jforge apt COMMAND -h")
22
+
23
+
24
+ def _emit(ctx, command, **values):
25
+ from .cli import emit
26
+ return emit(ctx, "apt " + command, **values)
27
+
28
+
29
+ @app.callback()
30
+ def apt(ctx: typer.Context):
31
+ if ctx.invoked_subcommand is None:
32
+ if ctx.obj.as_json:
33
+ _emit(ctx, "help", commands=sorted(ctx.command.commands))
34
+ else:
35
+ typer.echo(ctx.get_help())
36
+
37
+
38
+ @app.command("list")
39
+ def list_packages(ctx: typer.Context,
40
+ pattern: str = typer.Argument("*", help="Package name pattern.", metavar="<pattern>"),
41
+ installed_only: bool = typer.Option(False, "--installed", help="Show recorded installations only."),
42
+ upgradable: bool = typer.Option(False, "--upgradable", help="Show active packages with newer recipes.")):
43
+ """List available or installed user-local packages."""
44
+ records = installed(ctx.obj.home)
45
+ catalog = Catalog(ctx.obj.home)
46
+ if installed_only:
47
+ packages = records
48
+ else:
49
+ packages = []
50
+ for recipe in catalog.list():
51
+ matches = [r for r in records if r["name"] == recipe["name"] and r["version"] == recipe["version"]]
52
+ active = next((r for r in matches if r["active"]), None)
53
+ packages.append({"name": recipe["name"], "version": recipe["version"],
54
+ "status": "installed" if matches else "available", "active": bool(active),
55
+ "prefix": active["prefix"] if active else "", "description": recipe["description"]})
56
+ if upgradable:
57
+ packages = [r for r in records if r["active"] and any(
58
+ p["name"] == r["name"] and (Version(p["version"]) > Version(r["version"]) or
59
+ (Version(p["version"]) == Version(r["version"]) and p["source"] != r.get("source")))
60
+ for p in catalog.list())]
61
+ _emit(ctx, "list", packages=[p for p in packages if fnmatch.fnmatchcase(p["name"], pattern)])
62
+
63
+
64
+ @app.command()
65
+ def search(ctx: typer.Context,
66
+ query: str = typer.Argument(..., help="Match package names and descriptions.", metavar="<query>")):
67
+ """Search the recipe catalog without downloading or installing."""
68
+ _emit(ctx, "search", packages=[{"name": p["name"], "version": p["version"], "description": p["description"]}
69
+ for p in Catalog(ctx.obj.home).list(query)])
70
+
71
+
72
+ @app.command()
73
+ def show(ctx: typer.Context,
74
+ package: str = typer.Argument(..., help="Package or package=version.", metavar="<package>")):
75
+ """Show recipe details, dependencies and installed locations."""
76
+ req = requirement(package)
77
+ choices = [p for p in Catalog(ctx.obj.home).list() if p["name"] == req.name and Version(p["version"]) in req.specifier]
78
+ if not choices:
79
+ raise ForgeError(f"No recipe matches {package}.", hint="jforge apt search <query>")
80
+ recipe = max(choices, key=lambda p: Version(p["version"]))
81
+ _emit(ctx, "show", package=recipe, installed_instances=[r for r in installed(ctx.obj.home) if r["name"] == req.name])
82
+
83
+
84
+ def _install(ctx, packages, jobs, timeout, dry_run, mode):
85
+ from .cli import _console
86
+ if dry_run:
87
+ result = plan_install(ctx.obj.home, packages or [], jobs=jobs, mode=mode)
88
+ _emit(ctx, mode, dry_run=True, plan=result)
89
+ else:
90
+ result = install(ctx.obj.home, packages or [], jobs=jobs, timeout=timeout, mode=mode,
91
+ progress=lambda message: _console(stderr=True).print(message, markup=False))
92
+ _emit(ctx, mode, **result)
93
+
94
+
95
+ @app.command("install")
96
+ def install_packages(ctx: typer.Context,
97
+ packages: list[str] = typer.Argument(..., help="Package names or name=version specs.", metavar="<packages>"),
98
+ jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
99
+ timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
100
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the plan without writing or downloading.")):
101
+ """Download, verify, build and install into private prefixes."""
102
+ _install(ctx, packages, jobs, timeout, dry_run, "install")
103
+
104
+
105
+ @app.command()
106
+ def reinstall(ctx: typer.Context,
107
+ packages: list[str] = typer.Argument(..., help="Packages to rebuild into new instances.", metavar="<packages>"),
108
+ jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
109
+ timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
110
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the reinstall plan only.")):
111
+ """Rebuild requested packages while keeping working instances."""
112
+ _install(ctx, packages, jobs, timeout, dry_run, "reinstall")
113
+
114
+
115
+ @app.command()
116
+ def upgrade(ctx: typer.Context,
117
+ packages: Optional[list[str]] = typer.Argument(None, help="Upgrade these packages, or all manual roots.", metavar="<packages>"),
118
+ jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
119
+ timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
120
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the upgrade plan only.")):
121
+ """Select newer compatible recipes and retain previous instances."""
122
+ _install(ctx, packages, jobs, timeout, dry_run, "upgrade")
123
+
124
+
125
+ @app.command("full-upgrade")
126
+ def full_upgrade(ctx: typer.Context,
127
+ jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
128
+ timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
129
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the full upgrade plan only.")):
130
+ """Upgrade all roots and dependencies; old instances are retained."""
131
+ _install(ctx, [], jobs, timeout, dry_run, "full-upgrade")
132
+
133
+
134
+ @app.command()
135
+ def satisfy(ctx: typer.Context,
136
+ constraints: list[str] = typer.Argument(..., help="Version constraints, such as 'eigen>=3.4'.", metavar="<constraints>"),
137
+ jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
138
+ timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
139
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Resolve constraints without installing.")):
140
+ """Install recipes satisfying explicit version constraints."""
141
+ if dry_run:
142
+ _emit(ctx, "satisfy", dry_run=True, plan=plan_install(ctx.obj.home, constraints, jobs=jobs))
143
+ else:
144
+ from .cli import _console
145
+ _emit(ctx, "satisfy", **install(ctx.obj.home, constraints, jobs=jobs, timeout=timeout,
146
+ progress=lambda m: _console(stderr=True).print(m, markup=False)))
147
+
148
+
149
+ def _remove(ctx, packages, automatic, yes, dry_run):
150
+ command = "autoremove" if automatic else "remove"
151
+ plan = plan_remove(ctx.obj.home, packages, automatic=automatic)
152
+ if dry_run:
153
+ _emit(ctx, command, dry_run=True, **plan)
154
+ return
155
+ if plan["packages"] and not yes:
156
+ import sys
157
+ if ctx.obj.as_json or not sys.stdin.isatty():
158
+ raise ForgeError("Removal requires --yes in non-interactive/JSON mode.",
159
+ hint=f"jforge apt {command} {' '.join(packages)} --yes")
160
+ names = ", ".join(f"{p['name']}={p['version']} ({p['install_id']})" for p in plan["packages"])
161
+ if not typer.confirm(f"Remove {names}?", default=False):
162
+ raise ForgeError("Removal cancelled.")
163
+ _emit(ctx, command, **remove(ctx.obj.home, packages, automatic=automatic, expected_keys=plan["keys"]))
164
+
165
+
166
+ @app.command("remove")
167
+ def remove_packages(ctx: typer.Context,
168
+ packages: list[str] = typer.Argument(..., help="Package names or name=version specs.", metavar="<packages>"),
169
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm removal of the selected installations."),
170
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show what would be removed.")):
171
+ """Remove Forge-owned installations after dependency checks."""
172
+ _remove(ctx, packages, False, yes, dry_run)
173
+
174
+
175
+ @app.command()
176
+ def autoremove(ctx: typer.Context,
177
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm removal of unused automatic dependencies."),
178
+ dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show unused automatic dependencies.")):
179
+ """Remove automatic packages unreachable from retained manual installs."""
180
+ _remove(ctx, [], True, yes, dry_run)
181
+
182
+
183
+ @app.command("update")
184
+ def update_sources(ctx: typer.Context):
185
+ """Refresh recipe indexes; installed software is not changed."""
186
+ _emit(ctx, "update", **update(ctx.obj.home))
187
+
188
+
189
+ @app.command("import")
190
+ def import_official(ctx: typer.Context,
191
+ package: str = typer.Argument(..., help="Official binary or source package name.", metavar="<package>"),
192
+ suite: str = typer.Option(..., "--suite", help="Distribution codename, such as bookworm or jammy.", metavar="<str>"),
193
+ origin: str = typer.Option("debian", "--origin", help="Official distribution: debian or ubuntu.", metavar="<debian|ubuntu>"),
194
+ component: str = typer.Option("main", "--component", help="Repository component.", metavar="<str>"),
195
+ name: Optional[str] = typer.Option(None, "--name", help="Forge package name.", metavar="<str>"),
196
+ template: Optional[str] = typer.Option(None, "--template", help="Reuse a reviewed Forge recipe's build adapter.", metavar="<str>"),
197
+ source_version: Optional[str] = typer.Option(None, "--source-version", help="Pin an exact Debian source version.", metavar="<str>"),
198
+ keyring: Optional[Path] = typer.Option(None, "--keyring", help="Require gpgv verification using this trusted archive keyring.", metavar="<path>")):
199
+ """Import official source metadata and track recipes with apt update."""
200
+ from .official import import_package
201
+ result = import_package(ctx.obj.home, package, origin=origin, suite=suite, component=component,
202
+ name=name, template_name=template, source_version=source_version, keyring=keyring)
203
+ _emit(ctx, "import", **result)
204
+
205
+
206
+ @app.command("edit-sources")
207
+ def configure_sources(ctx: typer.Context,
208
+ add: Optional[str] = typer.Option(None, "--add", help="Add a local directory/file or HTTPS YAML index.", metavar="<source>"),
209
+ remove_source: Optional[str] = typer.Option(None, "--remove", help="Remove a configured source.", metavar="<source>"),
210
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm removal of a configured source.")):
211
+ """Show or edit Forge recipe sources without opening an editor."""
212
+ if add or remove_source:
213
+ if remove_source and not yes:
214
+ import sys
215
+ if ctx.obj.as_json or not sys.stdin.isatty():
216
+ raise ForgeError("Removing a source requires --yes in non-interactive/JSON mode.")
217
+ if not typer.confirm(f"Remove recipe source {remove_source}?", default=False):
218
+ raise ForgeError("Source removal cancelled.")
219
+ _emit(ctx, "edit-sources", **edit_sources(ctx.obj.home, add=add, remove_source=remove_source))
220
+ else:
221
+ _emit(ctx, "edit-sources", path=str(ctx.obj.home.root / "apt/sources.json"), **source_config(ctx.obj.home))
222
+
223
+
224
+ @app.command("env")
225
+ def env(ctx: typer.Context,
226
+ path: bool = typer.Option(False, "--path", help="Print only the current activation script path.")):
227
+ """Locate the versioned activation script for active packages."""
228
+ if path and ctx.obj.as_json:
229
+ raise ForgeError("--path and --json are mutually exclusive.")
230
+ activation = environment_path(ctx.obj.home)
231
+ if path:
232
+ print(activation)
233
+ else:
234
+ _emit(ctx, "env", activation=activation, source_command="source " + shlex.quote(activation))
@@ -0,0 +1,171 @@
1
+ """Recipe catalogs and explicit version constraints for user-local packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+
10
+ from jsonschema import Draft202012Validator
11
+ from packaging.requirements import InvalidRequirement, Requirement
12
+ from packaging.version import InvalidVersion, Version
13
+ import yaml
14
+
15
+ from .downloads import fetch
16
+ from .storage import ForgeError, ForgeHome, digest, now, read_json, write_json
17
+ from .suite import UniqueLoader
18
+
19
+
20
+ def requirement(text: str) -> Requirement:
21
+ value = re.sub(r"\s*\(([^()]+)\)\s*$", r"\1", text.strip())
22
+ value = re.sub(r"(?<=[A-Za-z0-9_])=(?!=)", "==", value)
23
+ try:
24
+ req = Requirement(value)
25
+ except InvalidRequirement as exc:
26
+ raise ForgeError(f"Invalid package constraint: {text!r}") from exc
27
+ if req.extras or req.marker or req.url or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", req.name):
28
+ raise ForgeError(f"Unsupported package constraint: {text!r}")
29
+ return req
30
+
31
+
32
+ def validate_recipe(recipe: dict, origin: str) -> dict:
33
+ schema = json.loads(files("jarvis_forge").joinpath("schemas/recipe.schema.json").read_text())
34
+ error = next(iter(Draft202012Validator(schema).iter_errors(recipe)), None)
35
+ if error:
36
+ raise ForgeError(f"Invalid recipe in {origin}: {error.message}")
37
+ if recipe.get("provenance", {}).get("status") == "draft":
38
+ raise ForgeError(f"Recipe is a draft and needs a build adapter: {origin}")
39
+ if recipe["source"].get("patch_archive") and "patch" not in recipe.get("requires_tools", []):
40
+ raise ForgeError(f"Recipes with patch archives must require the patch tool: {origin}")
41
+ try:
42
+ Version(recipe["version"])
43
+ except InvalidVersion as exc:
44
+ raise ForgeError(f"Invalid package version in {origin}: {recipe['version']}") from exc
45
+ subdir = Path(recipe["source"]["subdir"])
46
+ if subdir.is_absolute() or ".." in subdir.parts:
47
+ raise ForgeError(f"Invalid source subdirectory in {origin}.")
48
+ for dep in recipe.get("depends", []):
49
+ requirement(dep)
50
+ return recipe
51
+
52
+
53
+ def read_recipes(text: str, origin: str) -> list[dict]:
54
+ try:
55
+ data = yaml.load(text, Loader=UniqueLoader)
56
+ except (yaml.YAMLError, UnicodeError) as exc:
57
+ raise ForgeError(f"Cannot read recipe source {origin}: {exc}") from exc
58
+ if isinstance(data, dict) and "packages" in data:
59
+ if set(data) != {"schema_version", "packages"} or data["schema_version"] != 1 or not isinstance(data["packages"], list):
60
+ raise ForgeError(f"Invalid recipe index: {origin}")
61
+ recipes = data["packages"]
62
+ else:
63
+ recipes = [data]
64
+ return [validate_recipe(item, origin) for item in recipes]
65
+
66
+
67
+ class Catalog:
68
+ def __init__(self, home: ForgeHome):
69
+ self.home = home
70
+ self.items = {}
71
+ for path in sorted(files("jarvis_forge").joinpath("recipes").iterdir(), key=lambda p: p.name):
72
+ if path.name.endswith(".yaml"):
73
+ self._add(read_recipes(path.read_text(), f"bundled:{path.name}"), f"bundled:{path.name}")
74
+ cached = home.root / "cache/apt/catalog.json"
75
+ if cached.exists():
76
+ record = read_json(cached)
77
+ if record.get("schema_version") != 1 or not isinstance(record.get("packages"), list):
78
+ raise ForgeError(f"Invalid cached catalog: {cached}")
79
+ for item in record["packages"]:
80
+ if not isinstance(item, dict) or not isinstance(item.get("origin"), str):
81
+ raise ForgeError(f"Invalid cached recipe entry: {cached}")
82
+ self._add([validate_recipe(item.get("recipe"), item["origin"])], item["origin"])
83
+ official = home.root / "cache/apt/official.json"
84
+ if official.exists():
85
+ record = read_json(official)
86
+ if record.get("schema_version") != 1 or not isinstance(record.get("packages"), list):
87
+ raise ForgeError(f"Invalid official-source catalog: {official}")
88
+ for item in record["packages"]:
89
+ self._add([validate_recipe(item["recipe"], item["origin"])], item["origin"])
90
+ for path in sorted((home.root / "recipes").glob("*.yaml")):
91
+ self._add(read_recipes(path.read_text(encoding="utf-8"), str(path)), str(path))
92
+
93
+ def _add(self, recipes, origin):
94
+ for recipe in recipes:
95
+ self.items[(recipe["name"], recipe["version"])] = {"recipe": recipe, "origin": origin}
96
+
97
+ def list(self, query="") -> list[dict]:
98
+ return [dict(item["recipe"], origin=item["origin"]) for _, item in sorted(self.items.items())
99
+ if query.lower() in (item["recipe"]["name"] + " " + item["recipe"]["description"]).lower()]
100
+
101
+ def resolve(self, specs: list[str]) -> list[dict]:
102
+ def solve(pending, selected, constraints):
103
+ if not pending:
104
+ return selected
105
+ req, *rest = pending
106
+ limits = {key: list(value) for key, value in constraints.items()}
107
+ limits.setdefault(req.name, []).append(req.specifier)
108
+ matches = lambda v: all(v in spec for spec in limits[req.name])
109
+ if req.name in selected:
110
+ return solve(rest, selected, limits) if matches(Version(selected[req.name]["version"])) else None
111
+ choices = [item["recipe"] for (name, _), item in self.items.items()
112
+ if name == req.name and matches(Version(item["recipe"]["version"]))]
113
+ for choice in sorted(choices, key=lambda r: Version(r["version"]), reverse=True):
114
+ found = solve([requirement(dep) for dep in choice.get("depends", [])] + rest,
115
+ {**selected, req.name: choice}, limits)
116
+ if found is not None:
117
+ return found
118
+ return None
119
+
120
+ selected = solve([requirement(s) for s in specs], {}, {})
121
+ if selected is None:
122
+ raise ForgeError(f"No compatible recipes satisfy: {', '.join(specs)}", hint="jforge apt search <name>")
123
+ ordered, visiting, done = [], set(), set()
124
+
125
+ def visit(name):
126
+ if name in visiting:
127
+ raise ForgeError(f"Recipe dependency cycle involving {name}.")
128
+ if name in done:
129
+ return
130
+ visiting.add(name)
131
+ for spec in selected[name].get("depends", []):
132
+ visit(requirement(spec).name)
133
+ visiting.remove(name)
134
+ done.add(name)
135
+ ordered.append(selected[name])
136
+
137
+ for name in selected:
138
+ visit(name)
139
+ return ordered
140
+
141
+
142
+ def source_config(home: ForgeHome) -> dict:
143
+ path = home.root / "apt/sources.json"
144
+ config = read_json(path) if path.exists() else {"schema_version": 1, "sources": []}
145
+ if (config.get("schema_version") != 1 or not isinstance(config.get("sources"), list)
146
+ or not all(isinstance(s, str) for s in config["sources"])):
147
+ raise ForgeError(f"Invalid source configuration: {path}")
148
+ return config
149
+
150
+
151
+ def update_catalog(home: ForgeHome, operation: Path) -> dict:
152
+ entries, sources = [], []
153
+ for index, source in enumerate(source_config(home)["sources"]):
154
+ if source.startswith("https://"):
155
+ path = operation / f"source-{index}.yaml"
156
+ fetch(source, path, operation / f"source-{index}")
157
+ paths = [path]
158
+ else:
159
+ location = Path(source).expanduser()
160
+ if not location.is_absolute():
161
+ raise ForgeError(f"Local recipe sources must be absolute paths: {source}")
162
+ paths = sorted(location.glob("*.yaml")) if location.is_dir() else [location]
163
+ if not location.exists():
164
+ raise ForgeError(f"Recipe source does not exist: {source}")
165
+ for path in paths:
166
+ for recipe in read_recipes(path.read_text(encoding="utf-8"), source):
167
+ entries.append({"recipe": recipe, "origin": source})
168
+ sources.append({"source": source, "path": str(path), "sha256": digest(path)})
169
+ result = {"schema_version": 1, "updated_at": now(), "packages": entries, "sources": sources}
170
+ write_json(home.root / "cache/apt/catalog.json", result)
171
+ return {"sources": sources, "available": len(Catalog(home).list()), "updated_at": result["updated_at"]}