4b5602a719
- FilesPage storeToRefs + remotePath coercion; Monaco preload; list action buttons - text_io for UTF-8/LF; install/sync EOL; check_shell_eol + check_text_eol gates - ServerFormDialog, hash login redirect, AppAuth keep-session; design docs and audits Co-authored-by: Cursor <cursoragent@cursor.com>
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Remove orphaned files under web/app/assets/ not referenced by current Vite build.
|
|
|
|
Vite/Rolldown minified bundles reference chunks by bare filename (e.g. ServersPage-xxx.js)
|
|
without import() paths — scan file contents for known asset basenames.
|
|
|
|
Orphans newer than --retention-days are kept so long-lived browser tabs can still load
|
|
their lazy chunks after a deploy (see docs/design/specs/2026-06-02-keep-session-no-refresh-design.md).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
# Leading `_` required for Vite chunks like `_plugin-vue_export-helper-*.js`
|
|
ASSET_NAME_RE = re.compile(
|
|
r"[_A-Za-z0-9][A-Za-z0-9_.-]*\.(?:js|css|woff2?|ttf|eot)"
|
|
)
|
|
URL_RE = re.compile(r"""url\(["']?([^"')]+)["']?\)""")
|
|
DEFAULT_RETENTION_DAYS = 14
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> tuple[bool, int, Path]:
|
|
dry_run = "--dry-run" in argv
|
|
retention_days = DEFAULT_RETENTION_DAYS
|
|
positional: list[str] = []
|
|
i = 0
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg == "--dry-run":
|
|
i += 1
|
|
continue
|
|
if arg.startswith("--retention-days="):
|
|
retention_days = int(arg.split("=", 1)[1])
|
|
i += 1
|
|
continue
|
|
if arg == "--retention-days":
|
|
if i + 1 >= len(argv):
|
|
raise SystemExit("--retention-days requires a value")
|
|
retention_days = int(argv[i + 1])
|
|
i += 2
|
|
continue
|
|
positional.append(arg)
|
|
i += 1
|
|
if retention_days < 0:
|
|
raise SystemExit("--retention-days must be >= 0")
|
|
app_dir = Path(positional[0] if positional else "/www/wwwroot/api.synaglobal.vip/web/app")
|
|
return dry_run, retention_days, app_dir
|
|
|
|
|
|
def _referenced_basenames(text: str, known: set[str]) -> set[str]:
|
|
found: set[str] = set()
|
|
for m in ASSET_NAME_RE.finditer(text):
|
|
name = m.group(0)
|
|
if name in known:
|
|
found.add(name)
|
|
for raw in URL_RE.findall(text):
|
|
name = Path(raw).name
|
|
if name in known:
|
|
found.add(name)
|
|
return found
|
|
|
|
|
|
def collect(app_dir: Path) -> set[Path]:
|
|
assets = app_dir / "assets"
|
|
index = app_dir / "index.html"
|
|
if not index.is_file():
|
|
raise SystemExit(f"missing {index}")
|
|
|
|
all_names = {p.name for p in assets.iterdir() if p.is_file()}
|
|
keep_names: set[str] = set()
|
|
|
|
for m in re.finditer(r"/app/assets/([^\s\"']+)", index.read_text(encoding="utf-8")):
|
|
name = m.group(1).split("/")[-1]
|
|
if name in all_names:
|
|
keep_names.add(name)
|
|
|
|
if not keep_names:
|
|
raise SystemExit("no entry assets found in index.html")
|
|
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for name in list(keep_names):
|
|
path = assets / name
|
|
if not path.is_file():
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
for ref in _referenced_basenames(text, all_names):
|
|
if ref not in keep_names:
|
|
keep_names.add(ref)
|
|
changed = True
|
|
|
|
return {assets / n for n in keep_names}
|
|
|
|
|
|
def _partition_orphans(
|
|
orphan: list[Path],
|
|
retention_days: int,
|
|
) -> tuple[list[Path], list[Path]]:
|
|
"""Split orphans into delete-now vs keep-for-open-tabs (mtime within window)."""
|
|
if retention_days <= 0:
|
|
return orphan, []
|
|
cutoff = time.time() - retention_days * 86400
|
|
to_remove: list[Path] = []
|
|
retained: list[Path] = []
|
|
for p in orphan:
|
|
if p.stat().st_mtime >= cutoff:
|
|
retained.append(p)
|
|
else:
|
|
to_remove.append(p)
|
|
return to_remove, retained
|
|
|
|
|
|
def main() -> int:
|
|
dry_run, retention_days, app_dir = _parse_args(sys.argv[1:])
|
|
assets = app_dir / "assets"
|
|
if not assets.is_dir():
|
|
raise SystemExit(f"not a directory: {assets}")
|
|
|
|
keep = collect(app_dir)
|
|
all_files = {p for p in assets.iterdir() if p.is_file()}
|
|
orphan = sorted(all_files - keep, key=lambda p: p.name)
|
|
to_remove, retained = _partition_orphans(orphan, retention_days)
|
|
|
|
if dry_run:
|
|
print(
|
|
f"dry-run retention_days={retention_days} "
|
|
f"referenced_kept={len(keep)} "
|
|
f"would_remove={len(to_remove)} "
|
|
f"retained_orphans={len(retained)} "
|
|
f"total={len(all_files)}"
|
|
)
|
|
return 0
|
|
|
|
for p in to_remove:
|
|
p.unlink()
|
|
print(
|
|
f"kept={len(keep)} removed={len(to_remove)} "
|
|
f"retained_orphans={len(retained)} retention_days={retention_days} "
|
|
f"total_before={len(all_files)}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|