diff --git a/.agents/skills/development-environment/SKILL.md b/.agents/skills/development-environment/SKILL.md new file mode 100644 index 00000000..5a10e0e0 --- /dev/null +++ b/.agents/skills/development-environment/SKILL.md @@ -0,0 +1,58 @@ +--- +name: development-environment +description: Start ACECode's Web, Desktop, or TUI development environment safely, reusing a compatible build from the current Git worktree when possible. +--- + +# Development Environment + +Use this skill when the user asks to run, start, or open the ACECode development environment. + +## Select the target + +If the request does not name a target, ask exactly which development surface to run: + +- **Web** — starts the daemon-backed browser UI. +- **Desktop** — starts the native desktop shell against the local development frontend. +- **TUI** — starts the terminal interface in a new terminal window. + +Do not start anything until the user selects one target. If they name a target, proceed without repeating the question. + +## Use the shared launcher + +Run the target-specific repository launcher instead of implementing launch logic in the conversation: + +```powershell +.\scripts\dev_web.bat +.\scripts\dev_desktop.bat +.\scripts\dev_tui.bat +``` + +On macOS or Linux: + +```bash +./scripts/dev_web.sh +./scripts/dev_desktop.sh +./scripts/dev_tui.sh +``` + +Pass `--build-dir ` only when the user explicitly supplies a candidate build directory. Do not copy `acecode`, `acecode-desktop`, DLLs, or other build artifacts between worktrees. + +## Build reuse and rebuild policy + +启动器只复用当前工作树内的 CMake 构建,检查源码路径、平台、架构、目标产物及 Desktop 配置。多配置构建会编译并启动同一配置。其他已登记工作树仅可提供经验证的前端产物和编译缓存,不提供本工作树实际运行的程序。 + +Every launch incrementally builds the verified target, so source changes are incorporated even when the configured build is reused. Web and Desktop also refresh frontend assets when their inputs are newer than `web/dist`. + +If no compatible configured build exists, the launcher reports the platform CMake preset and asks for confirmation before configuration. Preserve that safety boundary: + +- Windows target-specific batch launchers automatically approve this first configuration so they work when double-clicked. +- For the shared Python launcher and POSIX target-specific launchers, state that configuration is needed, name the preset, and ask the user for explicit confirmation before adding `--yes`. +- If the user declines, do not configure, compile, or start a surface. + +The shared launcher calls the existing Python surface launchers: `scripts/dev_web.py` for Web and `scripts/dev_desktop.py` for Desktop. Web uses a worktree-isolated runtime directory and opens its resulting local URL; Desktop opens its application window; TUI opens a new terminal window. + +Windows 的 MSVC 构建会按 x64 或 ARM64 初始化 VS 环境;有效 MinGW 构建不要求 VS。Web 重建前发现既存 PID 记录时会明确失败并给出检查或停止命令;不要通过删 PID 文件、宽泛终止进程等方式绕过此检查。显式 `--run-dir` 只检查所指定的目录。 + +## Report outcome + +After a successful command, report the selected target and whether the build was reused or compiled. For Web, include the URL printed by the launcher. If startup fails, provide the launcher error and do not claim the environment is running. diff --git a/CMakePresets.json b/CMakePresets.json index c7504838..22bc39f4 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -64,6 +64,22 @@ "VCPKG_TARGET_TRIPLET": "x64-windows-static" } }, + { + "name": "windows-arm64-release", + "displayName": "Windows ARM64 Release", + "inherits": "release-base", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-windows-static" + } + }, + { + "name": "windows-arm64-desktop-release", + "displayName": "Windows ARM64 Desktop Release", + "inherits": "desktop-release-base", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-windows-static" + } + }, { "name": "windows-x64-winlibs-debug", "displayName": "Windows x64 WinLibs Debug", diff --git a/openspec/changes/add-development-environment-launcher/.openspec.yaml b/openspec/changes/add-development-environment-launcher/.openspec.yaml new file mode 100644 index 00000000..eaa6b1cd --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/add-development-environment-launcher/design.md b/openspec/changes/add-development-environment-launcher/design.md new file mode 100644 index 00000000..056d78fc --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/design.md @@ -0,0 +1,94 @@ +# Design + +## Context + +See `proposal.md` for motivation and `specs/development-environment-launcher/spec.md` for behavioral requirements. The repository already has Python-backed Web and Desktop launchers with thin platform wrappers. Web currently accepts an explicit build directory and runtime directory, while Desktop accepts an explicit build directory and rebuilds `web/dist` when its inputs are newer. TUI has no comparable wrapper. + +## Goals / Non-Goals + +**Goals:** + +- Provide one shared Python orchestration entry point and thin Windows/POSIX wrappers. +- Reuse existing Web and Desktop launch scripts rather than duplicating their surface-specific behavior. +- Reliably identify compatible builds from Git-registered worktrees before proposing a local build. +- Keep development daemons isolated per worktree and make start outcomes visible. + +**Non-Goals:** + +- Change production daemon, Desktop, TUI, or CMake behavior. +- Copy build artifacts, DLLs, or resources between worktrees. +- Search arbitrary user directories outside worktrees registered by the current Git repository. +- Automatically compile without interactive confirmation. + +## Decisions + +### Use a Python orchestration layer with thin platform wrappers + +A new `scripts/dev_environment.py` will parse the selected target and coordinate discovery, confirmation, incremental building, and launch. Dedicated target wrappers will select Python and a fixed target: `dev_web.bat` / `dev_web.sh`, `dev_desktop.bat` / `dev_desktop.sh`, and `dev_tui.bat` / `dev_tui.sh`. Python matches the existing launcher implementation and is portable across Windows, macOS, and Linux. + +Alternatives considered: + +- Separate shell implementations would duplicate platform logic and Git/CMake parsing. +- Extending `dev_web.py` or `dev_desktop.py` would couple target-independent discovery to a single surface. + +### Discover builds only from Git worktree registrations + +The orchestration layer will inspect only the current worktree's candidate build directories and read `CMakeCache.txt` to confirm that each build was configured from the current source directory. It validates platform, generator architecture clues, selected target configuration, and executable presence. Registered peer worktrees are used only for safe compiler-cache and frontend-artifact acceleration; their CMake/Ninja directories are never built or launched for the current worktree. + +Alternatives considered: + +- Scan sibling or home directories: this can find unrelated repositories and is slower. +- Compare only executable timestamps: timestamps do not prove the source revision or Desktop capability. + +### Delegate surface-specific startup + +The shared launcher performs the target's incremental CMake build after it validates or configures a build directory. For Web and Desktop, it then invokes the existing Python surface launchers directly, forwarding the validated `--build-dir`; this avoids recursing through target wrappers. The Desktop surface launcher continues to refresh frontend assets, and the shared launcher adds the equivalent freshness check before Web launch. Web receives a deterministic runtime directory below the current worktree's ignored development state. TUI is started from the validated `acecode` binary in a new terminal window using platform-specific process invocation. + +Alternatives considered: + +- Reimplement Web and Desktop startup in the new tool: would create two sources of truth for Web assets and Desktop development-mode behavior. +- Run TUI in the current terminal: conflicts with the agreed ability to continue launcher work after opening TUI. + +### Auto-configure missing builds from Windows direct entry points + +Double-clicked batch files do not provide a reliable input stream for the shared launcher's confirmation prompt. Each Windows target entry point will therefore append `--yes` when it calls `dev_environment.py`, approving only the missing-build configuration path. The shared Python launcher remains conservative for callers that invoke it directly, and POSIX wrappers retain their interactive confirmation behavior. + +### Initialize the Windows C++ toolchain in batch entry points + +Windows 包装脚本先选择 Python,公共启动器识别候选构建后才决定是否需要 MSVC。MinGW 构建直接使用已有编译器;MSVC 路径通过共享 `dev_windows_env.bat` 查询匹配 x64 或 ARM64 的 VS 组件并初始化环境。Python 只在子进程内捕获环境变量,再传递给后续编译,不输出环境内容。帮助、列表和 dry-run 不触发工具链初始化。Windows ARM64 对应的两个默认 configure presets 与 x64 使用相同的基础配置。 + +Alternatives considered: + +- Require callers to use a Developer Command Prompt: contradicts direct double-click and normal-shell entry point behavior. +- Duplicate Visual Studio path discovery in all three wrappers: risks inconsistent architecture and error handling. + +### Require interactive confirmation only for a new configuration + +When discovery cannot produce a compatible result, the tool calculates the native CMake preset for the selected target and prints configure/build commands. It asks a yes/no question only when stdin is interactive; non-interactive invocations fail with the same instructions rather than implicitly configuring. Once a build directory has been validated or configured, the target's incremental build runs without another confirmation so each launch reflects current sources. First-time development configurations pass `-DBUILD_TESTING=OFF`, because unit-test dependencies are optional in the vcpkg manifest and are not needed to run a development surface. + +Alternatives considered: + +- Auto-build: potentially expensive and unexpected. +- Always fail: forces developers to reconstruct platform-specific presets manually. + +## Risks / Trade-offs + +### 发布审查收敛 + +- 多配置构建记录实际产物的配置名称,并通过 `cmake --build --config` 编译同一配置;仅修改编译缓存 launcher 时保留已有 `CMAKE_BUILD_TYPE` 和 `BUILD_TESTING`。独立的嵌套 preset 不属于父级 CMake 构建。 +- 公共启动器向 Desktop 传递已验证的具体产物,向 Web 传递该可执行文件所在目录,避免二次发现选到其他配置。`--list` 只列举产物;`--rebuild` 强制刷新前端。 +- Windows TUI 直接创建新控制台进程;macOS 用 Terminal 的 AppleScript 入口执行经过逐参数 shell 引用的 `cd` 和 `exec`,确保工作目录与参数一致。 +- Web 重建前遇到既存 PID 记录时,复用原有 `daemon status` 身份校验并明确失败。此次不引入跨平台进程管理框架,也不调用现有仅按 PID 终止的 `daemon stop`。仅在核验成功后显示停止命令供开发者操作;无法确认身份时只显示检查命令。默认目录检查包含当前工作树旧提交的 runtime,显式目录只检查自身。 +- 回归使用临时目录与替身命令;Windows 另验证真实 VS 环境初始化。macOS/Linux 图形终端和 ARM64 原生编译不在本轮 Windows 主机验证范围内。 + +- [A valid external build is configured with an unusual directory layout] → inspect standard build directories plus explicit `--build-dir`; require an exact `CMakeCache.txt` source match. +- [CMake does not expose a fully portable architecture field] → require a runnable platform-native executable and compare configured generator/platform fields when present; reject uncertain configurations. +- [A worktree-specific runtime path is untracked] → create it under the repository's ignored `.acecode` development state and document it in the launcher output. +- [A platform lacks a supported graphical terminal launcher] → report the exact TUI executable command instead of silently starting TUI in the caller's terminal. + +## Migration Plan + +1. Add the shared launcher and thin wrappers without changing existing Web or Desktop launch commands. +2. Add the repository-local skill, directing requests through the shared policy. +3. Validate no-target selection, verified reuse, rejected reuse, declined rebuild, and each platform wrapper through focused tests or script help checks. +4. Roll back by removing the new launcher, wrappers, and skill; existing Web and Desktop launchers remain unchanged. diff --git a/openspec/changes/add-development-environment-launcher/proposal.md b/openspec/changes/add-development-environment-launcher/proposal.md new file mode 100644 index 00000000..0d895148 --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +Developers currently need to choose and invoke separate Web or Desktop launchers manually, while TUI startup and reuse of compatible build outputs require repository-specific knowledge. A single cross-platform entry point should guide the target selection and safely reuse an existing build from a related worktree when possible. + +## What Changes + +- Add a cross-platform development-environment launcher that starts the Web daemon, Desktop shell, or terminal UI using existing repository launchers and build outputs. +- Add dedicated Windows and POSIX entry points for Web, Desktop, and TUI so developers can start the desired target without providing a target argument or using an agent skill. +- Validate reusable builds from the current worktree against its source directory, platform, architecture, and requested target; use related worktrees only for safe cache and frontend-artifact acceleration. +- Incrementally rebuild every verified build before launch so source changes are incorporated; require confirmation only before configuring a missing or incompatible build. +- Initialize the Windows Visual Studio C++ developer environment from the direct launchers so incremental builds work from a normal shell or double-clicked batch file. +- Allow Windows direct launchers to configure a missing target build automatically, so double-clicked entry points do not wait for unavailable confirmation input. +- Give each Web development workspace an isolated daemon runtime directory and open the selected development surface after a successful start. +- Add a repository-local skill that asks for a target when omitted and applies the same validation and launch policy. + +## Capabilities + +### New Capabilities + +- `development-environment-launcher`: Guided, cross-platform selection, validation, and startup of ACECode Web, Desktop, and TUI development environments. + +### Modified Capabilities + +- None. + +## Impact + +- New shared launcher logic and thin `.bat` and `.sh` entry points under `scripts/`. +- Existing `scripts/dev_web.*` and `scripts/dev_desktop.*` remain the surface-specific launch mechanisms invoked by the new launcher. +- New repository-local skill under `.agents/skills/`. +- CMake preset selection and Git worktree metadata are used for build discovery and validation; no application protocol or production runtime behavior changes. diff --git a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md new file mode 100644 index 00000000..ad6e820d --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md @@ -0,0 +1,111 @@ +# Spec Delta + +## Purpose + +Provide a safe, consistent way to start ACECode development surfaces across supported platforms without manually locating compatible build artifacts. + +## ADDED Requirements + +### Requirement: Development target selection +The development-environment launcher SHALL start exactly one selected development target: Web, Desktop, or TUI. When no target is provided to an interactive launcher invocation, it SHALL prompt the developer to select one. A repository-local assistant skill SHALL ask the developer to select one of those targets when a request to run the development environment does not name a target. + +#### Scenario: Interactive target selection +- **WHEN** a developer starts the launcher without a target in an interactive terminal +- **THEN** the launcher prompts for Web, Desktop, or TUI and starts only the selected target + +#### Scenario: Skill target selection +- **WHEN** a developer asks the repository-local skill to run the development environment without naming a target +- **THEN** the skill asks whether to run Web, Desktop, or TUI before starting work + +### Requirement: Compatible build reuse +Before requesting a new configuration, the launcher and skill SHALL search the current worktree for a compatible existing build. A build is reusable only when its configured source directory is the current worktree, its executable is runnable on the current platform and architecture, and it contains the executable required by the selected target. A Desktop target additionally requires a Desktop-enabled build and Desktop executable. Other registered worktrees MAY provide content-addressed compiler cache entries and verified frontend artifacts, but their path-bound build directories and executables SHALL NOT be used for the current worktree. + +#### Scenario: Reuse a matching Web build +- **WHEN** the current worktree has a compatible configured `acecode` build +- **THEN** the launcher incrementally builds and starts the Web target with that build directory + +#### Scenario: Reject an incompatible Desktop build +- **WHEN** a matching build lacks Desktop support or the Desktop executable +- **THEN** the launcher does not use it for the Desktop target + +#### Scenario: 多配置构建保持产物一致 +- **WHEN** 同一 CMake 构建包含 Debug、Release 等多个配置 +- **THEN** 增量编译 SHALL 明确指定所选产物对应的配置,Web 与 Desktop SHALL 启动该产物,不能再次选择另一配置的旧程序 + +#### Scenario: 排除嵌套的独立构建 +- **WHEN** 某个候选目录的子目录存在独立 CMakeCache.txt +- **THEN** 该子目录的产物 SHALL 按子目录自己的配置与源码路径验证,不能归入父级构建 + +### Requirement: Fresh incremental builds and configuration confirmation +Before starting a selected target from a compatible build directory, the launcher SHALL run that target's incremental CMake build so changed source files are incorporated. Before starting Web or Desktop, it SHALL also ensure the development frontend assets are current. When no compatible configured build exists, the launcher and skill SHALL report the missing requirement and the CMake preset selected for the current platform, then configure the development target with testing disabled so optional unit-test dependencies do not block startup. They SHALL obtain explicit developer confirmation before configuring a new build unless a Windows direct entry point supplies its automatic approval. A declined confirmation SHALL leave source and build files unchanged and SHALL not start a development target. + +#### Scenario: Refresh a compatible build +- **WHEN** a compatible build exists and source files have changed +- **THEN** the launcher runs the selected target's incremental build before starting it + +#### Scenario: Confirm a required configuration +- **WHEN** the selected target has no compatible build and the developer confirms the proposed configuration +- **THEN** the launcher configures and incrementally builds the required target before starting it + +#### Scenario: Decline a required configuration +- **WHEN** the selected target has no compatible build and the developer declines the proposed configuration +- **THEN** the launcher exits without configuring, compiling, or starting a target + +### Requirement: Windows direct-launch configuration +Windows target-specific batch entry points SHALL pass automatic configuration approval to the shared launcher. When no compatible configured build exists, those direct entry points SHALL configure and build it without requiring console input. The shared Python launcher and POSIX direct entry points SHALL retain explicit confirmation requirements for a missing build. + +#### Scenario: Double-clicked Web launcher requires a first build +- **WHEN** a developer starts the Windows Web batch entry point and no compatible configured build exists +- **THEN** it configures, builds, and starts the Web target without waiting for confirmation input + +### Requirement: Windows compiler environment initialization +Windows 启动器 SHALL 在需要 MSVC 编译时初始化与本机及目标架构匹配的 Visual Studio C++ 环境;x64 与 ARM64 默认配置预设 SHALL 实际存在。已验证的 MinGW 构建 SHALL 可以使用现有工具链,不要求安装 Visual Studio。缺少所需 MSVC 工具时 SHALL 在开始编译前明确报错,并说明 Build Tools C++ 工作负载要求。帮助、产物列表和 dry-run SHALL 不要求初始化编译环境。 + +#### Scenario: Start from a normal Windows shell +- **WHEN** a developer starts a Windows target-specific entry point from a shell without Visual Studio compiler variables +- **THEN** the entry point initializes the developer environment and the incremental build receives the C++ standard-library include paths + +#### Scenario: Missing Visual Studio C++ tools +- **WHEN** Windows 启动器需要 MSVC,但找不到适配架构的 Visual Studio C++ 开发环境 +- **THEN** 启动器报告所需 Build Tools 组件,并且不开始编译 + +#### Scenario: 纯 MinGW 环境 +- **WHEN** 开发者提供当前工作树内有效的 x64-mingw-static 构建,且未安装 Visual Studio +- **THEN** 启动器使用该构建的 MinGW 工具链继续增量编译 + +### Requirement: Target-specific startup +The launcher SHALL reuse the repository's existing Web and Desktop launch scripts for those targets. It SHALL start TUI in a new terminal window. Web startup SHALL use a runtime directory isolated to the current worktree and SHALL open the resulting local Web URL after successful startup. Desktop startup SHALL open the Desktop application after successful startup. + +#### Scenario: Isolated Web startup +- **WHEN** a developer starts the Web target for a worktree +- **THEN** its daemon uses a worktree-specific runtime directory and opens that daemon's URL + +#### Scenario: TUI startup +- **WHEN** a developer starts the TUI target +- **THEN** the launcher opens the TUI executable in a new terminal window + +#### Scenario: TUI 保留工作目录和参数 +- **WHEN** 工作树路径或 TUI 参数包含空格、引号及 shell 特殊字符 +- **THEN** 新终端 SHALL 在当前工作树目录执行 TUI,并将每个参数按原值传递;Windows SHALL 不使用 cmd/start 重新解释这些参数 + +### Requirement: Web 开发实例占用检查 +Web 启动器 SHALL 在增量编译前检查所选 runtime 是否存在 daemon PID 记录。对于默认 runtime,检查 SHALL 包括当前工作树其他提交遗留的启动器目录。存在记录时 SHALL 使用现有 daemon status 机制有界核验并明确失败,避免编译后继续复用旧 worker 或覆盖正在运行的 Windows 可执行文件。核验成功时 SHALL 输出该实例的精确停止命令,核验失败时 SHALL 仅输出检查建议;启动器 SHALL NOT 自动终止进程或删除 runtime 文件。显式指定 runtime 时 SHALL 仅检查该目录。 + +#### Scenario: 已存在开发 daemon +- **WHEN** 所选或本工作树旧的默认 runtime 仍有 PID 记录 +- **THEN** 启动器在编译前退出,报告该目录和检查结果,不编译、不启动、不终止任何进程 + +#### Scenario: 显式指定 runtime +- **WHEN** 开发者传入 --run-dir +- **THEN** 启动器只检查该目录,不检查、终止或清理默认目录内的 daemon + +### Requirement: Target-specific cross-platform entry points +The repository SHALL provide dedicated thin Windows and POSIX entry points for Web, Desktop, and TUI. Each entry point SHALL select its target without requiring a target argument, delegate to the shared development-environment launcher, pass supported command-line arguments through unchanged, and provide a clear error when no supported Python interpreter is available. + +#### Scenario: Windows Web launch +- **WHEN** a Windows developer runs the Web batch entry point +- **THEN** it delegates to the shared launcher with the Web target selected + +#### Scenario: POSIX TUI launch +- **WHEN** a macOS or Linux developer runs the TUI shell entry point +- **THEN** it delegates to the shared launcher with the TUI target selected diff --git a/openspec/changes/add-development-environment-launcher/tasks.md b/openspec/changes/add-development-environment-launcher/tasks.md new file mode 100644 index 00000000..f9a15467 --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/tasks.md @@ -0,0 +1,29 @@ +# Tasks + +## 1. Shared launcher + +- [x] 1.1 Add the cross-platform Python launcher with interactive target selection, target parsing, and platform-specific CMake preset selection; verify its help output and target-validation behavior. +- [x] 1.2 Implement Git worktree build discovery and compatibility validation for source revision, executable, platform/architecture, and Desktop configuration; verify focused tests cover accepted and rejected candidates. +- [x] 1.3 Implement automatic incremental builds for verified configured builds, confirmation only for missing/incompatible configuration, and explicit non-interactive or declined-configuration failures; verify no configure command runs without confirmation. + +## 2. Target startup and entry points + +- [x] 2.1 Delegate Web and Desktop starts to the existing Python surface launchers after refreshing frontend assets, use an isolated Web runtime directory, and report launch outcomes; verify forwarded arguments with stub launchers. +- [x] 2.2 Start TUI from the verified executable in a new terminal window on supported platforms and report unsupported terminal-launch behavior; verify command construction with focused tests. +- [x] 2.3 Provide target-specific `dev_web`, `dev_desktop`, and `dev_tui` Windows/POSIX entry points that select a fixed target and forward supported arguments unchanged; verify syntax and `--help` delegation on available platforms. + +## 3. Windows toolchain and verification + +- [x] 3.1 Add a shared Windows Visual Studio developer-environment helper and invoke it from each target-specific batch entry point; verify a normal shell receives C++ compiler include paths. +- [x] 3.2 Make Windows target-specific entry points automatically approve a missing-build configuration, while preserving explicit confirmation for Python and POSIX callers; verify wrapper argument forwarding and focused launcher tests. +- [x] 3.3 Run Python compilation checks and `openspec validate add-development-environment-launcher --strict`. + +## 4. 发布前审查修复 + +- [x] 4.1 保留现有构建类型,并让多配置构建、产物选择与实际启动使用同一配置;补充 Debug/Release 并存回归。 +- [x] 4.2 完整传递 TUI 参数,Windows 直接创建新控制台以避免 shell 转义错误,macOS 显式设置工作目录;验证特殊字符参数与启动错误传播。 +- [x] 4.3 正确识别 Windows MinGW 构建,按本机架构初始化 MSVC,仅需要 MSVC 时要求安装 Visual Studio。 +- [x] 4.4 Web 重编译前有界核验既存 runtime 并明确失败;提供身份已验证实例的停止命令,身份不明时仅提示检查,不自动终止进程或删除状态文件。 +- [x] 4.5 运行隔离 Python 回归、语法检查及 OpenSpec 严格验证,记录三平台实际验证边界。 + +验证记录(2026-09-20):54 项 `dev_*test.py` 测试通过;9 个 Python 文件通过 Python 3.8 语法解析;3 个 POSIX 包装脚本通过 Bash 语法检查;两个 OpenSpec change 严格验证通过。Windows 实际验证 MSVC x64 环境初始化与包含空格、`&`、`%PATH%`、`!` 的辅助脚本路径。macOS/Linux 验证命令构造及参数保留,未启动图形界面;未运行 ARM64 原生构建。 diff --git a/openspec/changes/add-worktree-build-acceleration/.openspec.yaml b/openspec/changes/add-worktree-build-acceleration/.openspec.yaml new file mode 100644 index 00000000..eaa6b1cd --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/add-worktree-build-acceleration/design.md b/openspec/changes/add-worktree-build-acceleration/design.md new file mode 100644 index 00000000..ed3aa829 --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/design.md @@ -0,0 +1,60 @@ +# Design + +## Context + +See `proposal.md` for motivation and `specs/worktree-build-acceleration/spec.md` for requirements. `scripts/dev_environment.py` currently validates builds against Git worktrees, keeps build directories independent, incrementally builds the selected target, and calls the Desktop launcher module to refresh frontend assets. Its first-worktree build compiles all ACECode sources even when a related worktree has already compiled identical content. + +## Goals / Non-Goals + +**Goals:** + +- Preserve one CMake/Ninja build directory per worktree and selected preset. +- Reuse content-addressed compiler results through the user's existing `sccache` cache. +- Reuse a verified frontend production output only when Git and working-tree state make it safe. +- Make all acceleration paths optional and self-healing through ordinary local builds. + +**Non-Goals:** + +- Copy, link, or share CMake caches, Ninja files, object files, executables, or `node_modules` between worktrees. +- Download or install `sccache` automatically. +- Change production builds or require compiler caching for any developer. + +## Decisions + +### Configure sccache through CMake compiler launchers + +The launcher finds `sccache` from `PATH` plus conventional platform-specific package-manager installation directories. When executable, it passes `CMAKE_C_COMPILER_LAUNCHER` and `CMAKE_CXX_COMPILER_LAUNCHER` only during CMake configuration. A launcher-owned cache-state marker in the build directory records whether caching was successfully configured, allowing the next launch to decide whether it must reconfigure when availability changes. + +Alternatives considered: + +- Set process-wide compiler environment variables: harder to audit and can affect unrelated commands. +- Use `clcache`: works for MSVC but needs a separate implementation for other supported platforms. +- Share the build directory: unsafe because generated build metadata stores absolute worktree paths. + +### Treat cache instrumentation as best effort + +The launcher invokes `sccache --show-stats --stats-format=json` before and after building when supported, computes a delta for cache hits, misses, and errors, and prints it. Parsing or command failures disable only reporting. Cache configuration failures or cache-enabled build failures trigger a CMake reconfiguration without launchers and one ordinary-build retry; only a failed retry stops the requested launch. + +### Copy frontend artifacts, never link them + +The launcher considers registered Git worktrees at the same commit. It excludes a worktree if `git status --porcelain -- web` has output. It selects the newest valid `web/dist/index.html`, copies the entire `web/dist` tree into the current worktree, and invokes the existing freshness predicate. Linking would let a build in one worktree mutate another's assets; copying preserves ownership after the seed step. + +Alternatives considered: + +- Trust commit equality alone: local frontend modifications could make a copied artifact stale. +- Hash all frontend input files: stricter but slower and unnecessary once both trees are clean at the same commit. +- Require `node_modules`: not needed to serve a verified copied output. + +## Risks / Trade-offs + +- [A cache tool installs in an unrecognized path] → use `PATH` first, document the optional install hint, and safely fall back. +- [Other builds change global sccache statistics] → label figures as per-launch observed deltas rather than exact attribution. +- [A copied artifact becomes stale while copying] → compare frontend input modification times after the copy; local Vite build remains the fallback. +- [Cache configuration fails after a previously successful configuration] → reconfigure without launchers and continue the requested build. + +## Migration Plan + +1. Add pure helpers for cache discovery/state and frontend seed eligibility, with focused tests. +2. Integrate helpers into first-time and existing-build configuration flows. +3. Run the Web launcher in a clean worktree with and without `sccache`, confirming both fallback and accelerated paths. +4. Roll back by removing launcher cache settings and seed logic; all build directories and frontend output remain usable through the existing local build flow. diff --git a/openspec/changes/add-worktree-build-acceleration/proposal.md b/openspec/changes/add-worktree-build-acceleration/proposal.md new file mode 100644 index 00000000..49b5d045 --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/proposal.md @@ -0,0 +1,30 @@ +# Proposal + +## Why + +New Git worktrees require independent CMake build directories for correctness, but compiling every unchanged ACECode source file and rebuilding identical frontend output makes their first development launch unnecessarily slow. The development launcher should safely reuse cross-worktree caches and verified frontend output without sharing path-bound build directories. + +## What Changes + +- Detect and configure `sccache` as an optional cross-worktree C/C++ compiler cache for development builds. +- Report concise per-launch cache activity and fall back to normal compilation when cache discovery, configuration, or status collection fails. +- Detect cache-tool configuration changes and reconfigure the affected build directory before its next incremental build. +- Seed a new worktree's missing or stale `web/dist` from the newest eligible, clean, same-commit worktree and validate it before skipping Vite. +- Fall back to the existing local frontend build process if frontend artifact reuse is unavailable or fails. + +## Capabilities + +### New Capabilities + +- `worktree-build-acceleration`: Safe cross-worktree compiler-cache and frontend-artifact reuse for development launcher startup. + +### Modified Capabilities + +- None. + +## Impact + +- `scripts/dev_environment.py` gains compiler-cache detection, CMake configuration management, and frontend seed selection. +- `scripts/dev_desktop.py` exposes or shares frontend freshness checks used by the launcher. +- Launcher tests cover cache discovery/configuration transitions and frontend-copy eligibility/fallback. +- No production application runtime, daemon API, or shared CMake build directory behavior changes. diff --git a/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md new file mode 100644 index 00000000..e4eb190e --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md @@ -0,0 +1,55 @@ +# Spec Delta + +## Purpose + +Accelerate first development launches in new worktrees without sharing build directories that encode worktree-specific source paths. + +## ADDED Requirements + +### Requirement: Optional shared compiler cache +The development launcher SHALL detect an available `sccache` executable using the system path and supported platform-specific installation locations. When available, it SHALL configure C and C++ compiler launchers for a development build directory so worktrees may reuse the user's default `sccache` cache. When unavailable or unusable, the launcher SHALL continue with an ordinary local compilation and provide a concise status message with an installation suggestion when the cache is absent. + +#### Scenario: Configure an available compiler cache +- **WHEN** a launcher prepares a configured development build and finds a usable `sccache` executable +- **THEN** it configures C and C++ compiler launchers to use `sccache` before the target's incremental build + +#### Scenario: Cache tool unavailable +- **WHEN** a launcher cannot find `sccache` +- **THEN** it reports an optional installation suggestion and continues with normal compilation + +#### Scenario: Cache tool failure +- **WHEN** compiler-cache configuration, execution, statistics collection, or a cache-enabled build fails +- **THEN** the launcher reports a concise reason, reconfigures without compiler launchers when necessary, and retries ordinary compilation without blocking target startup + +### Requirement: Cache configuration transitions +The development launcher SHALL detect when an existing build directory's configured compiler-cache state differs from the currently available cache state. It SHALL reconfigure that build directory before its next incremental build to add or remove compiler launcher settings as required. + +#### Scenario: Install cache after initial configuration +- **WHEN** `sccache` becomes available after a build directory was configured without it +- **THEN** the next development launch reconfigures the build directory before building + +#### Scenario: Remove cache after configuration +- **WHEN** `sccache` is no longer available for a build directory configured to use it +- **THEN** the next development launch reconfigures the build directory without compiler launcher settings before building + +### Requirement: Per-launch compiler-cache status +When `sccache` is used successfully, the development launcher SHALL report a concise summary of the cache hits, misses, and errors observed during that launch's build operation. It SHALL not require cache statistics to start a development target. + +#### Scenario: Report build cache activity +- **WHEN** the cache statistics can be read before and after a development build +- **THEN** the launcher reports the per-launch differences for hits, misses, and errors + +### Requirement: Verified frontend artifact seed +Before a Web or Desktop development launch builds frontend assets, the launcher SHALL look for a seed `web/dist` from registered Git worktrees. It MAY copy a seed only when the source and current worktrees are at the same commit, both have no uncommitted changes under `web/`, the source has `web/dist/index.html`, and the current output is missing or stale. If more than one eligible source exists, it SHALL select the source with the newest `web/dist/index.html`. It SHALL validate copied output against current frontend input timestamps before skipping the local frontend build. + +#### Scenario: Seed missing frontend output +- **WHEN** a new worktree has no `web/dist`, and an eligible same-commit worktree has current frontend output +- **THEN** the launcher copies and validates the newest eligible output, then skips `pnpm build` + +#### Scenario: Dirty frontend worktree +- **WHEN** either the source or current worktree has uncommitted changes under `web/` +- **THEN** the launcher does not reuse that source's frontend output + +#### Scenario: Seed copy failure +- **WHEN** frontend artifact copying or validation fails +- **THEN** the launcher reports the fallback and runs the ordinary local frontend build diff --git a/openspec/changes/add-worktree-build-acceleration/tasks.md b/openspec/changes/add-worktree-build-acceleration/tasks.md new file mode 100644 index 00000000..b0dd7061 --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/tasks.md @@ -0,0 +1,17 @@ +# Tasks + +## 1. Compiler-cache integration + +- [x] 1.1 Add cross-platform `sccache` discovery, installation guidance, and cache-state helpers; verify focused unit tests cover PATH, conventional paths, and absent-cache fallback. +- [x] 1.2 Configure or remove CMake compiler launchers when cache availability changes, with ordinary-build fallback on cache configuration or cache-enabled build errors; verify subprocess command tests cover both transitions. +- [x] 1.3 Collect and display best-effort per-launch `sccache` hit, miss, and error deltas; verify statistic parsing and failure fallback with focused tests. + +## 2. Frontend artifact reuse + +- [x] 2.1 Implement clean same-commit worktree eligibility and newest `web/dist` source selection; verify dirty, mismatched-commit, missing-output, and newest-source cases. +- [x] 2.2 Copy and validate a frontend seed before the local Vite build, with local-build fallback on copy or freshness failure; verify focused tests cover successful skip and fallback. + +## 3. Integration and verification + +- [x] 3.1 Wire compiler-cache and frontend-seed behavior into Web and Desktop launcher flows while preserving isolated build directories; verify focused launcher tests pass. +- [x] 3.2 Run Python compilation, OpenSpec strict validation, and an end-to-end Web startup with cache absent or present; verify daemon page reachability and report the observed acceleration path. diff --git a/scripts/dev_build_artifacts.py b/scripts/dev_build_artifacts.py new file mode 100644 index 00000000..65bc45af --- /dev/null +++ b/scripts/dev_build_artifacts.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Shared development-build artifact discovery.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def candidate_build_directories(build_dir: Path, max_depth: int = 2) -> list[Path]: + if not build_dir.is_dir(): + return [] + directories = [build_dir] + frontier = [build_dir] + for _ in range(max_depth): + next_frontier: list[Path] = [] + for parent in frontier: + try: + children = sorted( + (path for path in parent.iterdir() if path.is_dir() and path.suffix != ".app"), + key=lambda path: str(path).lower(), + ) + except OSError: + continue + directories.extend(children) + next_frontier.extend(children) + frontier = next_frontier + return directories + + +def find_named_artifacts( + build_dir: Path, + names: list[str], + app_bundle: str | None = None, + require_executable: bool = True, +) -> list[Path]: + results: list[Path] = [] + seen: set[Path] = set() + for directory in candidate_build_directories(build_dir): + candidates = [directory / name for name in names] + if app_bundle: + candidates.insert(0, directory / app_bundle) + for candidate in candidates: + if not candidate.is_file() and not (app_bundle and candidate.name == app_bundle and candidate.is_dir()): + continue + if ( + require_executable + and candidate.is_file() + and os.name != "nt" + and not os.access(candidate, os.X_OK) + ): + continue + resolved = candidate.resolve() + if resolved not in seen: + seen.add(resolved) + results.append(candidate) + return results diff --git a/scripts/dev_desktop.bat b/scripts/dev_desktop.bat index 750125fa..b608e276 100644 --- a/scripts/dev_desktop.bat +++ b/scripts/dev_desktop.bat @@ -1,25 +1,22 @@ @echo off -REM ACECode Desktop 一键开发脚本 (Windows) -REM 用法: scripts\dev_desktop.bat [选项] -REM 详见 python scripts\dev_desktop.py --help - -setlocal enabledelayedexpansion +REM ACECode Desktop development launcher (Windows) +REM Usage: scripts\dev_desktop.bat [Desktop launcher options] +setlocal set "SCRIPT_DIR=%~dp0" -REM 选择 python 解释器 where python >nul 2>&1 -if %errorlevel%==0 ( +if not errorlevel 1 ( set "PYTHON=python" ) else ( where py >nul 2>&1 - if %errorlevel%==0 ( + if not errorlevel 1 ( set "PYTHON=py" ) else ( - echo [ERROR] 未找到 python 或 py,请先安装 Python 3.8+ + echo [ERROR] python or py was not found. Install Python 3.8+ first. exit /b 1 ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_desktop.py" %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" desktop --yes %* exit /b %errorlevel% diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py index cb153130..9eb29fcc 100644 --- a/scripts/dev_desktop.py +++ b/scripts/dev_desktop.py @@ -26,6 +26,12 @@ import sys from pathlib import Path +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from dev_build_artifacts import find_named_artifacts + def _supports_color() -> bool: if os.environ.get("NO_COLOR"): return False @@ -185,55 +191,12 @@ def build_web(web_dir: Path, pnpm: str, force: bool = False) -> None: def find_desktop_builds(build_dir: Path) -> list[Path]: """查找 build 根、直接子目录及 preset/config 两层布局的产物。""" - results: list[Path] = [] - if not build_dir.is_dir(): - return results - - search_dirs = [build_dir] - try: - first_level = sorted( - (path for path in build_dir.iterdir() - if path.is_dir() and path.suffix != ".app"), - key=lambda path: str(path).lower(), - ) - except OSError: - first_level = [] - search_dirs.extend(first_level) - for first in first_level: - try: - search_dirs.extend(sorted( - (path for path in first.iterdir() - if path.is_dir() and path.suffix != ".app"), - key=lambda path: str(path).lower(), - )) - except OSError: - continue - - seen = set() - for child in search_dirs: - # macOS .app bundle - app_bundle = child / "ACECode.app" - if app_bundle.is_dir(): - resolved = app_bundle.resolve() - if resolved not in seen: - seen.add(resolved) - results.append(app_bundle) - # Windows .exe - exe = child / "acecode-desktop.exe" - if exe.is_file(): - resolved = exe.resolve() - if resolved not in seen: - seen.add(resolved) - results.append(exe) - # Linux / macOS 裸可执行文件(非 .app) - binary = child / "acecode-desktop" - if binary.is_file() and os.access(binary, os.X_OK): - resolved = binary.resolve() - if resolved not in seen: - seen.add(resolved) - results.append(binary) - - return results + return find_named_artifacts( + build_dir, + ["acecode-desktop.exe", "acecode-desktop"], + "ACECode.app", + require_executable=False, + ) def display_path(path: Path, project_root: Path) -> str: diff --git a/scripts/dev_desktop.sh b/scripts/dev_desktop.sh index 09d7b360..618d38a1 100755 --- a/scripts/dev_desktop.sh +++ b/scripts/dev_desktop.sh @@ -1,20 +1,18 @@ #!/bin/bash -# ACECode Desktop 一键开发脚本 (macOS / Linux) -# 用法: ./scripts/dev_desktop.sh [选项] -# 详见 python scripts/dev_desktop.py --help +# ACECode Desktop development launcher (macOS / Linux) +# Usage: ./scripts/dev_desktop.sh [Desktop launcher options] set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# 选择 python 解释器 -if command -v python3 &>/dev/null; then +if command -v python3 >/dev/null 2>&1; then PYTHON=python3 -elif command -v python &>/dev/null; then +elif command -v python >/dev/null 2>&1; then PYTHON=python else - echo "[ERROR] 未找到 python3 或 python,请先安装 Python 3.8+" + echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2 exit 1 fi -exec "$PYTHON" "$SCRIPT_DIR/dev_desktop.py" "$@" +exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" desktop "$@" diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py new file mode 100644 index 00000000..0aafceb5 --- /dev/null +++ b/scripts/dev_environment.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +"""Launch ACECode Web, Desktop, or TUI development environments safely.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import platform +import re +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from dev_build_artifacts import find_named_artifacts + +TARGETS = ("web", "desktop", "tui") +CACHE_MARKER = ".acecode-sccache.json" + + +@dataclass(frozen=True) +class BuildCandidate: + build_dir: Path + source_dir: Path + executable: Path + configuration: str | None = None + + +def project_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def native_executable_name(name: str) -> str: + return f"{name}.exe" if os.name == "nt" else name + + +def executable_for(build_dir: Path, target: str) -> Path | None: + name = native_executable_name("acecode-desktop" if target == "desktop" else "acecode") + app_bundle = "ACECode.app" if target == "desktop" and sys.platform == "darwin" else None + matches = find_named_artifacts(build_dir, [name], app_bundle) + for executable in matches: + # A nested preset is a different CMake build, even when its binaries + # happen to be below this build's directory. Validate it separately. + directory = executable.parent + while directory != build_dir and not (directory / "CMakeCache.txt").is_file(): + directory = directory.parent + if directory == build_dir: + return executable + return None + + +def artifact_configuration(build_dir: Path, executable: Path) -> str | None: + configurations = cmake_cache_value(build_dir / "CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES") + if not configurations: + return None + relative_parts = executable.relative_to(build_dir).parts[:-1] + for configuration in configurations.split(";"): + if configuration in relative_parts: + return configuration + # Shared output directories still require an explicit build configuration. + return "Release" if "Release" in configurations.split(";") else configurations.split(";")[0] + + +def cmake_cache_value(cache: Path, key: str) -> str | None: + try: + pattern = re.compile(rf"^{re.escape(key)}(?::[^=]+)?=(.*)$") + for line in cache.read_text(encoding="utf-8", errors="replace").splitlines(): + match = pattern.match(line) + if match: + return match.group(1) + except OSError: + return None + return None + + +def cmake_source_dir(build_dir: Path) -> Path | None: + value = cmake_cache_value(build_dir / "CMakeCache.txt", "CMAKE_HOME_DIRECTORY") + return Path(value).resolve() if value else None + + +def configured_for_desktop(build_dir: Path) -> bool: + return cmake_cache_value(build_dir / "CMakeCache.txt", "ACECODE_BUILD_DESKTOP") == "ON" + + +def current_commit(root: Path) -> str | None: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + text=True, + capture_output=True, + check=False, + ) + return result.stdout.strip() if result.returncode == 0 else None + + +def registered_worktrees(root: Path) -> list[Path]: + result = subprocess.run( + ["git", "-C", str(root), "worktree", "list", "--porcelain"], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return [] + return [Path(line[len("worktree "):]).resolve() for line in result.stdout.splitlines() if line.startswith("worktree ")] + + +def build_directories(worktree: Path, explicit: Path | None = None) -> Iterable[Path]: + if explicit: + yield explicit.resolve() + return + build_root = worktree / "build" + if build_root.is_dir(): + yield build_root + yield from (path for path in build_root.iterdir() if path.is_dir()) + + +def platform_matches(build_dir: Path) -> bool: + triplet = cmake_cache_value(build_dir / "CMakeCache.txt", "VCPKG_TARGET_TRIPLET") + if not triplet: + return True + system = platform.system().lower() + expected_systems = ("windows", "mingw") if system == "windows" else ("osx",) if system == "darwin" else ("linux",) + machine = native_machine() + expected_arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None + triplet_lower = triplet.lower() + return any(expected in triplet_lower for expected in expected_systems) and (expected_arch is None or expected_arch in triplet_lower) + + +def find_compatible_build(root: Path, target: str, explicit: Path | None = None) -> BuildCandidate | None: + root = root.resolve() + directories = [(root / explicit).resolve()] if explicit else sorted( + build_directories(root), + key=lambda directory: (configured_for_desktop(directory), str(directory).lower()), + ) + for build_dir in directories: + source_dir = cmake_source_dir(build_dir) + executable = executable_for(build_dir, target) + if source_dir != root or not executable or not platform_matches(build_dir): + continue + if target == "desktop" and not configured_for_desktop(build_dir): + continue + return BuildCandidate(build_dir.resolve(), source_dir, executable, artifact_configuration(build_dir, executable)) + return None + + +def default_preset(target: str) -> str | None: + system = platform.system().lower() + machine = native_machine() + arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None + if not arch: + return None + prefix = {"windows": "windows", "darwin": "macos", "linux": "linux"}.get(system) + if not prefix: + return None + suffix = "-desktop-release" if target == "desktop" else "-release" + return f"{prefix}-{arch}{suffix}" + + +def native_machine() -> str: + if platform.system().lower() == "windows": + architecture = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE") + if architecture: + return architecture.lower() + return platform.machine().lower() + + +def needs_msvc(candidate: BuildCandidate | None) -> bool: + if candidate is None: + return True # The Windows presets use MSVC. + cache = candidate.build_dir / "CMakeCache.txt" + triplet = (cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET") or "").lower() + compiler = (cmake_cache_value(cache, "CMAKE_CXX_COMPILER") or "").lower() + return "mingw" not in triplet and not compiler.endswith(("g++.exe", "g++")) + + +def ensure_windows_environment(root: Path, candidate: BuildCandidate | None) -> bool: + if os.name != "nt" or not needs_msvc(candidate): + return True + environment = os.environ.copy() + environment["ACECODE_DEV_ENV_SCRIPT"] = str(root / "scripts/dev_windows_env.bat") + try: + # Expanding the path once, inside quotes, preserves spaces and cmd + # metacharacters in the checkout path. Never log the environment dump. + result = subprocess.run('"%ACECODE_DEV_ENV_SCRIPT%" --print-env', shell=True, + env=environment, capture_output=True, text=True, + errors="replace", timeout=60, check=False, + creationflags=subprocess.CREATE_NO_WINDOW) + except (OSError, subprocess.TimeoutExpired) as error: + print(f"[ERROR] Could not initialize the Visual Studio environment: {error}", file=sys.stderr) + return False + if result.returncode != 0: + print(result.stdout.strip() or result.stderr.strip() or "[ERROR] Visual Studio C++ initialization failed.", file=sys.stderr) + return False + for line in result.stdout.splitlines(): + key, separator, value = line.partition("=") + if separator and key and not key.startswith("="): + os.environ[key] = value + return True + + +def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool: + binary_dir = f"build/{preset}" + executable_target = "acecode-desktop" if target == "desktop" else "acecode" + print("[INFO] No compatible build was found in this repository's registered worktrees.") + print(f"[INFO] Proposed configure preset: {preset} (BUILD_TESTING=OFF)") + print(f"[INFO] Proposed build: cmake --build {binary_dir} --target {executable_target}") + if assume_yes: + return True + if not sys.stdin.isatty(): + print("[ERROR] Refusing to compile without interactive confirmation. Re-run with --yes to confirm.", file=sys.stderr) + return False + try: + return input("Configure and build now? [y/N] ").strip().lower() in {"y", "yes"} + except EOFError: + print("[ERROR] Confirmation input was unavailable; no configuration or build was started.", file=sys.stderr) + return False + + +def sccache_candidates() -> list[Path]: + executable = "sccache.exe" if os.name == "nt" else "sccache" + candidates: list[Path] = [] + from_path = shutil.which("sccache") + if from_path: + candidates.append(Path(from_path)) + if os.name == "nt": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + candidates.extend([ + Path(local_app_data) / "Microsoft" / "WinGet" / "Links" / executable, + Path(local_app_data) / "scoop" / "shims" / executable, + ]) + chocolatey = os.environ.get("ChocolateyInstall", r"C:\\ProgramData\\chocolatey") + candidates.append(Path(chocolatey) / "bin" / executable) + elif sys.platform == "darwin": + candidates.extend([Path("/opt/homebrew/bin/sccache"), Path("/usr/local/bin/sccache")]) + else: + candidates.append(Path.home() / ".cargo" / "bin" / "sccache") + return candidates + + +def find_sccache() -> Path | None: + for candidate in sccache_candidates(): + if not candidate.is_file(): + continue + resolved = candidate.resolve() + try: + result = subprocess.run( + [str(resolved), "--version"], + text=True, + capture_output=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if result.returncode == 0: + return resolved + return None + + +def sccache_install_hint() -> str: + if os.name == "nt": + return "Install sccache with: winget install Mozilla.sccache" + if sys.platform == "darwin": + return "Install sccache with: brew install sccache" + return "Install sccache with your package manager or cargo install sccache" + + +def cache_marker(build_dir: Path) -> Path: + return build_dir / CACHE_MARKER + + +def cached_sccache_state(build_dir: Path) -> tuple[str | None, bool] | None: + try: + data = json.loads(cache_marker(build_dir).read_text(encoding="utf-8")) + path = data.get("sccache") + enabled = data.get("enabled", True) + return (path if isinstance(path, str) else None, bool(enabled)) + except (OSError, ValueError, TypeError): + return None + + +def write_sccache_marker(build_dir: Path, sccache: Path | None, enabled: bool = True) -> None: + build_dir.mkdir(parents=True, exist_ok=True) + cache_marker(build_dir).write_text(json.dumps({"sccache": str(sccache) if sccache else None, "enabled": enabled}) + "\n", encoding="utf-8") + + +def cache_state_changed(build_dir: Path, sccache: Path | None) -> bool: + state = cached_sccache_state(build_dir) + return state is None or state[0] != (str(sccache) if sccache else None) + + +def sccache_disabled_for_build(build_dir: Path, sccache: Path | None) -> bool: + state = cached_sccache_state(build_dir) + return sccache is not None and state == (str(sccache), False) + + +def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | None) -> bool: + cache = build_dir / "CMakeCache.txt" + if cache.is_file(): + command = [ + "cmake", "-S", str(root), "-B", str(build_dir), + ] + triplet = cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET") + toolchain = cmake_cache_value(cache, "CMAKE_TOOLCHAIN_FILE") + overlay = cmake_cache_value(cache, "VCPKG_OVERLAY_PORTS") + if triplet: + command.append(f"-DVCPKG_TARGET_TRIPLET={triplet}") + if toolchain: + command.append(f"-DCMAKE_TOOLCHAIN_FILE={toolchain}") + if overlay: + command.append(f"-DVCPKG_OVERLAY_PORTS={overlay}") + if configured_for_desktop(build_dir): + command.append("-DACECODE_BUILD_DESKTOP=ON") + else: + command = ["cmake", "--preset", preset, "-DBUILD_TESTING=OFF"] + if sccache: + command.extend([ + f"-DCMAKE_C_COMPILER_LAUNCHER={sccache}", + f"-DCMAKE_CXX_COMPILER_LAUNCHER={sccache}", + ]) + else: + command.extend(["-DCMAKE_C_COMPILER_LAUNCHER=", "-DCMAKE_CXX_COMPILER_LAUNCHER="]) + if subprocess.run(command, cwd=root, check=False).returncode != 0: + return False + write_sccache_marker(build_dir, sccache) + return True + + +def _sum_integer_leaves(value) -> int: + if isinstance(value, bool): + return 0 + if isinstance(value, int): + return value + if isinstance(value, dict): + return sum(_sum_integer_leaves(child) for child in value.values()) + if isinstance(value, list): + return sum(_sum_integer_leaves(child) for child in value) + return 0 + + +def sccache_stats(sccache: Path) -> dict[str, int] | None: + result = subprocess.run([str(sccache), "--show-stats", "--stats-format=json"], text=True, capture_output=True, check=False) + if result.returncode != 0: + return None + try: + data = json.loads(result.stdout) + stats = data["stats"] + except (ValueError, KeyError, TypeError): + return None + return { + "hits": _sum_integer_leaves(stats.get("cache_hits", {})), + "misses": _sum_integer_leaves(stats.get("cache_misses", {})), + "errors": sum( + _sum_integer_leaves(stats.get(key, 0)) + for key in ("cache_errors", "cache_read_errors", "cache_write_errors", "dist_errors") + ), + } + + +def report_sccache_delta(before: dict[str, int] | None, after: dict[str, int] | None) -> None: + if before is None or after is None: + print("[INFO] sccache statistics unavailable; continuing.") + return + delta = {key: max(0, after[key] - before[key]) for key in before} + print(f"[INFO] sccache this build: hits={delta['hits']} misses={delta['misses']} errors={delta['errors']}") + + +def build_target(root: Path, build_dir: Path, target: str, sccache: Path | None = None, configuration: str | None = None) -> bool: + executable_target = "acecode-desktop" if target == "desktop" else "acecode" + before = sccache_stats(sccache) if sccache else None + command = ["cmake", "--build", str(build_dir), "--target", executable_target] + if configuration: + command.extend(("--config", configuration)) + result = subprocess.run( + command, + cwd=root, + check=False, + ) + if sccache: + report_sccache_delta(before, sccache_stats(sccache)) + return result.returncode == 0 + + +def configure_and_build(root: Path, preset: str, target: str, sccache: Path | None = None) -> Path | None: + build_dir = root / "build" / preset + if not configure_build(root, preset, build_dir, sccache): + return None + return build_dir if build_target(root, build_dir, target, sccache) else None + + +def web_worktree_is_clean(root: Path) -> bool: + result = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--", "web"], text=True, capture_output=True, check=False) + return result.returncode == 0 and not result.stdout.strip() + + +def newest_web_seed(root: Path) -> Path | None: + commit = current_commit(root) + if not commit or not web_worktree_is_clean(root): + return None + candidates: list[Path] = [] + for worktree in registered_worktrees(root): + if worktree == root.resolve() or current_commit(worktree) != commit or not web_worktree_is_clean(worktree): + continue + index = worktree / "web" / "dist" / "index.html" + if index.is_file(): + candidates.append(index) + return max(candidates, key=lambda item: item.stat().st_mtime, default=None) + + +def load_web_builder(root: Path): + script_path = root / "scripts" / "dev_desktop.py" + spec = importlib.util.spec_from_file_location("dev_desktop_for_environment", script_path) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def seed_web_assets(root: Path, builder) -> bool: + web_dir = root / "web" + index = web_dir / "dist" / "index.html" + if not builder.web_build_is_stale(web_dir, index): + return False + seed_index = newest_web_seed(root) + if seed_index is None: + return False + try: + if (web_dir / "dist").exists(): + shutil.rmtree(web_dir / "dist") + shutil.copytree(seed_index.parent, web_dir / "dist") + if builder.web_build_is_stale(web_dir, index): + print("[INFO] Reused Web assets were stale; falling back to local build.") + shutil.rmtree(web_dir / "dist", ignore_errors=True) + return False + print(f"[INFO] Reused Web assets from: {seed_index.parent.parent.parent}") + return True + except OSError as error: + print(f"[INFO] Could not reuse Web assets ({error}); falling back to local build.") + shutil.rmtree(web_dir / "dist", ignore_errors=True) + return False + + +def refresh_web_assets(root: Path, force: bool = False) -> bool: + module = load_web_builder(root) + if module is None: + print("[ERROR] Cannot load Web asset builder.", file=sys.stderr) + return False + if not force and seed_web_assets(root, module): + return True + try: + _, pnpm = module.ensure_node_and_pnpm() + module.build_web(root / "web", pnpm, force=force) + except (OSError, subprocess.SubprocessError, SystemExit): + return False + return True + + +def worktree_runtime_dir(root: Path) -> Path: + identity = current_commit(root) or root.name + safe = re.sub(r"[^A-Za-z0-9_.-]", "-", f"{root.name}-{identity[:12]}") + return root / ".acecode" / "dev-run" / safe + + +def selected_web_runtime_dir(root: Path, extra: list[str]) -> Path: + parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) + parser.add_argument("--run-dir", type=Path) + args, _ = parser.parse_known_args(extra) + if args.run_dir is None: + return worktree_runtime_dir(root) + return args.run_dir.resolve() if args.run_dir.is_absolute() else (root / args.run_dir).resolve() + + +def web_runtime_is_available(root: Path, candidate: BuildCandidate | None, extra: list[str]) -> bool: + run_dir = selected_web_runtime_dir(root, extra) + explicit = any(arg == "--run-dir" or arg.startswith("--run-dir=") for arg in extra) + if not explicit and run_dir.parent.is_dir(): + # A new commit changes the default run-dir name but an older worker + # can still hold this build's executable open. Inspect only this + # worktree's own launcher directories, without stopping any process. + prefix = re.sub(r"[^A-Za-z0-9_.-]", "-", root.name + "-") + for previous in sorted(run_dir.parent.iterdir()): + if previous.name.startswith(prefix) and (previous / "daemon.pid").exists(): + run_dir = previous + break + if not (run_dir / "daemon.pid").exists(): + return True + print(f"[ERROR] Existing Web daemon runtime must be stopped before rebuilding: {run_dir}", file=sys.stderr) + if candidate is None: + print("[ERROR] No verified executable is available to inspect that runtime; check it manually.", file=sys.stderr) + return False + command = [str(candidate.executable), "daemon", "status", f"--run-dir={run_dir}"] + try: + result = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=10, check=False) + if result.returncode == 0: + command = [command[0], "daemon", "stop", command[3]] + print("[INFO] Stop this verified daemon, then rerun the launcher:", file=sys.stderr) + else: + print("[ERROR] Runtime identity is unverified; inspect it before stopping or removing anything:", file=sys.stderr) + except (OSError, subprocess.TimeoutExpired): + print("[ERROR] Runtime identity check failed; inspect it manually:", file=sys.stderr) + formatted = "& " + " ".join("'" + part.replace("'", "''") + "'" for part in command) if os.name == "nt" else shlex.join(command) + print(("PowerShell: " if os.name == "nt" else "") + formatted, file=sys.stderr) + return False + + +def launch_surface(root: Path, target: str, candidate: BuildCandidate, dry_run: bool, extra: list[str]) -> int: + if target == "web": + run_dir = selected_web_runtime_dir(root, extra) + command = [sys.executable, str(root / "scripts" / "dev_web.py"), "--build-dir", str(candidate.executable.parent), "--run-dir", str(run_dir), *extra] + print(f"[INFO] Launching Web with build: {candidate.build_dir}") + print(f"[INFO] Isolated runtime directory: {run_dir}") + elif target == "desktop": + command = [sys.executable, str(root / "scripts" / "dev_desktop.py"), "--build-dir", str(candidate.executable), "--no-build", *extra] + print(f"[INFO] Launching Desktop with build: {candidate.build_dir}") + else: + command = tui_command(root, candidate.executable, extra) + if command is None: + print(f"[ERROR] Cannot open a new terminal automatically. Run: {candidate.executable}", file=sys.stderr) + return 1 + print(f"[INFO] Launching TUI in a new terminal with build: {candidate.build_dir}") + if dry_run: + print("[INFO] Dry run: " + " ".join(f'"{part}"' if " " in part else part for part in command)) + return 0 + try: + if target == "tui" and os.name == "nt": + subprocess.Popen(command, cwd=root, creationflags=subprocess.CREATE_NEW_CONSOLE) + return 0 + if target == "tui" and sys.platform != "darwin": + subprocess.Popen(command, cwd=root, start_new_session=True) + return 0 + # Surface scripts and osascript finish after creating the UI; waiting + # propagates launcher/argument failures without waiting for the GUI. + return subprocess.run(command, cwd=root, check=False).returncode + except OSError as error: + print(f"[ERROR] Could not start {target}: {error}", file=sys.stderr) + return 1 + + +def tui_command(root: Path, executable: Path, extra: list[str] | None = None) -> list[str] | None: + arguments = [str(executable), *(extra or [])] + if os.name == "nt": + return arguments + if sys.platform == "darwin": + shell_command = f"cd {shlex.quote(str(root))} && exec {shlex.join(arguments)}" + script = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(shell_command, ensure_ascii=False) + "\nend tell" + return ["osascript", "-e", script] + for terminal in ("x-terminal-emulator", "gnome-terminal", "konsole", "xterm"): + path = shutil.which(terminal) + if path: + if terminal == "gnome-terminal": + return [path, "--", *arguments] + return [path, "-e", *arguments] + return None + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Start an ACECode development environment", allow_abbrev=False) + parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start") + parser.add_argument("--build-dir", type=Path, help="build directory to validate and use") + parser.add_argument("--yes", action="store_true", help="confirm a required CMake build") + parser.add_argument("--dry-run", action="store_true", help="print the selected command without starting it") + args, extra = parser.parse_known_args() + args.extra = extra + return args + + +def choose_target(target: str | None) -> str | None: + if target: + return target + if not sys.stdin.isatty(): + print("[ERROR] Specify one target: web, desktop, or tui.", file=sys.stderr) + return None + selected = input("Choose development target [web/desktop/tui]: ").strip().lower() + if selected not in TARGETS: + print("[ERROR] Choose web, desktop, or tui.", file=sys.stderr) + return None + return selected + + +def main() -> int: + args = parse_args() + target = choose_target(args.target) + if not target: + return 2 + root = project_root() + if target == "desktop" and "--list" in args.extra: + command = [sys.executable, str(root / "scripts/dev_desktop.py"), *args.extra] + if args.build_dir: + command.extend(("--build-dir", str(args.build_dir))) + if args.dry_run: + print("[INFO] Dry run: " + subprocess.list2cmdline(command)) + return 0 + return subprocess.run(command, cwd=root, check=False).returncode + sccache = find_sccache() + if sccache: + print(f"[INFO] Using sccache: {sccache}") + else: + print(f"[INFO] sccache not found. {sccache_install_hint()}") + candidate = find_compatible_build(root, target, args.build_dir) + if args.build_dir and candidate is None: + print(f"[ERROR] --build-dir does not contain a compatible configured {target} build: {(root / args.build_dir).resolve()}", file=sys.stderr) + return 1 + if not args.dry_run and target == "web" and not web_runtime_is_available(root, candidate, args.extra): + return 1 + if not args.dry_run and not ensure_windows_environment(root, candidate): + return 1 + sccache_disabled = candidate is not None and sccache_disabled_for_build(candidate.build_dir, sccache) + if sccache_disabled: + print("[INFO] sccache is disabled for this build after a prior compiler failure.") + sccache = None + if candidate is None: + preset = default_preset(target) + if preset is None: + print("[ERROR] No supported CMake preset for this platform and architecture.", file=sys.stderr) + return 1 + if not ask_to_build(preset, target, args.yes): + return 1 + if args.dry_run: + print(f"[INFO] Dry run: cmake --preset {preset}") + return 0 + built = configure_and_build(root, preset, target, sccache) + if not built: + if sccache: + print("[INFO] sccache configuration failed; retrying normal compilation.") + built = configure_and_build(root, preset, target, None) + if not built: + return 1 + candidate = find_compatible_build(root, target, built) + if candidate is None: + print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr) + return 1 + if not args.dry_run and not sccache_disabled and cache_state_changed(candidate.build_dir, sccache): + preset = default_preset(target) + if preset is None: + print("[ERROR] Cannot reconfigure the build for this platform.", file=sys.stderr) + return 1 + if not configure_build(root, preset, candidate.build_dir, sccache): + if sccache: + print("[INFO] sccache reconfiguration failed; retrying without it.") + sccache = None + if not configure_build(root, preset, candidate.build_dir, None): + return 1 + else: + return 1 + if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache, candidate.configuration): + if sccache: + preset = default_preset(target) + print("[INFO] sccache build failed; retrying this build without sccache.") + if preset and configure_build(root, preset, candidate.build_dir, None) and build_target(root, candidate.build_dir, target, None, candidate.configuration): + write_sccache_marker(candidate.build_dir, find_sccache(), enabled=False) + sccache = None + else: + print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) + return 1 + else: + print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) + return 1 + if target in {"web", "desktop"} and not args.dry_run: + refreshed = refresh_web_assets(root, force=True) if target == "desktop" and "--rebuild" in args.extra else refresh_web_assets(root) + if not refreshed: + print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr) + return 1 + return launch_surface(root, target, candidate, args.dry_run, args.extra) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev_tui.bat b/scripts/dev_tui.bat new file mode 100644 index 00000000..4c8d9a3b --- /dev/null +++ b/scripts/dev_tui.bat @@ -0,0 +1,22 @@ +@echo off +REM ACECode TUI development launcher (Windows) +REM Usage: scripts\dev_tui.bat [launcher options] + +setlocal +set "SCRIPT_DIR=%~dp0" + +where python >nul 2>&1 +if not errorlevel 1 ( + set "PYTHON=python" +) else ( + where py >nul 2>&1 + if not errorlevel 1 ( + set "PYTHON=py" + ) else ( + echo [ERROR] python or py was not found. Install Python 3.8+ first. + exit /b 1 + ) +) + +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" tui --yes %* +exit /b %errorlevel% diff --git a/scripts/dev_tui.sh b/scripts/dev_tui.sh new file mode 100755 index 00000000..effe187e --- /dev/null +++ b/scripts/dev_tui.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# ACECode TUI development launcher (macOS / Linux) +# Usage: ./scripts/dev_tui.sh [launcher options] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON=python +else + echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2 + exit 1 +fi + +exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" tui "$@" diff --git a/scripts/dev_web.bat b/scripts/dev_web.bat index 8b127d48..6058b479 100644 --- a/scripts/dev_web.bat +++ b/scripts/dev_web.bat @@ -1,7 +1,6 @@ @echo off -REM ACECode Web UI daemon launcher (no Desktop GUI) -REM Usage: scripts\dev_web.bat [options] -REM Details: python scripts\dev_web.py --help +REM ACECode Web development launcher (Windows) +REM Usage: scripts\dev_web.bat [Web daemon options] setlocal set "SCRIPT_DIR=%~dp0" @@ -19,5 +18,5 @@ if not errorlevel 1 ( ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_web.py" %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web --yes %* exit /b %errorlevel% diff --git a/scripts/dev_web.py b/scripts/dev_web.py index 3e88e245..92836d4e 100644 --- a/scripts/dev_web.py +++ b/scripts/dev_web.py @@ -11,6 +11,8 @@ import webbrowser from pathlib import Path +from dev_build_artifacts import find_named_artifacts + def find_project_root() -> Path: current = Path(__file__).resolve().parent @@ -23,24 +25,14 @@ def find_project_root() -> Path: def find_executable(build_dir: Path) -> Path | None: name = "acecode.exe" if os.name == "nt" else "acecode" - candidates = [ - build_dir / name, - build_dir / "Release" / name, - build_dir / "Debug" / name, - build_dir / "MinSizeRel" / name, - build_dir / "RelWithDebInfo" / name, - ] - for candidate in candidates: - if candidate.is_file(): - return candidate - - matches = sorted(build_dir.glob(f"**/{name}")) if build_dir.is_dir() else [] + matches = find_named_artifacts(build_dir, [name]) return matches[0] if matches else None def main() -> int: parser = argparse.ArgumentParser( - description="Start ACECode Web UI daemon without starting the Desktop GUI" + description="Start ACECode Web UI daemon without starting the Desktop GUI", + allow_abbrev=False, ) parser.add_argument("--build-dir", default="build", help="ACECode build directory") parser.add_argument("--cwd", default=None, help="Workspace directory served by the daemon") @@ -113,23 +105,23 @@ def main() -> int: if args.foreground: return subprocess.run(command, cwd=project_root).returncode + runtime_dir = _runtime_dir(project_root, args.run_dir) + previous_port_mtime_ns = _port_mtime_ns(runtime_dir) run_options = {"cwd": project_root} if os.name == "nt": run_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP result = subprocess.run(command, **run_options) - # Exit 6 means the daemon validated an already-running instance. Other - # failures must not be hidden by a stale daemon.port from an earlier run. - if result.returncode not in (0, 6): - return result.returncode - - runtime_dir = _runtime_dir(project_root, args.run_dir) - port = _wait_for_port(runtime_dir) + # The Windows daemon wrapper can time out before its worker has finished + # loading configuration. Accept a nonzero wrapper exit only when it is + # followed by a freshly written port file from this launch. + allow_existing_port = result.returncode in (0, 6) + port = _wait_for_port(runtime_dir, previous_port_mtime_ns, allow_existing_port) if port is None: - print("[ERROR] Daemon started without a readable Web UI port.", file=sys.stderr) + print("[ERROR] Daemon started without a fresh readable Web UI port.", file=sys.stderr) return result.returncode or 1 - _open_web_ui(port, args.no_browser, already_running=result.returncode != 0) + _open_web_ui(port, args.no_browser, already_running=result.returncode == 6) return 0 @@ -152,13 +144,21 @@ def _open_web_ui(port: int, no_browser: bool, already_running: bool = False) -> webbrowser.open(url) -def _wait_for_port(runtime_dir: Path) -> int | None: +def _port_mtime_ns(runtime_dir: Path) -> int | None: + try: + return (runtime_dir / "daemon.port").stat().st_mtime_ns + except OSError: + return None + + +def _wait_for_port(runtime_dir: Path, previous_mtime_ns: int | None = None, allow_existing: bool = True) -> int | None: port_file = runtime_dir / "daemon.port" deadline = time.monotonic() + 30 while time.monotonic() < deadline: try: port = int(port_file.read_text(encoding="utf-8").strip()) - if 1 <= port <= 65535: + mtime_ns = port_file.stat().st_mtime_ns + if 1 <= port <= 65535 and (allow_existing or previous_mtime_ns is None or mtime_ns > previous_mtime_ns): return port except (OSError, ValueError): pass diff --git a/scripts/dev_web.sh b/scripts/dev_web.sh new file mode 100755 index 00000000..6a76b9cc --- /dev/null +++ b/scripts/dev_web.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# ACECode Web development launcher (macOS / Linux) +# Usage: ./scripts/dev_web.sh [Web daemon options] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON=python +else + echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2 + exit 1 +fi + +exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" web "$@" diff --git a/scripts/dev_windows_env.bat b/scripts/dev_windows_env.bat new file mode 100644 index 00000000..5247d702 --- /dev/null +++ b/scripts/dev_windows_env.bat @@ -0,0 +1,43 @@ +@echo off +REM Initialize the native Visual Studio C++ environment when MSVC is required. + +setlocal EnableDelayedExpansion +set "ACECODE_VSARCH=amd64" +set "ACECODE_VSCOMPONENT=Microsoft.VisualStudio.Component.VC.Tools.x86.x64" +if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "ACECODE_VSARCH=arm64" +if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" set "ACECODE_VSARCH=arm64" +if "!ACECODE_VSARCH!"=="arm64" set "ACECODE_VSCOMPONENT=Microsoft.VisualStudio.Component.VC.Tools.ARM64" +set "ACECODE_VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if not exist "!ACECODE_VSWHERE!" goto :missing_installer + +set "ACECODE_VSINSTALL=" +for /f "usebackq delims=" %%I in (`"!ACECODE_VSWHERE!" -latest -products * -requires !ACECODE_VSCOMPONENT! -property installationPath`) do set "ACECODE_VSINSTALL=%%I" +if not defined ACECODE_VSINSTALL goto :missing_tools + +set "ACECODE_VSDEVCMD=!ACECODE_VSINSTALL!\Common7\Tools\VsDevCmd.bat" +if not exist "!ACECODE_VSDEVCMD!" goto :missing_command + +for %%I in ("!ACECODE_VSDEVCMD!") do for %%A in (!ACECODE_VSARCH!) do endlocal & set "ACECODE_VSDEVCMD=%%~fI" & set "ACECODE_VSARCH=%%A" +call "%ACECODE_VSDEVCMD%" -arch=%ACECODE_VSARCH% -host_arch=%ACECODE_VSARCH% >nul +if errorlevel 1 goto :initialization_failed +if /i "%~1"=="--print-env" set +exit /b 0 + +:missing_installer +endlocal +echo [ERROR] Visual Studio Installer was not found. Install Visual Studio Build Tools with the Desktop development with C++ workload. +exit /b 1 + +:missing_tools +endlocal +echo [ERROR] Visual Studio C++ tools were not found. Install the Desktop development with C++ workload. +exit /b 1 + +:missing_command +endlocal +echo [ERROR] Visual Studio developer command script was not found. +exit /b 1 + +:initialization_failed +echo [ERROR] Failed to initialize the Visual Studio C++ developer environment. +exit /b 1 diff --git a/tests/scripts/dev_build_artifacts_test.py b/tests/scripts/dev_build_artifacts_test.py new file mode 100644 index 00000000..4d8f9bee --- /dev/null +++ b/tests/scripts/dev_build_artifacts_test.py @@ -0,0 +1,31 @@ +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +spec = importlib.util.spec_from_file_location("dev_build_artifacts", ROOT / "scripts/dev_build_artifacts.py") +artifacts = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = artifacts +spec.loader.exec_module(artifacts) + + +class DevBuildArtifactsTest(unittest.TestCase): + def test_finds_root_and_two_level_artifacts_without_deep_tree_scan(self): + with tempfile.TemporaryDirectory() as directory: + build = Path(directory) + direct = build / "acecode.exe" + nested = build / "preset" / "Release" / "acecode.exe" + too_deep = build / "a" / "b" / "c" / "acecode.exe" + for path in (direct, nested, too_deep): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + path.chmod(0o755) + self.assertEqual(artifacts.find_named_artifacts(build, ["acecode.exe"]), [direct, nested]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/dev_environment_review_test.py b/tests/scripts/dev_environment_review_test.py new file mode 100644 index 00000000..ff751df2 --- /dev/null +++ b/tests/scripts/dev_environment_review_test.py @@ -0,0 +1,255 @@ +"""Production-path regressions found during development launcher review.""" + +import argparse +import contextlib +import io +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +import dev_environment as launcher + + +class DevelopmentLauncherReviewTest(unittest.TestCase): + def make_candidate(self, root, configuration=None): + root = root.resolve() + build = root / "build" / "configured" + build.mkdir(parents=True) + cache = "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root.resolve()) + "\nACECODE_BUILD_DESKTOP:BOOL=ON\n" + if configuration: + cache += "CMAKE_CONFIGURATION_TYPES:STRING=Debug;Release\n" + (build / "CMakeCache.txt").write_text(cache, encoding="utf-8") + output = build / configuration if configuration else build + output.mkdir(exist_ok=True) + executable = output / launcher.native_executable_name("acecode-desktop") + executable.touch() + executable.chmod(0o755) + return launcher.BuildCandidate(build, root, executable, configuration) + + def test_multiconfig_build_and_desktop_launch_use_same_artifact(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + expected = self.make_candidate(root, "Debug") + release = expected.build_dir / "Release" / expected.executable.name + release.parent.mkdir() + release.touch() + release.chmod(0o755) + candidate = launcher.find_compatible_build(root, "desktop") + self.assertEqual(candidate.configuration, "Debug") + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertTrue(launcher.build_target(root, candidate.build_dir, "desktop", None, candidate.configuration)) + self.assertEqual(run.call_args.args[0][-2:], ["--config", "Debug"]) + self.assertEqual(launcher.launch_surface(root, "desktop", candidate, False, []), 0) + command = run.call_args.args[0] + self.assertEqual(command[command.index("--build-dir") + 1], str(expected.executable)) + self.assertNotIn(str(release), command) + + def test_reconfigure_preserves_existing_build_type_and_testing(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = self.make_candidate(root) + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertTrue(launcher.configure_build(root, "ignored", candidate.build_dir, None)) + command = run.call_args.args[0] + self.assertFalse(any(arg.startswith("-DCMAKE_BUILD_TYPE=") or arg.startswith("-DBUILD_TESTING=") for arg in command)) + + def test_relative_build_directory_is_resolved_against_checkout(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + expected = self.make_candidate(root) + actual = launcher.find_compatible_build(root, "desktop", Path("build/configured")) + self.assertEqual(actual.build_dir, expected.build_dir) + + def test_nested_preset_artifact_is_not_attributed_to_parent_build(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + expected = self.make_candidate(root) + parent = root / "build" + (parent / "CMakeCache.txt").write_text( + "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root) + "\nACECODE_BUILD_DESKTOP:BOOL=ON\n", encoding="utf-8") + self.assertIsNone(launcher.executable_for(parent, "desktop")) + self.assertEqual(launcher.find_compatible_build(root, "desktop").build_dir, expected.build_dir) + + def test_windows_tui_uses_no_shell_and_preserves_arguments(self): + root = Path("C:/dev/ACE & test%PATH%!") + executable = root / "acecode.exe" + candidate = launcher.BuildCandidate(root, root, executable) + extra = ["--cwd", "C:/project with spaces", "--prompt", 'quote " & %PATH% ! $x 中文'] + with patch.object(launcher.os, "name", "nt"), \ + patch.object(launcher.subprocess, "CREATE_NEW_CONSOLE", 0x10, create=True), \ + patch.object(launcher.subprocess, "Popen") as popen: + self.assertEqual(launcher.launch_surface(root, "tui", candidate, False, extra), 0) + self.assertEqual(popen.call_args.args[0], [str(executable), *extra]) + self.assertEqual(popen.call_args.kwargs, {"cwd": root, "creationflags": 0x10}) + + def test_macos_tui_preserves_cwd_and_shell_characters(self): + root = Path("/tmp/ACE ' & workspace") + executable = root / "acecode" + extra = ["--prompt", 'literal `echo wrong` $(echo wrong) "quotes" 中文'] + with patch.object(launcher.os, "name", "posix"), patch.object(launcher.sys, "platform", "darwin"): + command = launcher.tui_command(root, executable, extra) + self.assertEqual(command[:2], ["osascript", "-e"]) + quoted_command = command[2].split("do script ", 1)[1].split("\nend tell", 1)[0] + self.assertEqual(shlex.split(json.loads(quoted_command)), ["cd", str(root), "&&", "exec", str(executable), *extra]) + + def test_linux_tui_forwards_each_argument(self): + root = Path("/tmp/ACE & workspace") + for terminal, separator in (("gnome-terminal", "--"), ("konsole", "-e"), ("xterm", "-e")): + with self.subTest(terminal=terminal), patch.object(launcher.os, "name", "posix"), \ + patch.object(launcher.sys, "platform", "linux"), \ + patch.object(launcher.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}" if name == terminal else None): + self.assertEqual(launcher.tui_command(root, root / "acecode", ["--prompt", "two words"]), + [f"/usr/bin/{terminal}", separator, str(root / "acecode"), "--prompt", "two words"]) + + def test_surface_script_failure_is_returned(self): + root = Path("C:/work") + candidate = launcher.BuildCandidate(root, root, root / "acecode-desktop.exe") + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 7)): + self.assertEqual(launcher.launch_surface(root, "desktop", candidate, False, ["--invalid"]), 7) + + def test_mingw_is_compatible_and_does_not_require_visual_studio(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = self.make_candidate(root) + cache = candidate.build_dir / "CMakeCache.txt" + cache.write_text(cache.read_text(encoding="utf-8") + "VCPKG_TARGET_TRIPLET:STRING=x64-mingw-static\n", encoding="utf-8") + with patch.object(launcher.platform, "system", return_value="Windows"), \ + patch.object(launcher, "native_machine", return_value="amd64"), \ + patch.object(launcher.subprocess, "run") as run: + self.assertTrue(launcher.platform_matches(candidate.build_dir)) + self.assertFalse(launcher.needs_msvc(candidate)) + self.assertTrue(launcher.ensure_windows_environment(root, candidate)) + run.assert_not_called() + + def test_every_default_preset_exists(self): + presets = {item["name"] for item in json.loads((ROOT / "CMakePresets.json").read_text(encoding="utf-8"))["configurePresets"]} + for system in ("Windows", "Darwin", "Linux"): + for machine in ("amd64", "arm64"): + for target in launcher.TARGETS: + with self.subTest(system=system, machine=machine, target=target), \ + patch.object(launcher.platform, "system", return_value=system), \ + patch.object(launcher, "native_machine", return_value=machine): + self.assertIn(launcher.default_preset(target), presets) + + def test_arm64_windows_emulation_selects_native_architecture(self): + with patch.object(launcher.platform, "system", return_value="Windows"), \ + patch.object(launcher.platform, "machine", return_value="AMD64"), \ + patch.dict(os.environ, {"PROCESSOR_ARCHITECTURE": "AMD64", "PROCESSOR_ARCHITEW6432": "ARM64"}): + self.assertEqual(launcher.default_preset("desktop"), "windows-arm64-desktop-release") + + @unittest.skipUnless(os.name == "nt", "Windows environment setup") + def test_windows_environment_helper_preserves_metacharacters_in_path(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "ACE & %PATH% ! repo" + (root / "scripts").mkdir(parents=True) + (root / "scripts/dev_windows_env.bat").write_text( + "@echo off\nset ACECODE_TEST_ENV=ready\nset\n", encoding="ascii") + with patch.dict(os.environ, {}, clear=False): + self.assertTrue(launcher.ensure_windows_environment(root, None)) + self.assertEqual(os.environ.get("ACECODE_TEST_ENV"), "ready") + + @unittest.skipUnless(os.name == "nt", "Windows environment setup") + def test_missing_msvc_environment_fails_before_build(self): + root = Path("C:/missing-tools") + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, stdout="[ERROR] Missing C++ tools", stderr="")), \ + contextlib.redirect_stderr(io.StringIO()) as output: + self.assertFalse(launcher.ensure_windows_environment(root, None)) + self.assertIn("Missing C++ tools", output.getvalue()) + + def test_existing_runtime_only_runs_identity_check_never_stop(self): + for status in (0, 1): + with self.subTest(status=status), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = self.make_candidate(root) + run_dir = root / "explicit-runtime" + run_dir.mkdir() + (run_dir / "daemon.pid").write_text("123", encoding="ascii") + error_output = io.StringIO() + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], status)) as run, \ + contextlib.redirect_stderr(error_output): + self.assertFalse(launcher.web_runtime_is_available(root, candidate, [f"--run-dir={run_dir}"])) + self.assertEqual(run.call_args.args[0][1:3], ["daemon", "status"]) + self.assertEqual(run.call_args.kwargs["timeout"], 10) + self.assertEqual((run_dir / "daemon.pid").read_text(encoding="ascii"), "123") + self.assertIn("Stop this verified daemon" if status == 0 else "Runtime identity is unverified", error_output.getvalue()) + + def test_runtime_failure_prevents_configuration_build_and_launch(self): + root = Path("C:/work") + candidate = launcher.BuildCandidate(root, root, root / "acecode.exe") + args = argparse.Namespace(target="web", build_dir=None, yes=True, dry_run=False, extra=[]) + with patch.object(launcher, "parse_args", return_value=args), \ + patch.object(launcher, "project_root", return_value=root), \ + patch.object(launcher, "find_sccache", return_value=None), \ + patch.object(launcher, "find_compatible_build", return_value=candidate), \ + patch.object(launcher, "web_runtime_is_available", return_value=False), \ + patch.object(launcher, "ensure_windows_environment") as environment, \ + patch.object(launcher, "configure_build") as configure, \ + patch.object(launcher, "build_target") as build, \ + patch.object(launcher, "launch_surface") as launch: + self.assertEqual(launcher.main(), 1) + environment.assert_not_called() + configure.assert_not_called() + build.assert_not_called() + launch.assert_not_called() + + def test_runtime_from_previous_commit_also_blocks_rebuild(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + candidate = self.make_candidate(root) + previous = root / ".acecode/dev-run" / (root.name + "-oldcommit") + previous.mkdir(parents=True) + (previous / "daemon.pid").write_text("123", encoding="ascii") + with patch.object(launcher, "current_commit", return_value="newcommit"), \ + patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ + contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(launcher.web_runtime_is_available(root, candidate, [])) + self.assertEqual(run.call_args.args[0][-1], f"--run-dir={previous}") + + def test_explicit_runtime_does_not_inspect_other_launcher_daemons(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + candidate = self.make_candidate(root) + previous = root / ".acecode/dev-run" / (root.name + "-oldcommit") + previous.mkdir(parents=True) + (previous / "daemon.pid").write_text("123", encoding="ascii") + with patch.object(launcher.subprocess, "run") as run: + self.assertTrue(launcher.web_runtime_is_available(root, candidate, ["--run-dir", "custom-runtime"])) + run.assert_not_called() + + def test_desktop_list_does_not_build_or_initialize_toolchain(self): + args = argparse.Namespace(target="desktop", build_dir=Path("build/custom"), yes=True, dry_run=False, extra=["--list"]) + with patch.object(launcher, "parse_args", return_value=args), \ + patch.object(launcher, "find_sccache") as discover, \ + patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertEqual(launcher.main(), 0) + discover.assert_not_called() + self.assertIn("--list", run.call_args.args[0]) + + def test_force_web_rebuild_does_not_reuse_existing_assets(self): + builder = Mock() + builder.ensure_node_and_pnpm.return_value = ("node", "pnpm") + root = Path("C:/work") + with patch.object(launcher, "load_web_builder", return_value=builder), \ + patch.object(launcher, "seed_web_assets") as seed: + self.assertTrue(launcher.refresh_web_assets(root, force=True)) + seed.assert_not_called() + builder.build_web.assert_called_once_with(root / "web", "pnpm", force=True) + + def test_desktop_list_dry_run_does_not_spawn_a_process(self): + args = argparse.Namespace(target="desktop", build_dir=None, yes=False, dry_run=True, extra=["--list"]) + with patch.object(launcher, "parse_args", return_value=args), \ + patch.object(launcher.subprocess, "run") as run: + self.assertEqual(launcher.main(), 0) + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py new file mode 100644 index 00000000..636b6463 --- /dev/null +++ b/tests/scripts/dev_environment_test.py @@ -0,0 +1,286 @@ +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import subprocess +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +spec = importlib.util.spec_from_file_location("dev_environment", ROOT / "scripts/dev_environment.py") +dev_environment = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = dev_environment +spec.loader.exec_module(dev_environment) + + +class DevEnvironmentTest(unittest.TestCase): + def setUp(self): + environment = patch.object(dev_environment, "ensure_windows_environment", return_value=True) + environment.start() + self.addCleanup(environment.stop) + + def make_build(self, root, desktop=False, name="test"): + build = root / "build" / name + build.mkdir(parents=True) + (build / "CMakeCache.txt").write_text( + "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root) + "\n" + "VCPKG_TARGET_TRIPLET:STRING=x64-windows-static\n" + f"ACECODE_BUILD_DESKTOP:BOOL={'ON' if desktop else 'OFF'}\n", + encoding="utf-8", + ) + (build / dev_environment.native_executable_name("acecode")).touch() + (build / dev_environment.native_executable_name("acecode")).chmod(0o755) + if desktop: + (build / dev_environment.native_executable_name("acecode-desktop")).touch() + (build / dev_environment.native_executable_name("acecode-desktop")).chmod(0o755) + return build + + def test_web_candidate_prefers_non_desktop_build(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plain = self.make_build(root, name="plain") + self.make_build(root, desktop=True, name="desktop") + with patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "registered_worktrees", return_value=[root]), \ + patch.object(dev_environment, "platform_matches", return_value=True): + candidate = dev_environment.find_compatible_build(root, "web") + self.assertEqual(candidate.build_dir, plain.resolve()) + + def test_candidate_does_not_reuse_another_worktree_build(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "current" + other = Path(directory) / "other" + root.mkdir() + other.mkdir() + self.make_build(other) + with patch.object(dev_environment, "registered_worktrees", return_value=[root, other]), \ + patch.object(dev_environment, "platform_matches", return_value=True): + self.assertIsNone(dev_environment.find_compatible_build(root, "web")) + + def test_registered_worktrees_parses_porcelain_prefix(self): + root = Path("C:/work") + output = "worktree C:/work\nHEAD abc\n\nworktree C:/other\nHEAD def\n" + completed = subprocess.CompletedProcess([], 0, stdout=output) + with patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertEqual(dev_environment.registered_worktrees(root), [Path("C:/work"), Path("C:/other")]) + + def test_desktop_candidate_requires_desktop_configuration_and_executable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + build = self.make_build(root, desktop=False) + with patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "registered_worktrees", return_value=[root]), \ + patch.object(dev_environment, "platform_matches", return_value=True): + self.assertIsNone(dev_environment.find_compatible_build(root, "desktop")) + self.assertTrue((build / dev_environment.native_executable_name("acecode")).exists()) + + def test_tui_command_uses_new_windows_terminal(self): + executable = Path("C:/work/build/acecode.exe") + with patch.object(dev_environment.os, "name", "nt"): + command = dev_environment.tui_command(Path("C:/work"), executable) + self.assertEqual(command, [str(executable)]) + + def test_runtime_directory_is_scoped_to_worktree(self): + root = Path("C:/worktrees/my project") + with patch.object(dev_environment, "current_commit", return_value="abcdef1234567890"): + runtime = dev_environment.worktree_runtime_dir(root) + self.assertEqual(runtime, root / ".acecode/dev-run/my-project-abcdef123456") + + def test_noninteractive_target_selection_fails(self): + with patch.object(dev_environment.sys.stdin, "isatty", return_value=False): + self.assertIsNone(dev_environment.choose_target(None)) + + def test_sccache_discovery_prefers_usable_path_and_has_install_hint(self): + completed = subprocess.CompletedProcess([], 0, stdout="sccache 1.0") + with patch.object(dev_environment.shutil, "which", return_value="C:/tools/sccache.exe"), \ + patch.object(Path, "is_file", return_value=True), \ + patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertEqual(dev_environment.find_sccache(), Path("C:/tools/sccache.exe")) + self.assertIn("sccache", dev_environment.sccache_install_hint()) + + def test_sccache_discovery_rejects_unusable_file(self): + completed = subprocess.CompletedProcess([], 1, stdout="") + with patch.object(dev_environment, "sccache_candidates", return_value=[Path("C:/broken/sccache.exe")]), \ + patch.object(Path, "is_file", return_value=True), \ + patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertIsNone(dev_environment.find_sccache()) + + def test_cache_state_detects_configuration_transition(self): + with tempfile.TemporaryDirectory() as directory: + build = Path(directory) / "build" + cache = Path("C:/tools/sccache.exe") + self.assertTrue(dev_environment.cache_state_changed(build, cache)) + dev_environment.write_sccache_marker(build, cache) + self.assertFalse(dev_environment.cache_state_changed(build, cache)) + self.assertTrue(dev_environment.cache_state_changed(build, None)) + + def test_sccache_build_failure_disables_only_matching_cache_path(self): + with tempfile.TemporaryDirectory() as directory: + build = Path(directory) / "build" + cache = Path("C:/tools/sccache.exe") + dev_environment.write_sccache_marker(build, cache, enabled=False) + self.assertTrue(dev_environment.sccache_disabled_for_build(build, cache)) + self.assertFalse(dev_environment.sccache_disabled_for_build(build, Path("C:/tools/sccache-new.exe"))) + + def test_configure_and_build_disables_test_dependencies(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(dev_environment.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ + patch.object(dev_environment, "build_target", return_value=True): + self.assertEqual( + dev_environment.configure_and_build(root, "windows-x64-release", "web"), + root / "build/windows-x64-release", + ) + self.assertEqual( + run.call_args_list[0].args[0], + [ + "cmake", "--preset", "windows-x64-release", "-DBUILD_TESTING=OFF", + "-DCMAKE_C_COMPILER_LAUNCHER=", "-DCMAKE_CXX_COMPILER_LAUNCHER=", + ], + ) + + def test_existing_build_reconfiguration_targets_candidate_directory(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "source" + build = self.make_build(root) + cache = build / "CMakeCache.txt" + cache.write_text( + cache.read_text(encoding="utf-8") + + "CMAKE_TOOLCHAIN_FILE:FILEPATH=C:/vcpkg/toolchain.cmake\n" + + "VCPKG_OVERLAY_PORTS:STRING=C:/work/ports\n", + encoding="utf-8", + ) + with patch.object(dev_environment.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertTrue(dev_environment.configure_build(root, "windows-x64-release", build, None)) + command = run.call_args.args[0] + self.assertEqual(command[:5], ["cmake", "-S", str(root), "-B", str(build)]) + self.assertIn("-DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/toolchain.cmake", command) + + def test_newest_web_seed_requires_clean_same_commit_and_uses_newest_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "current" + older = Path(directory).resolve() / "older" + newer = Path(directory).resolve() / "newer" + for worktree in (root, older, newer): + (worktree / "web/dist").mkdir(parents=True) + (worktree / "web/dist/index.html").write_text("ok", encoding="utf-8") + os.utime(older / "web/dist/index.html", (1, 1)) + os.utime(newer / "web/dist/index.html", (2, 2)) + with patch.object(dev_environment, "registered_worktrees", return_value=[root, older, newer]), \ + patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "web_worktree_is_clean", return_value=True): + self.assertEqual(dev_environment.newest_web_seed(root), newer / "web/dist/index.html") + with patch.object(dev_environment, "registered_worktrees", return_value=[root, newer]), \ + patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "web_worktree_is_clean", side_effect=lambda item: item != newer): + self.assertIsNone(dev_environment.newest_web_seed(root)) + + def test_seed_web_assets_copies_current_frontend_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "current" + source = Path(directory) / "source" + (root / "web/src").mkdir(parents=True) + (root / "web/src/main.js").write_text("source", encoding="utf-8") + (source / "web/dist").mkdir(parents=True) + (source / "web/dist/index.html").write_text("output", encoding="utf-8") + freshness = iter([True, False]) + builder = type("Builder", (), {"web_build_is_stale": staticmethod(lambda _web, _index: next(freshness))}) + with patch.object(dev_environment, "newest_web_seed", return_value=source / "web/dist/index.html"): + self.assertTrue(dev_environment.seed_web_assets(root, builder)) + self.assertEqual((root / "web/dist/index.html").read_text(encoding="utf-8"), "output") + + def test_sccache_stats_parses_real_nested_shape(self): + payload = { + "stats": { + "cache_hits": {"counts": {"C/C++": 7}, "adv_counts": {"C/C++": 2}}, + "cache_misses": {"counts": {"C/C++": 3}, "adv_counts": {}}, + "cache_errors": {"counts": {"C/C++": 1}, "adv_counts": {}}, + "cache_read_errors": 2, + "cache_write_errors": 1, + "dist_errors": 4, + } + } + completed = subprocess.CompletedProcess([], 0, stdout=json.dumps(payload)) + with patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertEqual( + dev_environment.sccache_stats(Path("sccache")), + {"hits": 9, "misses": 3, "errors": 8}, + ) + + def test_main_refreshes_build_and_web_assets_before_web_start(self): + candidate = dev_environment.BuildCandidate( + Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") + ) + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_sccache", return_value=None), \ + patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ + patch.object(dev_environment, "cache_state_changed", return_value=False), \ + patch.object(dev_environment, "build_target", return_value=True) as build, \ + patch.object(dev_environment, "refresh_web_assets", return_value=True) as refresh, \ + patch.object(dev_environment, "launch_surface", return_value=0) as launch: + self.assertEqual(dev_environment.main(), 0) + build.assert_called_once_with(Path("C:/work"), candidate.build_dir, "web", None, None) + refresh.assert_called_once_with(Path("C:/work")) + launch.assert_called_once_with(Path("C:/work"), "web", candidate, False, []) + + def test_confirmation_eof_refuses_configuration(self): + with patch.object(dev_environment.sys.stdin, "isatty", return_value=True), \ + patch("builtins.input", side_effect=EOFError): + self.assertFalse(dev_environment.ask_to_build("windows-x64-release", "web", False)) + + def test_main_retries_failed_sccache_build_without_cache(self): + candidate = dev_environment.BuildCandidate(Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe")) + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + cache = Path("C:/tools/sccache.exe") + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_sccache", return_value=cache), \ + patch.object(dev_environment, "sccache_disabled_for_build", return_value=False), \ + patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ + patch.object(dev_environment, "cache_state_changed", return_value=False), \ + patch.object(dev_environment, "build_target", side_effect=[False, True]) as build, \ + patch.object(dev_environment, "configure_build", return_value=True) as configure, \ + patch.object(dev_environment, "refresh_web_assets", return_value=True), \ + patch.object(dev_environment, "launch_surface", return_value=0): + self.assertEqual(dev_environment.main(), 0) + self.assertEqual(build.call_args_list[0].args, (Path("C:/work"), candidate.build_dir, "web", cache, None)) + self.assertEqual(build.call_args_list[1].args, (Path("C:/work"), candidate.build_dir, "web", None, None)) + configure.assert_called_once_with(Path("C:/work"), "windows-x64-release", candidate.build_dir, None) + + def test_web_launch_forwards_the_build_and_isolated_runtime_directory(self): + candidate = dev_environment.BuildCandidate( + Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") + ) + with patch.object(dev_environment, "worktree_runtime_dir", return_value=Path("C:/work/.acecode/dev-run/test")): + result = dev_environment.launch_surface(Path("C:/work"), "web", candidate, dry_run=True, extra=[]) + self.assertEqual(result, 0) + + def test_windows_target_launchers_auto_approve_initial_configuration(self): + for target in ("web", "desktop", "tui"): + wrapper = (ROOT / "scripts" / f"dev_{target}.bat").read_text(encoding="utf-8") + self.assertIn(f'dev_environment.py" {target} --yes %*', wrapper) + + @unittest.skipUnless(os.name == "nt", "Windows batch wrapper") + def test_batch_wrapper_delegates_to_python(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "py.bat").write_text("@echo off\necho fake-python-launcher\nexit /b 7\n", encoding="ascii") + environment = dict(os.environ) + environment["PATH"] = str(root) + os.pathsep + str(Path(os.environ["SystemRoot"]) / "System32") + result = subprocess.run( + [os.environ["COMSPEC"], "/d", "/c", str(ROOT / "scripts/dev_web.bat"), "--help"], + cwd=root, env=environment, capture_output=True, text=True, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + self.assertEqual(result.returncode, 7, result.stdout + result.stderr) + self.assertIn("fake-python-launcher", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/dev_web_test.py b/tests/scripts/dev_web_test.py index 5fe790da..377a8ac2 100644 --- a/tests/scripts/dev_web_test.py +++ b/tests/scripts/dev_web_test.py @@ -9,6 +9,7 @@ ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) spec = importlib.util.spec_from_file_location("dev_web", ROOT / "scripts/dev_web.py") dev_web = importlib.util.module_from_spec(spec) spec.loader.exec_module(dev_web) @@ -22,9 +23,11 @@ def test_finds_platform_executable(self): executable = root / "Release" / name executable.parent.mkdir() executable.touch() + executable.chmod(0o755) self.assertEqual(dev_web.find_executable(root), executable) direct = root / name direct.touch() + direct.chmod(0o755) self.assertEqual(dev_web.find_executable(root), direct) def run_launcher(self, exit_code, port): @@ -41,11 +44,11 @@ def run_launcher(self, exit_code, port): result = dev_web.main() return result, wait.call_count, open_ui.call_args - def test_startup_failure_ignores_stale_port(self): + def test_worker_timeout_opens_a_fresh_port(self): result, reads, opened = self.run_launcher(3, 12345) - self.assertEqual(result, 3) - self.assertEqual(reads, 0) - self.assertIsNone(opened) + self.assertEqual(result, 0) + self.assertEqual(reads, 1) + self.assertEqual(opened.kwargs, {"already_running": False}) def test_verified_running_daemon_can_be_opened(self): result, reads, opened = self.run_launcher(6, 12345)