scc-cli 1.5.3__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.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Environment health checks for doctor module.
|
|
2
|
+
|
|
3
|
+
Checks for Git, Docker, WSL2, and workspace path requirements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..types import CheckResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_git() -> CheckResult:
|
|
15
|
+
"""Check if Git is installed and accessible."""
|
|
16
|
+
from ... import git as git_module
|
|
17
|
+
|
|
18
|
+
if not git_module.check_git_installed():
|
|
19
|
+
return CheckResult(
|
|
20
|
+
name="Git",
|
|
21
|
+
passed=False,
|
|
22
|
+
message="Git is not installed or not in PATH",
|
|
23
|
+
fix_hint="Install Git from https://git-scm.com/downloads",
|
|
24
|
+
fix_url="https://git-scm.com/downloads",
|
|
25
|
+
severity="error",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
version = git_module.get_git_version()
|
|
29
|
+
return CheckResult(
|
|
30
|
+
name="Git",
|
|
31
|
+
passed=True,
|
|
32
|
+
message="Git is installed and accessible",
|
|
33
|
+
version=version,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_docker() -> CheckResult:
|
|
38
|
+
"""Check if Docker is installed and running."""
|
|
39
|
+
from ... import docker as docker_module
|
|
40
|
+
|
|
41
|
+
version = docker_module.get_docker_version()
|
|
42
|
+
|
|
43
|
+
if version is None:
|
|
44
|
+
return CheckResult(
|
|
45
|
+
name="Docker",
|
|
46
|
+
passed=False,
|
|
47
|
+
message="Docker is not installed or not running",
|
|
48
|
+
fix_hint="Install Docker Desktop from https://docker.com/products/docker-desktop",
|
|
49
|
+
fix_url="https://docker.com/products/docker-desktop",
|
|
50
|
+
severity="error",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Parse and check minimum version
|
|
54
|
+
current = docker_module._parse_version(version)
|
|
55
|
+
required = docker_module._parse_version(docker_module.MIN_DOCKER_VERSION)
|
|
56
|
+
|
|
57
|
+
if current < required:
|
|
58
|
+
return CheckResult(
|
|
59
|
+
name="Docker",
|
|
60
|
+
passed=False,
|
|
61
|
+
message=f"Docker version {'.'.join(map(str, current))} is below minimum {docker_module.MIN_DOCKER_VERSION}",
|
|
62
|
+
version=version,
|
|
63
|
+
fix_hint="Update Docker Desktop to the latest version",
|
|
64
|
+
fix_url="https://docker.com/products/docker-desktop",
|
|
65
|
+
severity="error",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return CheckResult(
|
|
69
|
+
name="Docker",
|
|
70
|
+
passed=True,
|
|
71
|
+
message="Docker is installed and meets version requirements",
|
|
72
|
+
version=version,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_docker_sandbox() -> CheckResult:
|
|
77
|
+
"""Check if Docker sandbox feature is available."""
|
|
78
|
+
from ... import docker as docker_module
|
|
79
|
+
|
|
80
|
+
if not docker_module.check_docker_sandbox():
|
|
81
|
+
return CheckResult(
|
|
82
|
+
name="Docker Sandbox",
|
|
83
|
+
passed=False,
|
|
84
|
+
message="Docker sandbox feature is not available",
|
|
85
|
+
fix_hint=f"Requires Docker Desktop {docker_module.MIN_DOCKER_VERSION}+ with sandbox feature enabled",
|
|
86
|
+
fix_url="https://docs.docker.com/desktop/features/sandbox/",
|
|
87
|
+
severity="error",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return CheckResult(
|
|
91
|
+
name="Docker Sandbox",
|
|
92
|
+
passed=True,
|
|
93
|
+
message="Docker sandbox feature is available",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_docker_running() -> CheckResult:
|
|
98
|
+
"""Check if Docker daemon is running."""
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
["docker", "info"],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
timeout=10,
|
|
104
|
+
)
|
|
105
|
+
if result.returncode == 0:
|
|
106
|
+
return CheckResult(
|
|
107
|
+
name="Docker Daemon",
|
|
108
|
+
passed=True,
|
|
109
|
+
message="Docker daemon is running",
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
return CheckResult(
|
|
113
|
+
name="Docker Daemon",
|
|
114
|
+
passed=False,
|
|
115
|
+
message="Docker daemon is not running",
|
|
116
|
+
fix_hint="Start Docker Desktop or run 'sudo systemctl start docker'",
|
|
117
|
+
severity="error",
|
|
118
|
+
)
|
|
119
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
120
|
+
return CheckResult(
|
|
121
|
+
name="Docker Daemon",
|
|
122
|
+
passed=False,
|
|
123
|
+
message="Could not connect to Docker daemon",
|
|
124
|
+
fix_hint="Ensure Docker Desktop is running",
|
|
125
|
+
severity="error",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_wsl2() -> tuple[CheckResult, bool]:
|
|
130
|
+
"""Check WSL2 environment and return (result, is_wsl2)."""
|
|
131
|
+
from ... import platform as platform_module
|
|
132
|
+
|
|
133
|
+
is_wsl2 = platform_module.is_wsl2()
|
|
134
|
+
|
|
135
|
+
if is_wsl2:
|
|
136
|
+
return (
|
|
137
|
+
CheckResult(
|
|
138
|
+
name="WSL2 Environment",
|
|
139
|
+
passed=True,
|
|
140
|
+
message="Running in WSL2 (recommended for Windows)",
|
|
141
|
+
severity="info",
|
|
142
|
+
),
|
|
143
|
+
True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
CheckResult(
|
|
148
|
+
name="WSL2 Environment",
|
|
149
|
+
passed=True,
|
|
150
|
+
message="Not running in WSL2",
|
|
151
|
+
severity="info",
|
|
152
|
+
),
|
|
153
|
+
False,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_workspace_path(workspace: Path | None = None) -> CheckResult:
|
|
158
|
+
"""Check if workspace path is optimal (not on Windows mount in WSL2)."""
|
|
159
|
+
from ... import platform as platform_module
|
|
160
|
+
|
|
161
|
+
if workspace is None:
|
|
162
|
+
return CheckResult(
|
|
163
|
+
name="Workspace Path",
|
|
164
|
+
passed=True,
|
|
165
|
+
message="No workspace specified",
|
|
166
|
+
severity="info",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if platform_module.is_wsl2() and platform_module.is_windows_mount_path(workspace):
|
|
170
|
+
return CheckResult(
|
|
171
|
+
name="Workspace Path",
|
|
172
|
+
passed=False,
|
|
173
|
+
message=f"Workspace is on Windows filesystem: {workspace}",
|
|
174
|
+
fix_hint="Move project to ~/projects inside WSL for better performance",
|
|
175
|
+
severity="warning",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return CheckResult(
|
|
179
|
+
name="Workspace Path",
|
|
180
|
+
passed=True,
|
|
181
|
+
message=f"Workspace path is optimal: {workspace}",
|
|
182
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""JSON validation helpers for doctor module.
|
|
2
|
+
|
|
3
|
+
Provides enhanced JSON validation with code frames and helpful hints.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ...theme import Indicators
|
|
12
|
+
from ..types import JsonValidationResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_json_file(file_path: Path) -> JsonValidationResult:
|
|
16
|
+
"""
|
|
17
|
+
Validate a JSON file and extract detailed error information.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
file_path: Path to the JSON file to validate
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
JsonValidationResult with validation status and error details
|
|
24
|
+
"""
|
|
25
|
+
if not file_path.exists():
|
|
26
|
+
return JsonValidationResult(valid=True, file_path=file_path)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
content = file_path.read_text(encoding="utf-8")
|
|
30
|
+
json.loads(content)
|
|
31
|
+
return JsonValidationResult(valid=True, file_path=file_path)
|
|
32
|
+
except json.JSONDecodeError as e:
|
|
33
|
+
code_frame = format_code_frame(content, e.lineno, e.colno, file_path)
|
|
34
|
+
return JsonValidationResult(
|
|
35
|
+
valid=False,
|
|
36
|
+
error_message=e.msg,
|
|
37
|
+
line=e.lineno,
|
|
38
|
+
column=e.colno,
|
|
39
|
+
file_path=file_path,
|
|
40
|
+
code_frame=code_frame,
|
|
41
|
+
)
|
|
42
|
+
except OSError as e:
|
|
43
|
+
return JsonValidationResult(
|
|
44
|
+
valid=False,
|
|
45
|
+
error_message=f"Cannot read file: {e}",
|
|
46
|
+
file_path=file_path,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_code_frame(
|
|
51
|
+
content: str,
|
|
52
|
+
error_line: int,
|
|
53
|
+
error_col: int,
|
|
54
|
+
file_path: Path,
|
|
55
|
+
context_lines: int = 2,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Format a code frame showing the error location with context.
|
|
59
|
+
|
|
60
|
+
Creates a visual representation like:
|
|
61
|
+
10 │ "selected_profile": "dev-team",
|
|
62
|
+
11 │ "preferences": {
|
|
63
|
+
→ 12 │ "auto_update": true
|
|
64
|
+
│ ^
|
|
65
|
+
13 │ "show_tips": false
|
|
66
|
+
14 │ }
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
content: The file content
|
|
70
|
+
error_line: Line number where error occurred (1-indexed)
|
|
71
|
+
error_col: Column number where error occurred (1-indexed)
|
|
72
|
+
file_path: Path to the file (for display)
|
|
73
|
+
context_lines: Number of lines to show before/after error
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Formatted code frame string with Rich markup
|
|
77
|
+
"""
|
|
78
|
+
lines = content.splitlines()
|
|
79
|
+
total_lines = len(lines)
|
|
80
|
+
|
|
81
|
+
# Calculate line range to display
|
|
82
|
+
start_line = max(1, error_line - context_lines)
|
|
83
|
+
end_line = min(total_lines, error_line + context_lines)
|
|
84
|
+
|
|
85
|
+
# Calculate padding for line numbers
|
|
86
|
+
max_line_num = end_line
|
|
87
|
+
line_num_width = len(str(max_line_num))
|
|
88
|
+
|
|
89
|
+
frame_lines = []
|
|
90
|
+
|
|
91
|
+
# Add file path header
|
|
92
|
+
frame_lines.append(f"[dim]File: {file_path}[/dim]")
|
|
93
|
+
frame_lines.append("")
|
|
94
|
+
|
|
95
|
+
for line_num in range(start_line, end_line + 1):
|
|
96
|
+
line_content = lines[line_num - 1] if line_num <= total_lines else ""
|
|
97
|
+
|
|
98
|
+
# Truncate long lines to prevent secret leakage (keep first 80 chars)
|
|
99
|
+
if len(line_content) > 80:
|
|
100
|
+
line_content = line_content[:77] + "..."
|
|
101
|
+
|
|
102
|
+
if line_num == error_line:
|
|
103
|
+
# Error line with arrow indicator
|
|
104
|
+
frame_lines.append(
|
|
105
|
+
f"[bold red]{Indicators.get('ARROW')} {line_num:>{line_num_width}} │[/bold red] "
|
|
106
|
+
f"[white]{_escape_rich(line_content)}[/white]"
|
|
107
|
+
)
|
|
108
|
+
# Caret line pointing to error column
|
|
109
|
+
caret_padding = " " * (line_num_width + 4 + max(0, error_col - 1))
|
|
110
|
+
frame_lines.append(f"[bold red]{caret_padding}^[/bold red]")
|
|
111
|
+
else:
|
|
112
|
+
# Context line
|
|
113
|
+
frame_lines.append(
|
|
114
|
+
f"[dim] {line_num:>{line_num_width}} │[/dim] "
|
|
115
|
+
f"[dim]{_escape_rich(line_content)}[/dim]"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return "\n".join(frame_lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _escape_rich(text: str) -> str:
|
|
122
|
+
"""Escape Rich markup characters in text."""
|
|
123
|
+
return text.replace("[", "\\[").replace("]", "\\]")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_json_error_hints(error_message: str) -> list[str]:
|
|
127
|
+
"""
|
|
128
|
+
Get helpful hints based on common JSON error messages.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
error_message: The JSON decode error message
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of helpful hints for fixing the error
|
|
135
|
+
"""
|
|
136
|
+
hints = []
|
|
137
|
+
error_lower = error_message.lower()
|
|
138
|
+
|
|
139
|
+
if "expecting" in error_lower and "," in error_lower:
|
|
140
|
+
hints.append("Missing comma between values")
|
|
141
|
+
elif "expecting property name" in error_lower:
|
|
142
|
+
hints.append("Trailing comma after last item (not allowed in JSON)")
|
|
143
|
+
hints.append("Missing closing brace or bracket")
|
|
144
|
+
elif "expecting value" in error_lower:
|
|
145
|
+
hints.append("Missing value after colon or comma")
|
|
146
|
+
hints.append("Empty array or object element")
|
|
147
|
+
elif "expecting ':'" in error_lower:
|
|
148
|
+
hints.append("Missing colon after property name")
|
|
149
|
+
elif "unterminated string" in error_lower or "invalid \\escape" in error_lower:
|
|
150
|
+
hints.append("Unclosed string quote or invalid escape sequence")
|
|
151
|
+
elif "extra data" in error_lower:
|
|
152
|
+
hints.append("Multiple root objects (JSON must have single root)")
|
|
153
|
+
|
|
154
|
+
if not hints:
|
|
155
|
+
hints.append("Check JSON syntax near the indicated line")
|
|
156
|
+
|
|
157
|
+
return hints
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Organization and marketplace health checks for doctor module.
|
|
2
|
+
|
|
3
|
+
Checks for org config reachability, marketplace auth, and credential injection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from ..types import CheckResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_cached_org_config() -> dict[str, Any] | None:
|
|
15
|
+
"""Load cached organization config from cache directory.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Cached org config dict if valid, None otherwise.
|
|
19
|
+
"""
|
|
20
|
+
from ... import config
|
|
21
|
+
|
|
22
|
+
cache_file = config.CACHE_DIR / "org_config.json"
|
|
23
|
+
|
|
24
|
+
if not cache_file.exists():
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
content = cache_file.read_text()
|
|
29
|
+
return cast(dict[str, Any], json.loads(content))
|
|
30
|
+
except (json.JSONDecodeError, OSError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_org_config_reachable() -> CheckResult | None:
|
|
35
|
+
"""Check if organization config URL is reachable.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
CheckResult if org config is configured, None for standalone mode.
|
|
39
|
+
"""
|
|
40
|
+
from ... import config
|
|
41
|
+
from ...remote import fetch_org_config
|
|
42
|
+
|
|
43
|
+
user_config = config.load_user_config()
|
|
44
|
+
|
|
45
|
+
# Skip for standalone mode
|
|
46
|
+
if user_config.get("standalone"):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Skip if no org source configured
|
|
50
|
+
org_source = user_config.get("organization_source")
|
|
51
|
+
if not org_source:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
url = org_source.get("url")
|
|
55
|
+
if not url:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
auth = org_source.get("auth")
|
|
59
|
+
|
|
60
|
+
# Try to fetch org config
|
|
61
|
+
try:
|
|
62
|
+
org_config, etag, status = fetch_org_config(url, auth=auth, etag=None)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return CheckResult(
|
|
65
|
+
name="Org Config",
|
|
66
|
+
passed=False,
|
|
67
|
+
message=f"Failed to fetch org config: {e}",
|
|
68
|
+
fix_hint="Check network connection and URL",
|
|
69
|
+
severity="error",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if status == 401:
|
|
73
|
+
return CheckResult(
|
|
74
|
+
name="Org Config",
|
|
75
|
+
passed=False,
|
|
76
|
+
message=f"Authentication required (401) for {url}",
|
|
77
|
+
fix_hint="Configure auth with: scc setup",
|
|
78
|
+
severity="error",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if status == 403:
|
|
82
|
+
return CheckResult(
|
|
83
|
+
name="Org Config",
|
|
84
|
+
passed=False,
|
|
85
|
+
message=f"Access denied (403) for {url}",
|
|
86
|
+
fix_hint="Check your access permissions",
|
|
87
|
+
severity="error",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if status != 200 or org_config is None:
|
|
91
|
+
return CheckResult(
|
|
92
|
+
name="Org Config",
|
|
93
|
+
passed=False,
|
|
94
|
+
message=f"Failed to fetch org config (status: {status})",
|
|
95
|
+
fix_hint="Check URL and network connection",
|
|
96
|
+
severity="error",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
org_name = org_config.get("organization", {}).get("name", "Unknown")
|
|
100
|
+
return CheckResult(
|
|
101
|
+
name="Org Config",
|
|
102
|
+
passed=True,
|
|
103
|
+
message=f"Connected to: {org_name}",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_marketplace_auth_available() -> CheckResult | None:
|
|
108
|
+
"""Check if marketplace authentication token is available.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
CheckResult if marketplace is configured, None otherwise.
|
|
112
|
+
"""
|
|
113
|
+
from ... import config
|
|
114
|
+
from ...remote import resolve_auth
|
|
115
|
+
|
|
116
|
+
user_config = config.load_user_config()
|
|
117
|
+
org_config = load_cached_org_config()
|
|
118
|
+
|
|
119
|
+
# Skip if no org config
|
|
120
|
+
if org_config is None:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Skip if no profile selected
|
|
124
|
+
profile_name = user_config.get("selected_profile")
|
|
125
|
+
if not profile_name:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Find the profile
|
|
129
|
+
profiles = org_config.get("profiles", {})
|
|
130
|
+
profile = profiles.get(profile_name)
|
|
131
|
+
if not profile:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# Find the marketplace (dict-based schema)
|
|
135
|
+
marketplace_name = profile.get("marketplace")
|
|
136
|
+
marketplaces = org_config.get("marketplaces", {})
|
|
137
|
+
marketplace = marketplaces.get(marketplace_name)
|
|
138
|
+
|
|
139
|
+
if marketplace is None:
|
|
140
|
+
return CheckResult(
|
|
141
|
+
name="Marketplace Auth",
|
|
142
|
+
passed=False,
|
|
143
|
+
message=f"Marketplace '{marketplace_name}' not found in org config",
|
|
144
|
+
severity="error",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Check auth requirement
|
|
148
|
+
auth_spec = marketplace.get("auth")
|
|
149
|
+
|
|
150
|
+
if auth_spec is None:
|
|
151
|
+
return CheckResult(
|
|
152
|
+
name="Marketplace Auth",
|
|
153
|
+
passed=True,
|
|
154
|
+
message="Public marketplace (no auth needed)",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Try to resolve auth
|
|
158
|
+
try:
|
|
159
|
+
token = resolve_auth(auth_spec)
|
|
160
|
+
if token:
|
|
161
|
+
return CheckResult(
|
|
162
|
+
name="Marketplace Auth",
|
|
163
|
+
passed=True,
|
|
164
|
+
message=f"{auth_spec} is set",
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
# Provide helpful hint based on auth type
|
|
168
|
+
if auth_spec.startswith("env:"):
|
|
169
|
+
var_name = auth_spec.split(":", 1)[1]
|
|
170
|
+
hint = f"Set with: export {var_name}=your-token"
|
|
171
|
+
else:
|
|
172
|
+
cmd = auth_spec.split(":", 1)[1] if ":" in auth_spec else auth_spec
|
|
173
|
+
hint = f"Run manually to debug: {cmd}"
|
|
174
|
+
|
|
175
|
+
return CheckResult(
|
|
176
|
+
name="Marketplace Auth",
|
|
177
|
+
passed=False,
|
|
178
|
+
message=f"{auth_spec} not set or invalid",
|
|
179
|
+
fix_hint=hint,
|
|
180
|
+
severity="error",
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
return CheckResult(
|
|
184
|
+
name="Marketplace Auth",
|
|
185
|
+
passed=False,
|
|
186
|
+
message=f"Auth resolution failed: {e}",
|
|
187
|
+
severity="error",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_credential_injection() -> CheckResult | None:
|
|
192
|
+
"""Check what credentials will be injected into Docker container.
|
|
193
|
+
|
|
194
|
+
Shows env var NAMES only, never values. Prevents confusion about
|
|
195
|
+
whether tokens are being passed to the container.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
CheckResult showing injection status, None if no profile.
|
|
199
|
+
"""
|
|
200
|
+
from ... import config
|
|
201
|
+
|
|
202
|
+
user_config = config.load_user_config()
|
|
203
|
+
org_config = load_cached_org_config()
|
|
204
|
+
|
|
205
|
+
# Skip if no org config
|
|
206
|
+
if org_config is None:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# Skip if no profile selected
|
|
210
|
+
profile_name = user_config.get("selected_profile")
|
|
211
|
+
if not profile_name:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
# Find the profile
|
|
215
|
+
profiles = org_config.get("profiles", {})
|
|
216
|
+
profile = profiles.get(profile_name)
|
|
217
|
+
if not profile:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
# Find the marketplace (dict-based schema)
|
|
221
|
+
marketplace_name = profile.get("marketplace")
|
|
222
|
+
marketplaces = org_config.get("marketplaces", {})
|
|
223
|
+
marketplace = marketplaces.get(marketplace_name)
|
|
224
|
+
|
|
225
|
+
if marketplace is None:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# Check auth requirement
|
|
229
|
+
auth_spec = marketplace.get("auth")
|
|
230
|
+
|
|
231
|
+
if auth_spec is None:
|
|
232
|
+
return CheckResult(
|
|
233
|
+
name="Container Injection",
|
|
234
|
+
passed=True,
|
|
235
|
+
message="No credentials needed (public marketplace)",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Determine what env vars will be injected
|
|
239
|
+
env_vars = []
|
|
240
|
+
|
|
241
|
+
if auth_spec.startswith("env:"):
|
|
242
|
+
var_name = auth_spec.split(":", 1)[1]
|
|
243
|
+
env_vars.append(var_name)
|
|
244
|
+
|
|
245
|
+
# Add standard vars based on marketplace type
|
|
246
|
+
marketplace_type = marketplace.get("type")
|
|
247
|
+
if marketplace_type == "gitlab" and var_name != "GITLAB_TOKEN":
|
|
248
|
+
env_vars.append("GITLAB_TOKEN")
|
|
249
|
+
elif marketplace_type == "github" and var_name != "GITHUB_TOKEN":
|
|
250
|
+
env_vars.append("GITHUB_TOKEN")
|
|
251
|
+
|
|
252
|
+
if env_vars:
|
|
253
|
+
env_list = ", ".join(env_vars)
|
|
254
|
+
return CheckResult(
|
|
255
|
+
name="Container Injection",
|
|
256
|
+
passed=True,
|
|
257
|
+
message=f"Will inject [{env_list}] into Docker env",
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
return CheckResult(
|
|
261
|
+
name="Container Injection",
|
|
262
|
+
passed=True,
|
|
263
|
+
message="Command-based auth (resolved at runtime)",
|
|
264
|
+
)
|