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.
Files changed (52) hide show
  1. rawctx-0.1.0/PKG-INFO +75 -0
  2. rawctx-0.1.0/README.md +63 -0
  3. rawctx-0.1.0/pyproject.toml +26 -0
  4. rawctx-0.1.0/rawctx/__init__.py +3 -0
  5. rawctx-0.1.0/rawctx/cli.py +34 -0
  6. rawctx-0.1.0/rawctx/commands/__init__.py +1 -0
  7. rawctx-0.1.0/rawctx/commands/common.py +5 -0
  8. rawctx-0.1.0/rawctx/commands/convert.py +8 -0
  9. rawctx-0.1.0/rawctx/commands/info.py +110 -0
  10. rawctx-0.1.0/rawctx/commands/install.py +165 -0
  11. rawctx-0.1.0/rawctx/commands/login.py +120 -0
  12. rawctx-0.1.0/rawctx/commands/pack.py +26 -0
  13. rawctx-0.1.0/rawctx/commands/publish.py +121 -0
  14. rawctx-0.1.0/rawctx/commands/search.py +203 -0
  15. rawctx-0.1.0/rawctx/commands/validate.py +134 -0
  16. rawctx-0.1.0/rawctx/config.py +235 -0
  17. rawctx-0.1.0/rawctx/converter/__init__.py +1 -0
  18. rawctx-0.1.0/rawctx/converter/base.py +1 -0
  19. rawctx-0.1.0/rawctx/converter/cube_to_osi.py +1 -0
  20. rawctx-0.1.0/rawctx/converter/metricflow_to_osi.py +1 -0
  21. rawctx-0.1.0/rawctx/converter/owl_to_osi.py +1 -0
  22. rawctx-0.1.0/rawctx/formats/__init__.py +1 -0
  23. rawctx-0.1.0/rawctx/formats/cube.py +1 -0
  24. rawctx-0.1.0/rawctx/formats/metricflow.py +1 -0
  25. rawctx-0.1.0/rawctx/formats/osi.py +111 -0
  26. rawctx-0.1.0/rawctx/formats/owl.py +1 -0
  27. rawctx-0.1.0/rawctx/packaging/__init__.py +1 -0
  28. rawctx-0.1.0/rawctx/packaging/builder.py +106 -0
  29. rawctx-0.1.0/rawctx/packaging/manifest.py +220 -0
  30. rawctx-0.1.0/rawctx/packaging/resolver.py +1 -0
  31. rawctx-0.1.0/rawctx/registry/__init__.py +1 -0
  32. rawctx-0.1.0/rawctx/registry/auth.py +121 -0
  33. rawctx-0.1.0/rawctx/registry/client.py +269 -0
  34. rawctx-0.1.0/rawctx/registry/models.py +250 -0
  35. rawctx-0.1.0/rawctx/schemas/osi/v1.0/schema.json +94 -0
  36. rawctx-0.1.0/rawctx.egg-info/PKG-INFO +75 -0
  37. rawctx-0.1.0/rawctx.egg-info/SOURCES.txt +50 -0
  38. rawctx-0.1.0/rawctx.egg-info/dependency_links.txt +1 -0
  39. rawctx-0.1.0/rawctx.egg-info/entry_points.txt +2 -0
  40. rawctx-0.1.0/rawctx.egg-info/requires.txt +5 -0
  41. rawctx-0.1.0/rawctx.egg-info/top_level.txt +1 -0
  42. rawctx-0.1.0/setup.cfg +4 -0
  43. rawctx-0.1.0/tests/test_cli.py +16 -0
  44. rawctx-0.1.0/tests/test_config.py +88 -0
  45. rawctx-0.1.0/tests/test_install_command_online.py +96 -0
  46. rawctx-0.1.0/tests/test_login_command.py +166 -0
  47. rawctx-0.1.0/tests/test_publish_command.py +191 -0
  48. rawctx-0.1.0/tests/test_registry_client.py +123 -0
  49. rawctx-0.1.0/tests/test_registry_models.py +68 -0
  50. rawctx-0.1.0/tests/test_round1_cli.py +159 -0
  51. rawctx-0.1.0/tests/test_search_command_online.py +82 -0
  52. 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,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -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,5 @@
1
+ import click
2
+
3
+
4
+ def not_implemented(command_name: str) -> None:
5
+ click.echo(f"{command_name}: not implemented in Round 0")
@@ -0,0 +1,8 @@
1
+ import click
2
+
3
+ from .common import not_implemented
4
+
5
+
6
+ @click.command()
7
+ def convert() -> None:
8
+ not_implemented("convert")
@@ -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}")