grit-cli 0.1.0a0__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.
- grit/__init__.py +4 -0
- grit/__main__.py +6 -0
- grit/cli/__init__.py +0 -0
- grit/cli/cmd_audit.py +124 -0
- grit/cli/cmd_auth.py +114 -0
- grit/cli/cmd_compliance.py +109 -0
- grit/cli/cmd_config.py +76 -0
- grit/cli/cmd_credential.py +142 -0
- grit/cli/cmd_daemon.py +106 -0
- grit/cli/cmd_enterprise.py +177 -0
- grit/cli/cmd_hook.py +60 -0
- grit/cli/cmd_profile.py +305 -0
- grit/cli/cmd_service.py +108 -0
- grit/cli/cmd_session.py +144 -0
- grit/cli/cmd_setup.py +92 -0
- grit/cli/cmd_sync.py +133 -0
- grit/cli/main.py +130 -0
- grit/cloud/__init__.py +0 -0
- grit/cloud/auth.py +24 -0
- grit/cloud/client.py +15 -0
- grit/cloud/sync.py +12 -0
- grit/config/__init__.py +0 -0
- grit/config/app_config.py +44 -0
- grit/config/paths.py +108 -0
- grit/config/subscription.py +293 -0
- grit/constants.py +38 -0
- grit/daemon/__init__.py +0 -0
- grit/daemon/hook_manager.py +67 -0
- grit/daemon/pid.py +55 -0
- grit/daemon/recovery.py +41 -0
- grit/daemon/server.py +187 -0
- grit/daemon/watchdog.py +78 -0
- grit/enterprise/__init__.py +0 -0
- grit/enterprise/audit.py +18 -0
- grit/enterprise/compliance.py +24 -0
- grit/enterprise/sso.py +45 -0
- grit/exceptions.py +81 -0
- grit/git/__init__.py +0 -0
- grit/git/config.py +138 -0
- grit/git/credentials.py +277 -0
- grit/git/gpg.py +52 -0
- grit/git/hook.py +94 -0
- grit/git/repo.py +50 -0
- grit/git/ssh.py +20 -0
- grit/ipc/__init__.py +0 -0
- grit/ipc/client.py +80 -0
- grit/ipc/protocol.py +80 -0
- grit/ipc/server.py +114 -0
- grit/models/__init__.py +0 -0
- grit/models/profile.py +46 -0
- grit/models/session.py +61 -0
- grit/platform/__init__.py +0 -0
- grit/platform/base.py +35 -0
- grit/platform/linux.py +93 -0
- grit/platform/macos.py +76 -0
- grit/platform/windows.py +53 -0
- grit/platform/windows_service.py +200 -0
- grit/session/__init__.py +0 -0
- grit/session/detector.py +104 -0
- grit/session/engine.py +163 -0
- grit/storage/__init__.py +0 -0
- grit/storage/_lock.py +45 -0
- grit/storage/profile_store.py +106 -0
- grit/storage/session_store.py +103 -0
- grit/ui/__init__.py +0 -0
- grit/ui/notifications.py +32 -0
- grit/ui/popup.py +124 -0
- grit/ui/tray.py +148 -0
- grit_cli-0.1.0a0.dist-info/METADATA +301 -0
- grit_cli-0.1.0a0.dist-info/RECORD +73 -0
- grit_cli-0.1.0a0.dist-info/WHEEL +4 -0
- grit_cli-0.1.0a0.dist-info/entry_points.txt +8 -0
- grit_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
grit/__init__.py
ADDED
grit/__main__.py
ADDED
grit/cli/__init__.py
ADDED
|
File without changes
|
grit/cli/cmd_audit.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""CLI commands: grit audit <subcommand>
|
|
2
|
+
|
|
3
|
+
Enterprise audit log viewer and exporter.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group("audit")
|
|
15
|
+
def audit() -> None:
|
|
16
|
+
"""View and export the enterprise audit log."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@audit.command("show")
|
|
20
|
+
@click.option("--since", default=None, metavar="ISO_DATETIME",
|
|
21
|
+
help="Only show entries at or after this ISO timestamp (e.g. 2025-01-01T00:00:00Z).")
|
|
22
|
+
@click.option("--action", default=None, metavar="ACTION",
|
|
23
|
+
help="Filter by action type: profile_switch, session_create, git_config_write.")
|
|
24
|
+
@click.option("--limit", type=int, default=50, show_default=True,
|
|
25
|
+
help="Maximum number of entries to display.")
|
|
26
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON array.")
|
|
27
|
+
def show(since: str, action: str, limit: int, as_json: bool) -> None:
|
|
28
|
+
"""Display recent audit log entries."""
|
|
29
|
+
from grit.config.subscription import require_pro_installed
|
|
30
|
+
require_pro_installed("audit log")
|
|
31
|
+
from grit.enterprise.audit import export_entries
|
|
32
|
+
|
|
33
|
+
entries = export_entries(since=since)
|
|
34
|
+
|
|
35
|
+
if action:
|
|
36
|
+
entries = [e for e in entries if e.get("action") == action]
|
|
37
|
+
|
|
38
|
+
entries = entries[-limit:] # most recent N
|
|
39
|
+
|
|
40
|
+
if as_json:
|
|
41
|
+
click.echo(json.dumps(entries, indent=2))
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if not entries:
|
|
45
|
+
click.echo("No audit log entries found.")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
for entry in entries:
|
|
49
|
+
ts = entry.get("timestamp", "?")
|
|
50
|
+
act = entry.get("action", "?")
|
|
51
|
+
repo = entry.get("repo_path", "")
|
|
52
|
+
profile = entry.get("profile_name", entry.get("profile_id", ""))
|
|
53
|
+
key = entry.get("key", "")
|
|
54
|
+
|
|
55
|
+
if act == "profile_switch":
|
|
56
|
+
click.echo(f"{ts} profile_switch {profile!r} → {repo}")
|
|
57
|
+
elif act == "session_create":
|
|
58
|
+
prof = profile or entry.get("profile_id", "?")
|
|
59
|
+
click.echo(f"{ts} session_create profile={prof} repo={repo}")
|
|
60
|
+
elif act == "git_config_write":
|
|
61
|
+
click.echo(f"{ts} git_config_write {key} → {repo}")
|
|
62
|
+
else:
|
|
63
|
+
extra = {k: v for k, v in entry.items() if k not in ("timestamp", "action")}
|
|
64
|
+
click.echo(f"{ts} {act} {json.dumps(extra)}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@audit.command("export")
|
|
68
|
+
@click.option("--since", default=None, metavar="ISO_DATETIME",
|
|
69
|
+
help="Only export entries at or after this ISO timestamp.")
|
|
70
|
+
@click.option("--format", "fmt", type=click.Choice(["json", "csv", "jsonl"]), default="jsonl",
|
|
71
|
+
show_default=True)
|
|
72
|
+
@click.option("--output", "-o", default=None, metavar="FILE",
|
|
73
|
+
help="Write to FILE instead of stdout.")
|
|
74
|
+
def export(since: str, fmt: str, output: str) -> None:
|
|
75
|
+
"""Export audit log entries to JSON, JSONL, or CSV."""
|
|
76
|
+
from grit.config.subscription import require_pro_installed
|
|
77
|
+
require_pro_installed("audit log")
|
|
78
|
+
import csv
|
|
79
|
+
import io
|
|
80
|
+
|
|
81
|
+
from grit.enterprise.audit import export_entries
|
|
82
|
+
|
|
83
|
+
entries = export_entries(since=since)
|
|
84
|
+
|
|
85
|
+
if fmt == "jsonl":
|
|
86
|
+
lines = "\n".join(json.dumps(e, ensure_ascii=False) for e in entries)
|
|
87
|
+
text = lines + ("\n" if lines else "")
|
|
88
|
+
elif fmt == "json":
|
|
89
|
+
text = json.dumps(entries, indent=2, ensure_ascii=False) + "\n"
|
|
90
|
+
else: # csv
|
|
91
|
+
buf = io.StringIO()
|
|
92
|
+
fieldnames = ["timestamp", "action", "repo_path", "profile_id", "profile_name", "key"]
|
|
93
|
+
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore",
|
|
94
|
+
lineterminator="\n")
|
|
95
|
+
writer.writeheader()
|
|
96
|
+
writer.writerows(entries)
|
|
97
|
+
text = buf.getvalue()
|
|
98
|
+
|
|
99
|
+
if output:
|
|
100
|
+
try:
|
|
101
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
102
|
+
fh.write(text)
|
|
103
|
+
click.echo(f"Exported {len(entries)} entries to {output}")
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
click.echo(f"Error writing {output}: {exc}", err=True)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
else:
|
|
108
|
+
click.echo(text, nl=False)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@audit.command("clear")
|
|
112
|
+
@click.confirmation_option(prompt="This will permanently delete all audit log entries. Continue?")
|
|
113
|
+
def clear() -> None:
|
|
114
|
+
"""Delete the audit log file (irreversible)."""
|
|
115
|
+
from grit.config.subscription import require_pro_installed
|
|
116
|
+
require_pro_installed("audit log")
|
|
117
|
+
from grit.config.paths import audit_log_file
|
|
118
|
+
|
|
119
|
+
path = audit_log_file()
|
|
120
|
+
if path.exists():
|
|
121
|
+
path.unlink()
|
|
122
|
+
click.echo("Audit log cleared.")
|
|
123
|
+
else:
|
|
124
|
+
click.echo("No audit log found.")
|
grit/cli/cmd_auth.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""CLI commands: grit auth <subcommand>
|
|
2
|
+
|
|
3
|
+
Manages cloud authentication for Grit Pro / Enterprise features.
|
|
4
|
+
Uses device-flow OAuth2 so users never type passwords into the terminal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group("auth")
|
|
16
|
+
def auth() -> None:
|
|
17
|
+
"""Authenticate with Grit Cloud for Pro/Enterprise features."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@auth.command("login")
|
|
21
|
+
@click.option("--provider", type=click.Choice(["github", "google"]), default="github",
|
|
22
|
+
help="OAuth provider to use.")
|
|
23
|
+
def login(provider: str) -> None:
|
|
24
|
+
"""Log in to Grit Cloud via browser-based device flow."""
|
|
25
|
+
from grit.config.subscription import require_pro_installed
|
|
26
|
+
require_pro_installed("Grit Cloud login")
|
|
27
|
+
from grit.cloud.auth import poll_device_flow, save_tokens, start_device_flow
|
|
28
|
+
|
|
29
|
+
click.echo(f"Starting device-flow login with {provider}...")
|
|
30
|
+
try:
|
|
31
|
+
flow = start_device_flow(provider)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
click.echo(f"Login failed: {exc}", err=True)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
click.echo(f"\nOpen this URL in your browser:\n\n {flow['verification_uri']}\n")
|
|
37
|
+
click.echo(f"Enter code: {flow['user_code']}\n")
|
|
38
|
+
click.echo("Waiting for authorization...", nl=False)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
tokens = poll_device_flow(provider, flow["device_code"], flow["interval"])
|
|
42
|
+
except TimeoutError:
|
|
43
|
+
click.echo("\nLogin timed out. Please try again.")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
click.echo(f"\nLogin failed: {exc}", err=True)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
save_tokens(tokens)
|
|
50
|
+
click.echo(" done!")
|
|
51
|
+
|
|
52
|
+
# Fetch and store license
|
|
53
|
+
try:
|
|
54
|
+
from grit.cloud.client import GritCloudClient
|
|
55
|
+
client = GritCloudClient()
|
|
56
|
+
license_data = client.get_license_status()
|
|
57
|
+
from grit.config.subscription import save_license
|
|
58
|
+
save_license(license_data["token"], license_data["claims"])
|
|
59
|
+
tier = license_data["claims"].get("tier", "free")
|
|
60
|
+
click.echo(f"Logged in. Plan: {tier.upper()}")
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
click.echo(f"Could not fetch license: {exc}", err=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@auth.command("logout")
|
|
66
|
+
def logout() -> None:
|
|
67
|
+
"""Log out and remove stored credentials."""
|
|
68
|
+
from grit.config.subscription import require_pro_installed
|
|
69
|
+
require_pro_installed("Grit Cloud login")
|
|
70
|
+
from grit.cloud.auth import clear_tokens
|
|
71
|
+
from grit.config.subscription import clear_license
|
|
72
|
+
clear_tokens()
|
|
73
|
+
clear_license()
|
|
74
|
+
click.echo("Logged out. Grit will operate in free tier mode.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@auth.command("status")
|
|
78
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
79
|
+
def status(as_json: bool) -> None:
|
|
80
|
+
"""Show current authentication and subscription status."""
|
|
81
|
+
from grit.config.subscription import require_pro_installed
|
|
82
|
+
require_pro_installed("Grit Cloud login")
|
|
83
|
+
from grit.cloud.auth import load_tokens
|
|
84
|
+
from grit.config.subscription import load_license
|
|
85
|
+
|
|
86
|
+
tokens = load_tokens()
|
|
87
|
+
lic = load_license()
|
|
88
|
+
|
|
89
|
+
if as_json:
|
|
90
|
+
data = {
|
|
91
|
+
"authenticated": tokens is not None,
|
|
92
|
+
"tier": lic.tier,
|
|
93
|
+
"email": lic.email,
|
|
94
|
+
"expires_at": lic.expires_at,
|
|
95
|
+
"is_valid": lic.is_valid,
|
|
96
|
+
"in_grace_period": lic.is_in_grace_period,
|
|
97
|
+
}
|
|
98
|
+
click.echo(json.dumps(data, indent=2))
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if not tokens:
|
|
102
|
+
click.echo("Not logged in. Run `grit auth login` to authenticate.")
|
|
103
|
+
else:
|
|
104
|
+
click.echo(f"Logged in as: {lic.email or 'unknown'}")
|
|
105
|
+
|
|
106
|
+
click.echo(f"Plan: {lic.tier.upper()}")
|
|
107
|
+
click.echo(f"Expires: {lic.expires_at}")
|
|
108
|
+
if lic.is_in_grace_period:
|
|
109
|
+
click.echo(" (License expired — within 30-day grace period)")
|
|
110
|
+
elif not lic.is_valid:
|
|
111
|
+
click.echo(" (License expired — operating in free tier)")
|
|
112
|
+
click.echo(f"Profile limit: {'unlimited' if lic.profile_limit == -1 else lic.profile_limit}")
|
|
113
|
+
click.echo(f"Cloud sync: {'yes' if lic.allows_cloud_sync() else 'no (Pro)'}")
|
|
114
|
+
click.echo(f"Team profiles:{'yes' if lic.allows_team_profiles() else 'no (Pro)'}")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""CLI commands: grit compliance <subcommand>
|
|
2
|
+
|
|
3
|
+
Enterprise compliance reporting.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group("compliance")
|
|
16
|
+
def compliance() -> None:
|
|
17
|
+
"""Enterprise compliance checks and reporting."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@compliance.command("report")
|
|
21
|
+
@click.option("--since", default=None, metavar="ISO_DATETIME",
|
|
22
|
+
help="Include audit events since this ISO timestamp.")
|
|
23
|
+
@click.option("--output", "-o", default=None, metavar="FILE",
|
|
24
|
+
help="Write JSON report to FILE (default: print to stdout).")
|
|
25
|
+
@click.option("--json", "as_json", is_flag=True, help="Always output raw JSON.")
|
|
26
|
+
def report(since: str, output: str, as_json: bool) -> None:
|
|
27
|
+
"""Generate a compliance report (hooks, GPG, SSO, audit summary)."""
|
|
28
|
+
from grit.enterprise.compliance import generate_report, write_report
|
|
29
|
+
|
|
30
|
+
if output:
|
|
31
|
+
path = Path(output)
|
|
32
|
+
data = write_report(path, since=since)
|
|
33
|
+
click.echo(f"Report written to {output} (compliant={data['compliant']})")
|
|
34
|
+
if not data["compliant"]:
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
data = generate_report(since=since)
|
|
39
|
+
|
|
40
|
+
if as_json:
|
|
41
|
+
click.echo(json.dumps(data, indent=2))
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
click.echo(f"Compliance report — {data['generated_at']}")
|
|
45
|
+
click.echo(f"Overall: {'PASS' if data['compliant'] else 'FAIL'}")
|
|
46
|
+
click.echo("")
|
|
47
|
+
|
|
48
|
+
hooks = data["sections"].get("hook_inventory", {})
|
|
49
|
+
click.echo(
|
|
50
|
+
f"Hook inventory: {hooks.get('hooks_installed', '?')}/"
|
|
51
|
+
f"{hooks.get('total_repos', '?')} repos have Grit hook"
|
|
52
|
+
)
|
|
53
|
+
if hooks.get("missing_repos"):
|
|
54
|
+
for r in hooks["missing_repos"]:
|
|
55
|
+
click.echo(f" MISSING {r}")
|
|
56
|
+
|
|
57
|
+
gpg = data["sections"].get("gpg_enforcement", {})
|
|
58
|
+
click.echo(
|
|
59
|
+
f"GPG signing: {gpg.get('gpg_enabled', '?')}/"
|
|
60
|
+
f"{gpg.get('total_profiles', '?')} profiles have GPG key"
|
|
61
|
+
)
|
|
62
|
+
if gpg.get("profiles_without_gpg"):
|
|
63
|
+
for name in gpg["profiles_without_gpg"]:
|
|
64
|
+
click.echo(f" NO GPG {name}")
|
|
65
|
+
|
|
66
|
+
sso = data["sections"].get("sso_compliance", {})
|
|
67
|
+
click.echo(
|
|
68
|
+
f"SSO: configured={sso.get('sso_configured', '?')} "
|
|
69
|
+
f"enforce={sso.get('enforce_sso', '?')} "
|
|
70
|
+
f"active_session={sso.get('active_sso_session', '?')}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
audit = data["sections"].get("audit_summary", {})
|
|
74
|
+
click.echo(f"Audit events: {audit.get('total_events', 0)} total")
|
|
75
|
+
for action, count in audit.get("by_action", {}).items():
|
|
76
|
+
click.echo(f" {action}: {count}")
|
|
77
|
+
|
|
78
|
+
if not data["compliant"]:
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@compliance.command("hooks")
|
|
83
|
+
def hooks() -> None:
|
|
84
|
+
"""Check Grit hook installation across all watched repositories."""
|
|
85
|
+
from grit.enterprise.compliance import check_hook_inventory
|
|
86
|
+
|
|
87
|
+
result = check_hook_inventory()
|
|
88
|
+
status = "OK" if result["hooks_missing"] == 0 else "FAIL"
|
|
89
|
+
click.echo(
|
|
90
|
+
f"[{status}] {result['hooks_installed']}/{result['total_repos']} repos have hook installed"
|
|
91
|
+
)
|
|
92
|
+
for repo in result.get("missing_repos", []):
|
|
93
|
+
click.echo(f" MISSING: {repo}")
|
|
94
|
+
if result["hooks_missing"]:
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@compliance.command("gpg")
|
|
99
|
+
def gpg() -> None:
|
|
100
|
+
"""Check GPG signing configuration across all profiles."""
|
|
101
|
+
from grit.enterprise.compliance import check_gpg_enforcement
|
|
102
|
+
|
|
103
|
+
result = check_gpg_enforcement()
|
|
104
|
+
status = "OK" if result["gpg_missing"] == 0 else "WARN"
|
|
105
|
+
click.echo(
|
|
106
|
+
f"[{status}] {result['gpg_enabled']}/{result['total_profiles']} profiles have GPG key"
|
|
107
|
+
)
|
|
108
|
+
for name in result.get("profiles_without_gpg", []):
|
|
109
|
+
click.echo(f" NO GPG: {name}")
|
grit/cli/cmd_config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CLI commands: grit config <subcommand>"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from grit.config.app_config import AppConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group("config")
|
|
14
|
+
def config() -> None:
|
|
15
|
+
"""Get or set Grit application configuration."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@config.command("get")
|
|
19
|
+
@click.argument("key")
|
|
20
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
21
|
+
def get(key: str, as_json: bool) -> None:
|
|
22
|
+
"""Get a configuration value."""
|
|
23
|
+
cfg = AppConfig.load()
|
|
24
|
+
if not hasattr(cfg, key):
|
|
25
|
+
click.echo(f"Unknown config key: {key!r}", err=True)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
value = getattr(cfg, key)
|
|
28
|
+
if as_json:
|
|
29
|
+
click.echo(json.dumps({key: value}))
|
|
30
|
+
else:
|
|
31
|
+
click.echo(f"{key} = {value}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@config.command("set")
|
|
35
|
+
@click.argument("key")
|
|
36
|
+
@click.argument("value")
|
|
37
|
+
def set_config(key: str, value: str) -> None:
|
|
38
|
+
"""Set a configuration value."""
|
|
39
|
+
cfg = AppConfig.load()
|
|
40
|
+
if not hasattr(cfg, key):
|
|
41
|
+
click.echo(f"Unknown config key: {key!r}", err=True)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
# Coerce value to the field's type
|
|
44
|
+
field_type = type(getattr(cfg, key))
|
|
45
|
+
try:
|
|
46
|
+
coerced = value.lower() in ("true", "1", "yes") if field_type is bool else field_type(value)
|
|
47
|
+
except (ValueError, TypeError) as exc:
|
|
48
|
+
click.echo(f"Invalid value for {key!r}: {exc}", err=True)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
setattr(cfg, key, coerced)
|
|
51
|
+
cfg.save()
|
|
52
|
+
click.echo(f"Set {key} = {coerced}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@config.command("reset")
|
|
56
|
+
@click.option("--force", is_flag=True, help="Skip confirmation.")
|
|
57
|
+
def reset(force: bool) -> None:
|
|
58
|
+
"""Reset all configuration to defaults."""
|
|
59
|
+
if not force:
|
|
60
|
+
click.confirm("Reset all configuration to defaults?", abort=True)
|
|
61
|
+
AppConfig().save()
|
|
62
|
+
click.echo("Configuration reset to defaults.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@config.command("list")
|
|
66
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
67
|
+
def list_config(as_json: bool) -> None:
|
|
68
|
+
"""Show all configuration values."""
|
|
69
|
+
cfg = AppConfig.load()
|
|
70
|
+
from dataclasses import asdict
|
|
71
|
+
data = asdict(cfg)
|
|
72
|
+
if as_json:
|
|
73
|
+
click.echo(json.dumps(data, indent=2))
|
|
74
|
+
return
|
|
75
|
+
for k, v in data.items():
|
|
76
|
+
click.echo(f" {k} = {v}")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CLI commands: grit credential <subcommand>
|
|
2
|
+
|
|
3
|
+
Manages per-profile HTTPS credentials in the OS credential store so that
|
|
4
|
+
git push/pull authenticates as the right GitHub account when a Grit session
|
|
5
|
+
is active.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from grit.exceptions import GritError, ProfileNotFoundError
|
|
15
|
+
from grit.models.profile import Profile
|
|
16
|
+
from grit.storage.profile_store import ProfileStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def credential() -> None:
|
|
21
|
+
"""Manage HTTPS credentials for Git profiles."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _store() -> ProfileStore:
|
|
25
|
+
return ProfileStore()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_http_username(profile_name: str) -> tuple[Profile, str]:
|
|
29
|
+
"""Load profile and return (profile, http_username), exiting on error."""
|
|
30
|
+
store = _store()
|
|
31
|
+
try:
|
|
32
|
+
p = store.get_by_name(profile_name)
|
|
33
|
+
except ProfileNotFoundError as exc:
|
|
34
|
+
click.echo(str(exc), err=True)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
if not p.http_username:
|
|
38
|
+
click.echo(
|
|
39
|
+
f"Profile {profile_name!r} has no HTTP username set.\n"
|
|
40
|
+
f"Set one first with:\n\n"
|
|
41
|
+
f" grit profile edit {profile_name} --http-username <your-github-username>\n",
|
|
42
|
+
err=True,
|
|
43
|
+
)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
return p, p.http_username
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@credential.command("login")
|
|
50
|
+
@click.argument("profile_name", metavar="PROFILE")
|
|
51
|
+
@click.option(
|
|
52
|
+
"--host", default="github.com", show_default=True, help="Git host to authenticate with."
|
|
53
|
+
)
|
|
54
|
+
def login(profile_name: str, host: str) -> None:
|
|
55
|
+
"""Open a browser to authenticate with GitHub and store the credential.
|
|
56
|
+
|
|
57
|
+
Triggers GitHub's device flow: a browser tab opens with the authorization
|
|
58
|
+
code pre-filled so you just need to click Authorize.
|
|
59
|
+
"""
|
|
60
|
+
from grit.git.credentials import github_browser_login, store_credential
|
|
61
|
+
|
|
62
|
+
p, username = _require_http_username(profile_name)
|
|
63
|
+
|
|
64
|
+
click.echo(f"Authenticating profile {profile_name!r} ({username}@{host}) via browser...")
|
|
65
|
+
try:
|
|
66
|
+
token = github_browser_login(username_hint=username)
|
|
67
|
+
except GritError as exc:
|
|
68
|
+
click.echo(str(exc), err=True)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
store_credential(host, username, token)
|
|
73
|
+
except GritError as exc:
|
|
74
|
+
click.echo(f"Failed to store credential: {exc}", err=True)
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
click.echo(f"Authorized as {username} — credential stored.")
|
|
78
|
+
click.echo(f"\nNext time you run `grit session set {profile_name}`, git push will")
|
|
79
|
+
click.echo(f"authenticate as {username} automatically.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@credential.command("set")
|
|
83
|
+
@click.argument("profile_name", metavar="PROFILE")
|
|
84
|
+
@click.option("--token", required=True, prompt=True, hide_input=True, help="Personal access token.")
|
|
85
|
+
@click.option("--host", default="github.com", show_default=True, help="Git host.")
|
|
86
|
+
def set_credential(profile_name: str, token: str, host: str) -> None:
|
|
87
|
+
"""Store a Personal Access Token for a profile (manual alternative to login).
|
|
88
|
+
|
|
89
|
+
Use `grit credential login` for the browser-based flow instead.
|
|
90
|
+
"""
|
|
91
|
+
from grit.git.credentials import store_credential
|
|
92
|
+
|
|
93
|
+
p, username = _require_http_username(profile_name)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
store_credential(host, username, token)
|
|
97
|
+
except GritError as exc:
|
|
98
|
+
click.echo(f"Failed to store credential: {exc}", err=True)
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
click.echo(f"Credential stored for {username}@{host}.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@credential.command("clear")
|
|
105
|
+
@click.argument("profile_name", metavar="PROFILE")
|
|
106
|
+
@click.option("--host", default="github.com", show_default=True, help="Git host.")
|
|
107
|
+
def clear(profile_name: str, host: str) -> None:
|
|
108
|
+
"""Remove the stored credential for a profile."""
|
|
109
|
+
from grit.git.credentials import delete_credential
|
|
110
|
+
|
|
111
|
+
p, username = _require_http_username(profile_name)
|
|
112
|
+
|
|
113
|
+
delete_credential(host, username)
|
|
114
|
+
click.echo(f"Credential for {username}@{host} cleared.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@credential.command("show")
|
|
118
|
+
@click.argument("profile_name", metavar="PROFILE")
|
|
119
|
+
@click.option("--host", default="github.com", show_default=True, help="Git host.")
|
|
120
|
+
def show(profile_name: str, host: str) -> None:
|
|
121
|
+
"""Show whether a credential is stored for a profile (never shows the value)."""
|
|
122
|
+
from grit.git.credentials import has_credential
|
|
123
|
+
|
|
124
|
+
store = _store()
|
|
125
|
+
try:
|
|
126
|
+
p = store.get_by_name(profile_name)
|
|
127
|
+
except ProfileNotFoundError as exc:
|
|
128
|
+
click.echo(str(exc), err=True)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
click.echo(f"\n[{p.name}]")
|
|
132
|
+
click.echo(f" HTTP user: {p.http_username or '(not set)'}")
|
|
133
|
+
click.echo(f" Host: {host}")
|
|
134
|
+
|
|
135
|
+
if not p.http_username:
|
|
136
|
+
click.echo(" Credential stored: N/A (no HTTP username configured)")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
stored = has_credential(host, p.http_username)
|
|
140
|
+
click.echo(f" Credential stored: {'Yes' if stored else 'No'}")
|
|
141
|
+
if not stored:
|
|
142
|
+
click.echo(f"\n Run `grit credential login {profile_name}` to authenticate.")
|
grit/cli/cmd_daemon.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CLI commands: grit daemon <subcommand>"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from grit.daemon import pid as pid_mod
|
|
12
|
+
from grit.exceptions import DaemonNotRunningError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def daemon() -> None:
|
|
17
|
+
"""Control the Grit background daemon."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@daemon.command("start")
|
|
21
|
+
@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonise).")
|
|
22
|
+
@click.option("--verbose", "-v", is_flag=True)
|
|
23
|
+
def start(foreground: bool, verbose: bool) -> None:
|
|
24
|
+
"""Start the Grit daemon."""
|
|
25
|
+
running = pid_mod.get_running_pid()
|
|
26
|
+
if running:
|
|
27
|
+
click.echo(f"Daemon already running (PID {running}).")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
cmd = [sys.executable, "-m", "grit.daemon.server"]
|
|
31
|
+
if verbose:
|
|
32
|
+
cmd.append("--verbose")
|
|
33
|
+
|
|
34
|
+
if foreground:
|
|
35
|
+
import os
|
|
36
|
+
os.execv(sys.executable, cmd) # replace current process
|
|
37
|
+
else:
|
|
38
|
+
proc = subprocess.Popen(
|
|
39
|
+
cmd,
|
|
40
|
+
stdout=subprocess.DEVNULL,
|
|
41
|
+
stderr=subprocess.DEVNULL,
|
|
42
|
+
start_new_session=True,
|
|
43
|
+
)
|
|
44
|
+
click.echo(f"Daemon started (PID {proc.pid}).")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@daemon.command("stop")
|
|
48
|
+
def stop() -> None:
|
|
49
|
+
"""Stop the running daemon."""
|
|
50
|
+
import os
|
|
51
|
+
import signal as sig_mod
|
|
52
|
+
running = pid_mod.get_running_pid()
|
|
53
|
+
if not running:
|
|
54
|
+
click.echo("Daemon is not running.")
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
os.kill(running, sig_mod.SIGTERM)
|
|
58
|
+
click.echo(f"Sent SIGTERM to daemon (PID {running}).")
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
click.echo(f"Could not stop daemon: {exc}", err=True)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@daemon.command("status")
|
|
65
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
66
|
+
def status(as_json: bool) -> None:
|
|
67
|
+
"""Show daemon status."""
|
|
68
|
+
from grit.ipc.client import send_request
|
|
69
|
+
running = pid_mod.get_running_pid()
|
|
70
|
+
if not running:
|
|
71
|
+
if as_json:
|
|
72
|
+
click.echo(json.dumps({"running": False}))
|
|
73
|
+
else:
|
|
74
|
+
click.echo("Daemon is not running.")
|
|
75
|
+
sys.exit(2)
|
|
76
|
+
try:
|
|
77
|
+
resp = send_request("daemon-status")
|
|
78
|
+
info = resp.get("payload", {})
|
|
79
|
+
except DaemonNotRunningError:
|
|
80
|
+
info = {}
|
|
81
|
+
|
|
82
|
+
if as_json:
|
|
83
|
+
click.echo(json.dumps({"running": True, **info}))
|
|
84
|
+
return
|
|
85
|
+
click.echo(f"Daemon running (PID {running})")
|
|
86
|
+
if info:
|
|
87
|
+
click.echo(f" Version: {info.get('version', '?')}")
|
|
88
|
+
click.echo(f" Active sessions: {info.get('active_sessions', '?')}")
|
|
89
|
+
click.echo(f" Profiles: {info.get('profile_count', '?')}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@daemon.command("restart")
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def restart(ctx: click.Context) -> None:
|
|
95
|
+
"""Restart the daemon (stop then start)."""
|
|
96
|
+
import time
|
|
97
|
+
ctx.invoke(stop)
|
|
98
|
+
# SIGTERM is asynchronous — wait until the process is actually gone
|
|
99
|
+
for _ in range(50): # up to 5 seconds
|
|
100
|
+
time.sleep(0.1)
|
|
101
|
+
if not pid_mod.get_running_pid():
|
|
102
|
+
break
|
|
103
|
+
else:
|
|
104
|
+
click.echo("Timed out waiting for daemon to stop.", err=True)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
ctx.invoke(start)
|