sp2react 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- sp2react-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Stage 6a: deterministic code generation (seconds, no model calls).
|
|
2
|
+
|
|
3
|
+
Writes the React + FastAPI app skeleton, the specs, seed data and generated tests. Agents only
|
|
4
|
+
write what is screen-specific (page components, custom handlers); everything here is fixed code.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable, Optional
|
|
13
|
+
|
|
14
|
+
from ..analysis.inventory import Legacy, kebab
|
|
15
|
+
from ..spec.model import Spec
|
|
16
|
+
|
|
17
|
+
TEMPLATES = Path(__file__).parent / "templates"
|
|
18
|
+
AGENT_OWNED = re.compile(r"^(frontend/src/screens/[^/]+/(?!index).+|frontend/src/custom/(?!index\.ts|types\.ts).+|backend/app/custom/(?!__init__).+)$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ident(name: str) -> str:
|
|
22
|
+
s = re.sub(r"\W+", "_", name)
|
|
23
|
+
return ("_" + s) if s[0].isdigit() else s
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def copy_templates(out: Path) -> list[str]:
|
|
27
|
+
written = []
|
|
28
|
+
for src in TEMPLATES.rglob("*"):
|
|
29
|
+
if src.is_dir() or "__pycache__" in src.parts:
|
|
30
|
+
continue
|
|
31
|
+
rel = src.relative_to(TEMPLATES).as_posix()
|
|
32
|
+
dst = out / rel
|
|
33
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
shutil.copyfile(src, dst)
|
|
35
|
+
written.append(rel)
|
|
36
|
+
return written
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ----------------------------------------------------------------- seed data
|
|
40
|
+
def seed_rows(legacy: Legacy, list_key: str, fields: list[dict], export_dir: Optional[Path] = None) -> list[dict]:
|
|
41
|
+
"""Full migrated rows when a data export exists, otherwise the sample captured during extraction."""
|
|
42
|
+
from ..migrate import read_export, transform
|
|
43
|
+
if export_dir and (export_dir / f"{list_key}.jsonl").exists():
|
|
44
|
+
return transform(read_export(export_dir, list_key), fields)
|
|
45
|
+
return transform(legacy.items(list_key), fields)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def users_json(legacy: Legacy) -> list[dict]:
|
|
49
|
+
f = legacy.root / "siteusers.json"
|
|
50
|
+
data = json.loads(f.read_text(encoding="utf-8")) if f.exists() else {"d": {"results": []}}
|
|
51
|
+
groups = legacy.site.get("groups", [])
|
|
52
|
+
out = []
|
|
53
|
+
for u in data.get("d", {}).get("results", []):
|
|
54
|
+
if u.get("PrincipalType", 1) != 1:
|
|
55
|
+
continue
|
|
56
|
+
login = re.sub(r"^i:0#\.\w\|", "", u.get("LoginName", ""))
|
|
57
|
+
out.append({"id": u["Id"], "login": login, "title": u.get("Title"), "email": u.get("Email"),
|
|
58
|
+
"groups": [g["name"] for g in groups if login.lower() in [m.lower() for m in g.get("members", [])]]})
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------- generated src
|
|
63
|
+
def site_ts(legacy: Legacy, specs: list[Spec]) -> str:
|
|
64
|
+
prefix = legacy.prefix
|
|
65
|
+
routes: dict[str, str] = {}
|
|
66
|
+
for s in specs:
|
|
67
|
+
for u in s.screen.legacy_urls:
|
|
68
|
+
routes[u.lower()] = s.screen.route
|
|
69
|
+
if s.screen.kind == "list-form" and s.screen.list in legacy.lists:
|
|
70
|
+
l = legacy.lists[s.screen.list]
|
|
71
|
+
routes[f"{prefix}/{l['url']}".lower()] = s.screen.route
|
|
72
|
+
routes[f"{prefix}/{l['url']}/allitems.aspx".lower()] = s.screen.route
|
|
73
|
+
links = []
|
|
74
|
+
for q in legacy.site.get("quickLaunch", []):
|
|
75
|
+
url = re.sub(r"^https?://[^/]+", "", q.get("url") or "")
|
|
76
|
+
r = routes.get(url.lower().rstrip("/"))
|
|
77
|
+
links.append({"title": q.get("title"), "href": r or (legacy.site.get("url", "").split(prefix)[0] + url), "external": not r})
|
|
78
|
+
if not links:
|
|
79
|
+
links = [{"title": s.screen.title, "href": s.screen.route, "external": False} for s in specs]
|
|
80
|
+
return ("// Generated by sp2react from the legacy site's navigation. Links to screens not yet converted point at the old site.\n"
|
|
81
|
+
f"export const site = {json.dumps({'title': legacy.site.get('title') or 'Site', 'quickLaunch': links}, indent=2)};\n")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def app_tsx(specs: list[Spec], out: Path) -> str:
|
|
85
|
+
imports, entries, routes = [], [], []
|
|
86
|
+
for i, s in enumerate(specs):
|
|
87
|
+
var = f"spec{i}"
|
|
88
|
+
imports.append(f'import {var}Json from "./specs/{s.screen.id}.json";')
|
|
89
|
+
entries.append(f"{var}Json as unknown as Spec")
|
|
90
|
+
if s.screen.kind == "list-form":
|
|
91
|
+
routes.append(f' <Route path="{s.screen.route}/*" element={{<ListFormScreen spec={{specs[{i}]}} />}} />')
|
|
92
|
+
else:
|
|
93
|
+
page = out / "frontend" / "src" / "screens" / s.screen.id / "Page.tsx"
|
|
94
|
+
if page.exists():
|
|
95
|
+
comp = f"Page{i}"
|
|
96
|
+
imports.append(f'import {comp} from "./screens/{s.screen.id}/Page";')
|
|
97
|
+
else:
|
|
98
|
+
comp = "GenericPage"
|
|
99
|
+
routes.append(f' <Route path="{s.screen.route}" element={{<{comp} spec={{specs[{i}]}} />}} />')
|
|
100
|
+
home = next((s.screen.route for s in specs if s.screen.kind == "page"), specs[0].screen.route if specs else "/")
|
|
101
|
+
return "\n".join([
|
|
102
|
+
"// Generated by sp2react: one route per converted screen, plus redirects for the old SharePoint URLs.",
|
|
103
|
+
'import { Navigate, Route, Routes } from "react-router-dom";',
|
|
104
|
+
'import { GenericPage } from "./components/sp/GenericPage";',
|
|
105
|
+
'import { LegacyRedirect } from "./screens/LegacyRedirect";',
|
|
106
|
+
'import { ListFormScreen } from "./screens/ListFormScreen";',
|
|
107
|
+
'import type { Spec } from "./runtime/spec";',
|
|
108
|
+
'import { UserProvider } from "./runtime/user";',
|
|
109
|
+
*imports,
|
|
110
|
+
"",
|
|
111
|
+
f"export const specs: Spec[] = [{', '.join(entries)}];",
|
|
112
|
+
"",
|
|
113
|
+
"export default function App() {",
|
|
114
|
+
" return (",
|
|
115
|
+
" <UserProvider>",
|
|
116
|
+
" <Routes>",
|
|
117
|
+
f' <Route path="/" element={{<Navigate to="{home}" replace />}} />',
|
|
118
|
+
*routes,
|
|
119
|
+
' <Route path="*" element={<LegacyRedirect specs={specs} />} />',
|
|
120
|
+
" </Routes>",
|
|
121
|
+
" </UserProvider>",
|
|
122
|
+
" );",
|
|
123
|
+
"}",
|
|
124
|
+
"",
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def custom_index(out: Path) -> str:
|
|
129
|
+
folder = out / "frontend" / "src" / "custom"
|
|
130
|
+
mods = sorted(p.stem for p in folder.glob("*.ts") if p.stem not in ("index", "types"))
|
|
131
|
+
lines = ["// Generated by sp2react: registry of agent-written handlers (rule kind \"custom\").",
|
|
132
|
+
'import type { Handler } from "./types";']
|
|
133
|
+
lines += [f'import * as {ident(m)} from "./{m}";' for m in mods]
|
|
134
|
+
lines.append("")
|
|
135
|
+
body = ", ".join(f'"{m}": {ident(m)} as Handler' for m in mods)
|
|
136
|
+
lines.append(f"export const HANDLERS: Record<string, Handler> = {{{body}}};")
|
|
137
|
+
return "\n".join(lines) + "\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def legacy_css(legacy: Legacy, specs: list[Spec]) -> str:
|
|
141
|
+
seen, parts = set(), ["/* Site CSS carried over from the legacy SharePoint pages (order preserved). */"]
|
|
142
|
+
for s in specs:
|
|
143
|
+
for rel in s.screen.stylesheets:
|
|
144
|
+
if rel in seen:
|
|
145
|
+
continue
|
|
146
|
+
seen.add(rel)
|
|
147
|
+
txt = legacy.file_text(rel)
|
|
148
|
+
if txt:
|
|
149
|
+
parts.append(f"/* ---- {rel} ---- */\n{txt.strip()}")
|
|
150
|
+
return "\n\n".join(parts) + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def generated_tests(specs: list[Spec], seed_counts: dict[str, int], data_source: str = "sqlite") -> str:
|
|
154
|
+
"""Deterministic API tests derived from the specs (schema + rules)."""
|
|
155
|
+
db = data_source == "database"
|
|
156
|
+
lines = [
|
|
157
|
+
'"""Generated by sp2react from the screen specs. Regenerated on every convert run; add your own tests in other files."""',
|
|
158
|
+
"import os",
|
|
159
|
+
"",
|
|
160
|
+
"import pytest",
|
|
161
|
+
"from fastapi.testclient import TestClient",
|
|
162
|
+
"",
|
|
163
|
+
"from app.rules import REQUIRED_MESSAGE",
|
|
164
|
+
"",
|
|
165
|
+
]
|
|
166
|
+
if db:
|
|
167
|
+
lines += ['pytestmark = pytest.mark.skipif(not os.environ.get("DB_URL"), reason="set DB_URL (and MAPPING_FILE) to run the API tests against your database")', ""]
|
|
168
|
+
lines += [
|
|
169
|
+
"",
|
|
170
|
+
'@pytest.fixture(scope="module")',
|
|
171
|
+
"def client():",
|
|
172
|
+
" from app.main import create_app",
|
|
173
|
+
" return TestClient(create_app())",
|
|
174
|
+
"",
|
|
175
|
+
"",
|
|
176
|
+
"def test_screens_are_served(client):",
|
|
177
|
+
" ids = {s['id'] for s in client.get('/api/screens').json()}",
|
|
178
|
+
f" assert ids >= set({sorted(s.screen.id for s in specs)!r})",
|
|
179
|
+
"",
|
|
180
|
+
]
|
|
181
|
+
done = set()
|
|
182
|
+
for s in specs:
|
|
183
|
+
for name, l in s.lists.items():
|
|
184
|
+
if name in done:
|
|
185
|
+
continue
|
|
186
|
+
done.add(name)
|
|
187
|
+
fn = ident(name).lower()
|
|
188
|
+
lines += ["", f"def test_{fn}_items_served(client):",
|
|
189
|
+
f" r = client.get('/api/lists/{name}/items', params={{'$top': 5}})",
|
|
190
|
+
" assert r.status_code == 200, r.text"]
|
|
191
|
+
if not db:
|
|
192
|
+
lines += [f" assert r.json()['count'] == {seed_counts.get(name, 0)}"]
|
|
193
|
+
if s.screen.kind == "list-form" and s.screen.list and "new" in s.screen.modes:
|
|
194
|
+
lst = s.lists[s.screen.list]
|
|
195
|
+
hidden_new = {r.field for r in s.rules if r.kind == "visible" and (not r.modes or "new" in r.modes)}
|
|
196
|
+
readonly = {r.field for r in s.rules if r.kind in ("readonly", "derive", "set_on_save") and (not r.modes or "new" in r.modes)}
|
|
197
|
+
defaults = {f["name"] for f in lst.fields if "default" in f} | {r.field for r in s.rules if r.kind == "default"}
|
|
198
|
+
req = [f["name"] for f in lst.fields if f.get("required") and f["name"] not in hidden_new | readonly | defaults]
|
|
199
|
+
fn = ident(s.screen.id).lower()
|
|
200
|
+
if req:
|
|
201
|
+
lines += ["", f"def test_{fn}_required_fields_enforced(client):",
|
|
202
|
+
f" r = client.post('/api/lists/{s.screen.list}/items', json={{'values': {{}}}})",
|
|
203
|
+
" assert r.status_code == 422",
|
|
204
|
+
" fields = {e['field'] for e in r.json()['errors'] if e['message'] == REQUIRED_MESSAGE}",
|
|
205
|
+
f" assert set({req!r}) <= fields"]
|
|
206
|
+
return "\n".join(lines) + "\n"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
ENV_EXAMPLE = """# Copy to .env (or set in your service configuration). Never commit real values.
|
|
210
|
+
DATA_SOURCE=__SOURCE__
|
|
211
|
+
# database: your existing tables
|
|
212
|
+
DB_URL=mysql+pymysql://user:password@db-host:3306/sea
|
|
213
|
+
MAPPING_FILE=./mapping.json
|
|
214
|
+
# auth: dev (X-User header, local only) | header (identity header from your gateway)
|
|
215
|
+
AUTH_MODE=dev
|
|
216
|
+
IDENTITY_HEADER=X-MS-CLIENT-PRINCIPAL-NAME
|
|
217
|
+
CORS_ORIGINS=http://localhost:5173
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def generate(out: Path, specs: list[Spec], legacy: Legacy, export_dir: Optional[Path] = None,
|
|
222
|
+
data_source: str = "sqlite", mapping_file: Optional[Path] = None) -> dict:
|
|
223
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
written = copy_templates(out)
|
|
225
|
+
(out / "backend" / "specs").mkdir(parents=True, exist_ok=True)
|
|
226
|
+
(out / "frontend" / "src" / "specs").mkdir(parents=True, exist_ok=True)
|
|
227
|
+
for s in specs:
|
|
228
|
+
js = s.model_dump_json(indent=2, exclude_none=True)
|
|
229
|
+
(out / "backend" / "specs" / f"{s.screen.id}.json").write_text(js, encoding="utf-8")
|
|
230
|
+
(out / "frontend" / "src" / "specs" / f"{s.screen.id}.json").write_text(js, encoding="utf-8")
|
|
231
|
+
|
|
232
|
+
seed_dir = out / "backend" / "data" / "seed"
|
|
233
|
+
seed_dir.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
counts, lists_done = {}, set()
|
|
235
|
+
for s in specs:
|
|
236
|
+
for name, l in s.lists.items():
|
|
237
|
+
if name in lists_done:
|
|
238
|
+
continue
|
|
239
|
+
lists_done.add(name)
|
|
240
|
+
rows = seed_rows(legacy, name, l.fields, export_dir)
|
|
241
|
+
counts[name] = len(rows)
|
|
242
|
+
(seed_dir / f"{name}.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
|
243
|
+
if export_dir and (export_dir / "attachments" / name).exists():
|
|
244
|
+
dst = out / "backend" / "data" / "attachments" / name
|
|
245
|
+
if dst.exists():
|
|
246
|
+
shutil.rmtree(dst)
|
|
247
|
+
shutil.copytree(export_dir / "attachments" / name, dst)
|
|
248
|
+
(out / "backend" / "data" / "users.json").write_text(json.dumps(users_json(legacy), indent=2), encoding="utf-8")
|
|
249
|
+
|
|
250
|
+
src = out / "frontend" / "src"
|
|
251
|
+
(src / "site.ts").write_text(site_ts(legacy, specs), encoding="utf-8")
|
|
252
|
+
(src / "App.tsx").write_text(app_tsx(specs, out), encoding="utf-8")
|
|
253
|
+
(src / "custom" / "index.ts").write_text(custom_index(out), encoding="utf-8")
|
|
254
|
+
(src / "styles" / "legacy.css").write_text(legacy_css(legacy, specs), encoding="utf-8")
|
|
255
|
+
html = (out / "frontend" / "index.html").read_text(encoding="utf-8").replace("__SITE_TITLE__", legacy.site.get("title") or "Site")
|
|
256
|
+
(out / "frontend" / "index.html").write_text(html, encoding="utf-8")
|
|
257
|
+
(out / "backend" / "tests" / "test_generated.py").write_text(generated_tests(specs, counts, data_source), encoding="utf-8")
|
|
258
|
+
main_py = out / "backend" / "app" / "main.py"
|
|
259
|
+
main_py.write_text(main_py.read_text(encoding="utf-8").replace("__DEFAULT_SOURCE__", data_source), encoding="utf-8")
|
|
260
|
+
if data_source == "database":
|
|
261
|
+
if mapping_file and mapping_file.exists():
|
|
262
|
+
shutil.copyfile(mapping_file, out / "backend" / "mapping.json")
|
|
263
|
+
roles = out / "backend" / "data" / "roles.json"
|
|
264
|
+
if not roles.exists(): # SharePoint group names; members are filled in by your admins (or come from the users table)
|
|
265
|
+
groups = sorted({r for sp in specs for r in sp.roles} | {g["name"] for g in legacy.site.get("groups", [])})
|
|
266
|
+
roles.write_text(json.dumps({g: [] for g in groups}, indent=2), encoding="utf-8")
|
|
267
|
+
(out / "backend" / ".env.example").write_text(ENV_EXAMPLE.replace("__SOURCE__", data_source), encoding="utf-8")
|
|
268
|
+
shared = TEMPLATES.parent / "rule_vectors.json"
|
|
269
|
+
if shared.exists():
|
|
270
|
+
(out / "shared").mkdir(exist_ok=True)
|
|
271
|
+
shutil.copyfile(shared, out / "shared" / "rule_vectors.json")
|
|
272
|
+
return {"files": len(written) + 8 + 2 * len(specs), "seed": counts}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def refresh_registries(out: Path, specs: list[Spec]) -> None:
|
|
276
|
+
"""After agents add files: re-link App.tsx routes and the custom handler registry."""
|
|
277
|
+
src = out / "frontend" / "src"
|
|
278
|
+
(src / "App.tsx").write_text(app_tsx(specs, out), encoding="utf-8")
|
|
279
|
+
(src / "custom" / "index.ts").write_text(custom_index(out), encoding="utf-8")
|