refactorai-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- refactor_cli/__init__.py +8 -0
- refactor_cli/auth.py +120 -0
- refactor_cli/client.py +46 -0
- refactor_cli/commands/__init__.py +1 -0
- refactor_cli/commands/auth_cmds.py +85 -0
- refactor_cli/commands/engine_cmds.py +147 -0
- refactor_cli/commands/model_cmds.py +121 -0
- refactor_cli/commands/rules_cmds.py +131 -0
- refactor_cli/commands/run_cmds.py +2159 -0
- refactor_cli/commands/runtime_cmds.py +164 -0
- refactor_cli/commands/setup_cmds.py +69 -0
- refactor_cli/control_plane.py +240 -0
- refactor_cli/credentials.py +71 -0
- refactor_cli/main.py +68 -0
- refactor_cli/model_policy.py +171 -0
- refactor_cli/runtime_manager.py +241 -0
- refactor_cli/settings.py +33 -0
- refactor_cli/setup_flow.py +412 -0
- refactorai_cli-0.1.0.dist-info/METADATA +55 -0
- refactorai_cli-0.1.0.dist-info/RECORD +23 -0
- refactorai_cli-0.1.0.dist-info/WHEEL +5 -0
- refactorai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- refactorai_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Runtime and license commands for proprietary local runtime management (R12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from refactor_cli.auth import AuthError, ensure_authenticated
|
|
9
|
+
from refactor_cli.control_plane import ensure_lease, heartbeat, read_cached_lease, resolve_policy
|
|
10
|
+
from refactor_cli.runtime_manager import (
|
|
11
|
+
activate_runtime,
|
|
12
|
+
download_artifact,
|
|
13
|
+
resolve_runtime_manifest,
|
|
14
|
+
rollback_runtime,
|
|
15
|
+
runtime_status,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
app = typer.Typer(help="Manage local proprietary runtime artifacts.")
|
|
20
|
+
license_app = typer.Typer(help="Inspect developer-key license/auth state.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_auth(*, force_remote: bool = False):
|
|
24
|
+
try:
|
|
25
|
+
return ensure_authenticated(force_remote=force_remote)
|
|
26
|
+
except AuthError as exc:
|
|
27
|
+
console.print(f"[red]Blocked:[/red] {exc}")
|
|
28
|
+
raise typer.Exit(code=1) from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("status")
|
|
32
|
+
def status() -> None:
|
|
33
|
+
"""Show active runtime version and artifact state."""
|
|
34
|
+
data = runtime_status()
|
|
35
|
+
active = data.get("active_version") or "<none>"
|
|
36
|
+
rollback = data.get("rollback_version") or "<none>"
|
|
37
|
+
exists = bool(data.get("active_artifact_exists"))
|
|
38
|
+
icon = "yes" if exists else "no"
|
|
39
|
+
console.print(f"[bold]active runtime[/bold]: {active}")
|
|
40
|
+
console.print(f"[bold]rollback runtime[/bold]: {rollback}")
|
|
41
|
+
console.print(f"[bold]channel[/bold]: {data.get('channel')}")
|
|
42
|
+
console.print(f"[bold]updated at[/bold]: {data.get('updated_at') or '<never>'}")
|
|
43
|
+
console.print(f"[bold]active artifact present[/bold]: {icon}")
|
|
44
|
+
if active and not exists:
|
|
45
|
+
console.print(
|
|
46
|
+
"[yellow]Active runtime points to a missing artifact.[/yellow] "
|
|
47
|
+
"Run `refactor runtime update` to recover."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("update")
|
|
52
|
+
def update(
|
|
53
|
+
channel: str = typer.Option("stable", "--channel", help="Runtime release channel."),
|
|
54
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Resolve/verify only; do not activate."),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Resolve and install the latest runtime artifact for the channel."""
|
|
57
|
+
_require_auth(force_remote=True)
|
|
58
|
+
outcome = "error"
|
|
59
|
+
try:
|
|
60
|
+
manifest = resolve_runtime_manifest(channel=channel)
|
|
61
|
+
console.print(
|
|
62
|
+
f"[bold]resolved manifest[/bold]: version={manifest.runtime_version} "
|
|
63
|
+
f"channel={channel}"
|
|
64
|
+
)
|
|
65
|
+
artifact = download_artifact(manifest.artifact_url)
|
|
66
|
+
console.print(f"[bold]downloaded artifact[/bold]: {len(artifact)} bytes")
|
|
67
|
+
if dry_run:
|
|
68
|
+
# Verification happens in activate_runtime; dry-run still checks
|
|
69
|
+
# integrity by attempting activation in-memory style via duplicate call.
|
|
70
|
+
# We use a no-op by verifying digest directly in manager on activation.
|
|
71
|
+
from refactor_cli.runtime_manager import verify_sha256 # local import to keep module surface small
|
|
72
|
+
|
|
73
|
+
ok = verify_sha256(artifact, manifest.sha256)
|
|
74
|
+
if not ok:
|
|
75
|
+
raise RuntimeError("Runtime artifact checksum verification failed")
|
|
76
|
+
console.print("[green]Dry run OK:[/green] manifest resolved and artifact verified.")
|
|
77
|
+
outcome = "ok"
|
|
78
|
+
return
|
|
79
|
+
artifact_path = activate_runtime(manifest, artifact, channel=channel)
|
|
80
|
+
console.print(
|
|
81
|
+
f"[green]Runtime activated.[/green] version={manifest.runtime_version} "
|
|
82
|
+
f"path={artifact_path}"
|
|
83
|
+
)
|
|
84
|
+
outcome = "ok"
|
|
85
|
+
except RuntimeError as exc:
|
|
86
|
+
console.print(f"[red]Runtime update failed:[/red] {exc}")
|
|
87
|
+
raise typer.Exit(code=1) from exc
|
|
88
|
+
finally:
|
|
89
|
+
heartbeat(
|
|
90
|
+
command="runtime.update",
|
|
91
|
+
result=outcome,
|
|
92
|
+
duration_ms=0,
|
|
93
|
+
runtime_version=str(runtime_status().get("active_version") or ""),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("rollback")
|
|
98
|
+
def rollback() -> None:
|
|
99
|
+
"""Switch active runtime to the recorded rollback version."""
|
|
100
|
+
_require_auth()
|
|
101
|
+
try:
|
|
102
|
+
version = rollback_runtime()
|
|
103
|
+
except RuntimeError as exc:
|
|
104
|
+
console.print(f"[red]Runtime rollback failed:[/red] {exc}")
|
|
105
|
+
raise typer.Exit(code=1) from exc
|
|
106
|
+
console.print(f"[green]Runtime rollback complete.[/green] active_version={version}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@license_app.command("status")
|
|
110
|
+
def license_status(
|
|
111
|
+
force_remote: bool = typer.Option(
|
|
112
|
+
False,
|
|
113
|
+
"--force-remote",
|
|
114
|
+
help="Bypass local validation cache and revalidate with control plane.",
|
|
115
|
+
)
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Show resolved auth/license state from developer key validation."""
|
|
118
|
+
auth_ctx = _require_auth(force_remote=force_remote)
|
|
119
|
+
try:
|
|
120
|
+
lease = ensure_lease(force_refresh=True) if force_remote else read_cached_lease()
|
|
121
|
+
except RuntimeError as exc:
|
|
122
|
+
lease = None
|
|
123
|
+
console.print(f"[yellow]lease[/yellow]: unavailable ({exc})")
|
|
124
|
+
quota = auth_ctx.quota_remaining
|
|
125
|
+
quota_text = str(quota) if quota is not None else "unknown"
|
|
126
|
+
source = auth_ctx.key.source
|
|
127
|
+
cached = "yes" if auth_ctx.cached else "no"
|
|
128
|
+
console.print(f"[bold]account[/bold]: {auth_ctx.account_id}")
|
|
129
|
+
console.print(f"[bold]key source[/bold]: {source}")
|
|
130
|
+
console.print(f"[bold]cached validation[/bold]: {cached}")
|
|
131
|
+
console.print(f"[bold]quota remaining[/bold]: {quota_text}")
|
|
132
|
+
if lease is not None:
|
|
133
|
+
expires = int(max(0, lease.expires_at_epoch))
|
|
134
|
+
console.print(f"[bold]lease cached[/bold]: {'yes' if lease.cached else 'no'}")
|
|
135
|
+
console.print(f"[bold]lease expires (epoch)[/bold]: {expires}")
|
|
136
|
+
console.print(f"[bold]lease capabilities[/bold]: {', '.join(lease.capabilities) or '<none>'}")
|
|
137
|
+
console.print(f"[bold]policy revision[/bold]: {lease.policy_revision or '<unknown>'}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@license_app.command("refresh")
|
|
141
|
+
def license_refresh() -> None:
|
|
142
|
+
"""Refresh lease and policy from control plane."""
|
|
143
|
+
_require_auth(force_remote=True)
|
|
144
|
+
try:
|
|
145
|
+
lease = ensure_lease(force_refresh=True)
|
|
146
|
+
policy = resolve_policy(force_refresh=True)
|
|
147
|
+
except RuntimeError as exc:
|
|
148
|
+
console.print(f"[red]License refresh failed:[/red] {exc}")
|
|
149
|
+
heartbeat(command="license.refresh", result="error", duration_ms=0)
|
|
150
|
+
raise typer.Exit(code=1) from exc
|
|
151
|
+
revision = str((policy.get("bundle") or {}).get("revision_id") or lease.policy_revision or "<unknown>")
|
|
152
|
+
console.print(
|
|
153
|
+
f"[green]License refreshed.[/green] account={lease.account_id} "
|
|
154
|
+
f"capabilities={len(lease.capabilities)} policy={revision}"
|
|
155
|
+
)
|
|
156
|
+
heartbeat(
|
|
157
|
+
command="license.refresh",
|
|
158
|
+
result="ok",
|
|
159
|
+
duration_ms=0,
|
|
160
|
+
policy_revision=revision,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
app.add_typer(license_app, name="license")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Setup bootstrap command (R16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from refactor_cli.setup_flow import STAGE_IDS, SetupError, get_setup_diagnostics, run_setup
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ask_approval(auto_approve: bool, prompt: str) -> bool:
|
|
14
|
+
if auto_approve:
|
|
15
|
+
return True
|
|
16
|
+
if not typer.confirm(prompt, default=False):
|
|
17
|
+
return False
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup(
|
|
22
|
+
yes: bool = typer.Option(False, "--yes", help="Auto-approve setup actions."),
|
|
23
|
+
resume: bool = typer.Option(False, "--resume", help="Resume from last incomplete stage."),
|
|
24
|
+
from_stage: str | None = typer.Option(
|
|
25
|
+
None,
|
|
26
|
+
"--from-stage",
|
|
27
|
+
help=f"Start from a specific stage ({', '.join(STAGE_IDS)}).",
|
|
28
|
+
),
|
|
29
|
+
diagnostics: bool = typer.Option(False, "--diagnostics", help="Print setup checkpoint diagnostics and exit."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Bootstrap local machine/runtime and execution backend for refactor."""
|
|
32
|
+
if diagnostics:
|
|
33
|
+
payload = get_setup_diagnostics()
|
|
34
|
+
state = payload.get("state") or {}
|
|
35
|
+
console.print(f"[bold]status[/bold]: {state.get('status', 'unknown')}")
|
|
36
|
+
console.print(f"[bold]current stage[/bold]: {state.get('current_stage') or '<none>'}")
|
|
37
|
+
console.print(f"[bold]backend[/bold]: {state.get('execution_backend') or '<unset>'}")
|
|
38
|
+
console.print(f"[bold]completed[/bold]: {', '.join(state.get('completed_stages') or []) or '<none>'}")
|
|
39
|
+
if state.get("last_error"):
|
|
40
|
+
console.print(f"[yellow]last error[/yellow]: {state['last_error']}")
|
|
41
|
+
stages = payload.get("stages") or {}
|
|
42
|
+
if stages:
|
|
43
|
+
console.print("[bold]stage outputs[/bold]:")
|
|
44
|
+
for stage_id in STAGE_IDS:
|
|
45
|
+
if stage_id not in stages:
|
|
46
|
+
continue
|
|
47
|
+
data = stages[stage_id] or {}
|
|
48
|
+
console.print(f" - {stage_id}: {data.get('stage', stage_id)}")
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
result = run_setup(
|
|
52
|
+
auto_approve=yes,
|
|
53
|
+
resume=resume,
|
|
54
|
+
from_stage=from_stage,
|
|
55
|
+
ask_approval=lambda prompt: _ask_approval(yes, prompt),
|
|
56
|
+
)
|
|
57
|
+
except SetupError as exc:
|
|
58
|
+
console.print(f"[red]Setup failed:[/red] {exc}")
|
|
59
|
+
console.print("[yellow]Tip:[/yellow] Re-run with `refactor setup --resume` after addressing the issue.")
|
|
60
|
+
raise typer.Exit(code=1) from exc
|
|
61
|
+
if result.status == "already_completed":
|
|
62
|
+
console.print("[green]Setup already completed.[/green]")
|
|
63
|
+
console.print("Run `refactor setup --from-stage S3` to re-run a subset.")
|
|
64
|
+
return
|
|
65
|
+
console.print("[green]Setup completed successfully.[/green]")
|
|
66
|
+
if result.execution_backend:
|
|
67
|
+
console.print(f"[bold]backend[/bold]: {result.execution_backend}")
|
|
68
|
+
console.print(f"[bold]completed stages[/bold]: {', '.join(result.completed_stages)}")
|
|
69
|
+
console.print("[bold]next[/bold]: run `refactor init` and then `refactor review .`")
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Lease/policy/heartbeat client for control-plane contracts (R14)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from refactor_core.paths import ensure_dir, refactor_home
|
|
16
|
+
|
|
17
|
+
from refactor_cli.credentials import resolve_developer_key
|
|
18
|
+
from refactor_cli.settings import platform_url, policy_signing_key
|
|
19
|
+
|
|
20
|
+
LEASE_DIR = "leases"
|
|
21
|
+
LEASE_FILE = "current.json"
|
|
22
|
+
POLICY_DIR = "policy"
|
|
23
|
+
POLICY_FILE = "current.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LeaseContext:
|
|
28
|
+
token: str
|
|
29
|
+
account_id: str
|
|
30
|
+
expires_at_epoch: float
|
|
31
|
+
refresh_after_epoch: float
|
|
32
|
+
capabilities: list[str]
|
|
33
|
+
policy_revision: str
|
|
34
|
+
offline_grace_seconds: int
|
|
35
|
+
cached: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _lease_path() -> Path:
|
|
39
|
+
return refactor_home() / LEASE_DIR / LEASE_FILE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _policy_path() -> Path:
|
|
43
|
+
return refactor_home() / POLICY_DIR / POLICY_FILE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_iso_epoch(value: str | None) -> float:
|
|
47
|
+
if not value:
|
|
48
|
+
return 0.0
|
|
49
|
+
try:
|
|
50
|
+
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
51
|
+
except ValueError:
|
|
52
|
+
return 0.0
|
|
53
|
+
if dt.tzinfo is None:
|
|
54
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
55
|
+
return dt.timestamp()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_json(path: Path) -> dict | None:
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_json(path: Path, payload: dict) -> None:
|
|
68
|
+
ensure_dir(path.parent)
|
|
69
|
+
data = json.dumps(payload, indent=2).encode("utf-8")
|
|
70
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
71
|
+
with tmp.open("wb") as handle:
|
|
72
|
+
handle.write(data)
|
|
73
|
+
handle.flush()
|
|
74
|
+
tmp.replace(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _policy_signature(bundle: dict) -> str:
|
|
78
|
+
canonical = json.dumps(bundle, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
79
|
+
digest = hmac.new(policy_signing_key().encode("utf-8"), canonical, hashlib.sha256).hexdigest()
|
|
80
|
+
return f"hmac-sha256:{digest}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _verify_policy_payload(payload: dict) -> bool:
|
|
84
|
+
bundle = dict(payload.get("bundle") or {})
|
|
85
|
+
signature = str(payload.get("signature") or "")
|
|
86
|
+
if not bundle or not signature:
|
|
87
|
+
return False
|
|
88
|
+
return hmac.compare_digest(signature, _policy_signature(bundle))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _from_cached(payload: dict) -> LeaseContext | None:
|
|
92
|
+
token = str(payload.get("lease_token") or "")
|
|
93
|
+
account_id = str(payload.get("account_id") or "unknown")
|
|
94
|
+
expires_at = float(payload.get("expires_at_epoch") or 0.0)
|
|
95
|
+
refresh_after = float(payload.get("refresh_after_epoch") or 0.0)
|
|
96
|
+
now = time.time()
|
|
97
|
+
if not token or expires_at <= now:
|
|
98
|
+
return None
|
|
99
|
+
return LeaseContext(
|
|
100
|
+
token=token,
|
|
101
|
+
account_id=account_id,
|
|
102
|
+
expires_at_epoch=expires_at,
|
|
103
|
+
refresh_after_epoch=refresh_after,
|
|
104
|
+
capabilities=list(payload.get("capabilities") or []),
|
|
105
|
+
policy_revision=str(payload.get("policy_revision") or ""),
|
|
106
|
+
offline_grace_seconds=int(payload.get("offline_grace_seconds") or 0),
|
|
107
|
+
cached=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_cached_lease() -> LeaseContext | None:
|
|
112
|
+
payload = _read_json(_lease_path())
|
|
113
|
+
if not payload:
|
|
114
|
+
return None
|
|
115
|
+
return _from_cached(payload)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def request_lease(*, timeout: float = 20.0, cli_version: str = "0.1.0") -> LeaseContext:
|
|
119
|
+
resolved = resolve_developer_key()
|
|
120
|
+
if not resolved:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"No developer key configured. Run `refactor login`, set REFACTOR_API_KEY, "
|
|
123
|
+
"or add developer_key to refactor.config."
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
response = httpx.post(
|
|
127
|
+
f"{platform_url()}/v1/license/lease",
|
|
128
|
+
headers={"Authorization": f"Bearer {resolved.key}", "Content-Type": "application/json"},
|
|
129
|
+
json={"cli_version": cli_version},
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
)
|
|
132
|
+
except httpx.HTTPError as exc:
|
|
133
|
+
raise RuntimeError(f"Could not request lease: {exc}") from exc
|
|
134
|
+
if response.status_code >= 400:
|
|
135
|
+
raise RuntimeError(f"Lease request failed ({response.status_code}): {response.text[:500]}")
|
|
136
|
+
payload = response.json() if response.content else {}
|
|
137
|
+
token = str(payload.get("lease_token") or "")
|
|
138
|
+
expires_at_epoch = _parse_iso_epoch(payload.get("expires_at"))
|
|
139
|
+
refresh_after_epoch = _parse_iso_epoch(payload.get("refresh_after"))
|
|
140
|
+
account_id = str(payload.get("account_id") or "unknown")
|
|
141
|
+
context = LeaseContext(
|
|
142
|
+
token=token,
|
|
143
|
+
account_id=account_id,
|
|
144
|
+
expires_at_epoch=expires_at_epoch,
|
|
145
|
+
refresh_after_epoch=refresh_after_epoch,
|
|
146
|
+
capabilities=list(payload.get("capabilities") or []),
|
|
147
|
+
policy_revision=str(payload.get("policy_revision") or ""),
|
|
148
|
+
offline_grace_seconds=int(payload.get("offline_grace_seconds") or 0),
|
|
149
|
+
cached=False,
|
|
150
|
+
)
|
|
151
|
+
_write_json(
|
|
152
|
+
_lease_path(),
|
|
153
|
+
{
|
|
154
|
+
"lease_token": context.token,
|
|
155
|
+
"account_id": context.account_id,
|
|
156
|
+
"expires_at_epoch": context.expires_at_epoch,
|
|
157
|
+
"refresh_after_epoch": context.refresh_after_epoch,
|
|
158
|
+
"capabilities": context.capabilities,
|
|
159
|
+
"policy_revision": context.policy_revision,
|
|
160
|
+
"offline_grace_seconds": context.offline_grace_seconds,
|
|
161
|
+
"issued_at_epoch": time.time(),
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
return context
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ensure_lease(*, force_refresh: bool = False) -> LeaseContext:
|
|
168
|
+
if not force_refresh:
|
|
169
|
+
cached = read_cached_lease()
|
|
170
|
+
if cached:
|
|
171
|
+
now = time.time()
|
|
172
|
+
if cached.refresh_after_epoch <= 0 or now < cached.refresh_after_epoch:
|
|
173
|
+
return cached
|
|
174
|
+
try:
|
|
175
|
+
return request_lease()
|
|
176
|
+
except RuntimeError:
|
|
177
|
+
# Best-effort refresh; keep using non-expired lease.
|
|
178
|
+
return cached
|
|
179
|
+
return request_lease()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def resolve_policy(*, force_refresh: bool = False, project_id: str | None = None, timeout: float = 20.0) -> dict:
|
|
183
|
+
if not force_refresh:
|
|
184
|
+
cached = _read_json(_policy_path())
|
|
185
|
+
if cached and _verify_policy_payload(cached):
|
|
186
|
+
return cached
|
|
187
|
+
lease = ensure_lease()
|
|
188
|
+
try:
|
|
189
|
+
response = httpx.post(
|
|
190
|
+
f"{platform_url()}/v1/policy/resolve",
|
|
191
|
+
headers={"Authorization": f"Bearer {lease.token}", "Content-Type": "application/json"},
|
|
192
|
+
json={"project_id": project_id},
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
)
|
|
195
|
+
except httpx.HTTPError as exc:
|
|
196
|
+
raise RuntimeError(f"Could not resolve policy bundle: {exc}") from exc
|
|
197
|
+
if response.status_code >= 400:
|
|
198
|
+
raise RuntimeError(f"Policy resolve failed ({response.status_code}): {response.text[:500]}")
|
|
199
|
+
payload = response.json() if response.content else {}
|
|
200
|
+
if not _verify_policy_payload(payload):
|
|
201
|
+
raise RuntimeError("Policy signature verification failed")
|
|
202
|
+
_write_json(_policy_path(), payload)
|
|
203
|
+
return payload
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def heartbeat(
|
|
207
|
+
*,
|
|
208
|
+
command: str,
|
|
209
|
+
result: str,
|
|
210
|
+
duration_ms: int,
|
|
211
|
+
model_id: str = "",
|
|
212
|
+
runtime_version: str = "",
|
|
213
|
+
policy_revision: str = "",
|
|
214
|
+
timeout: float = 10.0,
|
|
215
|
+
) -> bool:
|
|
216
|
+
try:
|
|
217
|
+
lease = ensure_lease()
|
|
218
|
+
except RuntimeError:
|
|
219
|
+
return False
|
|
220
|
+
account_hash = hashlib.sha256(lease.account_id.encode("utf-8")).hexdigest()[:16]
|
|
221
|
+
payload = {
|
|
222
|
+
"account_id_hash": account_hash,
|
|
223
|
+
"command": command,
|
|
224
|
+
"result": result,
|
|
225
|
+
"duration_ms": max(0, int(duration_ms)),
|
|
226
|
+
"model_id": model_id,
|
|
227
|
+
"runtime_version": runtime_version,
|
|
228
|
+
"policy_revision": policy_revision or lease.policy_revision,
|
|
229
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
230
|
+
}
|
|
231
|
+
try:
|
|
232
|
+
response = httpx.post(
|
|
233
|
+
f"{platform_url()}/v1/license/heartbeat",
|
|
234
|
+
headers={"Authorization": f"Bearer {lease.token}", "Content-Type": "application/json"},
|
|
235
|
+
json=payload,
|
|
236
|
+
timeout=timeout,
|
|
237
|
+
)
|
|
238
|
+
except httpx.HTTPError:
|
|
239
|
+
return False
|
|
240
|
+
return response.status_code < 400
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Developer key storage and resolution.
|
|
2
|
+
|
|
3
|
+
Resolution precedence (highest wins), per ADR-203:
|
|
4
|
+
1. ``REFACTOR_API_KEY`` environment variable
|
|
5
|
+
2. ``developer_key`` in ``refactor.config`` (``${ENV}`` interpolated)
|
|
6
|
+
3. central credentials file ``~/.refactor/credentials.json``
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from refactor_core.constitution import find_config, load_config
|
|
17
|
+
from refactor_core.paths import credentials_path, ensure_dir, refactor_home
|
|
18
|
+
|
|
19
|
+
from refactor_cli.settings import env_api_key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ResolvedKey:
|
|
24
|
+
key: str
|
|
25
|
+
source: str # "env" | "config" | "credentials"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_credentials() -> dict:
|
|
29
|
+
path = credentials_path()
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_credentials(data: dict) -> Path:
|
|
39
|
+
ensure_dir(refactor_home())
|
|
40
|
+
path = credentials_path()
|
|
41
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
|
42
|
+
os.chmod(path, 0o600)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _config_key(project_root: Path | None) -> str | None:
|
|
47
|
+
config_path = find_config(project_root)
|
|
48
|
+
if not config_path:
|
|
49
|
+
return None
|
|
50
|
+
config = load_config(config_path)
|
|
51
|
+
value = config.get_setting("developer_key")
|
|
52
|
+
if isinstance(value, str) and value.strip():
|
|
53
|
+
return value.strip()
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_developer_key(project_root: Path | None = None) -> ResolvedKey | None:
|
|
58
|
+
"""Resolve the developer key using the documented precedence."""
|
|
59
|
+
env_key = env_api_key()
|
|
60
|
+
if env_key:
|
|
61
|
+
return ResolvedKey(key=env_key, source="env")
|
|
62
|
+
|
|
63
|
+
config_key = _config_key(project_root)
|
|
64
|
+
if config_key:
|
|
65
|
+
return ResolvedKey(key=config_key, source="config")
|
|
66
|
+
|
|
67
|
+
stored = load_credentials().get("developer_key")
|
|
68
|
+
if isinstance(stored, str) and stored.strip():
|
|
69
|
+
return ResolvedKey(key=stored.strip(), source="credentials")
|
|
70
|
+
|
|
71
|
+
return None
|
refactor_cli/main.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""`refactor` CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from refactor_cli import __version__
|
|
8
|
+
from refactor_cli.commands import auth_cmds, engine_cmds, model_cmds, rules_cmds, run_cmds, runtime_cmds, setup_cmds
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="refactor",
|
|
12
|
+
help="Local-first AI code review and refactor, run from your project folder.",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
add_completion=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version_callback(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"refactor {__version__}")
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def main(
|
|
26
|
+
version: bool = typer.Option(
|
|
27
|
+
False,
|
|
28
|
+
"--version",
|
|
29
|
+
"-V",
|
|
30
|
+
help="Show the CLI version and exit.",
|
|
31
|
+
callback=_version_callback,
|
|
32
|
+
is_eager=True,
|
|
33
|
+
),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""refactor CLI."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Auth (R7.1)
|
|
39
|
+
app.command()(auth_cmds.login)
|
|
40
|
+
app.command()(auth_cmds.whoami)
|
|
41
|
+
|
|
42
|
+
# Project / run surface (R7.0 scaffolding; auth-guarded where required)
|
|
43
|
+
app.command()(run_cmds.init)
|
|
44
|
+
app.command()(run_cmds.start)
|
|
45
|
+
app.command()(run_cmds.stop)
|
|
46
|
+
app.command()(run_cmds.shell)
|
|
47
|
+
app.command()(run_cmds.review)
|
|
48
|
+
app.command()(run_cmds.code)
|
|
49
|
+
app.command()(run_cmds.requests)
|
|
50
|
+
app.command()(run_cmds.apply)
|
|
51
|
+
app.command()(run_cmds.revert)
|
|
52
|
+
app.command()(run_cmds.status)
|
|
53
|
+
app.command()(run_cmds.diff)
|
|
54
|
+
app.command()(run_cmds.gc)
|
|
55
|
+
app.command()(run_cmds.check)
|
|
56
|
+
app.command()(run_cmds.doctor)
|
|
57
|
+
app.command()(run_cmds.config)
|
|
58
|
+
app.command()(setup_cmds.setup)
|
|
59
|
+
|
|
60
|
+
# Rule policy introspection (R12)
|
|
61
|
+
app.add_typer(rules_cmds.app, name="rules")
|
|
62
|
+
app.add_typer(runtime_cmds.app, name="runtime")
|
|
63
|
+
app.add_typer(engine_cmds.app, name="engine")
|
|
64
|
+
app.add_typer(model_cmds.app, name="model")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
app()
|