From 5932b2c709bd5aebb20ae0b2304c2c5db28f5ed1 Mon Sep 17 00:00:00 2001 From: Trae User Date: Wed, 2 Sep 2026 18:19:03 +0800 Subject: [PATCH] feat: Add verify-package skill for local packaging verification Builds, stages, and locally verifies TUI/desktop packages with zero release side effects: reuses the CI cmake install components, hash checks bundled models.dev and seed resources, checks desktop bundle layout, and launches the staged binaries under an isolated profile. Mirrored to the .acecode/.claude/.codex/.agents skill directories and covered by a POSIX contract test registered under if(UNIX). Closes #34 --- .acecode/skills/verify-package/SKILL.md | 121 +++++ .../verify-package/scripts/verify_package.py | 472 ++++++++++++++++++ .agents/skills/verify-package/SKILL.md | 121 +++++ .../verify-package/scripts/verify_package.py | 472 ++++++++++++++++++ .claude/skills/verify-package/SKILL.md | 121 +++++ .../verify-package/scripts/verify_package.py | 472 ++++++++++++++++++ .codex/skills/verify-package/SKILL.md | 121 +++++ .../verify-package/scripts/verify_package.py | 472 ++++++++++++++++++ tests/CMakeLists.txt | 7 + tests/scripts/verify_package_test.sh | 242 +++++++++ 10 files changed, 2621 insertions(+) create mode 100644 .acecode/skills/verify-package/SKILL.md create mode 100644 .acecode/skills/verify-package/scripts/verify_package.py create mode 100644 .agents/skills/verify-package/SKILL.md create mode 100644 .agents/skills/verify-package/scripts/verify_package.py create mode 100644 .claude/skills/verify-package/SKILL.md create mode 100644 .claude/skills/verify-package/scripts/verify_package.py create mode 100644 .codex/skills/verify-package/SKILL.md create mode 100644 .codex/skills/verify-package/scripts/verify_package.py create mode 100644 tests/scripts/verify_package_test.sh diff --git a/.acecode/skills/verify-package/SKILL.md b/.acecode/skills/verify-package/SKILL.md new file mode 100644 index 00000000..86ece467 --- /dev/null +++ b/.acecode/skills/verify-package/SKILL.md @@ -0,0 +1,121 @@ +--- +name: verify-package +description: Build, stage, and locally verify ACECode packages with zero release side effects. Use when asked to verify packaging locally, do a pre-release dry run or local packaging check, stage a CI-equivalent package on macOS or Windows, confirm bundled models.dev/seed resources resolve, or smoke-test TUI and desktop builds. Not for publishing, tagging, npm, update servers, or installers. +platforms: [macos, windows] +compatibility: ACECode skill system +metadata: + tags: [packaging, verification, local] +--- + +# Verify Package (Local) + +## Purpose + +Prove locally that ACECode packaging is correct — file layout, bundled +resources, resource resolution, and runtime startup — without any release +side effect. One command reproduces the CI `package.yml` staging layout and +runs the same resource validations, then smoke-runs the staged binaries. + +This skill is the side-effect-free counterpart of `acecode-release`: +verify-package never commits, tags, pushes, publishes, signs, or touches an +update server. It writes only inside the build/staging directories and the +system temp dir. + +## Required Inputs + +None. The script detects the repo root from its own location, defaults the +build directory to `/build`, and configures it if missing (MinSizeRel, +`BUILD_TESTING=OFF`, `ACECODE_BUILD_DESKTOP=ON`, Ninja when available, vcpkg +toolchain when `VCPKG_ROOT` is set). + +Prerequisite: `web/dist` must exist. If it is missing the script fails with +the exact rebuild command (`cd web && pnpm install --frozen-lockfile && +pnpm build`) instead of silently verifying the embedded placeholder page. + +## Quick Start + +macOS (TUI + desktop): + +```bash +python3 .acecode/skills/verify-package/scripts/verify_package.py +``` + +Windows (TUI + desktop): + +```powershell +python .acecode\skills\verify-package\scripts\verify_package.py +``` + +Useful variants: + +```bash +# Re-verify an existing build without recompiling +python3 .../verify_package.py --skip-build + +# Only one artifact set +python3 .../verify_package.py --target tui +python3 .../verify_package.py --target desktop + +# Existing non-default build tree (e.g. build/windows-x64-release) +python3 .../verify_package.py --build-dir build/windows-x64-release + +# Where the staged package lands (default: /verify-package-staging) +python3 .../verify_package.py --staging-dir /tmp/ace-verify +``` + +## What The Script Does + +1. Preflight: `web/dist/index.html` exists; cmake is on PATH; the build dir + is configured when `--skip-build` is used. +2. Configure + incremental build of `acecode` (and `acecode-desktop` for the + desktop target). Skipped under `--skip-build`. +3. Staging, mirroring the CI Package step: binaries (or the macOS + `ACECode.app` bundle) + READMEs, then + `cmake --install --component models_dev_registry` and + `--component default_seed_bundle` into the staging prefix. The staging + directory is wiped and rebuilt on every run. +4. Structural checks: staged `share/acecode/models_dev` holds exactly + `api.json`, `MANIFEST.json`, `LICENSE`, hash-equal to `assets/models_dev`; + the seed bundle is verified by reusing `scripts/verify_seed_bundle.py`. + For the desktop target it additionally checks daemon adjacency on + Windows (`acecode.exe` beside `acecode-desktop.exe`) and the macOS app + bundle layout (`Contents/MacOS/ACECode`, `Contents/MacOS/acecode-daemon`, + `Contents/Resources/share/acecode/...`). +5. Runtime probes with an isolated user profile (temp `HOME`/`USERPROFILE`, + so your real `~/.acecode` is never touched): + - TUI: run the staged binary with `--version`, then + `--validate-models-registry`; the latter must report `registry OK` with + a source inside the staged `share/acecode/models_dev` tree, proving + resource resolution does not fall back. + - Desktop: launch the staged app, wait for it to stay alive, then + terminate it. This proves startup, not visual correctness — eyeball the + UI yourself if the change is UI-facing. +6. Report: one `[PASS]`/`[FAIL]` line per check, then + `verify-package: PASS` (exit 0) or `verify-package: FAIL` (exit 1). + +## Reading The Report + +- A failed `tui models registry resolution` with a non-`models_dev` source + means the packaged resource layout or the search-path logic broke — this + is the class of bug that a bare build-dir run can never catch. +- Known expected behavior (not a bug): running the bare `build/acecode` + binary without a staged `share/` tree logs a registry fallback warning. + The staged run in this skill exists precisely to avoid that confusion. +- `desktop launch` only proves the process starts and stays alive. Visual + and interaction verification stays manual. +- `desktop launch` reports `[SKIP]` when an ACECode desktop instance is + already running: the single-instance guard would make a second instance + exit immediately, so the probe would say nothing. Close ACECode and + re-run to exercise startup. + +## Guardrails + +- Never add publishing behavior here: no git operations, no npm, no + `aceupdate.json`, no update-server uploads, no signing or notarization. + Release work belongs to the `acecode-release` skill. +- Do not bypass the `web/dist` preflight — verifying the embedded fallback + page proves nothing about the web UI. +- Linux is out of scope; the script mechanically supports a flat Linux + layout for tests, but real Linux verification happens in CI. +- The desktop launch probe runs a real GUI process. Run it on a machine + with a desktop session; in headless environments use `--target tui`. diff --git a/.acecode/skills/verify-package/scripts/verify_package.py b/.acecode/skills/verify-package/scripts/verify_package.py new file mode 100644 index 00000000..0aefc17c --- /dev/null +++ b/.acecode/skills/verify-package/scripts/verify_package.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Build, stage, and locally verify ACECode packages with zero release side effects. + +One command reproduces the CI package layout (binaries + share/acecode resources), +checks bundled resources, and smoke-runs the staged TUI / desktop binaries. +Performs no git, publishing, signing, or network operations of its own, and +writes only inside the repo build/staging directories and the system temp dir. + +Exit codes: 0 every check passed; 1 at least one check failed or could not run; +2 command-line usage error. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") +README_FILES = ("README.md", "README_CN.md") +BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") +DEFAULT_LAUNCH_TIMEOUT = 10 +STAGING_DIRNAME = "verify-package-staging" + + +class Report: + def __init__(self) -> None: + self.items: list[tuple[str, str, str]] = [] + + def add(self, name: str, status: str, detail: str = "") -> None: + self.items.append((name, status, detail)) + mark = {"pass": "[PASS]", "fail": "[FAIL]", "skip": "[SKIP]"}[status] + line = f"{mark} {name}" + if detail: + line += f": {detail}" + print(line) + + @property + def failed(self) -> int: + return sum(1 for _, status, _ in self.items if status == "fail") + + +def find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / "CMakeLists.txt").is_file() and (candidate / "assets").is_dir(): + return candidate + raise SystemExit( + "verify_package: unable to locate the ACECode repo root " + "(a directory holding CMakeLists.txt and assets/) above " + f"{start}; pass --repo explicitly." + ) + + +def detect_platform(explicit: str) -> str: + if explicit != "auto": + return explicit + if sys.platform == "darwin": + return "darwin" + if sys.platform == "win32": + return "windows" + return "linux" + + +def exe_name(platform: str, kind: str) -> str: + base = {"tui": "acecode", "desktop": "acecode-desktop", "daemon": "acecode"}[kind] + return base + (".exe" if platform == "windows" else "") + + +def find_built(build_dir: Path, relative: str) -> Path | None: + candidates = [build_dir / relative] + candidates += [build_dir / config / relative for config in BUILD_CONFIGS] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def check_models_dev(report: Report, label: str, models_dir: Path, assets_dir: Path) -> None: + if not models_dir.is_dir(): + report.add(label, "fail", f"missing directory {models_dir}") + return + packaged = { + path.relative_to(models_dir).as_posix(): sha256_file(path) + for path in sorted(models_dir.rglob("*")) + if path.is_file() + } + if set(packaged) != set(MODELS_DEV_FILES): + report.add( + label, + "fail", + f"expected exactly {list(MODELS_DEV_FILES)}, found {sorted(packaged)}", + ) + return + mismatched = [ + name for name in MODELS_DEV_FILES + if packaged[name] != sha256_file(assets_dir / name) + ] + if mismatched: + report.add(label, "fail", f"hash mismatch vs source assets: {mismatched}") + return + report.add(label, "pass", f"{len(packaged)} files hash-matched") + + +def check_seed_bundle(report: Report, label: str, repo: Path, packaged: Path) -> None: + script = repo / "scripts" / "verify_seed_bundle.py" + if not script.is_file(): + report.add(label, "fail", f"missing {script}") + return + result = subprocess.run( + [sys.executable, str(script), "--source", str(repo / "assets" / "seed"), + "--packaged", str(packaged)], + capture_output=True, text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + report.add(label, "fail", detail[-1] if detail else f"exit {result.returncode}") + return + report.add(label, "pass") + + +def run_tool(report: Report, name: str, command: list[str], **kwargs) -> bool: + try: + result = subprocess.run(command, **kwargs) + except FileNotFoundError as error: + report.add(name, "fail", str(error)) + return False + if result.returncode != 0: + output = "" + if isinstance(result, subprocess.CompletedProcess): + text = (result.stderr or result.stdout or "") + if isinstance(text, bytes): + text = text.decode(errors="replace") + output = text.strip().splitlines()[-1] if text.strip() else "" + report.add(name, "fail", f"exit {result.returncode}" + (f": {output}" if output else "")) + return False + report.add(name, "pass") + return True + + +def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, + build_dir: Path) -> bool: + ok = True + web_dist = repo / "web" / "dist" / "index.html" + if web_dist.is_file(): + report.add("preflight web/dist", "pass") + else: + report.add( + "preflight web/dist", "fail", + "web/dist/index.html is missing; CMake would embed a placeholder page. " + "Run: cd web && pnpm install --frozen-lockfile && pnpm build", + ) + ok = False + if cmake is None: + report.add("preflight cmake", "fail", "cmake not found on PATH") + ok = False + else: + report.add("preflight cmake", "pass") + if skip_build and not (build_dir / "CMakeCache.txt").is_file(): + report.add( + "preflight build dir", "fail", + f"{build_dir} is not configured; run without --skip-build", + ) + ok = False + else: + report.add("preflight build dir", "pass") + return ok + + +def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, + targets: list[str]) -> bool: + if not (build_dir / "CMakeCache.txt").is_file(): + command = [cmake, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if shutil.which("ninja"): + command.insert(4, "-G") + command.insert(5, "Ninja") + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + command += ["-DCMAKE_TOOLCHAIN_FILE", + str(Path(vcpkg_root) / "scripts" / "buildsystems" / "vcpkg.cmake")] + if not run_tool(report, "cmake configure", command): + return False + else: + report.add("cmake configure", "skip", "build dir already configured") + for target in targets: + if not run_tool(report, f"cmake build {target}", + [cmake, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", target]): + return False + return True + + +def stage(report: Report, repo: Path, build_dir: Path, staging: Path, + platform: str, targets: list[str], cmake: str) -> bool: + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + for name in README_FILES: + if (repo / name).is_file(): + shutil.copy2(repo / name, staging / name) + + if "tui" in targets: + tui_src = find_built(build_dir, exe_name(platform, "tui")) + if tui_src is None: + report.add("stage tui binary", "fail", + f"no built {exe_name(platform, 'tui')} under {build_dir}") + return False + shutil.copy2(tui_src, staging / exe_name(platform, "tui")) + report.add("stage tui binary", "pass", str(tui_src)) + + if "desktop" in targets: + if platform == "darwin": + app_src = find_built(build_dir, "ACECode.app") + if app_src is None or not app_src.is_dir(): + report.add("stage desktop bundle", "fail", + f"no built ACECode.app under {build_dir}; " + "build the acecode-desktop target first") + return False + shutil.copytree(app_src, staging / "ACECode.app") + report.add("stage desktop bundle", "pass", str(app_src)) + else: + desktop_src = find_built(build_dir, exe_name(platform, "desktop")) + daemon_src = find_built(build_dir, exe_name(platform, "daemon")) + if desktop_src is None or daemon_src is None: + report.add("stage desktop binary", "fail", + f"missing built {exe_name(platform, 'desktop')} or " + f"{exe_name(platform, 'daemon')} under {build_dir}") + return False + shutil.copy2(desktop_src, staging / exe_name(platform, "desktop")) + shutil.copy2(daemon_src, staging / exe_name(platform, "daemon")) + report.add("stage desktop binary", "pass", str(desktop_src)) + + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + if not run_tool(report, f"cmake install {component}", + [cmake, "--install", str(build_dir), + "--config", "MinSizeRel", "--prefix", str(staging), + "--component", component]): + return False + return True + + +def structural_checks(report: Report, repo: Path, staging: Path, + platform: str, targets: list[str]) -> None: + if "tui" in targets or platform != "darwin": + check_models_dev(report, "models_dev registry (staged share/)", + staging / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (staged share/)", repo, + staging / "share" / "acecode" / "seed") + if "desktop" not in targets: + return + if platform == "darwin": + bundle = staging / "ACECode.app" + contents = bundle / "Contents" + for required in (contents / "MacOS" / "ACECode", + contents / "MacOS" / "acecode-daemon"): + if required.is_file(): + report.add(f"app bundle {required.name}", "pass") + else: + report.add(f"app bundle {required.name}", "fail", + f"missing {required}") + check_models_dev(report, "models_dev registry (app bundle)", + contents / "Resources" / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (app bundle)", repo, + contents / "Resources" / "share" / "acecode" / "seed") + else: + daemon = staging / exe_name(platform, "daemon") + if daemon.is_file(): + report.add("desktop daemon adjacency", "pass") + else: + report.add("desktop daemon adjacency", "fail", + f"desktop needs {daemon} beside it") + + +def isolated_profile_env() -> tuple[dict[str, str], Path]: + home = Path(tempfile.mkdtemp(prefix="acecode-verify-home-")) + env = os.environ.copy() + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + if sys.platform == "win32": + roaming = home / "AppData" / "Roaming" + local = home / "AppData" / "Local" + roaming.mkdir(parents=True, exist_ok=True) + local.mkdir(parents=True, exist_ok=True) + env["APPDATA"] = str(roaming) + env["LOCALAPPDATA"] = str(local) + return env, home + + +def probe_tui(report: Report, staged_exe: Path, platform: str) -> None: + env, home = isolated_profile_env() + try: + version = subprocess.run([str(staged_exe), "--version"], capture_output=True, + text=True, env=env, timeout=120) + if version.returncode != 0 or "acecode v" not in version.stdout: + detail = (version.stderr or version.stdout).strip().splitlines() + report.add("tui --version", "fail", + detail[-1] if detail else f"exit {version.returncode}") + else: + report.add("tui --version", "pass", version.stdout.strip().splitlines()[0]) + + registry = subprocess.run([str(staged_exe), "--validate-models-registry"], + capture_output=True, text=True, env=env, timeout=300) + output = registry.stdout + registry.stderr + if registry.returncode != 0 or "registry OK" not in output: + detail = output.strip().splitlines() + report.add("tui models registry resolution", "fail", + detail[-1] if detail else f"exit {registry.returncode}") + elif "models_dev" not in output.replace("\\", "/"): + report.add("tui models registry resolution", "fail", + f"registry resolved away from the staged share/ tree: " + f"{output.strip().splitlines()[0]}") + else: + report.add("tui models registry resolution", "pass", + output.strip().splitlines()[0]) + except (subprocess.TimeoutExpired, OSError) as error: + report.add("tui runtime probe", "fail", str(error)) + finally: + shutil.rmtree(home, ignore_errors=True) + + +def existing_instance_running(platform: str) -> bool | None: + try: + if platform == "windows": + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq acecode-desktop.exe"], + capture_output=True, text=True, timeout=30) + return "acecode-desktop.exe" in (result.stdout or "") + if platform == "darwin": + result = subprocess.run(["pgrep", "-x", "ACECode"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + result = subprocess.run(["pgrep", "-x", "acecode-desktop"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + except (OSError, subprocess.SubprocessError): + return None + + +def probe_desktop(report: Report, staged: Path, platform: str, + launch_timeout: int) -> None: + running = existing_instance_running(platform) + if running: + report.add("desktop launch", "skip", + "an ACECode desktop instance is already running; the " + "single-instance guard would make this probe exit " + "immediately. Close ACECode and re-run to exercise startup.") + return + if platform == "darwin": + command = staged / "ACECode.app" / "Contents" / "MacOS" / "ACECode" + else: + command = staged / exe_name(platform, "desktop") + env, home = isolated_profile_env() + try: + process = subprocess.Popen([str(command)], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as error: + report.add("desktop launch", "fail", str(error)) + shutil.rmtree(home, ignore_errors=True) + return + deadline = time.monotonic() + launch_timeout + while time.monotonic() < deadline: + if process.poll() is None: + report.add("desktop launch", "pass", + f"alive after launch (pid {process.pid}); terminating") + process.terminate() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + shutil.rmtree(home, ignore_errors=True) + return + time.sleep(0.25) + if process.poll() == 0: + report.add("desktop launch", "fail", "exited immediately with code 0") + else: + report.add("desktop launch", "fail", + f"exited with code {process.returncode} during startup") + shutil.rmtree(home, ignore_errors=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build, stage, and locally verify ACECode packages " + "(no release side effects).") + parser.add_argument("--target", choices=("tui", "desktop", "all"), default="all", + help="which artifact set to verify (default: all)") + parser.add_argument("--skip-build", action="store_true", + help="reuse the existing build tree; stage and verify only") + parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), + default="auto", + help="override platform detection (mainly for tests)") + parser.add_argument("--repo", type=Path, default=None, + help="ACECode repo root (default: detected from this script)") + parser.add_argument("--build-dir", type=Path, default=None, + help="CMake build directory (default: /build)") + parser.add_argument("--staging-dir", type=Path, default=None, + help="staging output directory " + "(default: /verify-package-staging)") + parser.add_argument("--launch-timeout", type=int, default=DEFAULT_LAUNCH_TIMEOUT, + help="seconds to wait for the desktop app to stay alive " + "(default: 10)") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + report = Report() + repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() + build_dir = (args.build_dir or repo / "build").resolve() + staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() + platform = detect_platform(args.platform) + cmake = shutil.which("cmake") + targets = ["tui", "desktop"] if args.target == "all" else [args.target] + print(f"verify-package: repo={repo} build={build_dir} platform={platform} " + f"target={args.target} skip-build={args.skip_build}") + + if not preflight(report, repo, cmake, args.skip_build, build_dir): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not args.skip_build: + assert cmake is not None + if not configure_and_build(report, repo, build_dir, cmake, targets): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + structural_checks(report, repo, staging, platform, targets) + + staged_tui = staging / exe_name(platform, "tui") + if "tui" in targets and staged_tui.is_file(): + probe_tui(report, staged_tui, platform) + elif "tui" in targets: + report.add("tui runtime probe", "fail", f"missing staged {staged_tui}") + + if "desktop" in targets: + probe_desktop(report, staging, platform, args.launch_timeout) + + failed = report.failed + if failed == 0: + print(f"verify-package: PASS ({len(report.items)} checks) - staging at {staging}") + return 0 + print(f"verify-package: FAIL ({failed} of {len(report.items)} checks failed)") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except KeyboardInterrupt: + sys.exit(130) diff --git a/.agents/skills/verify-package/SKILL.md b/.agents/skills/verify-package/SKILL.md new file mode 100644 index 00000000..86ece467 --- /dev/null +++ b/.agents/skills/verify-package/SKILL.md @@ -0,0 +1,121 @@ +--- +name: verify-package +description: Build, stage, and locally verify ACECode packages with zero release side effects. Use when asked to verify packaging locally, do a pre-release dry run or local packaging check, stage a CI-equivalent package on macOS or Windows, confirm bundled models.dev/seed resources resolve, or smoke-test TUI and desktop builds. Not for publishing, tagging, npm, update servers, or installers. +platforms: [macos, windows] +compatibility: ACECode skill system +metadata: + tags: [packaging, verification, local] +--- + +# Verify Package (Local) + +## Purpose + +Prove locally that ACECode packaging is correct — file layout, bundled +resources, resource resolution, and runtime startup — without any release +side effect. One command reproduces the CI `package.yml` staging layout and +runs the same resource validations, then smoke-runs the staged binaries. + +This skill is the side-effect-free counterpart of `acecode-release`: +verify-package never commits, tags, pushes, publishes, signs, or touches an +update server. It writes only inside the build/staging directories and the +system temp dir. + +## Required Inputs + +None. The script detects the repo root from its own location, defaults the +build directory to `/build`, and configures it if missing (MinSizeRel, +`BUILD_TESTING=OFF`, `ACECODE_BUILD_DESKTOP=ON`, Ninja when available, vcpkg +toolchain when `VCPKG_ROOT` is set). + +Prerequisite: `web/dist` must exist. If it is missing the script fails with +the exact rebuild command (`cd web && pnpm install --frozen-lockfile && +pnpm build`) instead of silently verifying the embedded placeholder page. + +## Quick Start + +macOS (TUI + desktop): + +```bash +python3 .acecode/skills/verify-package/scripts/verify_package.py +``` + +Windows (TUI + desktop): + +```powershell +python .acecode\skills\verify-package\scripts\verify_package.py +``` + +Useful variants: + +```bash +# Re-verify an existing build without recompiling +python3 .../verify_package.py --skip-build + +# Only one artifact set +python3 .../verify_package.py --target tui +python3 .../verify_package.py --target desktop + +# Existing non-default build tree (e.g. build/windows-x64-release) +python3 .../verify_package.py --build-dir build/windows-x64-release + +# Where the staged package lands (default: /verify-package-staging) +python3 .../verify_package.py --staging-dir /tmp/ace-verify +``` + +## What The Script Does + +1. Preflight: `web/dist/index.html` exists; cmake is on PATH; the build dir + is configured when `--skip-build` is used. +2. Configure + incremental build of `acecode` (and `acecode-desktop` for the + desktop target). Skipped under `--skip-build`. +3. Staging, mirroring the CI Package step: binaries (or the macOS + `ACECode.app` bundle) + READMEs, then + `cmake --install --component models_dev_registry` and + `--component default_seed_bundle` into the staging prefix. The staging + directory is wiped and rebuilt on every run. +4. Structural checks: staged `share/acecode/models_dev` holds exactly + `api.json`, `MANIFEST.json`, `LICENSE`, hash-equal to `assets/models_dev`; + the seed bundle is verified by reusing `scripts/verify_seed_bundle.py`. + For the desktop target it additionally checks daemon adjacency on + Windows (`acecode.exe` beside `acecode-desktop.exe`) and the macOS app + bundle layout (`Contents/MacOS/ACECode`, `Contents/MacOS/acecode-daemon`, + `Contents/Resources/share/acecode/...`). +5. Runtime probes with an isolated user profile (temp `HOME`/`USERPROFILE`, + so your real `~/.acecode` is never touched): + - TUI: run the staged binary with `--version`, then + `--validate-models-registry`; the latter must report `registry OK` with + a source inside the staged `share/acecode/models_dev` tree, proving + resource resolution does not fall back. + - Desktop: launch the staged app, wait for it to stay alive, then + terminate it. This proves startup, not visual correctness — eyeball the + UI yourself if the change is UI-facing. +6. Report: one `[PASS]`/`[FAIL]` line per check, then + `verify-package: PASS` (exit 0) or `verify-package: FAIL` (exit 1). + +## Reading The Report + +- A failed `tui models registry resolution` with a non-`models_dev` source + means the packaged resource layout or the search-path logic broke — this + is the class of bug that a bare build-dir run can never catch. +- Known expected behavior (not a bug): running the bare `build/acecode` + binary without a staged `share/` tree logs a registry fallback warning. + The staged run in this skill exists precisely to avoid that confusion. +- `desktop launch` only proves the process starts and stays alive. Visual + and interaction verification stays manual. +- `desktop launch` reports `[SKIP]` when an ACECode desktop instance is + already running: the single-instance guard would make a second instance + exit immediately, so the probe would say nothing. Close ACECode and + re-run to exercise startup. + +## Guardrails + +- Never add publishing behavior here: no git operations, no npm, no + `aceupdate.json`, no update-server uploads, no signing or notarization. + Release work belongs to the `acecode-release` skill. +- Do not bypass the `web/dist` preflight — verifying the embedded fallback + page proves nothing about the web UI. +- Linux is out of scope; the script mechanically supports a flat Linux + layout for tests, but real Linux verification happens in CI. +- The desktop launch probe runs a real GUI process. Run it on a machine + with a desktop session; in headless environments use `--target tui`. diff --git a/.agents/skills/verify-package/scripts/verify_package.py b/.agents/skills/verify-package/scripts/verify_package.py new file mode 100644 index 00000000..0aefc17c --- /dev/null +++ b/.agents/skills/verify-package/scripts/verify_package.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Build, stage, and locally verify ACECode packages with zero release side effects. + +One command reproduces the CI package layout (binaries + share/acecode resources), +checks bundled resources, and smoke-runs the staged TUI / desktop binaries. +Performs no git, publishing, signing, or network operations of its own, and +writes only inside the repo build/staging directories and the system temp dir. + +Exit codes: 0 every check passed; 1 at least one check failed or could not run; +2 command-line usage error. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") +README_FILES = ("README.md", "README_CN.md") +BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") +DEFAULT_LAUNCH_TIMEOUT = 10 +STAGING_DIRNAME = "verify-package-staging" + + +class Report: + def __init__(self) -> None: + self.items: list[tuple[str, str, str]] = [] + + def add(self, name: str, status: str, detail: str = "") -> None: + self.items.append((name, status, detail)) + mark = {"pass": "[PASS]", "fail": "[FAIL]", "skip": "[SKIP]"}[status] + line = f"{mark} {name}" + if detail: + line += f": {detail}" + print(line) + + @property + def failed(self) -> int: + return sum(1 for _, status, _ in self.items if status == "fail") + + +def find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / "CMakeLists.txt").is_file() and (candidate / "assets").is_dir(): + return candidate + raise SystemExit( + "verify_package: unable to locate the ACECode repo root " + "(a directory holding CMakeLists.txt and assets/) above " + f"{start}; pass --repo explicitly." + ) + + +def detect_platform(explicit: str) -> str: + if explicit != "auto": + return explicit + if sys.platform == "darwin": + return "darwin" + if sys.platform == "win32": + return "windows" + return "linux" + + +def exe_name(platform: str, kind: str) -> str: + base = {"tui": "acecode", "desktop": "acecode-desktop", "daemon": "acecode"}[kind] + return base + (".exe" if platform == "windows" else "") + + +def find_built(build_dir: Path, relative: str) -> Path | None: + candidates = [build_dir / relative] + candidates += [build_dir / config / relative for config in BUILD_CONFIGS] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def check_models_dev(report: Report, label: str, models_dir: Path, assets_dir: Path) -> None: + if not models_dir.is_dir(): + report.add(label, "fail", f"missing directory {models_dir}") + return + packaged = { + path.relative_to(models_dir).as_posix(): sha256_file(path) + for path in sorted(models_dir.rglob("*")) + if path.is_file() + } + if set(packaged) != set(MODELS_DEV_FILES): + report.add( + label, + "fail", + f"expected exactly {list(MODELS_DEV_FILES)}, found {sorted(packaged)}", + ) + return + mismatched = [ + name for name in MODELS_DEV_FILES + if packaged[name] != sha256_file(assets_dir / name) + ] + if mismatched: + report.add(label, "fail", f"hash mismatch vs source assets: {mismatched}") + return + report.add(label, "pass", f"{len(packaged)} files hash-matched") + + +def check_seed_bundle(report: Report, label: str, repo: Path, packaged: Path) -> None: + script = repo / "scripts" / "verify_seed_bundle.py" + if not script.is_file(): + report.add(label, "fail", f"missing {script}") + return + result = subprocess.run( + [sys.executable, str(script), "--source", str(repo / "assets" / "seed"), + "--packaged", str(packaged)], + capture_output=True, text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + report.add(label, "fail", detail[-1] if detail else f"exit {result.returncode}") + return + report.add(label, "pass") + + +def run_tool(report: Report, name: str, command: list[str], **kwargs) -> bool: + try: + result = subprocess.run(command, **kwargs) + except FileNotFoundError as error: + report.add(name, "fail", str(error)) + return False + if result.returncode != 0: + output = "" + if isinstance(result, subprocess.CompletedProcess): + text = (result.stderr or result.stdout or "") + if isinstance(text, bytes): + text = text.decode(errors="replace") + output = text.strip().splitlines()[-1] if text.strip() else "" + report.add(name, "fail", f"exit {result.returncode}" + (f": {output}" if output else "")) + return False + report.add(name, "pass") + return True + + +def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, + build_dir: Path) -> bool: + ok = True + web_dist = repo / "web" / "dist" / "index.html" + if web_dist.is_file(): + report.add("preflight web/dist", "pass") + else: + report.add( + "preflight web/dist", "fail", + "web/dist/index.html is missing; CMake would embed a placeholder page. " + "Run: cd web && pnpm install --frozen-lockfile && pnpm build", + ) + ok = False + if cmake is None: + report.add("preflight cmake", "fail", "cmake not found on PATH") + ok = False + else: + report.add("preflight cmake", "pass") + if skip_build and not (build_dir / "CMakeCache.txt").is_file(): + report.add( + "preflight build dir", "fail", + f"{build_dir} is not configured; run without --skip-build", + ) + ok = False + else: + report.add("preflight build dir", "pass") + return ok + + +def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, + targets: list[str]) -> bool: + if not (build_dir / "CMakeCache.txt").is_file(): + command = [cmake, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if shutil.which("ninja"): + command.insert(4, "-G") + command.insert(5, "Ninja") + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + command += ["-DCMAKE_TOOLCHAIN_FILE", + str(Path(vcpkg_root) / "scripts" / "buildsystems" / "vcpkg.cmake")] + if not run_tool(report, "cmake configure", command): + return False + else: + report.add("cmake configure", "skip", "build dir already configured") + for target in targets: + if not run_tool(report, f"cmake build {target}", + [cmake, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", target]): + return False + return True + + +def stage(report: Report, repo: Path, build_dir: Path, staging: Path, + platform: str, targets: list[str], cmake: str) -> bool: + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + for name in README_FILES: + if (repo / name).is_file(): + shutil.copy2(repo / name, staging / name) + + if "tui" in targets: + tui_src = find_built(build_dir, exe_name(platform, "tui")) + if tui_src is None: + report.add("stage tui binary", "fail", + f"no built {exe_name(platform, 'tui')} under {build_dir}") + return False + shutil.copy2(tui_src, staging / exe_name(platform, "tui")) + report.add("stage tui binary", "pass", str(tui_src)) + + if "desktop" in targets: + if platform == "darwin": + app_src = find_built(build_dir, "ACECode.app") + if app_src is None or not app_src.is_dir(): + report.add("stage desktop bundle", "fail", + f"no built ACECode.app under {build_dir}; " + "build the acecode-desktop target first") + return False + shutil.copytree(app_src, staging / "ACECode.app") + report.add("stage desktop bundle", "pass", str(app_src)) + else: + desktop_src = find_built(build_dir, exe_name(platform, "desktop")) + daemon_src = find_built(build_dir, exe_name(platform, "daemon")) + if desktop_src is None or daemon_src is None: + report.add("stage desktop binary", "fail", + f"missing built {exe_name(platform, 'desktop')} or " + f"{exe_name(platform, 'daemon')} under {build_dir}") + return False + shutil.copy2(desktop_src, staging / exe_name(platform, "desktop")) + shutil.copy2(daemon_src, staging / exe_name(platform, "daemon")) + report.add("stage desktop binary", "pass", str(desktop_src)) + + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + if not run_tool(report, f"cmake install {component}", + [cmake, "--install", str(build_dir), + "--config", "MinSizeRel", "--prefix", str(staging), + "--component", component]): + return False + return True + + +def structural_checks(report: Report, repo: Path, staging: Path, + platform: str, targets: list[str]) -> None: + if "tui" in targets or platform != "darwin": + check_models_dev(report, "models_dev registry (staged share/)", + staging / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (staged share/)", repo, + staging / "share" / "acecode" / "seed") + if "desktop" not in targets: + return + if platform == "darwin": + bundle = staging / "ACECode.app" + contents = bundle / "Contents" + for required in (contents / "MacOS" / "ACECode", + contents / "MacOS" / "acecode-daemon"): + if required.is_file(): + report.add(f"app bundle {required.name}", "pass") + else: + report.add(f"app bundle {required.name}", "fail", + f"missing {required}") + check_models_dev(report, "models_dev registry (app bundle)", + contents / "Resources" / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (app bundle)", repo, + contents / "Resources" / "share" / "acecode" / "seed") + else: + daemon = staging / exe_name(platform, "daemon") + if daemon.is_file(): + report.add("desktop daemon adjacency", "pass") + else: + report.add("desktop daemon adjacency", "fail", + f"desktop needs {daemon} beside it") + + +def isolated_profile_env() -> tuple[dict[str, str], Path]: + home = Path(tempfile.mkdtemp(prefix="acecode-verify-home-")) + env = os.environ.copy() + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + if sys.platform == "win32": + roaming = home / "AppData" / "Roaming" + local = home / "AppData" / "Local" + roaming.mkdir(parents=True, exist_ok=True) + local.mkdir(parents=True, exist_ok=True) + env["APPDATA"] = str(roaming) + env["LOCALAPPDATA"] = str(local) + return env, home + + +def probe_tui(report: Report, staged_exe: Path, platform: str) -> None: + env, home = isolated_profile_env() + try: + version = subprocess.run([str(staged_exe), "--version"], capture_output=True, + text=True, env=env, timeout=120) + if version.returncode != 0 or "acecode v" not in version.stdout: + detail = (version.stderr or version.stdout).strip().splitlines() + report.add("tui --version", "fail", + detail[-1] if detail else f"exit {version.returncode}") + else: + report.add("tui --version", "pass", version.stdout.strip().splitlines()[0]) + + registry = subprocess.run([str(staged_exe), "--validate-models-registry"], + capture_output=True, text=True, env=env, timeout=300) + output = registry.stdout + registry.stderr + if registry.returncode != 0 or "registry OK" not in output: + detail = output.strip().splitlines() + report.add("tui models registry resolution", "fail", + detail[-1] if detail else f"exit {registry.returncode}") + elif "models_dev" not in output.replace("\\", "/"): + report.add("tui models registry resolution", "fail", + f"registry resolved away from the staged share/ tree: " + f"{output.strip().splitlines()[0]}") + else: + report.add("tui models registry resolution", "pass", + output.strip().splitlines()[0]) + except (subprocess.TimeoutExpired, OSError) as error: + report.add("tui runtime probe", "fail", str(error)) + finally: + shutil.rmtree(home, ignore_errors=True) + + +def existing_instance_running(platform: str) -> bool | None: + try: + if platform == "windows": + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq acecode-desktop.exe"], + capture_output=True, text=True, timeout=30) + return "acecode-desktop.exe" in (result.stdout or "") + if platform == "darwin": + result = subprocess.run(["pgrep", "-x", "ACECode"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + result = subprocess.run(["pgrep", "-x", "acecode-desktop"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + except (OSError, subprocess.SubprocessError): + return None + + +def probe_desktop(report: Report, staged: Path, platform: str, + launch_timeout: int) -> None: + running = existing_instance_running(platform) + if running: + report.add("desktop launch", "skip", + "an ACECode desktop instance is already running; the " + "single-instance guard would make this probe exit " + "immediately. Close ACECode and re-run to exercise startup.") + return + if platform == "darwin": + command = staged / "ACECode.app" / "Contents" / "MacOS" / "ACECode" + else: + command = staged / exe_name(platform, "desktop") + env, home = isolated_profile_env() + try: + process = subprocess.Popen([str(command)], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as error: + report.add("desktop launch", "fail", str(error)) + shutil.rmtree(home, ignore_errors=True) + return + deadline = time.monotonic() + launch_timeout + while time.monotonic() < deadline: + if process.poll() is None: + report.add("desktop launch", "pass", + f"alive after launch (pid {process.pid}); terminating") + process.terminate() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + shutil.rmtree(home, ignore_errors=True) + return + time.sleep(0.25) + if process.poll() == 0: + report.add("desktop launch", "fail", "exited immediately with code 0") + else: + report.add("desktop launch", "fail", + f"exited with code {process.returncode} during startup") + shutil.rmtree(home, ignore_errors=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build, stage, and locally verify ACECode packages " + "(no release side effects).") + parser.add_argument("--target", choices=("tui", "desktop", "all"), default="all", + help="which artifact set to verify (default: all)") + parser.add_argument("--skip-build", action="store_true", + help="reuse the existing build tree; stage and verify only") + parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), + default="auto", + help="override platform detection (mainly for tests)") + parser.add_argument("--repo", type=Path, default=None, + help="ACECode repo root (default: detected from this script)") + parser.add_argument("--build-dir", type=Path, default=None, + help="CMake build directory (default: /build)") + parser.add_argument("--staging-dir", type=Path, default=None, + help="staging output directory " + "(default: /verify-package-staging)") + parser.add_argument("--launch-timeout", type=int, default=DEFAULT_LAUNCH_TIMEOUT, + help="seconds to wait for the desktop app to stay alive " + "(default: 10)") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + report = Report() + repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() + build_dir = (args.build_dir or repo / "build").resolve() + staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() + platform = detect_platform(args.platform) + cmake = shutil.which("cmake") + targets = ["tui", "desktop"] if args.target == "all" else [args.target] + print(f"verify-package: repo={repo} build={build_dir} platform={platform} " + f"target={args.target} skip-build={args.skip_build}") + + if not preflight(report, repo, cmake, args.skip_build, build_dir): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not args.skip_build: + assert cmake is not None + if not configure_and_build(report, repo, build_dir, cmake, targets): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + structural_checks(report, repo, staging, platform, targets) + + staged_tui = staging / exe_name(platform, "tui") + if "tui" in targets and staged_tui.is_file(): + probe_tui(report, staged_tui, platform) + elif "tui" in targets: + report.add("tui runtime probe", "fail", f"missing staged {staged_tui}") + + if "desktop" in targets: + probe_desktop(report, staging, platform, args.launch_timeout) + + failed = report.failed + if failed == 0: + print(f"verify-package: PASS ({len(report.items)} checks) - staging at {staging}") + return 0 + print(f"verify-package: FAIL ({failed} of {len(report.items)} checks failed)") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except KeyboardInterrupt: + sys.exit(130) diff --git a/.claude/skills/verify-package/SKILL.md b/.claude/skills/verify-package/SKILL.md new file mode 100644 index 00000000..86ece467 --- /dev/null +++ b/.claude/skills/verify-package/SKILL.md @@ -0,0 +1,121 @@ +--- +name: verify-package +description: Build, stage, and locally verify ACECode packages with zero release side effects. Use when asked to verify packaging locally, do a pre-release dry run or local packaging check, stage a CI-equivalent package on macOS or Windows, confirm bundled models.dev/seed resources resolve, or smoke-test TUI and desktop builds. Not for publishing, tagging, npm, update servers, or installers. +platforms: [macos, windows] +compatibility: ACECode skill system +metadata: + tags: [packaging, verification, local] +--- + +# Verify Package (Local) + +## Purpose + +Prove locally that ACECode packaging is correct — file layout, bundled +resources, resource resolution, and runtime startup — without any release +side effect. One command reproduces the CI `package.yml` staging layout and +runs the same resource validations, then smoke-runs the staged binaries. + +This skill is the side-effect-free counterpart of `acecode-release`: +verify-package never commits, tags, pushes, publishes, signs, or touches an +update server. It writes only inside the build/staging directories and the +system temp dir. + +## Required Inputs + +None. The script detects the repo root from its own location, defaults the +build directory to `/build`, and configures it if missing (MinSizeRel, +`BUILD_TESTING=OFF`, `ACECODE_BUILD_DESKTOP=ON`, Ninja when available, vcpkg +toolchain when `VCPKG_ROOT` is set). + +Prerequisite: `web/dist` must exist. If it is missing the script fails with +the exact rebuild command (`cd web && pnpm install --frozen-lockfile && +pnpm build`) instead of silently verifying the embedded placeholder page. + +## Quick Start + +macOS (TUI + desktop): + +```bash +python3 .acecode/skills/verify-package/scripts/verify_package.py +``` + +Windows (TUI + desktop): + +```powershell +python .acecode\skills\verify-package\scripts\verify_package.py +``` + +Useful variants: + +```bash +# Re-verify an existing build without recompiling +python3 .../verify_package.py --skip-build + +# Only one artifact set +python3 .../verify_package.py --target tui +python3 .../verify_package.py --target desktop + +# Existing non-default build tree (e.g. build/windows-x64-release) +python3 .../verify_package.py --build-dir build/windows-x64-release + +# Where the staged package lands (default: /verify-package-staging) +python3 .../verify_package.py --staging-dir /tmp/ace-verify +``` + +## What The Script Does + +1. Preflight: `web/dist/index.html` exists; cmake is on PATH; the build dir + is configured when `--skip-build` is used. +2. Configure + incremental build of `acecode` (and `acecode-desktop` for the + desktop target). Skipped under `--skip-build`. +3. Staging, mirroring the CI Package step: binaries (or the macOS + `ACECode.app` bundle) + READMEs, then + `cmake --install --component models_dev_registry` and + `--component default_seed_bundle` into the staging prefix. The staging + directory is wiped and rebuilt on every run. +4. Structural checks: staged `share/acecode/models_dev` holds exactly + `api.json`, `MANIFEST.json`, `LICENSE`, hash-equal to `assets/models_dev`; + the seed bundle is verified by reusing `scripts/verify_seed_bundle.py`. + For the desktop target it additionally checks daemon adjacency on + Windows (`acecode.exe` beside `acecode-desktop.exe`) and the macOS app + bundle layout (`Contents/MacOS/ACECode`, `Contents/MacOS/acecode-daemon`, + `Contents/Resources/share/acecode/...`). +5. Runtime probes with an isolated user profile (temp `HOME`/`USERPROFILE`, + so your real `~/.acecode` is never touched): + - TUI: run the staged binary with `--version`, then + `--validate-models-registry`; the latter must report `registry OK` with + a source inside the staged `share/acecode/models_dev` tree, proving + resource resolution does not fall back. + - Desktop: launch the staged app, wait for it to stay alive, then + terminate it. This proves startup, not visual correctness — eyeball the + UI yourself if the change is UI-facing. +6. Report: one `[PASS]`/`[FAIL]` line per check, then + `verify-package: PASS` (exit 0) or `verify-package: FAIL` (exit 1). + +## Reading The Report + +- A failed `tui models registry resolution` with a non-`models_dev` source + means the packaged resource layout or the search-path logic broke — this + is the class of bug that a bare build-dir run can never catch. +- Known expected behavior (not a bug): running the bare `build/acecode` + binary without a staged `share/` tree logs a registry fallback warning. + The staged run in this skill exists precisely to avoid that confusion. +- `desktop launch` only proves the process starts and stays alive. Visual + and interaction verification stays manual. +- `desktop launch` reports `[SKIP]` when an ACECode desktop instance is + already running: the single-instance guard would make a second instance + exit immediately, so the probe would say nothing. Close ACECode and + re-run to exercise startup. + +## Guardrails + +- Never add publishing behavior here: no git operations, no npm, no + `aceupdate.json`, no update-server uploads, no signing or notarization. + Release work belongs to the `acecode-release` skill. +- Do not bypass the `web/dist` preflight — verifying the embedded fallback + page proves nothing about the web UI. +- Linux is out of scope; the script mechanically supports a flat Linux + layout for tests, but real Linux verification happens in CI. +- The desktop launch probe runs a real GUI process. Run it on a machine + with a desktop session; in headless environments use `--target tui`. diff --git a/.claude/skills/verify-package/scripts/verify_package.py b/.claude/skills/verify-package/scripts/verify_package.py new file mode 100644 index 00000000..0aefc17c --- /dev/null +++ b/.claude/skills/verify-package/scripts/verify_package.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Build, stage, and locally verify ACECode packages with zero release side effects. + +One command reproduces the CI package layout (binaries + share/acecode resources), +checks bundled resources, and smoke-runs the staged TUI / desktop binaries. +Performs no git, publishing, signing, or network operations of its own, and +writes only inside the repo build/staging directories and the system temp dir. + +Exit codes: 0 every check passed; 1 at least one check failed or could not run; +2 command-line usage error. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") +README_FILES = ("README.md", "README_CN.md") +BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") +DEFAULT_LAUNCH_TIMEOUT = 10 +STAGING_DIRNAME = "verify-package-staging" + + +class Report: + def __init__(self) -> None: + self.items: list[tuple[str, str, str]] = [] + + def add(self, name: str, status: str, detail: str = "") -> None: + self.items.append((name, status, detail)) + mark = {"pass": "[PASS]", "fail": "[FAIL]", "skip": "[SKIP]"}[status] + line = f"{mark} {name}" + if detail: + line += f": {detail}" + print(line) + + @property + def failed(self) -> int: + return sum(1 for _, status, _ in self.items if status == "fail") + + +def find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / "CMakeLists.txt").is_file() and (candidate / "assets").is_dir(): + return candidate + raise SystemExit( + "verify_package: unable to locate the ACECode repo root " + "(a directory holding CMakeLists.txt and assets/) above " + f"{start}; pass --repo explicitly." + ) + + +def detect_platform(explicit: str) -> str: + if explicit != "auto": + return explicit + if sys.platform == "darwin": + return "darwin" + if sys.platform == "win32": + return "windows" + return "linux" + + +def exe_name(platform: str, kind: str) -> str: + base = {"tui": "acecode", "desktop": "acecode-desktop", "daemon": "acecode"}[kind] + return base + (".exe" if platform == "windows" else "") + + +def find_built(build_dir: Path, relative: str) -> Path | None: + candidates = [build_dir / relative] + candidates += [build_dir / config / relative for config in BUILD_CONFIGS] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def check_models_dev(report: Report, label: str, models_dir: Path, assets_dir: Path) -> None: + if not models_dir.is_dir(): + report.add(label, "fail", f"missing directory {models_dir}") + return + packaged = { + path.relative_to(models_dir).as_posix(): sha256_file(path) + for path in sorted(models_dir.rglob("*")) + if path.is_file() + } + if set(packaged) != set(MODELS_DEV_FILES): + report.add( + label, + "fail", + f"expected exactly {list(MODELS_DEV_FILES)}, found {sorted(packaged)}", + ) + return + mismatched = [ + name for name in MODELS_DEV_FILES + if packaged[name] != sha256_file(assets_dir / name) + ] + if mismatched: + report.add(label, "fail", f"hash mismatch vs source assets: {mismatched}") + return + report.add(label, "pass", f"{len(packaged)} files hash-matched") + + +def check_seed_bundle(report: Report, label: str, repo: Path, packaged: Path) -> None: + script = repo / "scripts" / "verify_seed_bundle.py" + if not script.is_file(): + report.add(label, "fail", f"missing {script}") + return + result = subprocess.run( + [sys.executable, str(script), "--source", str(repo / "assets" / "seed"), + "--packaged", str(packaged)], + capture_output=True, text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + report.add(label, "fail", detail[-1] if detail else f"exit {result.returncode}") + return + report.add(label, "pass") + + +def run_tool(report: Report, name: str, command: list[str], **kwargs) -> bool: + try: + result = subprocess.run(command, **kwargs) + except FileNotFoundError as error: + report.add(name, "fail", str(error)) + return False + if result.returncode != 0: + output = "" + if isinstance(result, subprocess.CompletedProcess): + text = (result.stderr or result.stdout or "") + if isinstance(text, bytes): + text = text.decode(errors="replace") + output = text.strip().splitlines()[-1] if text.strip() else "" + report.add(name, "fail", f"exit {result.returncode}" + (f": {output}" if output else "")) + return False + report.add(name, "pass") + return True + + +def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, + build_dir: Path) -> bool: + ok = True + web_dist = repo / "web" / "dist" / "index.html" + if web_dist.is_file(): + report.add("preflight web/dist", "pass") + else: + report.add( + "preflight web/dist", "fail", + "web/dist/index.html is missing; CMake would embed a placeholder page. " + "Run: cd web && pnpm install --frozen-lockfile && pnpm build", + ) + ok = False + if cmake is None: + report.add("preflight cmake", "fail", "cmake not found on PATH") + ok = False + else: + report.add("preflight cmake", "pass") + if skip_build and not (build_dir / "CMakeCache.txt").is_file(): + report.add( + "preflight build dir", "fail", + f"{build_dir} is not configured; run without --skip-build", + ) + ok = False + else: + report.add("preflight build dir", "pass") + return ok + + +def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, + targets: list[str]) -> bool: + if not (build_dir / "CMakeCache.txt").is_file(): + command = [cmake, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if shutil.which("ninja"): + command.insert(4, "-G") + command.insert(5, "Ninja") + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + command += ["-DCMAKE_TOOLCHAIN_FILE", + str(Path(vcpkg_root) / "scripts" / "buildsystems" / "vcpkg.cmake")] + if not run_tool(report, "cmake configure", command): + return False + else: + report.add("cmake configure", "skip", "build dir already configured") + for target in targets: + if not run_tool(report, f"cmake build {target}", + [cmake, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", target]): + return False + return True + + +def stage(report: Report, repo: Path, build_dir: Path, staging: Path, + platform: str, targets: list[str], cmake: str) -> bool: + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + for name in README_FILES: + if (repo / name).is_file(): + shutil.copy2(repo / name, staging / name) + + if "tui" in targets: + tui_src = find_built(build_dir, exe_name(platform, "tui")) + if tui_src is None: + report.add("stage tui binary", "fail", + f"no built {exe_name(platform, 'tui')} under {build_dir}") + return False + shutil.copy2(tui_src, staging / exe_name(platform, "tui")) + report.add("stage tui binary", "pass", str(tui_src)) + + if "desktop" in targets: + if platform == "darwin": + app_src = find_built(build_dir, "ACECode.app") + if app_src is None or not app_src.is_dir(): + report.add("stage desktop bundle", "fail", + f"no built ACECode.app under {build_dir}; " + "build the acecode-desktop target first") + return False + shutil.copytree(app_src, staging / "ACECode.app") + report.add("stage desktop bundle", "pass", str(app_src)) + else: + desktop_src = find_built(build_dir, exe_name(platform, "desktop")) + daemon_src = find_built(build_dir, exe_name(platform, "daemon")) + if desktop_src is None or daemon_src is None: + report.add("stage desktop binary", "fail", + f"missing built {exe_name(platform, 'desktop')} or " + f"{exe_name(platform, 'daemon')} under {build_dir}") + return False + shutil.copy2(desktop_src, staging / exe_name(platform, "desktop")) + shutil.copy2(daemon_src, staging / exe_name(platform, "daemon")) + report.add("stage desktop binary", "pass", str(desktop_src)) + + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + if not run_tool(report, f"cmake install {component}", + [cmake, "--install", str(build_dir), + "--config", "MinSizeRel", "--prefix", str(staging), + "--component", component]): + return False + return True + + +def structural_checks(report: Report, repo: Path, staging: Path, + platform: str, targets: list[str]) -> None: + if "tui" in targets or platform != "darwin": + check_models_dev(report, "models_dev registry (staged share/)", + staging / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (staged share/)", repo, + staging / "share" / "acecode" / "seed") + if "desktop" not in targets: + return + if platform == "darwin": + bundle = staging / "ACECode.app" + contents = bundle / "Contents" + for required in (contents / "MacOS" / "ACECode", + contents / "MacOS" / "acecode-daemon"): + if required.is_file(): + report.add(f"app bundle {required.name}", "pass") + else: + report.add(f"app bundle {required.name}", "fail", + f"missing {required}") + check_models_dev(report, "models_dev registry (app bundle)", + contents / "Resources" / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (app bundle)", repo, + contents / "Resources" / "share" / "acecode" / "seed") + else: + daemon = staging / exe_name(platform, "daemon") + if daemon.is_file(): + report.add("desktop daemon adjacency", "pass") + else: + report.add("desktop daemon adjacency", "fail", + f"desktop needs {daemon} beside it") + + +def isolated_profile_env() -> tuple[dict[str, str], Path]: + home = Path(tempfile.mkdtemp(prefix="acecode-verify-home-")) + env = os.environ.copy() + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + if sys.platform == "win32": + roaming = home / "AppData" / "Roaming" + local = home / "AppData" / "Local" + roaming.mkdir(parents=True, exist_ok=True) + local.mkdir(parents=True, exist_ok=True) + env["APPDATA"] = str(roaming) + env["LOCALAPPDATA"] = str(local) + return env, home + + +def probe_tui(report: Report, staged_exe: Path, platform: str) -> None: + env, home = isolated_profile_env() + try: + version = subprocess.run([str(staged_exe), "--version"], capture_output=True, + text=True, env=env, timeout=120) + if version.returncode != 0 or "acecode v" not in version.stdout: + detail = (version.stderr or version.stdout).strip().splitlines() + report.add("tui --version", "fail", + detail[-1] if detail else f"exit {version.returncode}") + else: + report.add("tui --version", "pass", version.stdout.strip().splitlines()[0]) + + registry = subprocess.run([str(staged_exe), "--validate-models-registry"], + capture_output=True, text=True, env=env, timeout=300) + output = registry.stdout + registry.stderr + if registry.returncode != 0 or "registry OK" not in output: + detail = output.strip().splitlines() + report.add("tui models registry resolution", "fail", + detail[-1] if detail else f"exit {registry.returncode}") + elif "models_dev" not in output.replace("\\", "/"): + report.add("tui models registry resolution", "fail", + f"registry resolved away from the staged share/ tree: " + f"{output.strip().splitlines()[0]}") + else: + report.add("tui models registry resolution", "pass", + output.strip().splitlines()[0]) + except (subprocess.TimeoutExpired, OSError) as error: + report.add("tui runtime probe", "fail", str(error)) + finally: + shutil.rmtree(home, ignore_errors=True) + + +def existing_instance_running(platform: str) -> bool | None: + try: + if platform == "windows": + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq acecode-desktop.exe"], + capture_output=True, text=True, timeout=30) + return "acecode-desktop.exe" in (result.stdout or "") + if platform == "darwin": + result = subprocess.run(["pgrep", "-x", "ACECode"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + result = subprocess.run(["pgrep", "-x", "acecode-desktop"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + except (OSError, subprocess.SubprocessError): + return None + + +def probe_desktop(report: Report, staged: Path, platform: str, + launch_timeout: int) -> None: + running = existing_instance_running(platform) + if running: + report.add("desktop launch", "skip", + "an ACECode desktop instance is already running; the " + "single-instance guard would make this probe exit " + "immediately. Close ACECode and re-run to exercise startup.") + return + if platform == "darwin": + command = staged / "ACECode.app" / "Contents" / "MacOS" / "ACECode" + else: + command = staged / exe_name(platform, "desktop") + env, home = isolated_profile_env() + try: + process = subprocess.Popen([str(command)], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as error: + report.add("desktop launch", "fail", str(error)) + shutil.rmtree(home, ignore_errors=True) + return + deadline = time.monotonic() + launch_timeout + while time.monotonic() < deadline: + if process.poll() is None: + report.add("desktop launch", "pass", + f"alive after launch (pid {process.pid}); terminating") + process.terminate() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + shutil.rmtree(home, ignore_errors=True) + return + time.sleep(0.25) + if process.poll() == 0: + report.add("desktop launch", "fail", "exited immediately with code 0") + else: + report.add("desktop launch", "fail", + f"exited with code {process.returncode} during startup") + shutil.rmtree(home, ignore_errors=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build, stage, and locally verify ACECode packages " + "(no release side effects).") + parser.add_argument("--target", choices=("tui", "desktop", "all"), default="all", + help="which artifact set to verify (default: all)") + parser.add_argument("--skip-build", action="store_true", + help="reuse the existing build tree; stage and verify only") + parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), + default="auto", + help="override platform detection (mainly for tests)") + parser.add_argument("--repo", type=Path, default=None, + help="ACECode repo root (default: detected from this script)") + parser.add_argument("--build-dir", type=Path, default=None, + help="CMake build directory (default: /build)") + parser.add_argument("--staging-dir", type=Path, default=None, + help="staging output directory " + "(default: /verify-package-staging)") + parser.add_argument("--launch-timeout", type=int, default=DEFAULT_LAUNCH_TIMEOUT, + help="seconds to wait for the desktop app to stay alive " + "(default: 10)") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + report = Report() + repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() + build_dir = (args.build_dir or repo / "build").resolve() + staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() + platform = detect_platform(args.platform) + cmake = shutil.which("cmake") + targets = ["tui", "desktop"] if args.target == "all" else [args.target] + print(f"verify-package: repo={repo} build={build_dir} platform={platform} " + f"target={args.target} skip-build={args.skip_build}") + + if not preflight(report, repo, cmake, args.skip_build, build_dir): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not args.skip_build: + assert cmake is not None + if not configure_and_build(report, repo, build_dir, cmake, targets): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + structural_checks(report, repo, staging, platform, targets) + + staged_tui = staging / exe_name(platform, "tui") + if "tui" in targets and staged_tui.is_file(): + probe_tui(report, staged_tui, platform) + elif "tui" in targets: + report.add("tui runtime probe", "fail", f"missing staged {staged_tui}") + + if "desktop" in targets: + probe_desktop(report, staging, platform, args.launch_timeout) + + failed = report.failed + if failed == 0: + print(f"verify-package: PASS ({len(report.items)} checks) - staging at {staging}") + return 0 + print(f"verify-package: FAIL ({failed} of {len(report.items)} checks failed)") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except KeyboardInterrupt: + sys.exit(130) diff --git a/.codex/skills/verify-package/SKILL.md b/.codex/skills/verify-package/SKILL.md new file mode 100644 index 00000000..86ece467 --- /dev/null +++ b/.codex/skills/verify-package/SKILL.md @@ -0,0 +1,121 @@ +--- +name: verify-package +description: Build, stage, and locally verify ACECode packages with zero release side effects. Use when asked to verify packaging locally, do a pre-release dry run or local packaging check, stage a CI-equivalent package on macOS or Windows, confirm bundled models.dev/seed resources resolve, or smoke-test TUI and desktop builds. Not for publishing, tagging, npm, update servers, or installers. +platforms: [macos, windows] +compatibility: ACECode skill system +metadata: + tags: [packaging, verification, local] +--- + +# Verify Package (Local) + +## Purpose + +Prove locally that ACECode packaging is correct — file layout, bundled +resources, resource resolution, and runtime startup — without any release +side effect. One command reproduces the CI `package.yml` staging layout and +runs the same resource validations, then smoke-runs the staged binaries. + +This skill is the side-effect-free counterpart of `acecode-release`: +verify-package never commits, tags, pushes, publishes, signs, or touches an +update server. It writes only inside the build/staging directories and the +system temp dir. + +## Required Inputs + +None. The script detects the repo root from its own location, defaults the +build directory to `/build`, and configures it if missing (MinSizeRel, +`BUILD_TESTING=OFF`, `ACECODE_BUILD_DESKTOP=ON`, Ninja when available, vcpkg +toolchain when `VCPKG_ROOT` is set). + +Prerequisite: `web/dist` must exist. If it is missing the script fails with +the exact rebuild command (`cd web && pnpm install --frozen-lockfile && +pnpm build`) instead of silently verifying the embedded placeholder page. + +## Quick Start + +macOS (TUI + desktop): + +```bash +python3 .acecode/skills/verify-package/scripts/verify_package.py +``` + +Windows (TUI + desktop): + +```powershell +python .acecode\skills\verify-package\scripts\verify_package.py +``` + +Useful variants: + +```bash +# Re-verify an existing build without recompiling +python3 .../verify_package.py --skip-build + +# Only one artifact set +python3 .../verify_package.py --target tui +python3 .../verify_package.py --target desktop + +# Existing non-default build tree (e.g. build/windows-x64-release) +python3 .../verify_package.py --build-dir build/windows-x64-release + +# Where the staged package lands (default: /verify-package-staging) +python3 .../verify_package.py --staging-dir /tmp/ace-verify +``` + +## What The Script Does + +1. Preflight: `web/dist/index.html` exists; cmake is on PATH; the build dir + is configured when `--skip-build` is used. +2. Configure + incremental build of `acecode` (and `acecode-desktop` for the + desktop target). Skipped under `--skip-build`. +3. Staging, mirroring the CI Package step: binaries (or the macOS + `ACECode.app` bundle) + READMEs, then + `cmake --install --component models_dev_registry` and + `--component default_seed_bundle` into the staging prefix. The staging + directory is wiped and rebuilt on every run. +4. Structural checks: staged `share/acecode/models_dev` holds exactly + `api.json`, `MANIFEST.json`, `LICENSE`, hash-equal to `assets/models_dev`; + the seed bundle is verified by reusing `scripts/verify_seed_bundle.py`. + For the desktop target it additionally checks daemon adjacency on + Windows (`acecode.exe` beside `acecode-desktop.exe`) and the macOS app + bundle layout (`Contents/MacOS/ACECode`, `Contents/MacOS/acecode-daemon`, + `Contents/Resources/share/acecode/...`). +5. Runtime probes with an isolated user profile (temp `HOME`/`USERPROFILE`, + so your real `~/.acecode` is never touched): + - TUI: run the staged binary with `--version`, then + `--validate-models-registry`; the latter must report `registry OK` with + a source inside the staged `share/acecode/models_dev` tree, proving + resource resolution does not fall back. + - Desktop: launch the staged app, wait for it to stay alive, then + terminate it. This proves startup, not visual correctness — eyeball the + UI yourself if the change is UI-facing. +6. Report: one `[PASS]`/`[FAIL]` line per check, then + `verify-package: PASS` (exit 0) or `verify-package: FAIL` (exit 1). + +## Reading The Report + +- A failed `tui models registry resolution` with a non-`models_dev` source + means the packaged resource layout or the search-path logic broke — this + is the class of bug that a bare build-dir run can never catch. +- Known expected behavior (not a bug): running the bare `build/acecode` + binary without a staged `share/` tree logs a registry fallback warning. + The staged run in this skill exists precisely to avoid that confusion. +- `desktop launch` only proves the process starts and stays alive. Visual + and interaction verification stays manual. +- `desktop launch` reports `[SKIP]` when an ACECode desktop instance is + already running: the single-instance guard would make a second instance + exit immediately, so the probe would say nothing. Close ACECode and + re-run to exercise startup. + +## Guardrails + +- Never add publishing behavior here: no git operations, no npm, no + `aceupdate.json`, no update-server uploads, no signing or notarization. + Release work belongs to the `acecode-release` skill. +- Do not bypass the `web/dist` preflight — verifying the embedded fallback + page proves nothing about the web UI. +- Linux is out of scope; the script mechanically supports a flat Linux + layout for tests, but real Linux verification happens in CI. +- The desktop launch probe runs a real GUI process. Run it on a machine + with a desktop session; in headless environments use `--target tui`. diff --git a/.codex/skills/verify-package/scripts/verify_package.py b/.codex/skills/verify-package/scripts/verify_package.py new file mode 100644 index 00000000..0aefc17c --- /dev/null +++ b/.codex/skills/verify-package/scripts/verify_package.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Build, stage, and locally verify ACECode packages with zero release side effects. + +One command reproduces the CI package layout (binaries + share/acecode resources), +checks bundled resources, and smoke-runs the staged TUI / desktop binaries. +Performs no git, publishing, signing, or network operations of its own, and +writes only inside the repo build/staging directories and the system temp dir. + +Exit codes: 0 every check passed; 1 at least one check failed or could not run; +2 command-line usage error. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") +README_FILES = ("README.md", "README_CN.md") +BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") +DEFAULT_LAUNCH_TIMEOUT = 10 +STAGING_DIRNAME = "verify-package-staging" + + +class Report: + def __init__(self) -> None: + self.items: list[tuple[str, str, str]] = [] + + def add(self, name: str, status: str, detail: str = "") -> None: + self.items.append((name, status, detail)) + mark = {"pass": "[PASS]", "fail": "[FAIL]", "skip": "[SKIP]"}[status] + line = f"{mark} {name}" + if detail: + line += f": {detail}" + print(line) + + @property + def failed(self) -> int: + return sum(1 for _, status, _ in self.items if status == "fail") + + +def find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / "CMakeLists.txt").is_file() and (candidate / "assets").is_dir(): + return candidate + raise SystemExit( + "verify_package: unable to locate the ACECode repo root " + "(a directory holding CMakeLists.txt and assets/) above " + f"{start}; pass --repo explicitly." + ) + + +def detect_platform(explicit: str) -> str: + if explicit != "auto": + return explicit + if sys.platform == "darwin": + return "darwin" + if sys.platform == "win32": + return "windows" + return "linux" + + +def exe_name(platform: str, kind: str) -> str: + base = {"tui": "acecode", "desktop": "acecode-desktop", "daemon": "acecode"}[kind] + return base + (".exe" if platform == "windows" else "") + + +def find_built(build_dir: Path, relative: str) -> Path | None: + candidates = [build_dir / relative] + candidates += [build_dir / config / relative for config in BUILD_CONFIGS] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def check_models_dev(report: Report, label: str, models_dir: Path, assets_dir: Path) -> None: + if not models_dir.is_dir(): + report.add(label, "fail", f"missing directory {models_dir}") + return + packaged = { + path.relative_to(models_dir).as_posix(): sha256_file(path) + for path in sorted(models_dir.rglob("*")) + if path.is_file() + } + if set(packaged) != set(MODELS_DEV_FILES): + report.add( + label, + "fail", + f"expected exactly {list(MODELS_DEV_FILES)}, found {sorted(packaged)}", + ) + return + mismatched = [ + name for name in MODELS_DEV_FILES + if packaged[name] != sha256_file(assets_dir / name) + ] + if mismatched: + report.add(label, "fail", f"hash mismatch vs source assets: {mismatched}") + return + report.add(label, "pass", f"{len(packaged)} files hash-matched") + + +def check_seed_bundle(report: Report, label: str, repo: Path, packaged: Path) -> None: + script = repo / "scripts" / "verify_seed_bundle.py" + if not script.is_file(): + report.add(label, "fail", f"missing {script}") + return + result = subprocess.run( + [sys.executable, str(script), "--source", str(repo / "assets" / "seed"), + "--packaged", str(packaged)], + capture_output=True, text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + report.add(label, "fail", detail[-1] if detail else f"exit {result.returncode}") + return + report.add(label, "pass") + + +def run_tool(report: Report, name: str, command: list[str], **kwargs) -> bool: + try: + result = subprocess.run(command, **kwargs) + except FileNotFoundError as error: + report.add(name, "fail", str(error)) + return False + if result.returncode != 0: + output = "" + if isinstance(result, subprocess.CompletedProcess): + text = (result.stderr or result.stdout or "") + if isinstance(text, bytes): + text = text.decode(errors="replace") + output = text.strip().splitlines()[-1] if text.strip() else "" + report.add(name, "fail", f"exit {result.returncode}" + (f": {output}" if output else "")) + return False + report.add(name, "pass") + return True + + +def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, + build_dir: Path) -> bool: + ok = True + web_dist = repo / "web" / "dist" / "index.html" + if web_dist.is_file(): + report.add("preflight web/dist", "pass") + else: + report.add( + "preflight web/dist", "fail", + "web/dist/index.html is missing; CMake would embed a placeholder page. " + "Run: cd web && pnpm install --frozen-lockfile && pnpm build", + ) + ok = False + if cmake is None: + report.add("preflight cmake", "fail", "cmake not found on PATH") + ok = False + else: + report.add("preflight cmake", "pass") + if skip_build and not (build_dir / "CMakeCache.txt").is_file(): + report.add( + "preflight build dir", "fail", + f"{build_dir} is not configured; run without --skip-build", + ) + ok = False + else: + report.add("preflight build dir", "pass") + return ok + + +def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, + targets: list[str]) -> bool: + if not (build_dir / "CMakeCache.txt").is_file(): + command = [cmake, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if shutil.which("ninja"): + command.insert(4, "-G") + command.insert(5, "Ninja") + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + command += ["-DCMAKE_TOOLCHAIN_FILE", + str(Path(vcpkg_root) / "scripts" / "buildsystems" / "vcpkg.cmake")] + if not run_tool(report, "cmake configure", command): + return False + else: + report.add("cmake configure", "skip", "build dir already configured") + for target in targets: + if not run_tool(report, f"cmake build {target}", + [cmake, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", target]): + return False + return True + + +def stage(report: Report, repo: Path, build_dir: Path, staging: Path, + platform: str, targets: list[str], cmake: str) -> bool: + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + for name in README_FILES: + if (repo / name).is_file(): + shutil.copy2(repo / name, staging / name) + + if "tui" in targets: + tui_src = find_built(build_dir, exe_name(platform, "tui")) + if tui_src is None: + report.add("stage tui binary", "fail", + f"no built {exe_name(platform, 'tui')} under {build_dir}") + return False + shutil.copy2(tui_src, staging / exe_name(platform, "tui")) + report.add("stage tui binary", "pass", str(tui_src)) + + if "desktop" in targets: + if platform == "darwin": + app_src = find_built(build_dir, "ACECode.app") + if app_src is None or not app_src.is_dir(): + report.add("stage desktop bundle", "fail", + f"no built ACECode.app under {build_dir}; " + "build the acecode-desktop target first") + return False + shutil.copytree(app_src, staging / "ACECode.app") + report.add("stage desktop bundle", "pass", str(app_src)) + else: + desktop_src = find_built(build_dir, exe_name(platform, "desktop")) + daemon_src = find_built(build_dir, exe_name(platform, "daemon")) + if desktop_src is None or daemon_src is None: + report.add("stage desktop binary", "fail", + f"missing built {exe_name(platform, 'desktop')} or " + f"{exe_name(platform, 'daemon')} under {build_dir}") + return False + shutil.copy2(desktop_src, staging / exe_name(platform, "desktop")) + shutil.copy2(daemon_src, staging / exe_name(platform, "daemon")) + report.add("stage desktop binary", "pass", str(desktop_src)) + + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + if not run_tool(report, f"cmake install {component}", + [cmake, "--install", str(build_dir), + "--config", "MinSizeRel", "--prefix", str(staging), + "--component", component]): + return False + return True + + +def structural_checks(report: Report, repo: Path, staging: Path, + platform: str, targets: list[str]) -> None: + if "tui" in targets or platform != "darwin": + check_models_dev(report, "models_dev registry (staged share/)", + staging / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (staged share/)", repo, + staging / "share" / "acecode" / "seed") + if "desktop" not in targets: + return + if platform == "darwin": + bundle = staging / "ACECode.app" + contents = bundle / "Contents" + for required in (contents / "MacOS" / "ACECode", + contents / "MacOS" / "acecode-daemon"): + if required.is_file(): + report.add(f"app bundle {required.name}", "pass") + else: + report.add(f"app bundle {required.name}", "fail", + f"missing {required}") + check_models_dev(report, "models_dev registry (app bundle)", + contents / "Resources" / "share" / "acecode" / "models_dev", + repo / "assets" / "models_dev") + check_seed_bundle(report, "seed bundle (app bundle)", repo, + contents / "Resources" / "share" / "acecode" / "seed") + else: + daemon = staging / exe_name(platform, "daemon") + if daemon.is_file(): + report.add("desktop daemon adjacency", "pass") + else: + report.add("desktop daemon adjacency", "fail", + f"desktop needs {daemon} beside it") + + +def isolated_profile_env() -> tuple[dict[str, str], Path]: + home = Path(tempfile.mkdtemp(prefix="acecode-verify-home-")) + env = os.environ.copy() + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + if sys.platform == "win32": + roaming = home / "AppData" / "Roaming" + local = home / "AppData" / "Local" + roaming.mkdir(parents=True, exist_ok=True) + local.mkdir(parents=True, exist_ok=True) + env["APPDATA"] = str(roaming) + env["LOCALAPPDATA"] = str(local) + return env, home + + +def probe_tui(report: Report, staged_exe: Path, platform: str) -> None: + env, home = isolated_profile_env() + try: + version = subprocess.run([str(staged_exe), "--version"], capture_output=True, + text=True, env=env, timeout=120) + if version.returncode != 0 or "acecode v" not in version.stdout: + detail = (version.stderr or version.stdout).strip().splitlines() + report.add("tui --version", "fail", + detail[-1] if detail else f"exit {version.returncode}") + else: + report.add("tui --version", "pass", version.stdout.strip().splitlines()[0]) + + registry = subprocess.run([str(staged_exe), "--validate-models-registry"], + capture_output=True, text=True, env=env, timeout=300) + output = registry.stdout + registry.stderr + if registry.returncode != 0 or "registry OK" not in output: + detail = output.strip().splitlines() + report.add("tui models registry resolution", "fail", + detail[-1] if detail else f"exit {registry.returncode}") + elif "models_dev" not in output.replace("\\", "/"): + report.add("tui models registry resolution", "fail", + f"registry resolved away from the staged share/ tree: " + f"{output.strip().splitlines()[0]}") + else: + report.add("tui models registry resolution", "pass", + output.strip().splitlines()[0]) + except (subprocess.TimeoutExpired, OSError) as error: + report.add("tui runtime probe", "fail", str(error)) + finally: + shutil.rmtree(home, ignore_errors=True) + + +def existing_instance_running(platform: str) -> bool | None: + try: + if platform == "windows": + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq acecode-desktop.exe"], + capture_output=True, text=True, timeout=30) + return "acecode-desktop.exe" in (result.stdout or "") + if platform == "darwin": + result = subprocess.run(["pgrep", "-x", "ACECode"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + result = subprocess.run(["pgrep", "-x", "acecode-desktop"], + capture_output=True, text=True, timeout=30) + return result.returncode == 0 + except (OSError, subprocess.SubprocessError): + return None + + +def probe_desktop(report: Report, staged: Path, platform: str, + launch_timeout: int) -> None: + running = existing_instance_running(platform) + if running: + report.add("desktop launch", "skip", + "an ACECode desktop instance is already running; the " + "single-instance guard would make this probe exit " + "immediately. Close ACECode and re-run to exercise startup.") + return + if platform == "darwin": + command = staged / "ACECode.app" / "Contents" / "MacOS" / "ACECode" + else: + command = staged / exe_name(platform, "desktop") + env, home = isolated_profile_env() + try: + process = subprocess.Popen([str(command)], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as error: + report.add("desktop launch", "fail", str(error)) + shutil.rmtree(home, ignore_errors=True) + return + deadline = time.monotonic() + launch_timeout + while time.monotonic() < deadline: + if process.poll() is None: + report.add("desktop launch", "pass", + f"alive after launch (pid {process.pid}); terminating") + process.terminate() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + shutil.rmtree(home, ignore_errors=True) + return + time.sleep(0.25) + if process.poll() == 0: + report.add("desktop launch", "fail", "exited immediately with code 0") + else: + report.add("desktop launch", "fail", + f"exited with code {process.returncode} during startup") + shutil.rmtree(home, ignore_errors=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build, stage, and locally verify ACECode packages " + "(no release side effects).") + parser.add_argument("--target", choices=("tui", "desktop", "all"), default="all", + help="which artifact set to verify (default: all)") + parser.add_argument("--skip-build", action="store_true", + help="reuse the existing build tree; stage and verify only") + parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), + default="auto", + help="override platform detection (mainly for tests)") + parser.add_argument("--repo", type=Path, default=None, + help="ACECode repo root (default: detected from this script)") + parser.add_argument("--build-dir", type=Path, default=None, + help="CMake build directory (default: /build)") + parser.add_argument("--staging-dir", type=Path, default=None, + help="staging output directory " + "(default: /verify-package-staging)") + parser.add_argument("--launch-timeout", type=int, default=DEFAULT_LAUNCH_TIMEOUT, + help="seconds to wait for the desktop app to stay alive " + "(default: 10)") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + report = Report() + repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() + build_dir = (args.build_dir or repo / "build").resolve() + staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() + platform = detect_platform(args.platform) + cmake = shutil.which("cmake") + targets = ["tui", "desktop"] if args.target == "all" else [args.target] + print(f"verify-package: repo={repo} build={build_dir} platform={platform} " + f"target={args.target} skip-build={args.skip_build}") + + if not preflight(report, repo, cmake, args.skip_build, build_dir): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not args.skip_build: + assert cmake is not None + if not configure_and_build(report, repo, build_dir, cmake, targets): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + + structural_checks(report, repo, staging, platform, targets) + + staged_tui = staging / exe_name(platform, "tui") + if "tui" in targets and staged_tui.is_file(): + probe_tui(report, staged_tui, platform) + elif "tui" in targets: + report.add("tui runtime probe", "fail", f"missing staged {staged_tui}") + + if "desktop" in targets: + probe_desktop(report, staging, platform, args.launch_timeout) + + failed = report.failed + if failed == 0: + print(f"verify-package: PASS ({len(report.items)} checks) - staging at {staging}") + return 0 + print(f"verify-package: FAIL ({failed} of {len(report.items)} checks failed)") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except KeyboardInterrupt: + sys.exit(130) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 41512678..cd378b49 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,13 @@ if(UNIX) ${CMAKE_CURRENT_SOURCE_DIR}/scripts/macos_pkg_installation_test.sh ) set_tests_properties(macos_pkg_installation_contract PROPERTIES LABELS "unit") + + add_test( + NAME verify_package_contract + COMMAND /bin/bash + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/verify_package_test.sh + ) + set_tests_properties(verify_package_contract PROPERTIES LABELS "unit") endif() if(UNIX AND NOT APPLE) diff --git a/tests/scripts/verify_package_test.sh b/tests/scripts/verify_package_test.sh new file mode 100644 index 00000000..7e9298b6 --- /dev/null +++ b/tests/scripts/verify_package_test.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +verify_script="$repo_root/.acecode/skills/verify-package/scripts/verify_package.py" + +if command -v python3 >/dev/null 2>&1 && python3 -c "" >/dev/null 2>&1; then + python_bin="python3" +else + python_bin="python" +fi + +# The flow cases exec sh-shebang stub executables through the python script +# and resolve an extension-less sh cmake shim on PATH. Native Windows Python +# cannot exec those files and shutil.which("cmake") finds the real cmake.exe +# over the shim, so non-POSIX hosts only exercise the preflight-only cases. +# CI registers this test under if(UNIX), where every case runs. +full_mode=1 +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) full_mode=0 ;; +esac + +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/acecode-verify-package.XXXXXX")" +cleanup() { + if [[ -n "${temporary_root:-}" && -d "$temporary_root" ]]; then + rm -rf -- "$temporary_root" + fi +} +trap cleanup EXIT + +expect_status() { + local expected="$1" + local label="$2" + shift 2 + + local output="$temporary_root/output.txt" + local actual=0 + "$@" >"$output" 2>&1 || actual=$? + if [[ "$actual" -ne "$expected" ]]; then + echo "$label: expected exit $expected, got $actual" >&2 + sed -n '1,120p' "$output" >&2 + exit 1 + fi +} + +expect_output() { + local pattern="$1" + local label="$2" + if ! grep -q "$pattern" "$temporary_root/output.txt"; then + echo "$label: output missing pattern '$pattern'" >&2 + sed -n '1,120p' "$temporary_root/output.txt" >&2 + exit 1 + fi +} + +"$python_bin" -m py_compile "$verify_script" + +expect_status 0 "help" "$python_bin" "$verify_script" --help +expect_status 2 "unknown argument" "$python_bin" "$verify_script" --definitely-unknown + +# Build a fake repo tree: minimal assets, web/dist marker, READMEs, and a +# stub CMake that implements the two install components the script uses. +fixture="$temporary_root/fixture" +mkdir -p "$fixture/assets/models_dev" "$fixture/assets/seed/skills/demo" \ + "$fixture/web/dist" "$fixture/build" "$fixture/scripts" +printf 'models api\n' >"$fixture/assets/models_dev/api.json" +printf 'manifest\n' >"$fixture/assets/models_dev/MANIFEST.json" +printf 'license\n' >"$fixture/assets/models_dev/LICENSE" +printf 'seed skill\n' >"$fixture/assets/seed/skills/demo/SKILL.md" +printf '{"bundle":"test"}\n' >"$fixture/assets/seed/MANIFEST.json" +printf '1\n' >"$fixture/assets/seed/seed.version" +printf '\n' >"$fixture/web/dist/index.html" +printf 'readme\n' >"$fixture/README.md" +printf 'readme cn\n' >"$fixture/README_CN.md" +printf 'cmake cache marker\n' >"$fixture/build/CMakeCache.txt" +cp "$repo_root/scripts/verify_seed_bundle.py" "$fixture/scripts/verify_seed_bundle.py" + +# Negative: --skip-build without a configured build dir fails the preflight. +rm -rf "$temporary_root/nobuild" +mkdir -p "$temporary_root/nobuild" +expect_status 1 "unconfigured build dir" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target tui \ + --repo "$fixture" --build-dir "$temporary_root/nobuild" \ + --staging-dir "$fixture/staging" +expect_output "not configured" "unconfigured build dir detail" + +# Negative: missing web/dist fails with the rebuild command. +rm "$fixture/web/dist/index.html" +expect_status 1 "missing web dist" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target tui \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" +expect_output "pnpm build" "web dist rebuild hint" +printf '\n' >"$fixture/web/dist/index.html" + +if [[ "$full_mode" -eq 1 ]]; then + mkdir -p "$temporary_root/shim" + cat >"$temporary_root/shim/cmake" <<'EOF' +#!/bin/sh +set -eu +mode="" +installdir="" +prefix="" +component="" +while [ $# -gt 0 ]; do + case "$1" in + --install) mode="install"; installdir="$2"; shift ;; + --prefix) prefix="$2"; shift ;; + --component) component="$2"; shift ;; + esac + shift +done +if [ "$mode" = "install" ]; then + repo="$(cd "$installdir/.." && pwd)" + case "$component" in + models_dev_registry) + mkdir -p "$prefix/share/acecode/models_dev" + cp "$repo"/assets/models_dev/* "$prefix/share/acecode/models_dev/" + ;; + default_seed_bundle) + rm -rf "$prefix/share/acecode/seed" + cp -R "$repo/assets/seed" "$prefix/share/acecode/seed" + ;; + esac +fi +exit 0 +EOF + chmod +x "$temporary_root/shim/cmake" + PATH="$temporary_root/shim:$PATH" + export PATH + + write_tui_stub() { + local registry_rc="$1" + cat >"$fixture/build/acecode" <&2 + exit 1 ;; +esac +exit 0 +EOF + chmod +x "$fixture/build/acecode" + } + + write_desktop_stub() { + cat >"$fixture/build/acecode-desktop" <<'EOF' +#!/bin/sh +sleep 300 +EOF + chmod +x "$fixture/build/acecode-desktop" + cat >"$fixture/build/acecode-daemon" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "$fixture/build/acecode-daemon" + } + + # Case: TUI-only flat (linux) flow passes end to end. + write_tui_stub 0 + expect_status 0 "tui flow" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target tui \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" + expect_output "verify-package: PASS" "tui flow summary" + [[ -f "$fixture/staging/acecode" ]] + [[ -f "$fixture/staging/share/acecode/models_dev/api.json" ]] + [[ -f "$fixture/staging/share/acecode/seed/skills/demo/SKILL.md" ]] + + # Case: full linux flow including desktop launch and daemon adjacency. + write_desktop_stub + expect_status 0 "linux full flow" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target all \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" --launch-timeout 2 + expect_output "\[PASS\] desktop daemon adjacency" "daemon adjacency" + expect_output "\[PASS\] desktop launch" "desktop launch" + + # Case: macOS app bundle flow verifies bundle layout and resources. + mkdir -p "$fixture/build/ACECode.app/Contents/MacOS" \ + "$fixture/build/ACECode.app/Contents/Resources/share/acecode/models_dev" \ + "$fixture/build/ACECode.app/Contents/Resources/share/acecode/seed" + cat >"$fixture/build/ACECode.app/Contents/MacOS/ACECode" <<'EOF' +#!/bin/sh +sleep 300 +EOF + chmod +x "$fixture/build/ACECode.app/Contents/MacOS/ACECode" + cat >"$fixture/build/ACECode.app/Contents/MacOS/acecode-daemon" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "$fixture/build/ACECode.app/Contents/MacOS/acecode-daemon" + cp "$fixture/assets/models_dev/"* \ + "$fixture/build/ACECode.app/Contents/Resources/share/acecode/models_dev/" + cp -R "$fixture/assets/seed" \ + "$fixture/build/ACECode.app/Contents/Resources/share/acecode/seed" + expect_status 0 "darwin bundle flow" "$python_bin" "$verify_script" \ + --skip-build --platform darwin --target desktop \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" --launch-timeout 2 + expect_output "\[PASS\] app bundle acecode-daemon" "bundle daemon" + expect_output "\[PASS\] models_dev registry (app bundle)" "bundle models_dev" + + # Negative: mutated models.dev file set fails. + printf 'extra\n' >"$fixture/assets/models_dev/extra.json" + expect_status 1 "models_dev file count" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target tui \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" + expect_output "expected exactly" "models_dev count detail" + rm "$fixture/assets/models_dev/extra.json" + + # Negative: failing registry validation fails the run. + write_tui_stub 1 + expect_status 1 "registry validation failure" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target tui \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" + expect_output "tui models registry resolution" "registry failure item" + + # Negative: desktop exiting immediately fails the launch probe. + write_tui_stub 0 + cat >"$fixture/build/acecode-desktop" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "$fixture/build/acecode-desktop" + expect_status 1 "desktop immediate exit" "$python_bin" "$verify_script" \ + --skip-build --platform linux --target desktop \ + --repo "$fixture" --build-dir "$fixture/build" \ + --staging-dir "$fixture/staging" + expect_output "exited immediately" "desktop exit detail" +else + echo "verify_package_test: flow cases skipped on non-POSIX host" +fi + +echo "verify_package_test: OK"