frontos 0.2.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.
- frontos/__init__.py +3 -0
- frontos/__main__.py +6 -0
- frontos/cli.py +474 -0
- frontos/core/__init__.py +2 -0
- frontos/core/paths.py +70 -0
- frontos/core/redaction.py +35 -0
- frontos/core/store.py +319 -0
- frontos/engines/__init__.py +2 -0
- frontos/engines/design_pack.py +658 -0
- frontos/engines/detect.py +71 -0
- frontos/engines/dev_server.py +200 -0
- frontos/engines/patches.py +730 -0
- frontos/engines/qa.py +259 -0
- frontos/engines/scanner.py +503 -0
- frontos/engines/security.py +51 -0
- frontos/engines/visual.py +654 -0
- frontos/engines/workflow.py +398 -0
- frontos/mcp/__init__.py +2 -0
- frontos/mcp/server.py +837 -0
- frontos/server/__init__.py +2 -0
- frontos/server/http_api.py +315 -0
- frontos-0.2.0.dist-info/METADATA +375 -0
- frontos-0.2.0.dist-info/RECORD +27 -0
- frontos-0.2.0.dist-info/WHEEL +5 -0
- frontos-0.2.0.dist-info/entry_points.txt +2 -0
- frontos-0.2.0.dist-info/licenses/LICENSE +21 -0
- frontos-0.2.0.dist-info/top_level.txt +1 -0
frontos/__init__.py
ADDED
frontos/__main__.py
ADDED
frontos/cli.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from frontos import __version__
|
|
13
|
+
from frontos.core.store import Store
|
|
14
|
+
from frontos.engines.design_pack import DesignPackCompiler, list_presets, load_design_pack
|
|
15
|
+
from frontos.engines.detect import detect_project
|
|
16
|
+
from frontos.engines.patches import PatchEngine
|
|
17
|
+
from frontos.engines.qa import QAEngine, load_latest_qa
|
|
18
|
+
from frontos.engines.scanner import ProjectScanner, load_latest_scan
|
|
19
|
+
from frontos.engines.security import SecurityAuditor
|
|
20
|
+
from frontos.engines.visual import VisualQAEngine, load_latest_visual_comparison, load_latest_visual_report
|
|
21
|
+
from frontos.engines.workflow import WorkflowEngine, load_latest_workflow
|
|
22
|
+
from frontos.mcp.server import run_stdio
|
|
23
|
+
from frontos.server.http_api import serve
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
parser = build_parser()
|
|
28
|
+
args = parser.parse_args(argv)
|
|
29
|
+
if not hasattr(args, "func"):
|
|
30
|
+
parser.print_help()
|
|
31
|
+
return 0
|
|
32
|
+
try:
|
|
33
|
+
result = args.func(args)
|
|
34
|
+
except BrokenPipeError:
|
|
35
|
+
return 1
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
print(f"frontos: {exc}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
if result is not None:
|
|
40
|
+
print_json(result)
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
parser = argparse.ArgumentParser(prog="frontos", description="Local frontend specialist for AI coding agents.")
|
|
46
|
+
parser.add_argument("--home", help="Override FRONTOS_HOME for this command.")
|
|
47
|
+
parser.add_argument("--version", action="version", version=f"frontos {__version__}")
|
|
48
|
+
sub = parser.add_subparsers(dest="command")
|
|
49
|
+
|
|
50
|
+
status = sub.add_parser("status", help="Show FrontOS home, daemon, and registry status.")
|
|
51
|
+
status.set_defaults(func=cmd_status)
|
|
52
|
+
|
|
53
|
+
serve_cmd = sub.add_parser("serve", help="Run the local HTTP daemon in the foreground.")
|
|
54
|
+
serve_cmd.add_argument("--host", default="127.0.0.1")
|
|
55
|
+
serve_cmd.add_argument("--port", type=int, default=4791)
|
|
56
|
+
serve_cmd.set_defaults(func=cmd_serve)
|
|
57
|
+
|
|
58
|
+
stop = sub.add_parser("stop", help="Stop a foreground/background FrontOS daemon by PID file.")
|
|
59
|
+
stop.set_defaults(func=cmd_stop)
|
|
60
|
+
|
|
61
|
+
mcp = sub.add_parser("mcp", help="Run the MCP-compatible stdio server or print client config.")
|
|
62
|
+
mcp.add_argument("mcp_args", nargs="*", help="Use `frontos mcp config codex|cursor|claude` for setup snippets.")
|
|
63
|
+
mcp.set_defaults(func=cmd_mcp)
|
|
64
|
+
|
|
65
|
+
project = sub.add_parser("project", help="Manage registered projects.")
|
|
66
|
+
project_sub = project.add_subparsers(dest="project_command", required=True)
|
|
67
|
+
project_add = project_sub.add_parser("add", help="Register a local project.")
|
|
68
|
+
project_add.add_argument("path")
|
|
69
|
+
project_add.add_argument("--name")
|
|
70
|
+
project_add.add_argument("--write", action="store_true", help="Allow direct writes. Default is patch-only.")
|
|
71
|
+
project_add.set_defaults(func=cmd_project_add)
|
|
72
|
+
|
|
73
|
+
project_list = project_sub.add_parser("list", help="List registered projects.")
|
|
74
|
+
project_list.set_defaults(func=cmd_project_list)
|
|
75
|
+
|
|
76
|
+
project_scan = project_sub.add_parser("scan", help="Scan a registered project.")
|
|
77
|
+
project_scan.add_argument("project")
|
|
78
|
+
project_scan.set_defaults(func=cmd_project_scan)
|
|
79
|
+
|
|
80
|
+
project_configure = project_sub.add_parser("configure", help="Configure project runtime URL and dev command.")
|
|
81
|
+
project_configure.add_argument("project")
|
|
82
|
+
project_configure.add_argument("--base-url")
|
|
83
|
+
project_configure.add_argument("--dev-command")
|
|
84
|
+
project_configure.set_defaults(func=cmd_project_configure)
|
|
85
|
+
|
|
86
|
+
scan = sub.add_parser("scan", help="Alias for project scan.")
|
|
87
|
+
scan.add_argument("project")
|
|
88
|
+
scan.set_defaults(func=cmd_project_scan)
|
|
89
|
+
|
|
90
|
+
design = sub.add_parser("design-pack", help="Create or inspect Design Packs.")
|
|
91
|
+
design_sub = design.add_subparsers(dest="design_command", required=True)
|
|
92
|
+
design_presets = design_sub.add_parser("presets", help="List available Design Pack presets.")
|
|
93
|
+
design_presets.set_defaults(func=cmd_design_pack_presets)
|
|
94
|
+
design_create = design_sub.add_parser("create", help="Create a project-specific Design Pack.")
|
|
95
|
+
design_create.add_argument("project")
|
|
96
|
+
design_create.add_argument("--preset", help="Use a specific Style DNA preset.")
|
|
97
|
+
design_create.set_defaults(func=cmd_design_pack_create)
|
|
98
|
+
design_get = design_sub.add_parser("get", help="Print the latest Design Pack.")
|
|
99
|
+
design_get.add_argument("project")
|
|
100
|
+
design_get.add_argument("--pack-id")
|
|
101
|
+
design_get.set_defaults(func=cmd_design_pack_get)
|
|
102
|
+
|
|
103
|
+
qa = sub.add_parser("qa", help="Run design QA for a project.")
|
|
104
|
+
qa.add_argument("project")
|
|
105
|
+
qa.set_defaults(func=cmd_qa)
|
|
106
|
+
|
|
107
|
+
security = sub.add_parser("security", help="Run local security checks.")
|
|
108
|
+
security_sub = security.add_subparsers(dest="security_command", required=True)
|
|
109
|
+
security_audit = security_sub.add_parser("audit", help="Scan a registered project for secret-like values.")
|
|
110
|
+
security_audit.add_argument("project")
|
|
111
|
+
security_audit.set_defaults(func=cmd_security_audit)
|
|
112
|
+
|
|
113
|
+
patch = sub.add_parser("patch", help="Generate or preview patch artifacts.")
|
|
114
|
+
patch_sub = patch.add_subparsers(dest="patch_command", required=True)
|
|
115
|
+
patch_generate = patch_sub.add_parser("generate", help="Generate a patch artifact.")
|
|
116
|
+
patch_generate.add_argument("project")
|
|
117
|
+
patch_generate.add_argument("--route")
|
|
118
|
+
patch_generate.add_argument("--mode", default="repair_existing_frontend")
|
|
119
|
+
patch_generate.set_defaults(func=cmd_patch_generate)
|
|
120
|
+
patch_preview = patch_sub.add_parser("preview", help="Preview a patch artifact.")
|
|
121
|
+
patch_preview.add_argument("patch_id")
|
|
122
|
+
patch_preview.add_argument("--diff", action="store_true", help="Print the unified diff instead of JSON metadata.")
|
|
123
|
+
patch_preview.set_defaults(func=cmd_patch_preview)
|
|
124
|
+
patch_validate = patch_sub.add_parser("validate", help="Validate a staged patch before applying it.")
|
|
125
|
+
patch_validate.add_argument("patch_id")
|
|
126
|
+
patch_validate.set_defaults(func=cmd_patch_validate)
|
|
127
|
+
patch_apply = patch_sub.add_parser("apply", help="Apply a staged patch to the registered project.")
|
|
128
|
+
patch_apply.add_argument("patch_id")
|
|
129
|
+
patch_apply.add_argument("--confirm", action="store_true", help="Allow writing into a patch-only project.")
|
|
130
|
+
patch_apply.add_argument("--dry-run", action="store_true", help="Validate without writing staged files.")
|
|
131
|
+
patch_apply.set_defaults(func=cmd_patch_apply)
|
|
132
|
+
patch_rollback = patch_sub.add_parser("rollback", help="Rollback an applied patch using its snapshot.")
|
|
133
|
+
patch_rollback.add_argument("patch_id")
|
|
134
|
+
patch_rollback.add_argument("--confirm", action="store_true", help="Allow writing rollback files into the project.")
|
|
135
|
+
patch_rollback.set_defaults(func=cmd_patch_rollback)
|
|
136
|
+
|
|
137
|
+
visual = sub.add_parser("visual", help="Capture screenshots and visual QA metadata.")
|
|
138
|
+
visual_sub = visual.add_subparsers(dest="visual_command", required=True)
|
|
139
|
+
visual_capture = visual_sub.add_parser("capture", help="Capture route screenshots for a running project.")
|
|
140
|
+
visual_capture.add_argument("project")
|
|
141
|
+
visual_capture.add_argument("--phase", default="before", choices=["before", "after", "current"])
|
|
142
|
+
visual_capture.add_argument("--base-url")
|
|
143
|
+
visual_capture.add_argument("--route", action="append")
|
|
144
|
+
visual_capture.add_argument("--viewport", action="append", choices=["desktop", "tablet", "mobile"])
|
|
145
|
+
visual_capture.add_argument("--timeout-ms", type=int, default=30000)
|
|
146
|
+
visual_capture.add_argument("--dry-run", action="store_true")
|
|
147
|
+
visual_capture.set_defaults(func=cmd_visual_capture)
|
|
148
|
+
visual_latest = visual_sub.add_parser("latest", help="Print the latest visual QA report for a project.")
|
|
149
|
+
visual_latest.add_argument("project")
|
|
150
|
+
visual_latest.set_defaults(func=cmd_visual_latest)
|
|
151
|
+
visual_compare = visual_sub.add_parser("compare", help="Compare latest before/after screenshots.")
|
|
152
|
+
visual_compare.add_argument("project")
|
|
153
|
+
visual_compare.add_argument("--route")
|
|
154
|
+
visual_compare.add_argument("--viewport", action="append", choices=["desktop", "tablet", "mobile"])
|
|
155
|
+
visual_compare.add_argument("--min-change-percent", type=float, default=0.1)
|
|
156
|
+
visual_compare.add_argument("--max-change-percent", type=float, default=65.0)
|
|
157
|
+
visual_compare.set_defaults(func=cmd_visual_compare)
|
|
158
|
+
visual_compare_latest = visual_sub.add_parser(
|
|
159
|
+
"comparison-latest", help="Print the latest visual comparison report."
|
|
160
|
+
)
|
|
161
|
+
visual_compare_latest.add_argument("project")
|
|
162
|
+
visual_compare_latest.set_defaults(func=cmd_visual_compare_latest)
|
|
163
|
+
visual_doctor = visual_sub.add_parser("doctor", help="Check optional visual QA dependencies.")
|
|
164
|
+
visual_doctor.add_argument("--check-browser", action="store_true", help="Launch Chromium to verify browser setup.")
|
|
165
|
+
visual_doctor.set_defaults(func=cmd_visual_doctor)
|
|
166
|
+
|
|
167
|
+
workflow = sub.add_parser("workflow", help="Run orchestrated FrontOS workflows.")
|
|
168
|
+
workflow_sub = workflow.add_subparsers(dest="workflow_command", required=True)
|
|
169
|
+
workflow_repair = workflow_sub.add_parser("repair", help="Run the full repair workflow.")
|
|
170
|
+
workflow_repair.add_argument("project")
|
|
171
|
+
workflow_repair.add_argument("--route")
|
|
172
|
+
workflow_repair.add_argument("--mode", default="repair_existing_frontend")
|
|
173
|
+
workflow_repair.add_argument("--base-url")
|
|
174
|
+
workflow_repair.add_argument("--dev-command")
|
|
175
|
+
workflow_repair.add_argument("--apply", action="store_true")
|
|
176
|
+
workflow_repair.add_argument("--confirm", action="store_true")
|
|
177
|
+
workflow_repair.add_argument("--skip-visual", action="store_true")
|
|
178
|
+
workflow_repair.add_argument("--visual-dry-run", action="store_true")
|
|
179
|
+
workflow_repair.add_argument("--start-server", action="store_true")
|
|
180
|
+
workflow_repair.add_argument("--keep-server", action="store_true")
|
|
181
|
+
workflow_repair.add_argument("--viewport", action="append", choices=["desktop", "tablet", "mobile"])
|
|
182
|
+
workflow_repair.add_argument("--timeout-ms", type=int, default=30000)
|
|
183
|
+
workflow_repair.add_argument("--startup-timeout-s", type=int, default=30)
|
|
184
|
+
workflow_repair.set_defaults(func=cmd_workflow_repair)
|
|
185
|
+
workflow_latest = workflow_sub.add_parser("latest", help="Print the latest workflow report.")
|
|
186
|
+
workflow_latest.add_argument("project")
|
|
187
|
+
workflow_latest.set_defaults(func=cmd_workflow_latest)
|
|
188
|
+
|
|
189
|
+
repair = sub.add_parser("repair", help="Scan, design, patch, and QA a project or route.")
|
|
190
|
+
repair.add_argument("project")
|
|
191
|
+
repair.add_argument("--route")
|
|
192
|
+
repair.add_argument("--mode", default="repair_existing_frontend")
|
|
193
|
+
repair.set_defaults(func=cmd_repair)
|
|
194
|
+
|
|
195
|
+
return parser
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_status(args: argparse.Namespace) -> dict[str, Any]:
|
|
199
|
+
store = _store(args)
|
|
200
|
+
pid_path = store.home / "frontos.pid"
|
|
201
|
+
daemon = {"running": False, "pid": None}
|
|
202
|
+
if pid_path.exists():
|
|
203
|
+
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
|
204
|
+
daemon = {"running": _pid_running(pid), "pid": pid}
|
|
205
|
+
return {
|
|
206
|
+
"version": __version__,
|
|
207
|
+
"home": str(store.home),
|
|
208
|
+
"db": str(store.db_path),
|
|
209
|
+
"projects": store.count_projects(),
|
|
210
|
+
"daemon": daemon,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
215
|
+
print(f"FrontOS serving on http://{args.host}:{args.port}", file=sys.stderr)
|
|
216
|
+
serve(args.host, args.port, _home(args))
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_stop(args: argparse.Namespace) -> dict[str, Any]:
|
|
221
|
+
store = _store(args)
|
|
222
|
+
pid_path = store.home / "frontos.pid"
|
|
223
|
+
if not pid_path.exists():
|
|
224
|
+
return {"status": "not_running"}
|
|
225
|
+
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
|
226
|
+
if not _pid_running(pid):
|
|
227
|
+
pid_path.unlink(missing_ok=True)
|
|
228
|
+
return {"status": "stale_pid_removed", "pid": pid}
|
|
229
|
+
os.kill(pid, signal.SIGTERM)
|
|
230
|
+
return {"status": "stopping", "pid": pid}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_mcp(args: argparse.Namespace) -> dict[str, Any] | None:
|
|
234
|
+
if args.mcp_args:
|
|
235
|
+
if len(args.mcp_args) == 2 and args.mcp_args[0] == "config":
|
|
236
|
+
return _mcp_config(args.mcp_args[1])
|
|
237
|
+
raise ValueError("Use `frontos mcp` or `frontos mcp config codex|cursor|claude`")
|
|
238
|
+
run_stdio(_home(args))
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_project_add(args: argparse.Namespace) -> dict[str, Any]:
|
|
243
|
+
path = Path(args.path).expanduser().resolve()
|
|
244
|
+
return _store(args).add_project(path, args.name, detect_project(path), args.write)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_project_list(args: argparse.Namespace) -> dict[str, Any]:
|
|
248
|
+
return {"projects": _store(args).list_projects()}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def cmd_project_scan(args: argparse.Namespace) -> dict[str, Any]:
|
|
252
|
+
store = _store(args)
|
|
253
|
+
project = store.get_project(args.project)
|
|
254
|
+
return ProjectScanner(store).scan(project)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_project_configure(args: argparse.Namespace) -> dict[str, Any]:
|
|
258
|
+
store = _store(args)
|
|
259
|
+
project = store.get_project(args.project)
|
|
260
|
+
if args.base_url is None and args.dev_command is None:
|
|
261
|
+
return store.get_project_runtime(project["id"])
|
|
262
|
+
return store.set_project_runtime(project["id"], args.base_url, args.dev_command)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_design_pack_create(args: argparse.Namespace) -> dict[str, Any]:
|
|
266
|
+
store = _store(args)
|
|
267
|
+
project = store.get_project(args.project)
|
|
268
|
+
scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
|
|
269
|
+
return DesignPackCompiler(store).create(project, scan, preset_id=args.preset)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_design_pack_presets(args: argparse.Namespace) -> dict[str, Any]:
|
|
273
|
+
return {"presets": list_presets()}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_design_pack_get(args: argparse.Namespace) -> dict[str, Any]:
|
|
277
|
+
store = _store(args)
|
|
278
|
+
project = store.get_project(args.project)
|
|
279
|
+
pack = load_design_pack(store, project["id"], args.pack_id)
|
|
280
|
+
if pack is None:
|
|
281
|
+
raise KeyError(f"No design pack found for {project['id']}")
|
|
282
|
+
return pack
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_qa(args: argparse.Namespace) -> dict[str, Any]:
|
|
286
|
+
store = _store(args)
|
|
287
|
+
project = store.get_project(args.project)
|
|
288
|
+
scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
|
|
289
|
+
pack = load_design_pack(store, project["id"])
|
|
290
|
+
return QAEngine(store).run(project, scan, pack)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_security_audit(args: argparse.Namespace) -> dict[str, Any]:
|
|
294
|
+
store = _store(args)
|
|
295
|
+
project = store.get_project(args.project)
|
|
296
|
+
return SecurityAuditor(store).audit_project(project)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def cmd_patch_generate(args: argparse.Namespace) -> dict[str, Any]:
|
|
300
|
+
store = _store(args)
|
|
301
|
+
project = store.get_project(args.project)
|
|
302
|
+
scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
|
|
303
|
+
pack = load_design_pack(store, project["id"]) or DesignPackCompiler(store).create(project, scan)
|
|
304
|
+
return PatchEngine(store).generate(project, scan, pack, route=args.route, mode=args.mode)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def cmd_patch_preview(args: argparse.Namespace) -> Any:
|
|
308
|
+
preview = PatchEngine(_store(args)).preview(args.patch_id)
|
|
309
|
+
if args.diff:
|
|
310
|
+
print(preview["diff"])
|
|
311
|
+
return None
|
|
312
|
+
return preview["manifest"]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_patch_validate(args: argparse.Namespace) -> dict[str, Any]:
|
|
316
|
+
return PatchEngine(_store(args)).validate(args.patch_id)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cmd_patch_apply(args: argparse.Namespace) -> dict[str, Any]:
|
|
320
|
+
return PatchEngine(_store(args)).apply(args.patch_id, confirm=args.confirm, dry_run=args.dry_run)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_patch_rollback(args: argparse.Namespace) -> dict[str, Any]:
|
|
324
|
+
return PatchEngine(_store(args)).rollback(args.patch_id, confirm=args.confirm)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_visual_capture(args: argparse.Namespace) -> dict[str, Any]:
|
|
328
|
+
store = _store(args)
|
|
329
|
+
project = store.get_project(args.project)
|
|
330
|
+
scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
|
|
331
|
+
return VisualQAEngine(store).capture(
|
|
332
|
+
project,
|
|
333
|
+
scan,
|
|
334
|
+
phase=args.phase,
|
|
335
|
+
base_url=args.base_url,
|
|
336
|
+
routes=args.route,
|
|
337
|
+
viewports=args.viewport,
|
|
338
|
+
timeout_ms=args.timeout_ms,
|
|
339
|
+
dry_run=args.dry_run,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_visual_latest(args: argparse.Namespace) -> dict[str, Any]:
|
|
344
|
+
store = _store(args)
|
|
345
|
+
project = store.get_project(args.project)
|
|
346
|
+
report = load_latest_visual_report(store, project["id"])
|
|
347
|
+
if report is None:
|
|
348
|
+
raise KeyError(f"No visual report found for {project['id']}")
|
|
349
|
+
return report
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_visual_compare(args: argparse.Namespace) -> dict[str, Any]:
|
|
353
|
+
store = _store(args)
|
|
354
|
+
project = store.get_project(args.project)
|
|
355
|
+
return VisualQAEngine(store).compare(
|
|
356
|
+
project,
|
|
357
|
+
route=args.route,
|
|
358
|
+
viewports=args.viewport,
|
|
359
|
+
minimum_change_percent=args.min_change_percent,
|
|
360
|
+
maximum_change_percent=args.max_change_percent,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_visual_compare_latest(args: argparse.Namespace) -> dict[str, Any]:
|
|
365
|
+
store = _store(args)
|
|
366
|
+
project = store.get_project(args.project)
|
|
367
|
+
report = load_latest_visual_comparison(store, project["id"])
|
|
368
|
+
if report is None:
|
|
369
|
+
raise KeyError(f"No visual comparison report found for {project['id']}")
|
|
370
|
+
return report
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_visual_doctor(args: argparse.Namespace) -> dict[str, Any]:
|
|
374
|
+
return VisualQAEngine(_store(args)).doctor(check_browser=args.check_browser)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_workflow_repair(args: argparse.Namespace) -> dict[str, Any]:
|
|
378
|
+
return WorkflowEngine(_store(args)).repair(
|
|
379
|
+
args.project,
|
|
380
|
+
route=args.route,
|
|
381
|
+
mode=args.mode,
|
|
382
|
+
base_url=args.base_url,
|
|
383
|
+
dev_command=args.dev_command,
|
|
384
|
+
apply_patch=args.apply,
|
|
385
|
+
confirm=args.confirm,
|
|
386
|
+
capture_visual=not args.skip_visual,
|
|
387
|
+
visual_dry_run=args.visual_dry_run,
|
|
388
|
+
viewports=args.viewport,
|
|
389
|
+
timeout_ms=args.timeout_ms,
|
|
390
|
+
start_server=args.start_server,
|
|
391
|
+
stop_server=not args.keep_server,
|
|
392
|
+
startup_timeout_seconds=args.startup_timeout_s,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def cmd_workflow_latest(args: argparse.Namespace) -> dict[str, Any]:
|
|
397
|
+
store = _store(args)
|
|
398
|
+
project = store.get_project(args.project)
|
|
399
|
+
report = load_latest_workflow(store, project["id"])
|
|
400
|
+
if report is None:
|
|
401
|
+
raise KeyError(f"No workflow report found for {project['id']}")
|
|
402
|
+
return report
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def cmd_repair(args: argparse.Namespace) -> dict[str, Any]:
|
|
406
|
+
store = _store(args)
|
|
407
|
+
project = store.get_project(args.project)
|
|
408
|
+
scan = ProjectScanner(store).scan(project)
|
|
409
|
+
pack = load_design_pack(store, project["id"]) or DesignPackCompiler(store).create(project, scan)
|
|
410
|
+
patch = PatchEngine(store).generate(project, scan, pack, route=args.route, mode=args.mode)
|
|
411
|
+
qa = QAEngine(store).run(project, scan, pack, patch, job_id=patch["id"].replace("patch_", "job_"))
|
|
412
|
+
return {
|
|
413
|
+
"jobId": qa["jobId"],
|
|
414
|
+
"project": project["id"],
|
|
415
|
+
"mode": args.mode,
|
|
416
|
+
"route": args.route,
|
|
417
|
+
"designPack": {
|
|
418
|
+
"id": pack["id"],
|
|
419
|
+
"styleDNA": pack["styleDNA"]["name"],
|
|
420
|
+
"tokensGenerated": True,
|
|
421
|
+
"componentContractsGenerated": True,
|
|
422
|
+
"screenPatternsGenerated": True,
|
|
423
|
+
},
|
|
424
|
+
"artifacts": {
|
|
425
|
+
"appMap": f"frontos://project/{project['id']}/app-map",
|
|
426
|
+
"behaviorContracts": f"frontos://project/{project['id']}/behavior-contracts",
|
|
427
|
+
"frontendAudit": f"frontos://project/{project['id']}/audit/latest",
|
|
428
|
+
"patch": f"frontos://patch/{patch['id']}",
|
|
429
|
+
"beforeScreenshot": f"frontos://project/{project['id']}/screenshots/before",
|
|
430
|
+
"afterScreenshot": f"frontos://project/{project['id']}/screenshots/after",
|
|
431
|
+
"visualDiff": f"frontos://project/{project['id']}/visual-comparison/latest",
|
|
432
|
+
"visualComparisonReport": f"frontos://project/{project['id']}/visual-comparison/latest",
|
|
433
|
+
"qaReport": f"frontos://qa/{qa['jobId']}",
|
|
434
|
+
},
|
|
435
|
+
"scores": qa["scores"],
|
|
436
|
+
"status": "ready_for_review" if qa["status"] == "passed" else "needs_repair",
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def print_json(data: Any) -> None:
|
|
441
|
+
print(json.dumps(data, indent=2, sort_keys=True))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _store(args: argparse.Namespace) -> Store:
|
|
445
|
+
return Store(_home(args))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _home(args: argparse.Namespace) -> str | None:
|
|
449
|
+
return getattr(args, "home", None)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _pid_running(pid: int) -> bool:
|
|
453
|
+
try:
|
|
454
|
+
os.kill(pid, 0)
|
|
455
|
+
return True
|
|
456
|
+
except OSError:
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _mcp_config(client: str) -> dict[str, Any]:
|
|
461
|
+
command = shutil.which("frontos") or "frontos"
|
|
462
|
+
if client == "codex":
|
|
463
|
+
return {
|
|
464
|
+
"client": "codex",
|
|
465
|
+
"config": f'[mcp_servers.frontos]\ncommand = "{command}"\nargs = ["mcp"]\n',
|
|
466
|
+
}
|
|
467
|
+
if client == "cursor":
|
|
468
|
+
return {"client": "cursor", "config": {"mcpServers": {"frontos": {"command": command, "args": ["mcp"]}}}}
|
|
469
|
+
if client in {"claude", "claude-desktop"}:
|
|
470
|
+
return {
|
|
471
|
+
"client": "claude",
|
|
472
|
+
"config": {"mcpServers": {"frontos": {"command": command, "args": ["mcp"]}}},
|
|
473
|
+
}
|
|
474
|
+
raise ValueError("Supported MCP config clients: codex, cursor, claude")
|
frontos/core/__init__.py
ADDED
frontos/core/paths.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
FRONTOS_DIRS = (
|
|
8
|
+
"projects",
|
|
9
|
+
"design-packs",
|
|
10
|
+
"scans",
|
|
11
|
+
"behavior-contracts",
|
|
12
|
+
"screenshots",
|
|
13
|
+
"patches",
|
|
14
|
+
"qa-reports",
|
|
15
|
+
"registry",
|
|
16
|
+
"adapters",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def frontos_home(value: str | os.PathLike[str] | None = None) -> Path:
|
|
21
|
+
"""Return the configured FrontOS home directory."""
|
|
22
|
+
|
|
23
|
+
raw = value or os.environ.get("FRONTOS_HOME") or "~/.frontos"
|
|
24
|
+
return Path(raw).expanduser().resolve()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ensure_frontos_home(value: str | os.PathLike[str] | None = None) -> Path:
|
|
28
|
+
"""Create the FrontOS home layout and default config file."""
|
|
29
|
+
|
|
30
|
+
home = frontos_home(value)
|
|
31
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
for dirname in FRONTOS_DIRS:
|
|
33
|
+
(home / dirname).mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
config_path = home / "config.yml"
|
|
36
|
+
if not config_path.exists():
|
|
37
|
+
config_path.write_text(
|
|
38
|
+
"\n".join(
|
|
39
|
+
[
|
|
40
|
+
"version: 1",
|
|
41
|
+
"mode: local-first",
|
|
42
|
+
"default_permissions:",
|
|
43
|
+
" read: true",
|
|
44
|
+
" write: false",
|
|
45
|
+
" patchOnly: true",
|
|
46
|
+
"network:",
|
|
47
|
+
" allowSourceUpload: false",
|
|
48
|
+
"server:",
|
|
49
|
+
" host: 127.0.0.1",
|
|
50
|
+
" port: 4791",
|
|
51
|
+
"",
|
|
52
|
+
]
|
|
53
|
+
),
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
)
|
|
56
|
+
return home
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def slugify(value: str) -> str:
|
|
60
|
+
cleaned = []
|
|
61
|
+
previous_dash = False
|
|
62
|
+
for char in value.lower().strip():
|
|
63
|
+
if char.isalnum():
|
|
64
|
+
cleaned.append(char)
|
|
65
|
+
previous_dash = False
|
|
66
|
+
elif not previous_dash:
|
|
67
|
+
cleaned.append("-")
|
|
68
|
+
previous_dash = True
|
|
69
|
+
return "".join(cleaned).strip("-") or "project"
|
|
70
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SECRET_PATTERNS = {
|
|
7
|
+
"github_token": re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
|
|
8
|
+
"openai_key": re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
|
|
9
|
+
"aws_access_key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
10
|
+
"private_key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S),
|
|
11
|
+
"env_assignment": re.compile(
|
|
12
|
+
r"(?i)\b(api[_-]?key|token|secret|password|private[_-]?key)\s*=\s*['\"]?[^'\"\s]{8,}"
|
|
13
|
+
),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def redact_text(text: str) -> str:
|
|
18
|
+
redacted = text
|
|
19
|
+
for name, pattern in SECRET_PATTERNS.items():
|
|
20
|
+
redacted = pattern.sub(f"[REDACTED:{name}]", redacted)
|
|
21
|
+
return redacted
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def secret_findings(text: str) -> list[dict[str, str]]:
|
|
25
|
+
findings = []
|
|
26
|
+
for name, pattern in SECRET_PATTERNS.items():
|
|
27
|
+
for match in pattern.finditer(text):
|
|
28
|
+
findings.append({"type": name, "preview": _preview(match.group(0))})
|
|
29
|
+
return findings
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _preview(value: str) -> str:
|
|
33
|
+
if len(value) <= 12:
|
|
34
|
+
return "[REDACTED]"
|
|
35
|
+
return f"{value[:4]}...{value[-4:]}"
|