rawctx 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.
- rawctx-0.1.0/PKG-INFO +75 -0
- rawctx-0.1.0/README.md +63 -0
- rawctx-0.1.0/pyproject.toml +26 -0
- rawctx-0.1.0/rawctx/__init__.py +3 -0
- rawctx-0.1.0/rawctx/cli.py +34 -0
- rawctx-0.1.0/rawctx/commands/__init__.py +1 -0
- rawctx-0.1.0/rawctx/commands/common.py +5 -0
- rawctx-0.1.0/rawctx/commands/convert.py +8 -0
- rawctx-0.1.0/rawctx/commands/info.py +110 -0
- rawctx-0.1.0/rawctx/commands/install.py +165 -0
- rawctx-0.1.0/rawctx/commands/login.py +120 -0
- rawctx-0.1.0/rawctx/commands/pack.py +26 -0
- rawctx-0.1.0/rawctx/commands/publish.py +121 -0
- rawctx-0.1.0/rawctx/commands/search.py +203 -0
- rawctx-0.1.0/rawctx/commands/validate.py +134 -0
- rawctx-0.1.0/rawctx/config.py +235 -0
- rawctx-0.1.0/rawctx/converter/__init__.py +1 -0
- rawctx-0.1.0/rawctx/converter/base.py +1 -0
- rawctx-0.1.0/rawctx/converter/cube_to_osi.py +1 -0
- rawctx-0.1.0/rawctx/converter/metricflow_to_osi.py +1 -0
- rawctx-0.1.0/rawctx/converter/owl_to_osi.py +1 -0
- rawctx-0.1.0/rawctx/formats/__init__.py +1 -0
- rawctx-0.1.0/rawctx/formats/cube.py +1 -0
- rawctx-0.1.0/rawctx/formats/metricflow.py +1 -0
- rawctx-0.1.0/rawctx/formats/osi.py +111 -0
- rawctx-0.1.0/rawctx/formats/owl.py +1 -0
- rawctx-0.1.0/rawctx/packaging/__init__.py +1 -0
- rawctx-0.1.0/rawctx/packaging/builder.py +106 -0
- rawctx-0.1.0/rawctx/packaging/manifest.py +220 -0
- rawctx-0.1.0/rawctx/packaging/resolver.py +1 -0
- rawctx-0.1.0/rawctx/registry/__init__.py +1 -0
- rawctx-0.1.0/rawctx/registry/auth.py +121 -0
- rawctx-0.1.0/rawctx/registry/client.py +269 -0
- rawctx-0.1.0/rawctx/registry/models.py +250 -0
- rawctx-0.1.0/rawctx/schemas/osi/v1.0/schema.json +94 -0
- rawctx-0.1.0/rawctx.egg-info/PKG-INFO +75 -0
- rawctx-0.1.0/rawctx.egg-info/SOURCES.txt +50 -0
- rawctx-0.1.0/rawctx.egg-info/dependency_links.txt +1 -0
- rawctx-0.1.0/rawctx.egg-info/entry_points.txt +2 -0
- rawctx-0.1.0/rawctx.egg-info/requires.txt +5 -0
- rawctx-0.1.0/rawctx.egg-info/top_level.txt +1 -0
- rawctx-0.1.0/setup.cfg +4 -0
- rawctx-0.1.0/tests/test_cli.py +16 -0
- rawctx-0.1.0/tests/test_config.py +88 -0
- rawctx-0.1.0/tests/test_install_command_online.py +96 -0
- rawctx-0.1.0/tests/test_login_command.py +166 -0
- rawctx-0.1.0/tests/test_publish_command.py +191 -0
- rawctx-0.1.0/tests/test_registry_client.py +123 -0
- rawctx-0.1.0/tests/test_registry_models.py +68 -0
- rawctx-0.1.0/tests/test_round1_cli.py +159 -0
- rawctx-0.1.0/tests/test_search_command_online.py +82 -0
- rawctx-0.1.0/tests/test_search_info_install_offline.py +115 -0
rawctx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rawctx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: rawctx CLI scaffold
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.1.7
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: PyYAML>=6.0.2
|
|
10
|
+
Requires-Dist: jsonschema>=4.23.0
|
|
11
|
+
Requires-Dist: ruamel.yaml>=0.18.6
|
|
12
|
+
|
|
13
|
+
# rawctx CLI
|
|
14
|
+
|
|
15
|
+
Python Click-based CLI for rawctx Hub.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
- `rawctx login [--registry URL] [--id-token JWT] [--token-name NAME] [--expires-in-days N] [--no-browser]`
|
|
20
|
+
- `rawctx logout [--local-only]`
|
|
21
|
+
- `rawctx publish [TARGET_DIR] [--registry URL]`
|
|
22
|
+
- `rawctx search [QUERY] [--format F] [--domain D] [--source S] [--tags CSV] [--page N] [--size N] [--json] [--offline]`
|
|
23
|
+
- `rawctx install PACKAGE_REF [--dest PATH] [--offline] [--force] [--registry URL]`
|
|
24
|
+
- `rawctx info PACKAGE_REF [--json] [--offline] [--registry URL]`
|
|
25
|
+
- `rawctx validate [TARGET] --format auto|manifest|osi`
|
|
26
|
+
- `rawctx pack [TARGET_DIR] --output-dir dist`
|
|
27
|
+
|
|
28
|
+
## Auth Flow (Auto + Fallback)
|
|
29
|
+
|
|
30
|
+
1. Run `rawctx login`.
|
|
31
|
+
2. CLI opens (or prints) the OAuth URL from `POST /api/auth/login/github`.
|
|
32
|
+
3. Complete GitHub login in browser.
|
|
33
|
+
4. CLI automatically polls OAuth session status and captures `id_token`.
|
|
34
|
+
5. CLI calls `POST /api/auth/token` and stores API token in `~/.rawctx/config.yaml`.
|
|
35
|
+
|
|
36
|
+
Manual fallback:
|
|
37
|
+
|
|
38
|
+
- `rawctx login --id-token '<JWT>'`
|
|
39
|
+
|
|
40
|
+
## Config and Environment
|
|
41
|
+
|
|
42
|
+
Config file (default): `~/.rawctx/config.yaml`
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
registry: "https://api.rawctx.dev"
|
|
46
|
+
auth:
|
|
47
|
+
token: "rxctx_..."
|
|
48
|
+
token_id: "uuid"
|
|
49
|
+
token_name: "rawctx-cli"
|
|
50
|
+
issued_at: "2026-02-28T00:00:00+00:00"
|
|
51
|
+
profile:
|
|
52
|
+
username: "owner"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Environment overrides:
|
|
56
|
+
|
|
57
|
+
- `RAWCTX_CONFIG` (config path)
|
|
58
|
+
- `RAWCTX_REGISTRY` (registry URL)
|
|
59
|
+
- `RAWCTX_TOKEN` (auth token)
|
|
60
|
+
|
|
61
|
+
Priority: CLI option > env var > config > default.
|
|
62
|
+
|
|
63
|
+
## Offline Mode
|
|
64
|
+
|
|
65
|
+
`--offline` is supported for:
|
|
66
|
+
|
|
67
|
+
- `search`
|
|
68
|
+
- `info`
|
|
69
|
+
- `install`
|
|
70
|
+
|
|
71
|
+
Cache paths:
|
|
72
|
+
|
|
73
|
+
- index: `~/.rawctx/cache/packages.json`
|
|
74
|
+
- archives: `~/.rawctx/cache/archives/@scope/name/<version>.rawctx.tar.gz`
|
|
75
|
+
- installs: `~/.rawctx/packages/@scope/name/<version>/`
|
rawctx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# rawctx CLI
|
|
2
|
+
|
|
3
|
+
Python Click-based CLI for rawctx Hub.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- `rawctx login [--registry URL] [--id-token JWT] [--token-name NAME] [--expires-in-days N] [--no-browser]`
|
|
8
|
+
- `rawctx logout [--local-only]`
|
|
9
|
+
- `rawctx publish [TARGET_DIR] [--registry URL]`
|
|
10
|
+
- `rawctx search [QUERY] [--format F] [--domain D] [--source S] [--tags CSV] [--page N] [--size N] [--json] [--offline]`
|
|
11
|
+
- `rawctx install PACKAGE_REF [--dest PATH] [--offline] [--force] [--registry URL]`
|
|
12
|
+
- `rawctx info PACKAGE_REF [--json] [--offline] [--registry URL]`
|
|
13
|
+
- `rawctx validate [TARGET] --format auto|manifest|osi`
|
|
14
|
+
- `rawctx pack [TARGET_DIR] --output-dir dist`
|
|
15
|
+
|
|
16
|
+
## Auth Flow (Auto + Fallback)
|
|
17
|
+
|
|
18
|
+
1. Run `rawctx login`.
|
|
19
|
+
2. CLI opens (or prints) the OAuth URL from `POST /api/auth/login/github`.
|
|
20
|
+
3. Complete GitHub login in browser.
|
|
21
|
+
4. CLI automatically polls OAuth session status and captures `id_token`.
|
|
22
|
+
5. CLI calls `POST /api/auth/token` and stores API token in `~/.rawctx/config.yaml`.
|
|
23
|
+
|
|
24
|
+
Manual fallback:
|
|
25
|
+
|
|
26
|
+
- `rawctx login --id-token '<JWT>'`
|
|
27
|
+
|
|
28
|
+
## Config and Environment
|
|
29
|
+
|
|
30
|
+
Config file (default): `~/.rawctx/config.yaml`
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
registry: "https://api.rawctx.dev"
|
|
34
|
+
auth:
|
|
35
|
+
token: "rxctx_..."
|
|
36
|
+
token_id: "uuid"
|
|
37
|
+
token_name: "rawctx-cli"
|
|
38
|
+
issued_at: "2026-02-28T00:00:00+00:00"
|
|
39
|
+
profile:
|
|
40
|
+
username: "owner"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Environment overrides:
|
|
44
|
+
|
|
45
|
+
- `RAWCTX_CONFIG` (config path)
|
|
46
|
+
- `RAWCTX_REGISTRY` (registry URL)
|
|
47
|
+
- `RAWCTX_TOKEN` (auth token)
|
|
48
|
+
|
|
49
|
+
Priority: CLI option > env var > config > default.
|
|
50
|
+
|
|
51
|
+
## Offline Mode
|
|
52
|
+
|
|
53
|
+
`--offline` is supported for:
|
|
54
|
+
|
|
55
|
+
- `search`
|
|
56
|
+
- `info`
|
|
57
|
+
- `install`
|
|
58
|
+
|
|
59
|
+
Cache paths:
|
|
60
|
+
|
|
61
|
+
- index: `~/.rawctx/cache/packages.json`
|
|
62
|
+
- archives: `~/.rawctx/cache/archives/@scope/name/<version>.rawctx.tar.gz`
|
|
63
|
+
- installs: `~/.rawctx/packages/@scope/name/<version>/`
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rawctx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "rawctx CLI scaffold"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"click>=8.1.7",
|
|
13
|
+
"httpx>=0.28.1",
|
|
14
|
+
"PyYAML>=6.0.2",
|
|
15
|
+
"jsonschema>=4.23.0",
|
|
16
|
+
"ruamel.yaml>=0.18.6",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
rawctx = "rawctx.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
rawctx = ["schemas/**/*.json"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from rawctx import __version__
|
|
4
|
+
from rawctx.commands.info import info
|
|
5
|
+
from rawctx.commands.install import install
|
|
6
|
+
from rawctx.commands.login import login, logout
|
|
7
|
+
from rawctx.commands.pack import pack
|
|
8
|
+
from rawctx.commands.publish import publish
|
|
9
|
+
from rawctx.commands.search import search
|
|
10
|
+
from rawctx.commands.validate import validate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
14
|
+
@click.version_option(version=__version__, prog_name="rawctx")
|
|
15
|
+
@click.option("--debug", is_flag=True, default=False, help="Enable debug mode.")
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def main(ctx: click.Context, debug: bool) -> None:
|
|
18
|
+
"""rawctx CLI."""
|
|
19
|
+
ctx.ensure_object(dict)
|
|
20
|
+
ctx.obj["debug"] = debug
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
main.add_command(publish)
|
|
24
|
+
main.add_command(login)
|
|
25
|
+
main.add_command(logout)
|
|
26
|
+
main.add_command(search)
|
|
27
|
+
main.add_command(install)
|
|
28
|
+
main.add_command(info)
|
|
29
|
+
main.add_command(validate)
|
|
30
|
+
main.add_command(pack)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command modules for rawctx CLI."""
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from rawctx.config import ConfigStore, cache_key, load_package_cache, resolve_registry, resolve_token, save_package_cache, upsert_cache_entry
|
|
10
|
+
from rawctx.registry.client import RegistryClient, RegistryError
|
|
11
|
+
from rawctx.registry.models import PackageDetail, VersionInfo, parse_package_ref
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.argument("package_ref", required=True)
|
|
16
|
+
@click.option("--json", "json_output", is_flag=True, default=False)
|
|
17
|
+
@click.option("--offline", is_flag=True, default=False)
|
|
18
|
+
@click.option("--registry", "registry_override", default=None, help="Registry base URL")
|
|
19
|
+
def info(package_ref: str, json_output: bool, offline: bool, registry_override: str | None) -> None:
|
|
20
|
+
try:
|
|
21
|
+
parsed = parse_package_ref(package_ref)
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
raise click.UsageError(str(exc)) from exc
|
|
24
|
+
|
|
25
|
+
store = ConfigStore()
|
|
26
|
+
config = store.load()
|
|
27
|
+
cache = load_package_cache(store.paths)
|
|
28
|
+
|
|
29
|
+
package: PackageDetail
|
|
30
|
+
versions: list[VersionInfo]
|
|
31
|
+
|
|
32
|
+
if offline:
|
|
33
|
+
package, versions = _load_offline(cache, parsed.scope, parsed.name)
|
|
34
|
+
else:
|
|
35
|
+
registry = resolve_registry(cli_registry=registry_override, config=config)
|
|
36
|
+
token = resolve_token(config)
|
|
37
|
+
client = RegistryClient(registry=registry, token=token)
|
|
38
|
+
try:
|
|
39
|
+
package = client.get_package(scope=parsed.scope, name=parsed.name)
|
|
40
|
+
versions, _meta = client.list_versions(scope=parsed.scope, name=parsed.name, page=1, size=100)
|
|
41
|
+
upsert_cache_entry(
|
|
42
|
+
cache,
|
|
43
|
+
scope=parsed.scope,
|
|
44
|
+
name=parsed.name,
|
|
45
|
+
package=asdict(package),
|
|
46
|
+
versions=[asdict(item) for item in versions],
|
|
47
|
+
)
|
|
48
|
+
save_package_cache(store.paths, cache)
|
|
49
|
+
except RegistryError as exc:
|
|
50
|
+
raise click.ClickException(str(exc)) from exc
|
|
51
|
+
finally:
|
|
52
|
+
client.close()
|
|
53
|
+
|
|
54
|
+
selected_version = None
|
|
55
|
+
if parsed.version:
|
|
56
|
+
selected_version = next((item for item in versions if item.version == parsed.version), None)
|
|
57
|
+
if selected_version is None:
|
|
58
|
+
raise click.ClickException(f"Version not found: {parsed.normalized}")
|
|
59
|
+
|
|
60
|
+
if json_output:
|
|
61
|
+
payload = {
|
|
62
|
+
"package": asdict(package),
|
|
63
|
+
"versions": [asdict(item) for item in versions],
|
|
64
|
+
"selected_version": asdict(selected_version) if selected_version else None,
|
|
65
|
+
}
|
|
66
|
+
click.echo(json.dumps(payload, indent=2))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
click.echo(f"Package: {package.package_name}")
|
|
70
|
+
click.echo(f"Description: {package.description or '-'}")
|
|
71
|
+
click.echo(f"Format: {package.format}")
|
|
72
|
+
click.echo(f"Source: {package.source or '-'} Domain: {package.domain or '-'}")
|
|
73
|
+
click.echo(f"Downloads: {package.download_count} Stars: {package.star_count}")
|
|
74
|
+
click.echo(f"Tags: {', '.join(package.tags) if package.tags else '-'}")
|
|
75
|
+
|
|
76
|
+
if selected_version:
|
|
77
|
+
click.echo(f"Version: {selected_version.version} ({selected_version.status})")
|
|
78
|
+
if selected_version.model_summary:
|
|
79
|
+
summary = selected_version.model_summary
|
|
80
|
+
click.echo(
|
|
81
|
+
"Model summary: "
|
|
82
|
+
f"datasets={summary.get('dataset_count', 0)} "
|
|
83
|
+
f"measures={summary.get('measure_count', 0)} "
|
|
84
|
+
f"dimensions={summary.get('dimension_count', 0)} "
|
|
85
|
+
f"relationships={summary.get('relationship_count', 0)}"
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
if versions:
|
|
89
|
+
click.echo("Versions: " + ", ".join(item.version for item in versions))
|
|
90
|
+
else:
|
|
91
|
+
click.echo("Versions: -")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_offline(cache: dict[str, dict[str, Any]], scope: str, name: str) -> tuple[PackageDetail, list[VersionInfo]]:
|
|
95
|
+
entry = cache.get(cache_key(scope, name))
|
|
96
|
+
if not entry:
|
|
97
|
+
raise click.ClickException(f"No cached package found for @{scope}/{name}")
|
|
98
|
+
|
|
99
|
+
package_raw = entry.get("package")
|
|
100
|
+
if not isinstance(package_raw, dict):
|
|
101
|
+
raise click.ClickException(f"Cached package data is invalid for @{scope}/{name}")
|
|
102
|
+
|
|
103
|
+
versions_raw = entry.get("versions")
|
|
104
|
+
versions: list[VersionInfo] = []
|
|
105
|
+
if isinstance(versions_raw, list):
|
|
106
|
+
for item in versions_raw:
|
|
107
|
+
if isinstance(item, dict):
|
|
108
|
+
versions.append(VersionInfo.from_api(item))
|
|
109
|
+
|
|
110
|
+
return PackageDetail.from_api(package_raw), versions
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from rawctx.config import ConfigStore, cache_key, load_package_cache, resolve_registry, resolve_token, save_package_cache, upsert_cache_entry
|
|
13
|
+
from rawctx.registry.client import RegistryClient, RegistryError
|
|
14
|
+
from rawctx.registry.models import choose_latest_version, is_valid_semver, parse_package_ref
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.argument("package_ref", required=True)
|
|
19
|
+
@click.option("--dest", "dest_path", default=None, help="Install destination root")
|
|
20
|
+
@click.option("--offline", is_flag=True, default=False)
|
|
21
|
+
@click.option("--force", is_flag=True, default=False)
|
|
22
|
+
@click.option("--registry", "registry_override", default=None, help="Registry base URL")
|
|
23
|
+
def install(
|
|
24
|
+
package_ref: str,
|
|
25
|
+
dest_path: str | None,
|
|
26
|
+
offline: bool,
|
|
27
|
+
force: bool,
|
|
28
|
+
registry_override: str | None,
|
|
29
|
+
) -> None:
|
|
30
|
+
try:
|
|
31
|
+
parsed = parse_package_ref(package_ref)
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise click.UsageError(str(exc)) from exc
|
|
34
|
+
|
|
35
|
+
store = ConfigStore()
|
|
36
|
+
config = store.load()
|
|
37
|
+
cache = load_package_cache(store.paths)
|
|
38
|
+
|
|
39
|
+
destination_root = Path(dest_path).expanduser().resolve() if dest_path else None
|
|
40
|
+
if destination_root is not None:
|
|
41
|
+
destination_root.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
selected_version: str
|
|
44
|
+
archive_bytes: bytes | None = None
|
|
45
|
+
archive_path: Path | None = None
|
|
46
|
+
|
|
47
|
+
if offline:
|
|
48
|
+
selected_version = _select_offline_version(cache, store.paths.archives_dir, parsed.scope, parsed.name, parsed.version)
|
|
49
|
+
archive_path = store.paths.archive_path(parsed.scope, parsed.name, selected_version)
|
|
50
|
+
if not archive_path.exists():
|
|
51
|
+
raise click.ClickException(f"No cached archive found for @{parsed.scope}/{parsed.name}@{selected_version}")
|
|
52
|
+
else:
|
|
53
|
+
registry = resolve_registry(cli_registry=registry_override, config=config)
|
|
54
|
+
token = resolve_token(config)
|
|
55
|
+
client = RegistryClient(registry=registry, token=token)
|
|
56
|
+
try:
|
|
57
|
+
package = client.get_package(scope=parsed.scope, name=parsed.name)
|
|
58
|
+
versions, _meta = client.list_versions(scope=parsed.scope, name=parsed.name, page=1, size=100)
|
|
59
|
+
selected_version = parsed.version or _select_latest_version([item.version for item in versions])
|
|
60
|
+
if not selected_version:
|
|
61
|
+
raise click.ClickException(f"No installable version found for @{parsed.scope}/{parsed.name}")
|
|
62
|
+
|
|
63
|
+
download = client.request_download(scope=parsed.scope, name=parsed.name, version=selected_version)
|
|
64
|
+
download_url = str(download.get("download_url") or "")
|
|
65
|
+
if not download_url:
|
|
66
|
+
raise click.ClickException("Registry did not return download_url")
|
|
67
|
+
|
|
68
|
+
archive_bytes = client.download_bytes(download_url=download_url)
|
|
69
|
+
archive_path = store.paths.archive_path(parsed.scope, parsed.name, selected_version)
|
|
70
|
+
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
archive_path.write_bytes(archive_bytes)
|
|
72
|
+
|
|
73
|
+
upsert_cache_entry(
|
|
74
|
+
cache,
|
|
75
|
+
scope=parsed.scope,
|
|
76
|
+
name=parsed.name,
|
|
77
|
+
package=asdict(package),
|
|
78
|
+
versions=[asdict(item) for item in versions],
|
|
79
|
+
)
|
|
80
|
+
save_package_cache(store.paths, cache)
|
|
81
|
+
except RegistryError as exc:
|
|
82
|
+
raise click.ClickException(str(exc)) from exc
|
|
83
|
+
finally:
|
|
84
|
+
client.close()
|
|
85
|
+
|
|
86
|
+
if archive_path is None:
|
|
87
|
+
raise click.ClickException("archive path resolution failed")
|
|
88
|
+
|
|
89
|
+
install_target = store.paths.install_path(parsed.scope, parsed.name, selected_version, destination=destination_root)
|
|
90
|
+
if install_target.exists():
|
|
91
|
+
if not force:
|
|
92
|
+
raise click.ClickException(f"Install target already exists: {install_target}. Use --force to overwrite.")
|
|
93
|
+
shutil.rmtree(install_target)
|
|
94
|
+
|
|
95
|
+
install_target.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
_extract_archive(
|
|
97
|
+
archive_bytes=archive_bytes if archive_bytes is not None else archive_path.read_bytes(),
|
|
98
|
+
target_dir=install_target,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
click.echo(f"Installed @{parsed.scope}/{parsed.name}@{selected_version} -> {install_target}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _select_offline_version(
|
|
105
|
+
cache: dict[str, dict[str, Any]],
|
|
106
|
+
archives_root: Path,
|
|
107
|
+
scope: str,
|
|
108
|
+
name: str,
|
|
109
|
+
preferred_version: str | None,
|
|
110
|
+
) -> str:
|
|
111
|
+
if preferred_version:
|
|
112
|
+
return preferred_version
|
|
113
|
+
|
|
114
|
+
entry = cache.get(cache_key(scope, name), {})
|
|
115
|
+
candidates: list[str] = []
|
|
116
|
+
versions = entry.get("versions")
|
|
117
|
+
if isinstance(versions, list):
|
|
118
|
+
for item in versions:
|
|
119
|
+
if isinstance(item, dict):
|
|
120
|
+
candidate = item.get("version")
|
|
121
|
+
if isinstance(candidate, str) and is_valid_semver(candidate):
|
|
122
|
+
candidates.append(candidate)
|
|
123
|
+
|
|
124
|
+
archive_dir = archives_root / f"@{scope}" / name
|
|
125
|
+
if archive_dir.exists():
|
|
126
|
+
for file_path in archive_dir.glob("*.rawctx.tar.gz"):
|
|
127
|
+
version = file_path.name.removesuffix(".rawctx.tar.gz")
|
|
128
|
+
if is_valid_semver(version):
|
|
129
|
+
candidates.append(version)
|
|
130
|
+
|
|
131
|
+
selected = _select_latest_version(candidates)
|
|
132
|
+
if not selected:
|
|
133
|
+
raise click.ClickException(f"No cached versions found for @{scope}/{name}")
|
|
134
|
+
return selected
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _select_latest_version(versions: list[str]) -> str | None:
|
|
138
|
+
return choose_latest_version(versions)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_archive(*, archive_bytes: bytes, target_dir: Path) -> None:
|
|
142
|
+
try:
|
|
143
|
+
with tarfile.open(fileobj=BytesIO(archive_bytes), mode="r:gz") as tar:
|
|
144
|
+
for member in tar.getmembers():
|
|
145
|
+
if not member.name.startswith("package/"):
|
|
146
|
+
continue
|
|
147
|
+
relative = Path(member.name).relative_to("package")
|
|
148
|
+
if not relative.parts:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
destination = (target_dir / relative).resolve()
|
|
152
|
+
if target_dir not in destination.parents and destination != target_dir:
|
|
153
|
+
raise click.ClickException(f"Unsafe archive entry: {member.name}")
|
|
154
|
+
|
|
155
|
+
if member.isdir():
|
|
156
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
extracted = tar.extractfile(member)
|
|
161
|
+
if extracted is None:
|
|
162
|
+
continue
|
|
163
|
+
destination.write_bytes(extracted.read())
|
|
164
|
+
except tarfile.TarError as exc:
|
|
165
|
+
raise click.ClickException(f"Invalid package archive: {exc}") from exc
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from rawctx.config import ConfigStore, resolve_registry, resolve_token
|
|
6
|
+
from rawctx.registry.auth import (
|
|
7
|
+
login_with_id_token,
|
|
8
|
+
logout_auth,
|
|
9
|
+
start_oauth_login,
|
|
10
|
+
wait_for_oauth_id_token,
|
|
11
|
+
)
|
|
12
|
+
from rawctx.registry.client import RegistryClient, RegistryError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.option("--registry", "registry_override", default=None, help="Registry base URL")
|
|
17
|
+
@click.option("--id-token", default=None, help="JWT id_token from OAuth callback")
|
|
18
|
+
@click.option("--token-name", default="rawctx-cli", show_default=True, help="Name for the generated API token")
|
|
19
|
+
@click.option("--expires-in-days", type=int, default=None, help="Token expiration in days")
|
|
20
|
+
@click.option("--no-browser", is_flag=True, default=False, help="Do not open browser automatically")
|
|
21
|
+
def login(
|
|
22
|
+
registry_override: str | None,
|
|
23
|
+
id_token: str | None,
|
|
24
|
+
token_name: str,
|
|
25
|
+
expires_in_days: int | None,
|
|
26
|
+
no_browser: bool,
|
|
27
|
+
) -> None:
|
|
28
|
+
store = ConfigStore()
|
|
29
|
+
config = store.load()
|
|
30
|
+
registry = resolve_registry(cli_registry=registry_override, config=config)
|
|
31
|
+
config.registry = registry
|
|
32
|
+
|
|
33
|
+
client = RegistryClient(registry=registry)
|
|
34
|
+
try:
|
|
35
|
+
login_info = start_oauth_login(client=client, open_browser=not no_browser)
|
|
36
|
+
except RegistryError as exc:
|
|
37
|
+
client.close()
|
|
38
|
+
raise click.ClickException(str(exc)) from exc
|
|
39
|
+
|
|
40
|
+
click.echo(f"Open this URL and sign in with GitHub:\n{login_info.authorize_url}")
|
|
41
|
+
|
|
42
|
+
provided_token = id_token.strip() if id_token else ""
|
|
43
|
+
if not provided_token:
|
|
44
|
+
if login_info.session_id:
|
|
45
|
+
click.echo("Waiting for OAuth login to complete in browser...")
|
|
46
|
+
try:
|
|
47
|
+
provided_token = wait_for_oauth_id_token(
|
|
48
|
+
client=client,
|
|
49
|
+
session_id=login_info.session_id,
|
|
50
|
+
poll_interval_seconds=login_info.poll_interval_seconds,
|
|
51
|
+
)
|
|
52
|
+
except RegistryError as exc:
|
|
53
|
+
raise click.ClickException(str(exc)) from exc
|
|
54
|
+
else:
|
|
55
|
+
click.echo("Automatic token capture is unavailable on this registry.")
|
|
56
|
+
click.echo("After login, copy the id_token and paste it below.")
|
|
57
|
+
provided_token = click.prompt("id_token", type=str).strip()
|
|
58
|
+
|
|
59
|
+
if not provided_token:
|
|
60
|
+
client.close()
|
|
61
|
+
raise click.ClickException("id_token is required")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
token_info = login_with_id_token(
|
|
65
|
+
client=client,
|
|
66
|
+
store=store,
|
|
67
|
+
config=config,
|
|
68
|
+
id_token=provided_token,
|
|
69
|
+
token_name=token_name,
|
|
70
|
+
expires_in_days=expires_in_days,
|
|
71
|
+
)
|
|
72
|
+
except RegistryError as exc:
|
|
73
|
+
raise click.ClickException(str(exc)) from exc
|
|
74
|
+
finally:
|
|
75
|
+
client.close()
|
|
76
|
+
|
|
77
|
+
click.echo(f"Login succeeded. API token saved to {store.paths.config_path}")
|
|
78
|
+
click.echo(f"Token id: {token_info.id}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@click.command()
|
|
82
|
+
@click.option("--local-only", is_flag=True, default=False, help="Delete local token only")
|
|
83
|
+
def logout(local_only: bool) -> None:
|
|
84
|
+
store = ConfigStore()
|
|
85
|
+
config = store.load()
|
|
86
|
+
|
|
87
|
+
if not config.auth.token:
|
|
88
|
+
click.echo("No local auth token found.")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
registry = resolve_registry(cli_registry=None, config=config)
|
|
92
|
+
token = resolve_token(config)
|
|
93
|
+
|
|
94
|
+
client: RegistryClient | None = None
|
|
95
|
+
if not local_only and token:
|
|
96
|
+
client = RegistryClient(registry=registry, token=token)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
had_token, revoked = logout_auth(
|
|
100
|
+
client=client,
|
|
101
|
+
store=store,
|
|
102
|
+
config=config,
|
|
103
|
+
local_only=local_only,
|
|
104
|
+
)
|
|
105
|
+
except RegistryError as exc:
|
|
106
|
+
raise click.ClickException(str(exc)) from exc
|
|
107
|
+
finally:
|
|
108
|
+
if client is not None:
|
|
109
|
+
client.close()
|
|
110
|
+
|
|
111
|
+
if not had_token:
|
|
112
|
+
click.echo("No local auth token found.")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if local_only:
|
|
116
|
+
click.echo("Logged out locally.")
|
|
117
|
+
elif revoked:
|
|
118
|
+
click.echo("Token revoked on server and removed locally.")
|
|
119
|
+
else:
|
|
120
|
+
click.echo("Local token removed. Server token could not be revoked.")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from rawctx.commands.validate import validate_target
|
|
6
|
+
from rawctx.packaging.builder import build_package_archive
|
|
7
|
+
from rawctx.packaging.manifest import ValidationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument("target_dir", required=False, default=".")
|
|
12
|
+
@click.option("--output-dir", default="dist", show_default=True)
|
|
13
|
+
def pack(target_dir: str, output_dir: str) -> None:
|
|
14
|
+
target_path = Path(target_dir).expanduser().resolve()
|
|
15
|
+
if not target_path.is_dir():
|
|
16
|
+
raise click.UsageError("pack target must be a directory")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
validation_result = validate_target(target_path, "auto")
|
|
20
|
+
if validation_result.manifest is None:
|
|
21
|
+
raise click.ClickException("manifest validation did not produce a manifest")
|
|
22
|
+
build = build_package_archive(target_path, Path(output_dir), manifest=validation_result.manifest)
|
|
23
|
+
except ValidationError as exc:
|
|
24
|
+
raise click.ClickException(str(exc)) from exc
|
|
25
|
+
|
|
26
|
+
click.echo(f"package created: {build.archive_path}")
|