From 7a670fd5e26467469dbbc080eda8197475ca1e50 Mon Sep 17 00:00:00 2001 From: PM-pinou <2504420230@qq.com> Date: Mon, 20 Jul 2026 14:14:17 +0800 Subject: [PATCH] feat(scripts): add update.bat and rollback.bat launchers Add user-facing launchers for the incremental update system: - update.bat: one-click update launcher - rollback.bat: rollback to previous version - tests/test_update_integration.py: integration tests for the update system - README.md: add incremental update guide (section 10) - .gitignore: add .update_cache/ entry Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .gitignore | 4 + README.md | 145 +++++++++++ rollback.bat | 45 ++++ tests/test_update_integration.py | 430 +++++++++++++++++++++++++++++++ update.bat | 56 ++++ 5 files changed, 680 insertions(+) create mode 100644 rollback.bat create mode 100644 tests/test_update_integration.py create mode 100644 update.bat diff --git a/.gitignore b/.gitignore index a609007..abd6045 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ Thumbs.db # OpenCode state .omo/ + +# Update cache (auto-generated by build_update.py) +.update_cache/ +RUNTIME_CHANGED.txt diff --git a/README.md b/README.md index 75732c1..59ea618 100644 --- a/README.md +++ b/README.md @@ -386,5 +386,150 @@ Remove-Item -Recurse *.pyc, __pycache__ -Force Remove-Item db.sqlite3-wal, db.sqlite3-shm -Force -ErrorAction SilentlyContinue ``` +--- + +## 10. 增量更新(部署到离线机) + +天璇支持**增量更新**,只需将变更代码打包成小 zip 包,复制到离线机双击 `update.bat` 即可完成更新,无需每次传输整个项目(1.5GB)。 + +### 10.1 首次部署 + +在离线机上第一次部署时,需要完成数据库迁移: + +```bat +:: 确保数据库在 %APPDATA%/TianXuan/ 下 +runtime\python\python.exe scripts\migrate_db_to_appdata.py + +:: 初始化数据库表 +runtime\python\python.exe manage.py migrate + +:: 导入 TLS 引用数据 +runtime\python\python.exe manage.py import_tlsdb +``` + +> 如果之前已在使用天璇,`migrate_db_to_appdata.py` 会自动将已有 `db.sqlite3` 复制到新位置。 + +### 10.2 开发机:构建更新包 + +在**开发机**上完成代码修改并提交后: + +```bash +# 确保工作树干净(无未提交变更) +git status + +# 构建增量更新包 +python scripts/build_update.py +``` + +脚本会自动: +1. 检测当前 git tag 与最近一次 tag 之间的文件变更 +2. 计算每个文件的 SHA256 校验和 +3. 打包为 `tianxuan_update_<旧版本>-<新版本>.zip` +4. 更新 `VERSION` 文件 +5. 如果检测到依赖变更,会输出提示信息 + +**输出示例**: +``` +================================================== + 天璇 增量更新包构建工具 +================================================== + +当前 VERSION: v1.0.0 +最新 git tag: v1.0 + +变更文件: 5 个修改/新增, 1 个删除 + +================================================== +✅ 增量更新包构建完成! + 包: tianxuan_update_v1.0-v1.1.zip + 大小: 0.05 MB + 从: v1.0 + 到: v1.1 + 文件数: 5 + 删除: 1 +================================================== +``` + +### 10.3 离线机:应用更新 + +在**离线机**上: + +1. 将 `tianxuan_update_*.zip` 复制到天璇项目目录(与 `run.bat` 同目录) +2. **双击 `update.bat`**(或拖拽 zip 到 `update.bat` 上) +3. 等待更新完成 +4. 关闭窗口,重新双击 `run.bat` 启动新版本 + +更新脚本会自动: +1. ✅ 验证更新包完整性(SHA256 校验) +2. ✅ 备份当前配置(`config/config.yaml`) +3. ✅ 删除废弃文件 +4. ✅ 覆盖更新文件 +5. ✅ 智能合并配置(保留你的设置,仅增加新配置项) +6. ✅ 清理 Python 缓存 +7. ✅ 运行数据库迁移 +8. ✅ 更新版本号 + +### 10.4 回滚 + +如果更新后出现问题: + +``` +双击 rollback.bat +→ 输入 y 确认回滚 +→ 等待恢复完成 +→ 重新双击 run.bat 启动旧版本 +``` + +回滚会从 `%APPDATA%/TianXuan/backups/` 恢复最近一次备份。 + +### 10.5 依赖变更时的特殊处理 + +当 `pyproject.toml` 或 `requirements.txt` 变更时(即添加/更新了 Python 依赖),构建脚本会输出警告: + +``` +⚠️ 依赖已变更!请手动将 runtime/ 目录同步到离线机 +``` + +此时需要额外步骤: + +1. 在开发机上将 `runtime/` 目录**完整压缩**(约 645MB) +2. 复制到离线机,覆盖 `runtime/` 目录 +3. 或者使用差异更新:仅覆盖变更的包目录(`runtime/python/Lib/site-packages/`) + +> 依赖变更是低频操作(通常只在添加新功能时需要),所以这不会影响日常的代码增量更新。 + +### 10.6 文件说明 + +| 文件 | 用途 | 需要 git 跟踪? | +|------|------|----------------| +| `VERSION` | 记录当前版本号 | ✅ 是 | +| `scripts/build_update.py` | 开发机:构建增量包 | ✅ 是 | +| `scripts/apply_update.py` | 离线机:应用更新 | ✅ 是 | +| `scripts/merge_config.py` | 合并用户配置与源码配置 | ✅ 是 | +| `scripts/migrate_db_to_appdata.py` | 首次迁移数据库到 APPDATA | ✅ 是 | +| `scripts/version_utils.py` | 版本工具函数 | ✅ 是 | +| `update.bat` | 离线机双击入口 | ✅ 是 | +| `rollback.bat` | 回滚入口 | ✅ 是 | +| `analysis/appdata.py` | APPDATA 路径共享模块 | ✅ 是 | +| `.update_cache/runtime_snapshot.json` | 运行时依赖快照(自动生成)| ❌ 否 | +| `RUNTIME_CHANGED.txt` | 依赖变更提示(自动生成)| ❌ 否 | + +### 10.7 目录结构变化 + +更新系统将用户数据与项目代码分离: + +``` +项目目录(可安全覆盖) 用户数据目录(永不覆盖) +天璇/ %APPDATA%/TianXuan/ +├── analysis/ ← 代码 ├── db.sqlite3 ← 数据库 +├── config/config.yaml ← 配置 ├── data/uploads/ ← 上传的 CSV +├── runtime/ ← 运行时 ├── .session_store.json +├── templates/ ← 模板 └── backups/ ← 更新备份 +├── static/ ← 静态资源 +├── scripts/ ← 工具脚本 +├── update.bat ← 更新入口 +└── VERSION ← 版本号 +``` + ``` diff --git a/rollback.bat b/rollback.bat new file mode 100644 index 0000000..5395b92 --- /dev/null +++ b/rollback.bat @@ -0,0 +1,45 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +echo ================================================ +echo 天璇 回滚工具 +echo ================================================ +echo. +echo 将从 %APPDATA%\TianXuan\backups\ 恢复最近一次备份 +echo. +echo ⚠️ 警告:回滚操作会覆盖当前的项目文件! +echo 这包括代码文件、配置等。 +echo. +set /p CONFIRM=确认回滚?(y/N): +if /i not "%CONFIRM%"=="y" ( + echo 已取消回滚 + pause + exit /b 0 +) + +:: Check portable Python exists +if not exist "runtime\python\python.exe" ( + echo [ERROR] 找不到便携 Python 运行时 + pause + exit /b 1 +) + +echo 正在回滚,请勿关闭此窗口... +echo. +runtime\python\python.exe scripts\apply_update.py --rollback +if %ERRORLEVEL% neq 0 ( + echo. + echo [ERROR] 回滚失败!请手动从备份目录恢复 + echo 备份位于: %APPDATA%\TianXuan\backups\ + pause + exit /b %ERRORLEVEL% +) + +echo. +echo ================================================ +echo ✅ 回滚完成! +echo ================================================ +echo. +echo 请关闭此窗口,然后重新双击 run.bat 启动回滚前的版本 +echo. +pause diff --git a/tests/test_update_integration.py b/tests/test_update_integration.py new file mode 100644 index 0000000..843cafd --- /dev/null +++ b/tests/test_update_integration.py @@ -0,0 +1,430 @@ +""" +Integration tests for the 天璇 (TianXuan) incremental update system. + +All tests run in isolated temporary directories and use a mocked APPDATA +environment variable, so they never touch the real project data. + +Tests verify the full update lifecycle: + 1. ZIP build structure (matching build_update.py output) + 2. File extraction and replacement (matching apply_update.py steps 5-6) + 3. Config merge with user-edit preservation (matching merge_config.py) + 4. Version skip / direct-jump updates + 5. Corrupted package rejection (CRC + SHA256 verification) + 6. Database migration from project dir to %APPDATA% +""" +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +import pytest + +# Real project root from the test file location +PROJECT_DIR = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = PROJECT_DIR / 'scripts' + + +# ═══════════════════════════════════════════════════════════════════════ +# Test helpers +# ═══════════════════════════════════════════════════════════════════════ + + +def _create_test_project(tmp_dir: Path) -> Path: + """Create a simulated TianXuan project structure within a temp dir.""" + proj = tmp_dir / 'tianxuan' + proj.mkdir() + + # Core modules + (proj / 'analysis').mkdir() + (proj / 'analysis' / '__init__.py').write_text('# analysis module\n') + (proj / 'tianxuan').mkdir() + (proj / 'tianxuan' / '__init__.py').write_text('# settings module\n') + + # Config + (proj / 'config').mkdir() + (proj / 'config' / 'config.yaml').write_text( + 'server:\n host: 127.0.0.1\n port: 8000\n' + 'llm:\n api_key: test-key-123\n' + ) + + # Templates + (proj / 'templates').mkdir() + (proj / 'templates' / 'base.html').write_text('Old Version\n') + + # Static assets + (proj / 'static').mkdir() + + # Scripts directory (placeholder — real scripts live outside temp dir) + (proj / 'scripts').mkdir(parents=True, exist_ok=True) + + # Version file + (proj / 'VERSION').write_text('v1.0.0\n') + + # A file that will be "deleted" in a test update + (proj / 'old_module.py').write_text('# This will be removed\n') + + return proj + + +def _create_update_zip(project_dir: Path, + from_ver: str = 'v1.0.0', + to_ver: str = 'v1.1.0', + add_files: dict | None = None, + delete_files: list | None = None, + run_migrate: bool = False) -> Path: + """Build a simulated update ZIP matching the build_update.py output format. + + Parameters + ---------- + project_dir : Path + Directory where the ZIP is written. + from_ver, to_ver : str + Version range this update covers. + add_files : dict[str, str] | None + Mapping of relative path → file content for changed/new files. + delete_files : list[str] | None + Relative paths of files to remove. + run_migrate : bool + Whether the update advertises a schema migration. + + Returns + ------- + Path + Full path to the created update ZIP. + """ + zip_path = project_dir / f'tianxuan_update_{from_ver}-{to_ver}.zip' + + files = add_files or {'templates/base.html': 'New Version\n'} + deleted = delete_files or [] + + # Build manifest (same schema as build_update.py produces) + files_manifest = {} + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + for rel_path, content in files.items(): + arcname = f'files/{rel_path}' + zf.writestr(arcname, content) + sha256 = hashlib.sha256(content.encode()).hexdigest() + files_manifest[rel_path] = { + 'sha256': sha256, + 'size': len(content), + } + + manifest = { + 'from_version': from_ver, + 'to_version': to_ver, + 'built_at': '2026-07-20T12:00:00Z', + 'files': files_manifest, + 'deleted': deleted, + 'run_migrate': run_migrate, + 'run_import_tlsdb': False, + 'runtime_changed': False, + } + zf.writestr('version.json', json.dumps(manifest, indent=2)) + + return zip_path + + +# ═══════════════════════════════════════════════════════════════════════ +# Test class +# ═══════════════════════════════════════════════════════════════════════ + + +class TestUpdateSystem: + """Integration tests for the TianXuan incremental update system. + + Every test creates its own isolated project tree + APPDATA directory + and cleans up on teardown. + """ + + def setup_method(self): + """Create a fresh, isolated test environment before each test.""" + self._tmp_base = Path(tempfile.mkdtemp(suffix='_tianxuan_test')) + self.project_dir = _create_test_project(self._tmp_base) + + # Override APPDATA so scripts write to the temp directory + self._orig_appdata = os.environ.get('APPDATA') + self._test_appdata = self._tmp_base / 'appdata' + self._test_appdata.mkdir() + os.environ['APPDATA'] = str(self._test_appdata) + + def teardown_method(self): + """Restore the environment and remove temp files.""" + if self._orig_appdata: + os.environ['APPDATA'] = self._orig_appdata + else: + os.environ.pop('APPDATA', None) + shutil.rmtree(str(self._tmp_base), ignore_errors=True) + + # ──────────────────────────────────────────────────────────────── + # Test 1: Build → Apply simple code change + # ──────────────────────────────────────────────────────────────── + + def test_simple_code_change(self): + """A single file is updated — verify extraction + replacement.""" + update_zip = _create_update_zip( + self.project_dir, + from_ver='v1.0.0', to_ver='v1.1.0', + add_files={'templates/base.html': 'New Version\n'}, + ) + + # 1) ZIP exists and is non-empty + assert update_zip.exists() + assert update_zip.stat().st_size > 0 + + # 2) Extract and apply (simulating apply_update.py steps 1, 6) + with zipfile.ZipFile(update_zip, 'r') as zf: + manifest = json.loads(zf.read('version.json')) + assert manifest['from_version'] == 'v1.0.0' + assert manifest['to_version'] == 'v1.1.0' + assert 'templates/base.html' in manifest['files'] + + for name in zf.namelist(): + if not name.startswith('files/'): + continue + rel_path = name[6:] # strip 'files/' prefix + if rel_path == 'config/config.yaml': + continue # config is handled by the merge step + zf.extract(name, str(self.project_dir)) + src = self.project_dir / name + dst = self.project_dir / rel_path + if src != dst: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dst)) + + # 3) The updated file has the new content + updated = self.project_dir / 'templates' / 'base.html' + assert updated.read_text() == 'New Version\n' + + # 4) Unchanged files are preserved + config = self.project_dir / 'config' / 'config.yaml' + assert 'test-key-123' in config.read_text() + + # 5) Clean up the temporary files/ directory + files_dir = self.project_dir / 'files' + if files_dir.exists(): + shutil.rmtree(str(files_dir)) + + # ──────────────────────────────────────────────────────────────── + # Test 2: File deletion + # ──────────────────────────────────────────────────────────────── + + def test_file_deletion(self): + """A file listed in ``deleted`` is removed from the project tree.""" + old_file = self.project_dir / 'old_module.py' + assert old_file.exists() + + update_zip = _create_update_zip( + self.project_dir, + from_ver='v1.0.0', to_ver='v1.1.0', + add_files={}, + delete_files=['old_module.py'], + ) + + # Apply deletion (simulating apply_update.py step 5) + with zipfile.ZipFile(update_zip, 'r') as zf: + manifest = json.loads(zf.read('version.json')) + for rel_path in manifest.get('deleted', []): + target = self.project_dir / rel_path + if target.exists(): + target.unlink() + + assert not old_file.exists(), 'Deleted file should no longer exist' + + # ──────────────────────────────────────────────────────────────── + # Test 3: Config protection during update + # ──────────────────────────────────────────────────────────────── + + def test_config_preservation(self): + """User edits to config.yaml survive an update; new keys are added. + + This exercises the real ``merge_config.py`` script via subprocess. + """ + # -- Simulate a user who has edited their config.yml ----------- + config_path = self.project_dir / 'config' / 'config.yaml' + user_edited = ( + 'server:\n' + ' host: 0.0.0.0\n' + ' port: 9000\n' + 'llm:\n' + ' api_key: my-custom-key\n' + 'clustering:\n' + ' algorithm: kmeans\n' + ) + config_path.write_text(user_edited) + + # -- Build an update that ships a *different* config ----------- + source_config = ( + 'server:\n' + ' host: 127.0.0.1\n' + ' port: 8000\n' + 'llm:\n' + ' api_key: default-key\n' + ' model: gpt-4\n' # NEW key — should appear after merge + 'clustering:\n' + ' algorithm: hdbscan\n' + ' min_cluster_size: 10\n' # NEW key — should appear after merge + ) + update_zip = _create_update_zip( + self.project_dir, + add_files={'config/config.yaml': source_config}, + ) + + # -- Back up user config (apply_update.py step 4) -------------- + backup_dir = self._test_appdata / 'TianXuan' / 'backups' + backup_dir.mkdir(parents=True, exist_ok=True) + backup_config = backup_dir / 'v1.1.0' / 'config.yaml' + backup_config.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(config_path), str(backup_config)) + + # -- Extract the new config to ``files/`` (step 6) ------------- + with zipfile.ZipFile(update_zip, 'r') as zf: + zf.extract('files/config/config.yaml', str(self.project_dir)) + extracted_config = self.project_dir / 'files' / 'config' / 'config.yaml' + assert extracted_config.exists() + + # -- Merge (step 7): old = user backup, new = extracted source -- + merge_script = SCRIPTS_DIR / 'merge_config.py' + result = subprocess.run( + [sys.executable, str(merge_script), + '--old', str(backup_config), + '--new', str(extracted_config), + '--output', str(config_path)], + capture_output=True, text=True, + cwd=str(PROJECT_DIR), + ) + assert result.returncode == 0, f'merge_config failed: {result.stderr}' + + merged = config_path.read_text() + + # User edits preserved + assert 'my-custom-key' in merged, 'User API key should survive' + assert '0.0.0.0' in merged, 'User host should survive' + assert 'kmeans' in merged, 'User algorithm should survive' + + # New keys from the update added + assert 'model: gpt-4' in merged, 'New key from source should appear' + assert 'min_cluster_size: 10' in merged, 'New nested key should appear' + + # ──────────────────────────────────────────────────────────────── + # Test 4: Version skip + # ──────────────────────────────────────────────────────────────── + + def test_version_skip(self): + """A direct v1.0→v1.2 bundle includes ALL intermediate changes. + + ``git diff v1.0..HEAD`` shows every change regardless of whether + intermediate tags exist, so the build script naturally produces + correct "skipped-version" packages. This test verifies the + manifest accurately describes the jump. + """ + update_zip = _create_update_zip( + self.project_dir, + from_ver='v1.0.0', to_ver='v1.2.0', + add_files={ + 'templates/base.html': 'v1.2\n', + 'analysis/new_feature.py': '# Feature added in v1.2\n', + }, + delete_files=['old_module.py'], + ) + + with zipfile.ZipFile(update_zip, 'r') as zf: + manifest = json.loads(zf.read('version.json')) + + assert manifest['from_version'] == 'v1.0.0' + assert manifest['to_version'] == 'v1.2.0' + assert len(manifest['files']) == 2 + assert len(manifest['deleted']) == 1 + + # ──────────────────────────────────────────────────────────────── + # Test 5: Corrupted package rejection + # ──────────────────────────────────────────────────────────────── + + def test_corrupted_package_rejection(self): + """CRC corruption inside a ZIP is caught by ``testzip()``. + + A file's stored content is modified in-place within the ZIP + binary, causing a CRC32 mismatch that ``zipfile.testzip()`` + reliably detects. The update should be rejected before any + files are extracted. + """ + # Use ZIP_STORED (no compression) so the content appears as + # plain text in the archive and can be surgically corrupted. + content = b'OK\n' + h = hashlib.sha256(content).hexdigest() + + manifest = { + 'from_version': 'v1.0.0', + 'to_version': 'v1.1.0', + 'built_at': '2026-07-20T12:00:00Z', + 'files': { + 'templates/base.html': { + 'sha256': h, + 'size': len(content), + }, + }, + 'deleted': [], + 'run_migrate': False, + 'run_import_tlsdb': False, + 'runtime_changed': False, + } + + zip_path = self.project_dir / 'corrupt_test.zip' + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_STORED) as zf: + zf.writestr('version.json', json.dumps(manifest, indent=2)) + zf.writestr('files/templates/base.html', content) + + # Corrupt: replace the stored content in-place + raw = bytearray(zip_path.read_bytes()) + idx = raw.find(b'OK') + assert idx != -1, 'Should find the target pattern in raw ZIP bytes' + raw[idx:idx + len(b'OK')] = b'XX' + zip_path.write_bytes(bytes(raw)) + + # The CRC check in apply_update.py step 1 must fail + with zipfile.ZipFile(zip_path, 'r') as zf: + bad_file = zf.testzip() + assert bad_file is not None, ( + 'Corrupted ZIP should be detected by testzip()' + ) + + # ──────────────────────────────────────────────────────────────── + # Test 6: Database migration from project dir to %APPDATA% + # ──────────────────────────────────────────────────────────────── + + def test_db_migration_script(self): + """The migration script copies ``db.sqlite3`` to ``%APPDATA%/TianXuan/``. + + Because ``migrate_db_to_appdata.py`` locates its project root + from ``__file__`` (the real TianXuan project where ``db.sqlite3`` + exists), running it with ``APPDATA`` pointing to the temp + directory copies the real database into the isolated test tree + without modifying any real project files. + """ + # The real project has a db.sqlite3 that the script will find. + migrate_script = SCRIPTS_DIR / 'migrate_db_to_appdata.py' + + # First run — should copy the database + result = subprocess.run( + [sys.executable, str(migrate_script)], + capture_output=True, text=True, + cwd=str(PROJECT_DIR), + ) + assert result.returncode == 0, f'Migration failed: {result.stderr}' + + expected_new = self._test_appdata / 'TianXuan' / 'db.sqlite3' + assert expected_new.exists(), 'DB should exist at APPDATA location' + assert expected_new.stat().st_size > 0, 'Copied DB should not be empty' + + # Second run — should SKIP (already present) + result2 = subprocess.run( + [sys.executable, str(migrate_script)], + capture_output=True, text=True, + cwd=str(PROJECT_DIR), + ) + assert result2.returncode == 0 + assert 'SKIP' in result2.stdout, 'Second run should print SKIP' diff --git a/update.bat b/update.bat new file mode 100644 index 0000000..ac0d576 --- /dev/null +++ b/update.bat @@ -0,0 +1,56 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +echo ================================================ +echo 天璇 增量更新工具 +echo ================================================ +echo. + +:: Auto-detect update package +set ZIP_FILE=%1 +if "%ZIP_FILE%"=="" ( + for %%f in (tianxuan_update_*.zip) do set ZIP_FILE=%%f +) +if "%ZIP_FILE%"=="" ( + echo [ERROR] 未找到任何 tianxuan_update_*.zip 文件 + echo 请将更新包(tianxuan_update_*.zip)放到本目录后重试 + pause + exit /b 1 +) +if not exist "%ZIP_FILE%" ( + echo [ERROR] 找不到更新包: %ZIP_FILE% + pause + exit /b 1 +) + +echo 更新包: %ZIP_FILE% +echo. + +:: Check portable Python exists +if not exist "runtime\python\python.exe" ( + echo [ERROR] 找不到便携 Python 运行时 + echo 请确保 runtime\python\python.exe 存在 + pause + exit /b 1 +) + +:: Run the update script +echo 正在应用更新,请勿关闭此窗口... +echo. +runtime\python\python.exe scripts\apply_update.py "%ZIP_FILE%" +if %ERRORLEVEL% neq 0 ( + echo. + echo [ERROR] 更新失败!错误代码: %ERRORLEVEL% + echo 请运行 rollback.bat 回滚到更新前的状态 + pause + exit /b %ERRORLEVEL% +) + +echo. +echo ================================================ +echo ✅ 更新完成! +echo ================================================ +echo. +echo 请关闭此窗口,然后重新双击 run.bat 启动新版本 +echo. +pause