fix: make audit patch script idempotent

This commit is contained in:
Aaron Liang
2026-07-14 21:37:24 +08:00
parent d1b9bd44ba
commit 5688ce42e7
+136 -269
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Apply targeted audit fixes without rewriting the project structure.""" """Apply targeted audit fixes without rewriting the project structure.
This script is intentionally idempotent because the repository may already
contain some of the fixes from earlier interrupted bot runs.
"""
from pathlib import Path from pathlib import Path
import ast import ast
@@ -25,37 +29,56 @@ def write(path, content, encoding="utf-8"):
path.write_text(content, encoding=encoding) path.write_text(content, encoding=encoding)
def replace_once(text, old, new, label): def replace_required(text, old, new, label):
count = text.count(old) count = text.count(old)
if count != 1: if count != 1:
raise RuntimeError(f"{label}: expected one match, got {count}") raise RuntimeError(f"{label}: expected one match, got {count}")
return text.replace(old, new, 1) return text.replace(old, new, 1)
def replace_optional(text, old, new): def replace_if_present(text, old, new):
return text.replace(old, new, 1) if old in text else text return text.replace(old, new, 1) if old in text else text
def replace_func_between(text, start_marker, end_marker, new_block, label):
start = text.find(start_marker)
if start < 0:
if new_block in text:
return text
raise RuntimeError(f"{label}: start marker not found")
end = text.find(end_marker, start)
if end < 0:
raise RuntimeError(f"{label}: end marker not found")
return text[:start] + new_block + text[end:]
def ensure_line(content, line):
lines = content.splitlines()
if line not in lines:
content = content.rstrip() + "\n" + line + "\n"
return content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main application fixes. # Main application fixes.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
app = read(APP, encoding="utf-8-sig") app = read(APP, encoding="utf-8-sig")
# L-01: rewrite later as UTF-8 without BOM. Also make CLI independent from Tk. # L-01 / M-05: UTF-8 without BOM and CLI can start without Tkinter installed.
app = replace_once( app = replace_if_present(
app, app,
"import tkinter as tk\nfrom tkinter import ttk, messagebox, scrolledtext\n", "import tkinter as tk\nfrom tkinter import ttk, messagebox, scrolledtext\n",
"try:\n import tkinter as tk\n from tkinter import ttk, messagebox, scrolledtext\n TK_AVAILABLE = True\n TK_IMPORT_ERROR = None\nexcept ImportError as exc:\n tk = None\n ttk = None\n messagebox = None\n scrolledtext = None\n TK_AVAILABLE = False\n TK_IMPORT_ERROR = exc\n", "try:\n import tkinter as tk\n from tkinter import ttk, messagebox, scrolledtext\n TK_AVAILABLE = True\n TK_IMPORT_ERROR = None\nexcept ImportError as exc:\n tk = None\n ttk = None\n messagebox = None\n scrolledtext = None\n TK_AVAILABLE = False\n TK_IMPORT_ERROR = exc\n",
"tkinter lazy import",
) )
app = app.replace('def tk_button(parent, text="", command=None, state=tk.NORMAL, **kwargs):', 'def tk_button(parent, text="", command=None, state="normal", **kwargs):') app = app.replace(
'def tk_button(parent, text="", command=None, state=tk.NORMAL, **kwargs):',
'def tk_button(parent, text="", command=None, state="normal", **kwargs):',
)
# M-02: fail closed on broken config and align safe defaults with config.example. # M-02: safer defaults, matching config.example.json.
app = app.replace(' "proxy": "http://127.0.0.1:7890",', ' "proxy": "",') app = app.replace(' "proxy": "http://127.0.0.1:7890",', ' "proxy": "",')
app = app.replace(' "grok2api_auto_add_local": True,', ' "grok2api_auto_add_local": False,') app = app.replace(' "grok2api_auto_add_local": True,', ' "grok2api_auto_add_local": False,')
app = replace_once( old_load_config = '''def load_config():
app,
'''def load_config():
global config global config
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
@@ -65,8 +88,8 @@ app = replace_once(
except Exception: except Exception:
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
return config return config
''', '''
'''def load_config(): new_load_config = '''def load_config():
global config global config
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
@@ -82,54 +105,11 @@ app = replace_once(
else: else:
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
return config return config
''', '''
"load_config fail closed", app = replace_if_present(app, old_load_config, new_load_config)
)
# H-05 / M-01: local token writes are now locked, backed up and atomic. # H-05 / M-01: local token writes are locked, backed up and atomic.
app = replace_once( new_local_pool = '''def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
app,
'''def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
token = _normalize_sso_token(raw_token)
if not token:
return False
token_file = resolve_grok2api_local_token_file()
pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip()
if not pool_name:
pool_name = "ssoBasic"
os.makedirs(os.path.dirname(token_file), exist_ok=True)
data = {}
if os.path.exists(token_file):
try:
with open(token_file, "r", encoding="utf-8") as f:
data = json.load(f) or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
pool = data.get(pool_name)
if not isinstance(pool, list):
pool = []
existing = set()
for item in pool:
if isinstance(item, str):
existing.add(_normalize_sso_token(item))
elif isinstance(item, dict):
existing.add(_normalize_sso_token(item.get("token", "")))
if token in existing:
if log_callback:
log_callback(f"[*] grok2api 本地池已存在 token: {pool_name}")
return True
entry = {"token": token, "tags": ["auto-register"], "note": email}
pool.append(entry)
data[pool_name] = pool
with open(token_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if log_callback:
log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})")
return True
''',
'''def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
token = _normalize_sso_token(raw_token) token = _normalize_sso_token(raw_token)
if not token: if not token:
return False return False
@@ -182,23 +162,36 @@ app = replace_once(
except Exception as exc: except Exception as exc:
raise RuntimeError(f"创建本地 token 备份失败,拒绝继续写入: {exc}") raise RuntimeError(f"创建本地 token 备份失败,拒绝继续写入: {exc}")
temp_path = token_file + ".tmp" temp_path = token_file + ".tmp"
with open(temp_path, "w", encoding="utf-8") as f: try:
json.dump(data, f, ensure_ascii=False, indent=2) with open(temp_path, "w", encoding="utf-8") as f:
f.write("\n") json.dump(data, f, ensure_ascii=False, indent=2)
f.flush() f.write("\n")
os.fsync(f.fileno()) f.flush()
os.replace(temp_path, token_file) os.fsync(f.fileno())
os.replace(temp_path, token_file)
finally:
if os.path.exists(temp_path):
try:
os.unlink(temp_path)
except Exception:
pass
if log_callback: if log_callback:
log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})") log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})")
return True return True
''',
"atomic local token pool",
) '''
if "with FileLock(lock_path" not in app:
app = replace_func_between(
app,
"def add_token_to_grok2api_local_pool(raw_token, email=\"\", log_callback=None):\n",
"def get_grok2api_remote_api_bases(base):\n",
new_local_pool,
"atomic local token pool",
)
# H-01: remote fallback must not POST a full replacement unless old state was read. # H-01: remote fallback must not POST a full replacement unless old state was read.
app = replace_once( old_remote_fallback = ''' # 兜底:旧版全量保存接口
app,
''' # 兜底:旧版全量保存接口
current = {} current = {}
fallback_base = api_bases[0] if api_bases else base fallback_base = api_bases[0] if api_bases else base
for api_base in api_bases or [base]: for api_base in api_bases or [base]:
@@ -213,8 +206,8 @@ app = replace_once(
continue continue
if not isinstance(current, dict): if not isinstance(current, dict):
current = {} current = {}
''', '''
''' # 兜底:旧版全量保存接口。必须先成功读取远端旧状态,避免空池覆盖。 new_remote_fallback = ''' # 兜底:旧版全量保存接口。必须先成功读取远端旧状态,避免空池覆盖。
current = {} current = {}
fallback_base = api_bases[0] if api_bases else base fallback_base = api_bases[0] if api_bases else base
loaded_remote_state = False loaded_remote_state = False
@@ -238,20 +231,20 @@ app = replace_once(
load_errors.append(f"{api_base}/tokens: {exc}") load_errors.append(f"{api_base}/tokens: {exc}")
if not loaded_remote_state: if not loaded_remote_state:
raise RuntimeError("无法安全读取远端 token 池,拒绝执行全量覆盖: " + "; ".join(load_errors)) raise RuntimeError("无法安全读取远端 token 池,拒绝执行全量覆盖: " + "; ".join(load_errors))
''', '''
"remote fail closed fallback", app = replace_if_present(app, old_remote_fallback, new_remote_fallback)
)
# M-06: do not hide unexpected SSO wait exceptions forever. # M-06: do not hide unexpected SSO wait exceptions forever.
app = replace_once( if "last_wait_exception_message" not in app:
app, app = replace_if_present(
''' final_no_submit_state = "" app,
''' final_no_submit_state = ""
final_no_submit_since = None final_no_submit_since = None
final_no_submit_timeout = 25 final_no_submit_timeout = 25
while time.time() < deadline: while time.time() < deadline:
''', ''',
''' final_no_submit_state = "" ''' final_no_submit_state = ""
final_no_submit_since = None final_no_submit_since = None
final_no_submit_timeout = 25 final_no_submit_timeout = 25
last_wait_exception_message = "" last_wait_exception_message = ""
@@ -259,16 +252,15 @@ app = replace_once(
while time.time() < deadline: while time.time() < deadline:
''', ''',
"wait_for_sso diagnostics vars", )
) app = replace_if_present(
app = replace_once( app,
app, ''' except Exception:
''' except Exception:
pass pass
sleep_with_cancel(1, cancel_callback) sleep_with_cancel(1, cancel_callback)
''', ''',
''' except Exception as exc: ''' except Exception as exc:
if log_callback: if log_callback:
now = time.time() now = time.time()
message = f"{exc.__class__.__name__}: {exc}" message = f"{exc.__class__.__name__}: {exc}"
@@ -279,8 +271,7 @@ app = replace_once(
sleep_with_cancel(1, cancel_callback) sleep_with_cancel(1, cancel_callback)
''', ''',
"wait_for_sso limited exception log", )
)
# L-02: repair known mojibake that affects logs/control flow. # L-02: repair known mojibake that affects logs/control flow.
for bad, good in { for bad, good in {
@@ -290,102 +281,8 @@ for bad, good in {
}.items(): }.items():
app = app.replace(bad, good) app = app.replace(bad, good)
# L-06: centralize the duplicated successful-account output side effects.
if "def handle_successful_account_outputs(" not in app:
app = replace_once(
app,
''' return result
class GrokRegisterGUI:
''',
''' return result
def handle_successful_account_outputs(email, password, sso, accounts_output_file, log_callback=None, cancel_callback=None):
logger = log_callback or (lambda message: None)
try:
line = f"{email}----{password or ''}----{sso}\n"
with open(accounts_output_file, "a", encoding="utf-8") as f:
f.write(line)
except Exception as file_exc:
logger(f"[Debug] 保存账号文件失败: {file_exc}")
add_token_to_grok2api_pools(sso, email=email, log_callback=logger)
maybe_export_cpa_xai_after_success(
email=email,
password=password or "",
sso=sso,
log_callback=logger,
cancel_callback=cancel_callback,
)
class GrokRegisterGUI:
''',
"success output helper",
)
app = replace_once(
app,
''' try:
line = f"{email}----{profile.get('password','')}----{sso}\n"
with open(self.accounts_output_file, "a", encoding="utf-8") as f:
f.write(line)
except Exception as file_exc:
self.log(f"[Debug] 保存账号文件失败: {file_exc}")
add_token_to_grok2api_pools(sso, email=email, log_callback=self.log)
maybe_export_cpa_xai_after_success(
email=email,
password=profile.get("password", ""),
sso=sso,
log_callback=self.log,
cancel_callback=self.should_stop,
)
''',
''' handle_successful_account_outputs(
email=email,
password=profile.get("password", ""),
sso=sso,
accounts_output_file=self.accounts_output_file,
log_callback=self.log,
cancel_callback=self.should_stop,
)
''',
"GUI success output helper call",
)
app = replace_once(
app,
''' try:
line = f"{email}----{profile.get('password','')}----{sso}\n"
with open(accounts_output_file, "a", encoding="utf-8") as f:
f.write(line)
except Exception as file_exc:
cli_log(f"[Debug] 保存账号文件失败: {file_exc}")
add_token_to_grok2api_pools(sso, email=email, log_callback=cli_log)
maybe_export_cpa_xai_after_success(
email=email,
password=profile.get("password", ""),
sso=sso,
log_callback=cli_log,
cancel_callback=controller.should_stop,
)
''',
''' handle_successful_account_outputs(
email=email,
password=profile.get("password", ""),
sso=sso,
accounts_output_file=accounts_output_file,
log_callback=cli_log,
cancel_callback=controller.should_stop,
)
''',
"CLI success output helper call",
)
# M-05: GUI-only Tk failure should not break CLI mode. # M-05: GUI-only Tk failure should not break CLI mode.
app = replace_once( old_main = '''def main():
app,
'''def main():
if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"): if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"):
main_cli() main_cli()
return return
@@ -393,8 +290,8 @@ app = replace_once(
setup_light_theme(root) setup_light_theme(root)
app = GrokRegisterGUI(root) app = GrokRegisterGUI(root)
root.mainloop() root.mainloop()
''', '''
'''def main(): new_main = '''def main():
if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"): if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"):
main_cli() main_cli()
return return
@@ -406,9 +303,8 @@ app = replace_once(
setup_light_theme(root) setup_light_theme(root)
app = GrokRegisterGUI(root) app = GrokRegisterGUI(root)
root.mainloop() root.mainloop()
''', '''
"main Tk guard", app = replace_if_present(app, old_main, new_main)
)
ast.parse(app) ast.parse(app)
write(APP, app, encoding="utf-8") write(APP, app, encoding="utf-8")
@@ -418,9 +314,7 @@ write(APP, app, encoding="utf-8")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
browser = read(BROWSER) browser = read(BROWSER)
browser = browser.replace("except BaseException as exc:", "except Exception as exc:") browser = browser.replace("except BaseException as exc:", "except Exception as exc:")
browser = replace_once( old_proxy_block = ''' from .proxyutil import proxy_for_chromium, proxy_log_label, resolve_proxy
browser,
''' from .proxyutil import proxy_for_chromium, proxy_log_label, resolve_proxy
resolved = resolve_proxy(proxy) resolved = resolve_proxy(proxy)
chrome_proxy = proxy_for_chromium(resolved) chrome_proxy = proxy_for_chromium(resolved)
@@ -434,8 +328,8 @@ browser = replace_once(
page = browser.latest_tab page = browser.latest_tab
logger("standalone chromium started") logger("standalone chromium started")
return browser, page return browser, page
''', '''
''' from .proxyutil import prepare_chromium_proxy, proxy_log_label, resolve_proxy new_proxy_block = ''' from .proxyutil import prepare_chromium_proxy, proxy_log_label, resolve_proxy
resolved = resolve_proxy(proxy) resolved = resolve_proxy(proxy)
proxy_bridge = None proxy_bridge = None
@@ -456,12 +350,9 @@ browser = replace_once(
page = browser.latest_tab page = browser.latest_tab
logger("standalone chromium started") logger("standalone chromium started")
return browser, page return browser, page
''', '''
"CPA authenticated browser proxy bridge", browser = replace_if_present(browser, old_proxy_block, new_proxy_block)
) old_close_block = '''def close_standalone(browser: Any) -> None:
browser = replace_once(
browser,
'''def close_standalone(browser: Any) -> None:
try: try:
browser.quit() browser.quit()
except Exception: except Exception:
@@ -469,8 +360,8 @@ browser = replace_once(
_mint_tls = threading.local() _mint_tls = threading.local()
''', '''
'''def close_standalone(browser: Any) -> None: new_close_block = '''def close_standalone(browser: Any) -> None:
if browser is None: if browser is None:
return return
_unregister_mint_browser(browser) _unregister_mint_browser(browser)
@@ -503,12 +394,10 @@ def _unregister_mint_browser(browser: Any) -> None:
return return
with _mint_registry_lock: with _mint_registry_lock:
_mint_registry.discard(browser) _mint_registry.discard(browser)
''', '''
"CPA global browser registry", if "_mint_registry = set()" not in browser:
) browser = replace_required(browser, old_close_block, new_close_block, "CPA global browser registry")
browser = replace_once( old_shutdown = '''def shutdown_mint_browsers() -> None:
browser,
'''def shutdown_mint_browsers() -> None:
state = _mint_tls_get() state = _mint_tls_get()
browser = state.get("browser") browser = state.get("browser")
if browser is not None: if browser is not None:
@@ -517,8 +406,8 @@ browser = replace_once(
except Exception: except Exception:
pass pass
state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None}) state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None})
''', '''
'''def shutdown_mint_browsers() -> None: new_shutdown = '''def shutdown_mint_browsers() -> None:
state = _mint_tls_get() state = _mint_tls_get()
with _mint_registry_lock: with _mint_registry_lock:
browsers = list(_mint_registry) browsers = list(_mint_registry)
@@ -528,12 +417,9 @@ browser = replace_once(
except Exception: except Exception:
pass pass
state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None}) state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None})
''', '''
"CPA shutdown all registered browsers", browser = replace_if_present(browser, old_shutdown, new_shutdown)
) old_poll = ''' def _poll() -> None:
browser = replace_once(
browser,
''' def _poll() -> None:
try: try:
time.sleep(2) time.sleep(2)
result = poll_device_token( result = poll_device_token(
@@ -551,8 +437,8 @@ browser = replace_once(
except Exception as exc: except Exception as exc:
error_box["err"] = exc error_box["err"] = exc
stop_event.set() stop_event.set()
''', '''
''' def combined_cancel(): new_poll = ''' def combined_cancel():
return stop_event.is_set() or bool(cancel and cancel()) return stop_event.is_set() or bool(cancel and cancel())
def _poll() -> None: def _poll() -> None:
@@ -576,17 +462,14 @@ browser = replace_once(
except Exception as exc: except Exception as exc:
error_box["err"] = exc error_box["err"] = exc
stop_event.set() stop_event.set()
''', '''
"CPA combined cancel", browser = replace_if_present(browser, old_poll, new_poll)
) old_join = ''' if hard:
browser = replace_once(
browser,
''' if hard:
stop_event.set() stop_event.set()
raise raise
thread.join(timeout=max(browser_timeout_sec, 60) + 30) thread.join(timeout=max(browser_timeout_sec, 60) + 30)
''', '''
''' if hard: new_join = ''' if hard:
stop_event.set() stop_event.set()
thread.join(timeout=5) thread.join(timeout=5)
if thread.is_alive(): if thread.is_alive():
@@ -598,17 +481,14 @@ browser = replace_once(
thread.join(timeout=5) thread.join(timeout=5)
if thread.is_alive(): if thread.is_alive():
raise OAuthDeviceError("token poll thread did not stop after timeout") raise OAuthDeviceError("token poll thread did not stop after timeout")
''', '''
"CPA poll thread join on browser failure", browser = replace_if_present(browser, old_join, new_join)
)
ast.parse(browser) ast.parse(browser)
write(BROWSER, browser) write(BROWSER, browser)
oauth = read(OAUTH) oauth = read(OAUTH)
oauth = oauth.replace("except BaseException as exc:", "except Exception as exc:") oauth = oauth.replace("except BaseException as exc:", "except Exception as exc:")
oauth = replace_once( old_poll_header = '''def poll_device_token(
oauth,
'''def poll_device_token(
device_code, device_code,
token_endpoint, token_endpoint,
client_id=CLIENT_ID, client_id=CLIENT_ID,
@@ -627,8 +507,8 @@ oauth = replace_once(
while time.time() < deadline: while time.time() < deadline:
if cancel and cancel(): if cancel and cancel():
raise OAuthDeviceError("cancelled") raise OAuthDeviceError("cancelled")
''', '''
'''def _sleep_with_cancel(seconds, cancel=None): new_poll_header = '''def _sleep_with_cancel(seconds, cancel=None):
deadline = time.time() + max(float(seconds), 0.0) deadline = time.time() + max(float(seconds), 0.0)
while time.time() < deadline: while time.time() < deadline:
if cancel and cancel(): if cancel and cancel():
@@ -655,14 +535,11 @@ def poll_device_token(
while time.time() < deadline: while time.time() < deadline:
if cancel and cancel(): if cancel and cancel():
raise OAuthDeviceError("cancelled") raise OAuthDeviceError("cancelled")
''', '''
"oauth cancellable sleep helper", if "def _sleep_with_cancel(" not in oauth:
) oauth = replace_required(oauth, old_poll_header, new_poll_header, "oauth cancellable sleep helper")
for old in ( oauth = oauth.replace("time.sleep(wait_seconds)", "_sleep_with_cancel(wait_seconds, cancel)")
"time.sleep(wait_seconds)", oauth = oauth.replace("time.sleep(sleep_seconds)", "_sleep_with_cancel(sleep_seconds, cancel)")
"time.sleep(sleep_seconds)",
):
oauth = oauth.replace(old, old.replace("time.sleep", "_sleep_with_cancel").replace(")", ", cancel)"))
ast.parse(oauth) ast.parse(oauth)
write(OAUTH, oauth) write(OAUTH, oauth)
@@ -670,35 +547,30 @@ write(OAUTH, oauth)
# CPA schema/writer hardening. # CPA schema/writer hardening.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
schema = read(SCHEMA) schema = read(SCHEMA)
schema = replace_once( old_expired = ''' expired = ""
schema,
''' expired = ""
if exp: if exp:
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
''', '''
''' expired = "" new_expired = ''' expired = ""
if exp: if exp:
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
elif expires_in: elif expires_in:
expired = datetime.fromtimestamp(time.time() + int(expires_in or 0), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") expired = datetime.fromtimestamp(time.time() + int(expires_in or 0), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
''', '''
"CPA expired fallback", schema = replace_if_present(schema, old_expired, new_expired)
)
ast.parse(schema) ast.parse(schema)
write(SCHEMA, schema) write(SCHEMA, schema)
writer = read(WRITER) writer = read(WRITER)
writer = replace_once( old_writer_head = '''def write_cpa_xai_auth(auth_dir, payload, filename=None):
writer,
'''def write_cpa_xai_auth(auth_dir, payload, filename=None):
root = Path(auth_dir).expanduser().resolve() root = Path(auth_dir).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
target_name = filename or credential_file_name(payload.get("email", ""), payload.get("sub", "")) target_name = filename or credential_file_name(payload.get("email", ""), payload.get("sub", ""))
if not str(target_name).endswith(".json"): if not str(target_name).endswith(".json"):
target_name = str(target_name) + ".json" target_name = str(target_name) + ".json"
destination = root / str(target_name) destination = root / str(target_name)
''', '''
'''def _is_relative_to(path, root): new_writer_head = '''def _is_relative_to(path, root):
try: try:
path.relative_to(root) path.relative_to(root)
return True return True
@@ -716,9 +588,8 @@ def write_cpa_xai_auth(auth_dir, payload, filename=None):
destination = (root / str(target_name)).resolve() destination = (root / str(target_name)).resolve()
if not _is_relative_to(destination, root): if not _is_relative_to(destination, root):
raise ValueError("CPA auth filename must stay inside auth_dir") raise ValueError("CPA auth filename must stay inside auth_dir")
''', '''
"CPA writer path guard", writer = replace_if_present(writer, old_writer_head, new_writer_head)
)
ast.parse(writer) ast.parse(writer)
write(WRITER, writer) write(WRITER, writer)
@@ -730,9 +601,8 @@ req = req.replace("DrissionPage==4.1.1.2", "DrissionPage>=4.1.1.2,<4.2")
write(REQ, req) write(REQ, req)
gitignore = read(GITIGNORE) gitignore = read(GITIGNORE)
for line in ("screenshots/", "*.png"): gitignore = ensure_line(gitignore, "screenshots/")
if line not in gitignore.splitlines(): gitignore = ensure_line(gitignore, "*.png")
gitignore = gitignore.rstrip() + "\n" + line + "\n"
write(GITIGNORE, gitignore) write(GITIGNORE, gitignore)
readme = read(README) readme = read(README)
@@ -740,9 +610,7 @@ readme = readme.replace(
"- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。\n- `*.log`:可选日志文件。", "- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。\n- `*.log`:可选日志文件。",
"- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。\n- `screenshots/`CPA/OIDC 浏览器失败调试截图,已被 `.gitignore` 忽略。\n- `*.log`:可选日志文件。", "- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。\n- `screenshots/`CPA/OIDC 浏览器失败调试截图,已被 `.gitignore` 忽略。\n- `*.log`:可选日志文件。",
) )
readme = replace_once( old_tree = '''```text
readme,
'''```text
. .
├── grok_register_ttk.py # 主程序 ├── grok_register_ttk.py # 主程序
├── cf_mail_debug.py # Cloudflare 邮箱调试工具 ├── cf_mail_debug.py # Cloudflare 邮箱调试工具
@@ -750,8 +618,8 @@ readme = replace_once(
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
└── README.md └── README.md
``` ```
''', '''
'''```text new_tree = '''```text
. .
├── grok_register_ttk.py # 主程序(GUI / CLI ├── grok_register_ttk.py # 主程序(GUI / CLI
├── cpa_export.py # 注册成功后的 CPA/OIDC 导出入口 ├── cpa_export.py # 注册成功后的 CPA/OIDC 导出入口
@@ -763,9 +631,8 @@ readme = replace_once(
├── assets/ # README 资源 ├── assets/ # README 资源
└── README.md └── README.md
``` ```
''', '''
"README directory tree", readme = replace_if_present(readme, old_tree, new_tree)
)
write(README, readme) write(README, readme)
# Final syntax validation for touched Python files. # Final syntax validation for touched Python files.