ai-dev-cli-tools 0.5.0a1__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.
- ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
- ai_dev_tools/__init__.py +3 -0
- ai_dev_tools/cache/__init__.py +11 -0
- ai_dev_tools/cache/graph.py +136 -0
- ai_dev_tools/cache/repository.py +169 -0
- ai_dev_tools/cache/validation.py +154 -0
- ai_dev_tools/cli.py +387 -0
- ai_dev_tools/completion.py +72 -0
- ai_dev_tools/config.py +223 -0
- ai_dev_tools/context/__init__.py +5 -0
- ai_dev_tools/context/builder.py +506 -0
- ai_dev_tools/context/incremental.py +107 -0
- ai_dev_tools/context/models.py +59 -0
- ai_dev_tools/context/profiles.py +49 -0
- ai_dev_tools/context/selection.py +270 -0
- ai_dev_tools/context/symbols.py +178 -0
- ai_dev_tools/detectors/__init__.py +1 -0
- ai_dev_tools/detectors/environment.py +125 -0
- ai_dev_tools/detectors/project.py +189 -0
- ai_dev_tools/detectors/repository_map.py +129 -0
- ai_dev_tools/detectors/runtime.py +190 -0
- ai_dev_tools/detectors/workspaces.py +228 -0
- ai_dev_tools/git/__init__.py +1 -0
- ai_dev_tools/git/inspect.py +219 -0
- ai_dev_tools/models/__init__.py +1 -0
- ai_dev_tools/models/report.py +95 -0
- ai_dev_tools/models/workspace.py +48 -0
- ai_dev_tools/parsers/__init__.py +1 -0
- ai_dev_tools/parsers/logs.py +372 -0
- ai_dev_tools/parsers/registry.py +60 -0
- ai_dev_tools/reporters/__init__.py +1 -0
- ai_dev_tools/reporters/progressive.py +161 -0
- ai_dev_tools/reporters/writer.py +74 -0
- ai_dev_tools/runners/__init__.py +1 -0
- ai_dev_tools/runners/baseline.py +190 -0
- ai_dev_tools/runners/bootstrap.py +191 -0
- ai_dev_tools/runners/bootstrap_models.py +64 -0
- ai_dev_tools/runners/bootstrap_strategies.py +444 -0
- ai_dev_tools/runners/cache.py +23 -0
- ai_dev_tools/runners/check.py +509 -0
- ai_dev_tools/runners/check_checkpoint.py +50 -0
- ai_dev_tools/runners/check_models.py +51 -0
- ai_dev_tools/runners/check_scheduler.py +94 -0
- ai_dev_tools/runners/check_selection.py +267 -0
- ai_dev_tools/runners/diagnostics.py +96 -0
- ai_dev_tools/runners/feedback.py +193 -0
- ai_dev_tools/runners/finish.py +105 -0
- ai_dev_tools/runners/focused.py +37 -0
- ai_dev_tools/runners/index.py +44 -0
- ai_dev_tools/runtime/__init__.py +3 -0
- ai_dev_tools/runtime/runner.py +380 -0
- ai_dev_tools/runtime/supervisor.py +145 -0
- ai_dev_tools/security/__init__.py +1 -0
- ai_dev_tools/security/secrets.py +58 -0
- ai_dev_tools/utils/__init__.py +1 -0
- ai_dev_tools/utils/subprocess.py +74 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ai_dev_tools.models.report import Artifact, Report
|
|
10
|
+
|
|
11
|
+
BASELINE_SCHEMA_VERSION = "1"
|
|
12
|
+
_SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_baseline(project_root: Path, action: str, name: str | None = None) -> Report:
|
|
16
|
+
root = project_root.resolve()
|
|
17
|
+
report = Report(command=f"baseline {action}" + (f" {name}" if name else ""), project_root=root)
|
|
18
|
+
directory = root / ".ai" / "cache" / "baselines"
|
|
19
|
+
if action == "list":
|
|
20
|
+
names = sorted(path.stem for path in directory.glob("*.json"))
|
|
21
|
+
report.summary = {"baselines": names, "count": len(names)}
|
|
22
|
+
return report
|
|
23
|
+
if not name or not _SAFE_NAME.fullmatch(name):
|
|
24
|
+
report.status = "failed"
|
|
25
|
+
report.exit_code = 2
|
|
26
|
+
report.summary = {
|
|
27
|
+
"message": "Baseline name must use 1-64 letters, digits, dots, dashes, or underscores.",
|
|
28
|
+
"reason_code": "INVALID_BASELINE_NAME",
|
|
29
|
+
}
|
|
30
|
+
return report
|
|
31
|
+
path = directory / f"{name}.json"
|
|
32
|
+
if action == "create":
|
|
33
|
+
snapshot = _snapshot(root, name)
|
|
34
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
36
|
+
report.summary = {
|
|
37
|
+
"name": name,
|
|
38
|
+
"reports": len(snapshot["reports"]),
|
|
39
|
+
"failures": len(_all_failures(snapshot)),
|
|
40
|
+
"created_at": snapshot["created_at"],
|
|
41
|
+
}
|
|
42
|
+
report.artifacts.append(Artifact(str(path), "baseline", "Local report baseline"))
|
|
43
|
+
return report
|
|
44
|
+
if action == "compare":
|
|
45
|
+
baseline = _load_baseline(path)
|
|
46
|
+
if baseline is None:
|
|
47
|
+
report.status = "failed"
|
|
48
|
+
report.exit_code = 1
|
|
49
|
+
report.summary = {
|
|
50
|
+
"message": f"Baseline does not exist or is invalid: {name}",
|
|
51
|
+
"reason_code": "BASELINE_NOT_FOUND",
|
|
52
|
+
}
|
|
53
|
+
return report
|
|
54
|
+
current = _snapshot(root, name)
|
|
55
|
+
report.summary = _compare(baseline, current)
|
|
56
|
+
report.summary["name"] = name
|
|
57
|
+
report.summary["baseline_created_at"] = baseline.get("created_at")
|
|
58
|
+
report.summary["ready"] = not (
|
|
59
|
+
report.summary["new_failures"] or report.summary["status_regressions"]
|
|
60
|
+
)
|
|
61
|
+
report.status = "success" if report.summary["ready"] else "failed"
|
|
62
|
+
return report
|
|
63
|
+
raise ValueError(f"Unsupported baseline action: {action}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _snapshot(root: Path, name: str) -> dict[str, Any]:
|
|
67
|
+
reports: dict[str, dict[str, object]] = {}
|
|
68
|
+
paths = [
|
|
69
|
+
*sorted((root / ".ai" / "reports").glob("*latest.json")),
|
|
70
|
+
*sorted((root / ".ai" / "context").glob("*latest.json")),
|
|
71
|
+
]
|
|
72
|
+
for path in paths:
|
|
73
|
+
try:
|
|
74
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, json.JSONDecodeError):
|
|
76
|
+
continue
|
|
77
|
+
command = payload.get("command")
|
|
78
|
+
if not isinstance(command, str):
|
|
79
|
+
continue
|
|
80
|
+
reports[command] = {
|
|
81
|
+
"status": payload.get("status"),
|
|
82
|
+
"failures": sorted(_failure_signatures(payload)),
|
|
83
|
+
"issue_codes": sorted(_issue_codes(payload)),
|
|
84
|
+
"source": str(path.relative_to(root)),
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
"schema_version": BASELINE_SCHEMA_VERSION,
|
|
88
|
+
"name": name,
|
|
89
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
90
|
+
"reports": reports,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _compare(baseline: dict[str, Any], current: dict[str, Any]) -> dict[str, object]:
|
|
95
|
+
old_reports = _report_map(baseline)
|
|
96
|
+
new_reports = _report_map(current)
|
|
97
|
+
old_failures = _all_failures(baseline)
|
|
98
|
+
new_failures = _all_failures(current)
|
|
99
|
+
changed_statuses = [
|
|
100
|
+
{
|
|
101
|
+
"command": command,
|
|
102
|
+
"baseline": old_reports[command].get("status"),
|
|
103
|
+
"current": new_reports[command].get("status"),
|
|
104
|
+
}
|
|
105
|
+
for command in sorted(old_reports.keys() & new_reports.keys())
|
|
106
|
+
if old_reports[command].get("status") != new_reports[command].get("status")
|
|
107
|
+
]
|
|
108
|
+
old_issues = _all_report_values(baseline, "issue_codes")
|
|
109
|
+
new_issues = _all_report_values(current, "issue_codes")
|
|
110
|
+
status_regressions = [
|
|
111
|
+
item
|
|
112
|
+
for item in changed_statuses
|
|
113
|
+
if item["current"] in {"failed", "blocked", "environment_error"}
|
|
114
|
+
]
|
|
115
|
+
unchanged = sum(
|
|
116
|
+
old_reports[command] == new_reports[command]
|
|
117
|
+
for command in old_reports.keys() & new_reports.keys()
|
|
118
|
+
)
|
|
119
|
+
return {
|
|
120
|
+
"new_failures": sorted(new_failures - old_failures),
|
|
121
|
+
"resolved_failures": sorted(old_failures - new_failures),
|
|
122
|
+
"new_issue_codes": sorted(new_issues - old_issues),
|
|
123
|
+
"resolved_issue_codes": sorted(old_issues - new_issues),
|
|
124
|
+
"changed_statuses": changed_statuses,
|
|
125
|
+
"status_regressions": status_regressions,
|
|
126
|
+
"new_reports": sorted(new_reports.keys() - old_reports.keys()),
|
|
127
|
+
"missing_reports": sorted(old_reports.keys() - new_reports.keys()),
|
|
128
|
+
"unchanged_reports": unchanged,
|
|
129
|
+
"baseline_report_count": len(old_reports),
|
|
130
|
+
"current_report_count": len(new_reports),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_baseline(path: Path) -> dict[str, Any] | None:
|
|
135
|
+
try:
|
|
136
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
137
|
+
except (OSError, json.JSONDecodeError):
|
|
138
|
+
return None
|
|
139
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
|
140
|
+
return None
|
|
141
|
+
return payload
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _report_map(snapshot: dict[str, Any]) -> dict[str, dict[str, object]]:
|
|
145
|
+
reports = snapshot.get("reports")
|
|
146
|
+
if not isinstance(reports, dict):
|
|
147
|
+
return {}
|
|
148
|
+
return {str(command): value for command, value in reports.items() if isinstance(value, dict)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _all_report_values(snapshot: dict[str, Any], key: str) -> set[str]:
|
|
152
|
+
values: set[str] = set()
|
|
153
|
+
for command, report in _report_map(snapshot).items():
|
|
154
|
+
entries = report.get(key)
|
|
155
|
+
if isinstance(entries, list):
|
|
156
|
+
values.update(f"{command}:{entry}" for entry in entries if isinstance(entry, str))
|
|
157
|
+
return values
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _all_failures(snapshot: dict[str, Any]) -> set[str]:
|
|
161
|
+
failures: set[str] = set()
|
|
162
|
+
for command, report in _report_map(snapshot).items():
|
|
163
|
+
values = report.get("failures")
|
|
164
|
+
if isinstance(values, list):
|
|
165
|
+
failures.update(f"{command}:{value}" for value in values if isinstance(value, str))
|
|
166
|
+
return failures
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _failure_signatures(value: object) -> set[str]:
|
|
170
|
+
result: set[str] = set()
|
|
171
|
+
if isinstance(value, dict):
|
|
172
|
+
for key, child in value.items():
|
|
173
|
+
if key in {"failure_signature", "signature"} and isinstance(child, str) and child:
|
|
174
|
+
result.add(child)
|
|
175
|
+
else:
|
|
176
|
+
result.update(_failure_signatures(child))
|
|
177
|
+
elif isinstance(value, list):
|
|
178
|
+
for child in value:
|
|
179
|
+
result.update(_failure_signatures(child))
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _issue_codes(payload: dict[str, object]) -> set[str]:
|
|
184
|
+
result: set[str] = set()
|
|
185
|
+
issues = payload.get("issues")
|
|
186
|
+
if isinstance(issues, list):
|
|
187
|
+
for issue in issues:
|
|
188
|
+
if isinstance(issue, dict) and isinstance(issue.get("code"), str):
|
|
189
|
+
result.add(str(issue["code"]))
|
|
190
|
+
return result
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from ai_dev_tools.config import Settings, load_settings
|
|
9
|
+
from ai_dev_tools.detectors.environment import run_doctor
|
|
10
|
+
from ai_dev_tools.detectors.project import scan_project
|
|
11
|
+
from ai_dev_tools.models.report import Artifact, Issue, Report
|
|
12
|
+
from ai_dev_tools.reporters.writer import write_json, write_markdown
|
|
13
|
+
from ai_dev_tools.runners.bootstrap_models import (
|
|
14
|
+
BootstrapOptions as BootstrapOptions,
|
|
15
|
+
)
|
|
16
|
+
from ai_dev_tools.runners.bootstrap_models import (
|
|
17
|
+
BootstrapPlan as BootstrapPlan,
|
|
18
|
+
)
|
|
19
|
+
from ai_dev_tools.runners.bootstrap_models import (
|
|
20
|
+
BootstrapStep as BootstrapStep,
|
|
21
|
+
)
|
|
22
|
+
from ai_dev_tools.runners.bootstrap_strategies import (
|
|
23
|
+
build_bootstrap_plan as build_bootstrap_plan,
|
|
24
|
+
)
|
|
25
|
+
from ai_dev_tools.security.secrets import mask_text
|
|
26
|
+
from ai_dev_tools.utils.subprocess import CommandResult, run_command
|
|
27
|
+
|
|
28
|
+
BootstrapStatus = Literal["planned", "executed", "skipped", "failed"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_bootstrap(project_root: Path, options: BootstrapOptions) -> Report:
|
|
32
|
+
settings = load_settings(project_root)
|
|
33
|
+
report = Report(command="bootstrap", project_root=settings.project_root)
|
|
34
|
+
scan = scan_project(settings.project_root)
|
|
35
|
+
doctor = run_doctor(settings.project_root)
|
|
36
|
+
plan = build_bootstrap_plan(settings, options)
|
|
37
|
+
missing_tools = _missing_required_tools(plan, doctor)
|
|
38
|
+
incompatible_runtimes = list(doctor.summary.get("incompatible_runtimes", []))
|
|
39
|
+
log_path = _log_path(settings.logs_directory)
|
|
40
|
+
|
|
41
|
+
if settings.warnings:
|
|
42
|
+
report.issues.extend(
|
|
43
|
+
Issue("warning", warning, code="CONFIG_WARNING") for warning in settings.warnings
|
|
44
|
+
)
|
|
45
|
+
if incompatible_runtimes:
|
|
46
|
+
report.status = "blocked"
|
|
47
|
+
report.issues.extend(
|
|
48
|
+
Issue(
|
|
49
|
+
"error",
|
|
50
|
+
(
|
|
51
|
+
f"Incompatible {item.get('runtime')} runtime: "
|
|
52
|
+
f"requires {item.get('constraint')}, detected {item.get('detected')}."
|
|
53
|
+
),
|
|
54
|
+
code="INCOMPATIBLE_RUNTIME",
|
|
55
|
+
)
|
|
56
|
+
for item in incompatible_runtimes
|
|
57
|
+
if isinstance(item, dict)
|
|
58
|
+
)
|
|
59
|
+
elif not plan.steps and plan.project_type == "unknown":
|
|
60
|
+
report.status = "blocked"
|
|
61
|
+
report.issues.append(
|
|
62
|
+
Issue("error", "No supported project bootstrap strategy detected.", code="NO_STRATEGY")
|
|
63
|
+
)
|
|
64
|
+
elif missing_tools:
|
|
65
|
+
report.status = "blocked"
|
|
66
|
+
report.issues.extend(
|
|
67
|
+
Issue("error", f"Missing required bootstrap tool: {tool}", code="MISSING_RUNTIME")
|
|
68
|
+
for tool in missing_tools
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
report.status = "success"
|
|
72
|
+
|
|
73
|
+
executed: list[dict[str, object]] = []
|
|
74
|
+
created_venv = False
|
|
75
|
+
created_env = False
|
|
76
|
+
smoke_check = "skipped"
|
|
77
|
+
|
|
78
|
+
should_execute = report.status == "success" and not options.explain and not options.dry_run
|
|
79
|
+
if should_execute:
|
|
80
|
+
for step in plan.steps:
|
|
81
|
+
result = _execute_step(step, settings, log_path)
|
|
82
|
+
executed.append(_step_result(step, result))
|
|
83
|
+
if step.action == "create_venv" and result.exit_code == 0:
|
|
84
|
+
created_venv = True
|
|
85
|
+
if step.action == "copy_env" and result.exit_code == 0:
|
|
86
|
+
created_env = True
|
|
87
|
+
if result.exit_code != 0:
|
|
88
|
+
report.status = "failed"
|
|
89
|
+
report.exit_code = result.exit_code
|
|
90
|
+
report.issues.append(
|
|
91
|
+
Issue("error", f"Bootstrap command failed: {step.name}", code="COMMAND_FAILED")
|
|
92
|
+
)
|
|
93
|
+
break
|
|
94
|
+
if report.status == "success" and settings.bootstrap.run_smoke_check:
|
|
95
|
+
smoke_results = [_execute_step(step, settings, log_path) for step in plan.smoke_steps]
|
|
96
|
+
executed.extend(
|
|
97
|
+
_step_result(step, result)
|
|
98
|
+
for step, result in zip(plan.smoke_steps, smoke_results, strict=True)
|
|
99
|
+
)
|
|
100
|
+
smoke_check = (
|
|
101
|
+
"passed" if all(result.exit_code == 0 for result in smoke_results) else "failed"
|
|
102
|
+
)
|
|
103
|
+
if smoke_check == "failed":
|
|
104
|
+
report.status = "partial"
|
|
105
|
+
report.issues.append(
|
|
106
|
+
Issue("warning", "Bootstrap smoke check failed.", code="SMOKE_FAILED")
|
|
107
|
+
)
|
|
108
|
+
elif options.explain or options.dry_run:
|
|
109
|
+
smoke_check = "planned" if plan.smoke_steps else "skipped"
|
|
110
|
+
|
|
111
|
+
summary: dict[str, object] = {
|
|
112
|
+
"project_type": plan.project_type,
|
|
113
|
+
"package_manager": plan.package_manager,
|
|
114
|
+
"dry_run": options.dry_run,
|
|
115
|
+
"explain": options.explain,
|
|
116
|
+
"planned_commands": len(plan.steps),
|
|
117
|
+
"executed_commands": len([item for item in executed if item["exit_code"] == 0]),
|
|
118
|
+
"created_venv": created_venv,
|
|
119
|
+
"created_env": created_env,
|
|
120
|
+
"smoke_check": smoke_check,
|
|
121
|
+
"plan": plan.to_dict(),
|
|
122
|
+
"executed": executed,
|
|
123
|
+
"missing_tools": missing_tools,
|
|
124
|
+
"runtime_compatibility": doctor.summary.get("runtime_compatibility", []),
|
|
125
|
+
"scan": scan.summary,
|
|
126
|
+
"modifications": "NONE" if options.explain or options.dry_run else "PLANNED",
|
|
127
|
+
}
|
|
128
|
+
if log_path.exists():
|
|
129
|
+
summary["full_log"] = str(log_path)
|
|
130
|
+
report.artifacts.append(Artifact(str(log_path), "log", "Full bootstrap command output"))
|
|
131
|
+
report.summary = summary
|
|
132
|
+
report.finish()
|
|
133
|
+
write_markdown(report, settings.reports_directory / "bootstrap-latest.md")
|
|
134
|
+
write_json(report, settings.reports_directory / "bootstrap-latest.json")
|
|
135
|
+
return report
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _execute_step(step: BootstrapStep, settings: Settings, log_path: Path) -> CommandResult:
|
|
139
|
+
working_directory = (
|
|
140
|
+
settings.project_root / step.workspace if step.workspace else settings.project_root
|
|
141
|
+
)
|
|
142
|
+
if step.action == "copy_env":
|
|
143
|
+
source = working_directory / ".env.example"
|
|
144
|
+
target = working_directory / ".env"
|
|
145
|
+
if target.exists():
|
|
146
|
+
result = CommandResult(step.command, 0, "Skipped existing .env", "", 0.0)
|
|
147
|
+
else:
|
|
148
|
+
shutil.copyfile(source, target)
|
|
149
|
+
result = CommandResult(step.command, 0, "Created .env from .env.example", "", 0.0)
|
|
150
|
+
else:
|
|
151
|
+
result = run_command(step.command, working_directory, settings.bootstrap.timeout_seconds)
|
|
152
|
+
result.stdout = mask_text(result.stdout)
|
|
153
|
+
result.stderr = mask_text(result.stderr)
|
|
154
|
+
_append_log(log_path, step, result)
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _append_log(log_path: Path, step: BootstrapStep, result: CommandResult) -> None:
|
|
159
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
with log_path.open("a", encoding="utf-8") as handle:
|
|
161
|
+
handle.write(f"$ {' '.join(step.command)}\n")
|
|
162
|
+
handle.write(result.combined_output + "\n")
|
|
163
|
+
handle.write(f"EXIT_CODE={result.exit_code} DURATION={result.duration_seconds}s\n\n")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _step_result(step: BootstrapStep, result: CommandResult) -> dict[str, object]:
|
|
167
|
+
return {
|
|
168
|
+
"name": step.name,
|
|
169
|
+
"command": step.command,
|
|
170
|
+
"exit_code": result.exit_code,
|
|
171
|
+
"duration_seconds": result.duration_seconds,
|
|
172
|
+
"timed_out": result.timed_out,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _missing_required_tools(plan: BootstrapPlan, doctor: Report) -> list[str]:
|
|
177
|
+
tools = doctor.summary.get("tools", {})
|
|
178
|
+
if not isinstance(tools, dict):
|
|
179
|
+
return plan.required_tools
|
|
180
|
+
missing: list[str] = []
|
|
181
|
+
for tool in plan.required_tools:
|
|
182
|
+
if tool == "python":
|
|
183
|
+
continue
|
|
184
|
+
tool_info = tools.get(tool)
|
|
185
|
+
if not isinstance(tool_info, dict) or tool_info.get("status") != "ok":
|
|
186
|
+
missing.append(tool)
|
|
187
|
+
return sorted(set(missing))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _log_path(logs_dir: Path) -> Path:
|
|
191
|
+
return logs_dir / f"bootstrap-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.log"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
BootstrapStatus = Literal["planned", "executed", "skipped", "failed"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class BootstrapOptions:
|
|
11
|
+
dry_run: bool = False
|
|
12
|
+
explain: bool = False
|
|
13
|
+
create_env: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class BootstrapStep:
|
|
18
|
+
name: str
|
|
19
|
+
command: list[str]
|
|
20
|
+
reason: str
|
|
21
|
+
source: str
|
|
22
|
+
modifies_project: bool = True
|
|
23
|
+
required_tool: str | None = None
|
|
24
|
+
action: str = "command"
|
|
25
|
+
workspace: str = ""
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, object]:
|
|
28
|
+
return {**asdict(self), "reason_code": _bootstrap_reason_code(self)}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bootstrap_reason_code(step: BootstrapStep) -> str:
|
|
32
|
+
if step.action == "create_venv":
|
|
33
|
+
return "CREATE_VIRTUAL_ENVIRONMENT"
|
|
34
|
+
if step.action == "copy_env":
|
|
35
|
+
return "CREATE_ENVIRONMENT_FILE"
|
|
36
|
+
if step.workspace:
|
|
37
|
+
return "WORKSPACE_BOOTSTRAP"
|
|
38
|
+
if "smoke" in step.name.lower() or "verify" in step.reason.lower():
|
|
39
|
+
return "SMOKE_VALIDATION"
|
|
40
|
+
return "PROJECT_BOOTSTRAP"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class BootstrapPlan:
|
|
45
|
+
project_type: str
|
|
46
|
+
package_manager: str | None
|
|
47
|
+
steps: list[BootstrapStep]
|
|
48
|
+
smoke_steps: list[BootstrapStep]
|
|
49
|
+
required_tools: list[str]
|
|
50
|
+
env_available: bool = False
|
|
51
|
+
env_will_create: bool = False
|
|
52
|
+
monorepo_subprojects: list[str] | None = None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, object]:
|
|
55
|
+
return {
|
|
56
|
+
"project_type": self.project_type,
|
|
57
|
+
"package_manager": self.package_manager,
|
|
58
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
59
|
+
"smoke_steps": [step.to_dict() for step in self.smoke_steps],
|
|
60
|
+
"required_tools": self.required_tools,
|
|
61
|
+
"env_available": self.env_available,
|
|
62
|
+
"env_will_create": self.env_will_create,
|
|
63
|
+
"monorepo_subprojects": self.monorepo_subprojects or [],
|
|
64
|
+
}
|