From 7b0fa26399cec3106bc93b92a1d9ee343ec9f861 Mon Sep 17 00:00:00 2001 From: PineHomePC Date: Sun, 23 Aug 2026 22:44:06 +0800 Subject: [PATCH] =?UTF-8?q?build:=20=E9=83=A8=E7=BD=B2=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E4=B8=8E=E6=89=93=E5=8C=85=E8=84=9A=E6=9C=AC=EF=BC=88deploy/sc?= =?UTF-8?q?ripts/Docker=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/1-question.md | 23 + .github/ISSUE_TEMPLATE/2-feature_request.md | 43 + .github/ISSUE_TEMPLATE/3-documentation.md | 36 + .github/ISSUE_TEMPLATE/4-bug_report.md | 62 + .../ISSUE_TEMPLATE/5-support_environment.md | 40 + .github/ISSUE_TEMPLATE/config.yml | 12 + .github/PULL_REQUEST_TEMPLATE.md | 76 ++ .github/condarc | 3 + .github/dependabot.yml | 65 + Makefile | 62 + deploy/Dockerfile | 111 ++ deploy/config/supervisord.conf.template | 42 + deploy/entrypoint.sh | 51 + docker-compose.yml | 27 + scripts/README.md | 53 + scripts/check-channels.sh | 186 +++ scripts/check_channel_contracts.py | 106 ++ scripts/cleanup_windows_sandbox.py | 1049 ++++++++++++++ scripts/docker_build.sh | 31 + scripts/docker_sync_latest.sh | 76 ++ scripts/gen_browser_manual.py | 38 + scripts/github/real_behavior_proof_check.py | 104 ++ scripts/github/real_behavior_proof_policy.py | 223 +++ scripts/install.bat | 567 ++++++++ scripts/install.ps1 | 481 +++++++ scripts/install.sh | 376 +++++ scripts/pack-tauri/build_macos_pyinstaller.sh | 198 +++ scripts/pack-tauri/build_pyinstaller.ps1 | 248 ++++ scripts/pack-tauri/build_pyinstaller.sh | 158 +++ scripts/pack-tauri/build_win_pyinstaller.ps1 | 211 +++ .../pack-tauri/finalize_tauri_bootstrap.mjs | 23 + .../pack-tauri/generate_update_manifest.py | 298 ++++ scripts/pack-tauri/pineagents.spec | 278 ++++ scripts/pack-tauri/sign_macos_bundle.sh | 119 ++ scripts/pack-tauri/stage_node_runtime.py | 141 ++ scripts/pack-tauri/stage_python_runtime.py | 248 ++++ scripts/pack-tauri/sync_tauri_version.mjs | 131 ++ scripts/pack/README.md | 98 ++ scripts/pack/README_zh.md | 95 ++ scripts/pack/assets/icon.icns | Bin 0 -> 109861 bytes scripts/pack/assets/icon.ico | Bin 0 -> 113136 bytes scripts/pack/assets/icon.svg | 1 + scripts/pack/build_common.py | 251 ++++ scripts/pack/build_macos.sh | 184 +++ scripts/pack/build_win.ps1 | 394 ++++++ scripts/pack/desktop.nsi | 56 + scripts/pack/generate_oss_metadata.py | 218 +++ scripts/pack/generate_plugin_metadata.py | 452 ++++++ scripts/pack/merge_plugin_index.py | 95 ++ scripts/pack/patch_main_index.py | 61 + scripts/review-bot/prompts.py | 110 ++ scripts/review-bot/review_runner.py | 405 ++++++ scripts/review-bot/setup_review_workspace.py | 428 ++++++ scripts/run_tests.py | 281 ++++ scripts/startup_profile/.gitignore | 11 + scripts/startup_profile/README.md | 97 ++ scripts/startup_profile/README_zh.md | 97 ++ scripts/startup_profile/analyze.py | 322 +++++ scripts/startup_profile/tracer.py | 223 +++ scripts/startup_profile/viewer.html | 1215 +++++++++++++++++ scripts/verify/desktop_verify.py | 949 +++++++++++++ scripts/verify/launch_tauri_macos.sh | 63 + scripts/verify/launch_tauri_windows.ps1 | 148 ++ scripts/verify/requirements-verify.txt | 9 + scripts/website_build.sh | 27 + scripts/wheel_build.ps1 | 47 + scripts/wheel_build.sh | 34 + 67 files changed, 12367 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/1-question.md create mode 100644 .github/ISSUE_TEMPLATE/2-feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/3-documentation.md create mode 100644 .github/ISSUE_TEMPLATE/4-bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/5-support_environment.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/condarc create mode 100644 .github/dependabot.yml create mode 100644 Makefile create mode 100644 deploy/Dockerfile create mode 100644 deploy/config/supervisord.conf.template create mode 100644 deploy/entrypoint.sh create mode 100644 docker-compose.yml create mode 100644 scripts/README.md create mode 100644 scripts/check-channels.sh create mode 100644 scripts/check_channel_contracts.py create mode 100644 scripts/cleanup_windows_sandbox.py create mode 100644 scripts/docker_build.sh create mode 100644 scripts/docker_sync_latest.sh create mode 100644 scripts/gen_browser_manual.py create mode 100644 scripts/github/real_behavior_proof_check.py create mode 100644 scripts/github/real_behavior_proof_policy.py create mode 100644 scripts/install.bat create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh create mode 100644 scripts/pack-tauri/build_macos_pyinstaller.sh create mode 100644 scripts/pack-tauri/build_pyinstaller.ps1 create mode 100644 scripts/pack-tauri/build_pyinstaller.sh create mode 100644 scripts/pack-tauri/build_win_pyinstaller.ps1 create mode 100644 scripts/pack-tauri/finalize_tauri_bootstrap.mjs create mode 100644 scripts/pack-tauri/generate_update_manifest.py create mode 100644 scripts/pack-tauri/pineagents.spec create mode 100644 scripts/pack-tauri/sign_macos_bundle.sh create mode 100644 scripts/pack-tauri/stage_node_runtime.py create mode 100644 scripts/pack-tauri/stage_python_runtime.py create mode 100644 scripts/pack-tauri/sync_tauri_version.mjs create mode 100644 scripts/pack/README.md create mode 100644 scripts/pack/README_zh.md create mode 100644 scripts/pack/assets/icon.icns create mode 100644 scripts/pack/assets/icon.ico create mode 100644 scripts/pack/assets/icon.svg create mode 100644 scripts/pack/build_common.py create mode 100644 scripts/pack/build_macos.sh create mode 100644 scripts/pack/build_win.ps1 create mode 100644 scripts/pack/desktop.nsi create mode 100644 scripts/pack/generate_oss_metadata.py create mode 100644 scripts/pack/generate_plugin_metadata.py create mode 100644 scripts/pack/merge_plugin_index.py create mode 100644 scripts/pack/patch_main_index.py create mode 100644 scripts/review-bot/prompts.py create mode 100644 scripts/review-bot/review_runner.py create mode 100644 scripts/review-bot/setup_review_workspace.py create mode 100644 scripts/run_tests.py create mode 100644 scripts/startup_profile/.gitignore create mode 100644 scripts/startup_profile/README.md create mode 100644 scripts/startup_profile/README_zh.md create mode 100644 scripts/startup_profile/analyze.py create mode 100644 scripts/startup_profile/tracer.py create mode 100644 scripts/startup_profile/viewer.html create mode 100644 scripts/verify/desktop_verify.py create mode 100644 scripts/verify/launch_tauri_macos.sh create mode 100644 scripts/verify/launch_tauri_windows.ps1 create mode 100644 scripts/verify/requirements-verify.txt create mode 100644 scripts/website_build.sh create mode 100644 scripts/wheel_build.ps1 create mode 100644 scripts/wheel_build.sh diff --git a/.github/ISSUE_TEMPLATE/1-question.md b/.github/ISSUE_TEMPLATE/1-question.md new file mode 100644 index 0000000..f028518 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-question.md @@ -0,0 +1,23 @@ +--- +name: Question / Discussion +about: Ask a question or start a discussion (consider GitHub Discussions for open-ended topics) +title: "[Question]: " +labels: ["question", "triage"] +assignees: [] +--- + +## Question or topic + +[What would you like to ask or discuss?] + +## Context + +[Relevant setup: QwenPaw version, channel, skill, or use case. This helps others answer.] + +## Tried so far + +[Optional: what you already tried or read (docs, issues, etc.).] + +--- + +**Note:** For general questions or ideas, [GitHub Discussions](https://github.com/agentscope-ai/QwenPaw/discussions) may get more visibility. Use this template when the answer might lead to a bug report, feature request, or doc change. diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.md b/.github/ISSUE_TEMPLATE/2-feature_request.md new file mode 100644 index 0000000..402ba00 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature_request.md @@ -0,0 +1,43 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement", "triage"] +assignees: [] +--- + +## Summary + +[One or two sentences: what do you want and why?] + +## Component(s) Affected + +- [ ] Core / Backend (app, agents, config, providers, utils, local_models) +- [ ] Console (frontend web UI) +- [ ] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.) +- [ ] Skills +- [ ] CLI +- [ ] Documentation (website) +- [ ] Tests +- [ ] CI/CD +- [ ] Scripts / Deploy + +## Problem / Motivation + +[What problem does this solve? Who benefits?] + +## Proposed Solution + +[Describe the feature or change you have in mind. Be as specific as possible.] + +## Alternatives Considered + +[Any other approaches or workarounds you thought about.] + +## Additional Context + +[Screenshots, examples, links to docs or similar features elsewhere.] + +## Willing to Contribute + +- [ ] I am willing to open a PR for this feature (after discussion). diff --git a/.github/ISSUE_TEMPLATE/3-documentation.md b/.github/ISSUE_TEMPLATE/3-documentation.md new file mode 100644 index 0000000..fed4a9f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-documentation.md @@ -0,0 +1,36 @@ +--- +name: Documentation +about: Report docs issues or suggest documentation improvements +title: "[Docs]: " +labels: ["documentation", "triage"] +assignees: [] +--- + +## Summary + +[What is wrong or missing in the docs? One or two sentences.] + +**Docs location:** [e.g. website, README, `website/public/docs/quickstart.en.md`, Console UI copy] + +## Type + +- [ ] Typo / wording fix +- [ ] Outdated or incorrect content +- [ ] Missing section or topic +- [ ] Broken link or image +- [ ] Translation (zh/en) +- [ ] Other + +## Details + +[Describe the issue or the change you suggest. If possible, point to the exact file or URL.] + +**Current (if applicable):** +[Quote or describe current text.] + +**Suggested:** +[Proposed text or structure.] + +## Additional Context + +[Optional: related issues, screenshots, which audience this affects.] diff --git a/.github/ISSUE_TEMPLATE/4-bug_report.md b/.github/ISSUE_TEMPLATE/4-bug_report.md new file mode 100644 index 0000000..8d4f9e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-bug_report.md @@ -0,0 +1,62 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: "[Bug]: " +labels: ["bug", "triage"] +assignees: [] +--- + +## QwenPaw Version + +[Provide the version of QwenPaw you are using, e.g. 0.x.x or git commit hash.] +[Using `qwenpaw --version` in your command line or checking the version in the console UI can help.] + +## Description + +[Describe the bug clearly and concisely. What happened vs what you expected?] + +**Related PR(s):** #(optional) + +**Security considerations:** [If applicable, e.g. auth, env/config exposure] + +## Component(s) Affected + +- [ ] Core / Backend (app, agents, config, providers, utils, local_models) +- [ ] Console (frontend web UI) +- [ ] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.) +- [ ] Skills +- [ ] CLI +- [ ] Documentation (website) +- [ ] Tests +- [ ] CI/CD +- [ ] Scripts / Deploy + +## Environment + +- **QwenPaw version:** [e.g. 0.x.x or git commit] +- **OS:** [e.g. macOS 14, Ubuntu 22.04, Windows 11] +- **Install method:** [pip / one-line install / Docker / from source] +- **Python version (if applicable):** [e.g. 3.10] + +## Steps to Reproduce + +1. +2. +3. + +## Actual vs Expected + +- **Actual:** +- **Expected:** + +## Logs / Screenshots + +[Paste relevant log output or attach screenshots. Use code blocks for logs.] + +``` +(paste logs here) +``` + +## Additional Notes + +[Optional: workarounds, similar issues, etc.] diff --git a/.github/ISSUE_TEMPLATE/5-support_environment.md b/.github/ISSUE_TEMPLATE/5-support_environment.md new file mode 100644 index 0000000..6b4f596 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/5-support_environment.md @@ -0,0 +1,40 @@ +--- +name: Support / Environment +description: Issues with install, Docker, platform, or runtime environment +title: "[Support]: " +labels: ["support", "triage"] +assignees: [] +--- + +## Summary + +[What went wrong during install, run, or in your environment?] + +## Environment + +- **OS:** [e.g. macOS 14 (Apple Silicon / Intel), Ubuntu 22.04, Windows 11] +- **Install method:** [pip / one-line install (install.sh or install.ps1) / Docker / from source / ModelScope] +- **QwenPaw version:** [e.g. 0.x.x or commit] +- **Python version (if applicable):** [e.g. 3.10, 3.12] + +## Steps you took + +1. +2. +3. + +## What happened + +[Error message, log excerpt, or behavior. Paste relevant output in code blocks.] + +``` +(paste here) +``` + +## Expected + +[What you expected to happen.] + +## Additional context + +[Optional: conda/venv, WSL, proxy, firewall, or other env details.] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..733cb3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +# Issue template chooser. Templates are listed alphanumerically. +# See: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository + +blank_issues_enabled: true + +contact_links: + - name: GitHub Discussions + url: https://github.com/agentscope-ai/QwenPaw/discussions + about: Ask questions, share ideas, or discuss QwenPaw here. + - name: View docs + url: https://qwenpaw.agentscope.io/ + about: QwenPaw docs and guides. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..feb0944 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,76 @@ +## Description + +[Describe what this PR does and why] + +**Related Issue:** Fixes #(issue_number) or Relates to #(issue_number) + +**Security Considerations:** [If applicable, e.g. channel auth, env/config handling] + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation +- [ ] Refactoring + +## Component(s) Affected + +- [ ] Core / Backend (app, agents, config, providers, utils, local_models) +- [ ] Console (frontend web UI) +- [ ] Channels (DingTalk, Lark, QQ, Discord, iMessage, etc.) +- [ ] Skills +- [ ] CLI +- [ ] Documentation (website) +- [ ] Tests +- [ ] CI/CD +- [ ] Scripts / Deploy + +## Checklist + +- [ ] I ran `pre-commit run --all-files` locally and it passes +- [ ] If pre-commit auto-fixed files, I committed those changes and reran checks +- [ ] I ran tests locally (`pytest` or as relevant) and they pass +- [ ] Documentation updated (if needed) +- [ ] Ready for review + +### For Channel Changes (DingTalk, Lark, QQ, Console, etc.) + +- [ ] I ran `./scripts/check-channels.sh` (or `./scripts/check-channels.sh --changed`) and it passes +- [ ] **Contract test** exists in `tests/contract/channels/test__contract.py` (REQUIRED) +- [ ] Contract test implements `create_instance()` with proper channel initialization +- [ ] All 19 contract verification points pass (see `tests/contract/channels/__init__.py`) +- [ ] **Optional**: Unit tests in `tests/unit/channels/test_.py` for complex internal logic + +## Testing + +[How to test these changes] + +## Evidence + + + +Examples of valid evidence: +- Terminal transcript of the test run (e.g. `pytest tests/unit/app/chats/ -q` output) +- Screenshot of the Console UI showing the fix +- CI artifact link +- `pre-commit run --all-files` summary + +```bash +pre-commit run --all-files +# paste summary result + +pytest +# paste summary result +``` + +## Additional Notes + +[Optional: any other context] diff --git a/.github/condarc b/.github/condarc new file mode 100644 index 0000000..a034d96 --- /dev/null +++ b/.github/condarc @@ -0,0 +1,3 @@ +# Explicit channels so conda does not warn about implicit 'defaults' +channels: + - defaults diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fefec40 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,65 @@ +version: 2 + +# Dependabot: automated dependency updates for Python + npm. +# Checks weekly, opens PRs with changelogs and test results. +# Only minor/patch updates by default — major versions need manual review. + +updates: + # Python backend dependencies (pyproject.toml) + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "UTC" + open-pull-requests-limit: 5 + allow: + - update-type: "version-update:semver-minor" + - update-type: "version-update:semver-patch" + commit-message: + prefix: "deps" + include: scope + labels: + - "dependencies" + - "python" + groups: + python-dev-deps: + patterns: + - "pytest*" + - "black" + - "flake8" + - "pylint" + - "mypy" + - "pre-commit" + - "ruff" + + # Console frontend dependencies (package.json) + - package-ecosystem: "npm" + directory: "/console" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "UTC" + open-pull-requests-limit: 5 + allow: + - update-type: "version-update:semver-minor" + - update-type: "version-update:semver-patch" + commit-message: + prefix: "deps" + include: scope + labels: + - "dependencies" + - "frontend" + + # GitHub Actions dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "github-actions" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8ed2a68 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +# CoPaw Test & Coverage Makefile + +.PHONY: test test-unit test-contract test-integration test-channel test-channel-contract coverage-full clean gen-browser-manual + +# Python path +PYTHON := python +PYTEST := python -m pytest + +# Default: run all tests +test: + $(PYTEST) tests/ -v --tb=short -q + +# Unit tests only +test-unit: + $(PYTEST) tests/unit/ -v --tb=short + +# Contract tests (interface compliance) +test-contract: + $(PYTEST) tests/contract/ -v --tb=short + +# Integration tests +test-integration: + $(PYTEST) tests/integration/ -v --tb=short + +# Full coverage (all modules) +coverage-full: + $(PYTEST) tests/unit/ tests/integration/ -v \ + --cov=src/pineagents \ + --cov-report=term-missing \ + --cov-report=html + +# Check contract coverage for all channels +check-contracts: + $(PYTHON) scripts/check_channel_contracts.py + +# Clean generated files +clean: + rm -rf htmlcov/ .pytest_cache/ + rm -f coverage.xml coverage-sa.xml .coverage + +# Quick check (fast feedback) +quick: + @qp_test_workdir=$$(mktemp -d); \ + trap 'rm -rf "$$qp_test_workdir"' EXIT; \ + QWENPAW_WORKING_DIR="$$qp_test_workdir" \ + $(PYTEST) tests/unit/ -x -q --tb=line + +gen-browser-manual: + $(PYTHON) scripts/gen_browser_manual.py + +# Channel-specific tests +test-channel: + @echo "Running Channel unit tests..." + $(PYTEST) tests/unit/channels/ -v --tb=short + +test-channel-contract: + @echo "Running Channel contract tests..." + $(PYTEST) tests/contract/channels/ -v --tb=short + +# BaseChannel core unit tests (optional, not enforced) +test-base-core: + $(PYTEST) tests/unit/channels/test_base_core.py -v diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..59cb21e --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,111 @@ +# Base images — override with --build-arg for environments without ACR access. +ARG NODE_IMAGE=agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/node:slim +ARG UV_IMAGE=agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/uv:latest + +# ----------------------------------------------------------------------------- +# Stage 1: build console frontend (dist not committed in repo). +# ----------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS console-builder +WORKDIR /app +COPY console /app/console +RUN cd /app/console && npm ci --include=dev && npm run build + +# Alias for uv binary so COPY --from can reference a build-arg image. +FROM ${UV_IMAGE} AS uv-src + +# ----------------------------------------------------------------------------- +# Stage 2: runtime image with Python, Chromium, and app. +# ----------------------------------------------------------------------------- +FROM ${NODE_IMAGE} + +# ENV variables +ENV NODE_ENV=production +ENV WORKSPACE_DIR=/app +ENV QWENPAW_WORKING_DIR=/app/working +ENV QWENPAW_SECRET_DIR=/app/working.secret +ENV QWENPAW_BACKUP_DIR=/app/working.backups + +# Channel filtering: use QWENPAW_DISABLED_CHANNELS (exclusion, recommended) +# or QWENPAW_ENABLED_CHANNELS (whitelist). Override at runtime with -e. +ARG QWENPAW_DISABLED_CHANNELS="imessage" +ENV QWENPAW_DISABLED_CHANNELS=${QWENPAW_DISABLED_CHANNELS} +ARG QWENPAW_ENABLED_CHANNELS="" +ENV QWENPAW_ENABLED_CHANNELS=${QWENPAW_ENABLED_CHANNELS} + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --fix-missing \ + curl \ + python3 \ + python3-pip \ + python3-venv \ + build-essential \ + libssl-dev \ + git \ + supervisor \ + vim \ + gettext-base \ + xfce4 \ + xfce4-terminal \ + xvfb \ + dbus-x11 \ + fonts-wqy-zenhei \ + fonts-wqy-microhei \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + + +RUN apt-get update && apt-get install -y --fix-missing \ + chromium \ + chromium-sandbox \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxtst6 \ + libnss3 \ + libglib2.0-0 \ + libdrm2 \ + libgbm1 \ + libasound2 \ + fonts-liberation \ + libu2f-udev \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + + +RUN sed -i 's/^CHROMIUM_FLAGS=""/CHROMIUM_FLAGS="--no-sandbox"/' /usr/bin/chromium + +# Playwright: use system Chromium (already installed above). +ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium +# Avoid Playwright downloading its own browser when executable_path is used. +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +# Indicate running in container (used e.g. for Chromium --no-sandbox). +ENV QWENPAW_RUNNING_IN_CONTAINER=1 + +WORKDIR ${WORKSPACE_DIR} + +RUN python3 -m venv venv +ENV PATH="/app/venv/bin:$PATH" + +COPY pyproject.toml setup.py README.md ./ +COPY src ./src +COPY website/public/docs/ ./src/pineagents/docs/ +# Inject console dist from build stage (repo does not commit dist). +COPY --from=console-builder /app/console/dist/ ./src/pineagents/console/ +# Speed up Python package installation with uv. +COPY --from=uv-src /uv /bin/uv +RUN uv pip install --no-cache-dir . && rm -rf ./build + + +# PineAgents app port (default 8088). Override at runtime with -e QWENPAW_PORT=3000. +ENV QWENPAW_PORT=8088 + +COPY deploy/config/supervisord.conf.template /etc/supervisor/conf.d/supervisord.conf.template +COPY --chmod=755 deploy/entrypoint.sh /entrypoint.sh + +EXPOSE 8088 + +CMD ["/entrypoint.sh"] diff --git a/deploy/config/supervisord.conf.template b/deploy/config/supervisord.conf.template new file mode 100644 index 0000000..a2190e2 --- /dev/null +++ b/deploy/config/supervisord.conf.template @@ -0,0 +1,42 @@ +[supervisord] +user=root +logfile=/var/log/supervisord.log +pidfile=/var/log/supervisord.pid +nodaemon=true + +[program:dbus] +command=/bin/sh -c "rm -f /run/dbus/pid; mkdir -p /run/dbus; exec /usr/bin/dbus-daemon --system --nofork" +autostart=true +autorestart=true +stderr_logfile=/var/log/dbus.err.log +stdout_logfile=/var/log/dbus.out.log + +[program:app] +command=pineagents app --host 0.0.0.0 --port ${QWENPAW_PORT} +autostart=true +autorestart=unexpected +startretries=5 +startsecs=10 +priority=30 +stopwaitsecs=30 +stderr_logfile=/var/log/app.err.log +stdout_logfile=/var/log/app.out.log +environment=DISPLAY=":1",PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="/usr/bin/chromium",QWENPAW_RUNNING_IN_CONTAINER="1" + +[program:xvfb] +command=/bin/sh -c "rm -f /tmp/.X1-lock /tmp/.X11-unix/X1; mkdir -p /tmp/.X11-unix; exec /usr/bin/Xvfb :1 -screen 0 1280x800x24" +autostart=true +autorestart=true +priority=10 +stderr_logfile=/var/log/xvfb.err.log +stdout_logfile=/var/log/xvfb.out.log +environment=DISPLAY=":1" + +[program:xfce4] +command=/bin/sh -c 'export DISPLAY=:1; for i in $(seq 1 200); do [ -S /tmp/.X11-unix/X1 ] && break; sleep 0.1; done; exec dbus-run-session startxfce4' +autostart=true +autorestart=true +priority=20 +stderr_logfile=/var/log/xfce4.err.log +stdout_logfile=/var/log/xfce4.out.log +environment=DISPLAY=":1" diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..9dc6e57 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Substitute QWENPAW_PORT in supervisord template and start supervisord. +# Default port 8088; override at runtime with -e QWENPAW_PORT=3000. +set -e + +is_auth_enabled() { + if [ "${QWENPAW_AUTH_ENABLED+x}" ]; then + flag="${QWENPAW_AUTH_ENABLED}" + else + flag="${COPAW_AUTH_ENABLED:-}" + fi + flag="$(printf '%s' "$flag" | tr '[:upper:]' '[:lower:]')" + [ "$flag" = "true" ] || [ "$flag" = "1" ] || [ "$flag" = "yes" ] +} + +warn_if_auth_off_container_bind() { + if is_auth_enabled; then + return + fi + + cat >&2 < /etc/supervisor/conf.d/supervisord.conf +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e06bd87 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +volumes: + pineagents-data: + name: pineagents-data + pineagents-secrets: + name: pineagents-secrets + pineagents-backups: + name: pineagents-backups + +services: + pineagents: + # 私有镜像仓库(PineAgents 闭源商业镜像,替换为你的 registry) + image: pineagents/pineagents:latest + init: true + container_name: pineagents + restart: always + ports: + - "127.0.0.1:8088:8088" + # environment: + # - PINEAGENTS_AUTH_ENABLED=true + # - PINEAGENTS_AUTH_USERNAME=admin + # - PINEAGENTS_AUTH_PASSWORD=yourpassword + volumes: + - pineagents-data:/app/working + - pineagents-secrets:/app/working.secret + - pineagents-backups:/app/working.backups diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..b5188f5 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,53 @@ +# Scripts + +Run from **repo root**. + +## Build wheel (with latest console) + +```bash +bash scripts/wheel_build.sh +``` + +- Builds the console frontend (`console/`), copies `console/dist` to `src/pineagents/console/dist`, then builds the wheel. Output: `dist/*.whl`. + +## Build website + +```bash +bash scripts/website_build.sh +``` + +- Installs dependencies (pnpm or npm) and runs the Vite build. Output: `website/dist/`. + +## Build Docker image + +```bash +bash scripts/docker_build.sh [IMAGE_TAG] [EXTRA_ARGS...] +``` + +- Default tag: `qwenpaw:latest`. Uses `deploy/Dockerfile` (multi-stage: builds console then Python app). +- Example: `bash scripts/docker_build.sh myreg/qwenpaw:v1 --no-cache`. + +## Run Test + +```bash +# Run all tests +python scripts/run_tests.py + +# Run all unit tests +python scripts/run_tests.py -u + +# Run unit tests for a specific module +python scripts/run_tests.py -u providers + +# Run integration tests +python scripts/run_tests.py -i + +# Run all tests and generate a coverage report +python scripts/run_tests.py -a -c + +# Run tests in parallel (requires pytest-xdist) +python scripts/run_tests.py -p + +# Show help +python scripts/run_tests.py -h +``` \ No newline at end of file diff --git a/scripts/check-channels.sh b/scripts/check-channels.sh new file mode 100644 index 0000000..32cf31e --- /dev/null +++ b/scripts/check-channels.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# +# Channel Pre-Commit Check Script +# ================================= +# +# Run this script before committing channel changes to catch issues early. +# +# Note: Contract tests are the PRIMARY gate (tests/contract/channels/). +# Unit tests are optional supplements (tests/unit/channels/). +# +# Usage: +# ./scripts/check-channels.sh # Check all channels (contract tests) +# ./scripts/check-channels.sh dingtalk # Check specific channel +# ./scripts/check-channels.sh --changed # Only check changed channels +# + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Parse arguments +TARGET="${1:-all}" +CHECK_CHANGED=0 + +if [ "$TARGET" == "--changed" ] || [ "$TARGET" == "-c" ]; then + CHECK_CHANGED=1 + TARGET="changed" +fi + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE}CoPaw Channel Pre-Commit Check${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Check if we're in a git repo +if [ ! -d "$PROJECT_ROOT/.git" ]; then + echo -e "${RED}Error: Not a git repository${NC}" + exit 1 +fi + +cd "$PROJECT_ROOT" + +# Determine which channels to test +if [ "$CHECK_CHANGED" -eq 1 ]; then + echo -e "${YELLOW}Detecting changed channels...${NC}" + + # Get changed channel files + CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null || echo "") + STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || echo "") + + ALL_CHANGED="$CHANGED_FILES $STAGED_FILES" + + # Check if base.py changed + if echo "$ALL_CHANGED" | grep -qE "channels/(base|registry|manager|renderer)\.py"; then + echo -e "${YELLOW}⚠️ BaseChannel or common code changed - running ALL channel tests${NC}" + CHANNELS="all" + else + # Extract modified channels + CHANNELS=$(echo "$ALL_CHANGED" | grep -oE 'channels/[^/]+' | sed 's/channels\///' | sort -u | grep -v "^$" || true) + + if [ -z "$CHANNELS" ]; then + echo -e "${GREEN}✅ No channel changes detected${NC}" + exit 0 + fi + + echo -e "${BLUE}Changed channels: $CHANNELS${NC}" + fi +elif [ "$TARGET" == "all" ]; then + CHANNELS="all" +else + CHANNELS="$TARGET" +fi + +# Setup Python environment +echo "" +echo -e "${BLUE}Setting up Python environment...${NC}" + +if ! command -v python3 &> /dev/null; then + echo -e "${RED}Error: python3 not found${NC}" + exit 1 +fi + +# Check if dependencies are installed +if ! python3 -c "import copaw" 2>/dev/null; then + echo -e "${YELLOW}Installing dependencies...${NC}" + pip install -e ".[dev]" -q +fi + +# Run tests +echo "" +echo -e "${BLUE}Running tests...${NC}" + +EXIT_CODE=0 + +if [ "$CHANNELS" == "all" ]; then + # Run ALL contract tests (PRIMARY gate) + echo -e "${YELLOW}Running ALL channel CONTRACT tests (PRIMARY)...${NC}" + + if ! pytest tests/contract/channels -v --tb=short; then + EXIT_CODE=1 + fi + + # Run optional unit tests (informational) + echo "" + echo -e "${YELLOW}Running optional UNIT tests (supplemental)...${NC}" + + if ! pytest tests/unit/channels -v --tb=short 2>/dev/null; then + echo -e "${YELLOW}⚠️ Some unit tests failed (optional, does not block PR)${NC}" + fi +else + # Run specific channel contract tests + for ch in $CHANNELS; do + echo "" + echo -e "${BLUE}----------------------------------------${NC}" + echo -e "${BLUE}Testing channel: $ch${NC}" + echo -e "${BLUE}----------------------------------------${NC}" + + # PRIMARY: Check if contract test file exists + CONTRACT_TEST_FILE="tests/contract/channels/test_${ch}_contract.py" + + if [ -f "$CONTRACT_TEST_FILE" ]; then + echo -e "${GREEN}✅ Contract test found: $CONTRACT_TEST_FILE${NC}" + + if ! pytest "$CONTRACT_TEST_FILE" -v --tb=short; then + echo -e "${RED}❌ Contract tests FAILED for $ch${NC}" + EXIT_CODE=1 + else + echo -e "${GREEN}✅ Contract tests PASSED for $ch${NC}" + fi + else + echo -e "${RED}❌ CONTRACT TEST MISSING for $ch${NC}" + echo -e "${RED} Required: $CONTRACT_TEST_FILE${NC}" + echo -e "${YELLOW} Template: tests/contract/channels/test_console_contract.py${NC}" + EXIT_CODE=1 + fi + + # OPTIONAL: Check if unit test file exists + UNIT_TEST_FILE="tests/unit/channels/test_${ch}.py" + if [ -f "$UNIT_TEST_FILE" ]; then + echo "" + echo -e "${BLUE}Running optional unit tests for $ch...${NC}" + if ! pytest "$UNIT_TEST_FILE" -v --tb=short 2>/dev/null; then + echo -e "${YELLOW}⚠️ Unit tests failed (optional)${NC}" + else + echo -e "${GREEN}✅ Unit tests passed${NC}" + fi + fi + done + + # Run base channel contract tests if base might be affected + if echo "$ALL_CHANGED" | grep -qE "channels/base\.py"; then + echo "" + echo -e "${BLUE}Running BaseChannel contract tests...${NC}" + if ! pytest tests/contract/channels/ -v --tb=short; then + EXIT_CODE=1 + fi + fi +fi + +# Summary +echo "" +echo -e "${BLUE}========================================${NC}" +if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✅ All checks passed!${NC}" + echo -e "${GREEN}You can safely commit your changes.${NC}" +else + echo -e "${RED}❌ Some checks failed${NC}" + echo "" + echo "Required fixes:" + echo " - Create missing contract test: tests/contract/channels/test__contract.py" + echo " - Ensure contract test implements create_instance() method" + echo " - Fix failing contract test assertions" + echo "" + echo "Note: Unit tests are OPTIONAL and do not block PR merging." +fi +echo -e "${BLUE}========================================${NC}" + +exit $EXIT_CODE diff --git a/scripts/check_channel_contracts.py b/scripts/check_channel_contracts.py new file mode 100644 index 0000000..5a7eab3 --- /dev/null +++ b/scripts/check_channel_contracts.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Check that all Channel subclasses have Contract test coverage. + +Usage: + python scripts/check_channel_contracts.py + +Notes: + - Static scan, no dependencies required (including pytest) + - Compares Channel classes in src/ with test files in tests/contract/ + +CI Integration (future): + - Run on PR to ensure new Channels have contract tests +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def get_all_channel_classes() -> set[str]: + """Scan all Channel subclasses from source code (non-runtime).""" + src_dir = Path(__file__).parent.parent / "src" + channels_dir = src_dir / "qwenpaw" / "app" / "channels" + classes = set() + + for channel_file in channels_dir.rglob("channel.py"): + content = channel_file.read_text() + # Match class XXXChannel(BaseChannel) + matches = re.findall( + r"class\s+(\w+Channel)\s*\(\s*BaseChannel\s*\)", + content, + ) + classes.update(matches) + + return classes + + +def get_tested_channels_from_content() -> set[str]: + """Read actual tested channel class names from test files.""" + contract_dir = ( + Path(__file__).parent.parent / "tests" / "contract" / "channels" + ) + tested = set() + + if not contract_dir.exists(): + return tested + + for test_file in contract_dir.glob("test_*_contract.py"): + content = test_file.read_text() + # Find from XXXXX import YYYYChannel + # Or find in create_instance return XXXXChannel(...) + # Match common channel import patterns + import_matches = re.findall( + r"from\s+[\w.]+\s+import\s+(\w+Channel)", + content, + ) + # Also directly instantiated in create_instance + instance_matches = re.findall( + r"return\s+(\w+Channel)\s*\(", + content, + ) + tested.update(import_matches) + tested.update(instance_matches) + + return tested + + +def main() -> int: + all_channels = get_all_channel_classes() + tested = get_tested_channels_from_content() + untested = all_channels - tested + + print("\n📊 Channel Contract Coverage") + print(f" Total channels: {len(all_channels)}") + print(f" With tests: {len(tested)}") + print(f" Missing: {len(untested)}") + + if tested: + print(f"\n✅ Tested: {', '.join(sorted(tested))}") + + if untested: + print("\n❌ Missing contract tests:") + for name in sorted(untested): + # Convert to snake_case for filename suggestion + snake = ( + re.sub(r"(? bool: + """Check if the current process has administrator privileges.""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined] + except (AttributeError, OSError): + return False + + +def _get_state_dir() -> Path: + """Returns the QwenPaw state directory (~/.qwenpaw).""" + return ( + Path(os.environ.get("USERPROFILE", os.path.expanduser("~"))) + / ".qwenpaw" + ) + + +def _remove_ace_by_sid_api( # pylint: disable=R0911,R0912 + path: str, + sid_string: str, +) -> bool: + """Removes all ACEs matching a SID from a path's DACL using Win32 API. + + This matches the sandbox code's direct DACL manipulation approach: + GetNamedSecurityInfoW -> enumerate ACEs -> DeleteAce -> SetNamedSecurityInfoW. + + Returns True if no matching ACEs remain (success or already clean). + Returns False on API failure. + """ + try: + advapi32 = ctypes.WinDLL("advapi32.dll", use_last_error=True) + except OSError: + return False + + # Convert string SID to binary SID + target_psid = ctypes.c_void_p() + if not advapi32.ConvertStringSidToSidW( + ctypes.c_wchar_p(sid_string), + ctypes.byref(target_psid), + ): + return False + + try: + # Get current DACL + p_dacl = ctypes.c_void_p() + p_sd = ctypes.c_void_p() + err = advapi32.GetNamedSecurityInfoW( + ctypes.c_wchar_p(path), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + ctypes.byref(p_dacl), + None, + ctypes.byref(p_sd), + ) + if err != ERROR_SUCCESS: + return False + + try: + if not p_dacl.value: + # NULL DACL means full access — no ACEs to remove + return True + + # Get ACE count + class ACL_SIZE_INFORMATION(ctypes.Structure): + _fields_ = [ + ("AceCount", ctypes.wintypes.DWORD), + ("AclBytesInUse", ctypes.wintypes.DWORD), + ("AclBytesFree", ctypes.wintypes.DWORD), + ] + + acl_info = ACL_SIZE_INFORMATION() + AclSizeInformation = 2 + if not advapi32.GetAclInformation( + p_dacl, + ctypes.byref(acl_info), + ctypes.sizeof(acl_info), + AclSizeInformation, + ): + return False + + # Find and collect indices of matching ACEs (reverse order) + indices_to_delete: List[int] = [] + for i in range(acl_info.AceCount): + ace_ptr = ctypes.c_void_p() + if not advapi32.GetAce(p_dacl, i, ctypes.byref(ace_ptr)): + continue + if ace_ptr.value is None: + continue + + # ACE header is 4 bytes (type, flags, size) + # SID starts at offset 8 for ACCESS_ALLOWED_ACE / ACCESS_DENIED_ACE + ace_sid_ptr = ctypes.c_void_p(ace_ptr.value + 8) + if advapi32.EqualSid(ace_sid_ptr, target_psid): + indices_to_delete.append(i) + + if not indices_to_delete: + # No matching ACEs — already clean + return True + + # Delete ACEs in reverse order to preserve indices + for idx in reversed(indices_to_delete): + advapi32.DeleteAce(p_dacl, idx) + + # Write back modified DACL + err = advapi32.SetNamedSecurityInfoW( + ctypes.c_wchar_p(path), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + p_dacl, + None, + ) + return err == ERROR_SUCCESS + finally: + ctypes.windll.kernel32.LocalFree(p_sd) # type: ignore[attr-defined] + finally: + ctypes.windll.kernel32.LocalFree(target_psid) # type: ignore[attr-defined] + + +def _remove_acl_with_retry(path: str, sid: str, max_attempts: int = 3) -> bool: + """Removes ACEs for a SID with retry logic. + + Returns True if the SID was successfully removed or path doesn't exist. + """ + if not os.path.exists(path): + return True + + for attempt in range(1, max_attempts + 1): + if _remove_ace_by_sid_api(path, sid): + return True + if attempt < max_attempts: + time.sleep(0.5) + + return False + + +# ═══════════════════════════════════════════════════════════════════════════ +# NtSetSecurityObject-based traverse ACE removal (elevated sandbox) +# ═══════════════════════════════════════════════════════════════════════════ + +# CreateFile constants +READ_CONTROL = 0x00020000 +WRITE_DAC = 0x00040000 +FILE_SHARE_ALL = 0x07 +OPEN_EXISTING = 3 +FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + +# NtSetSecurityObject constant +DACL_SECURITY_INFORMATION_NT = 4 + + +def _remove_traverse_ace( # pylint: disable=R0911,R0912 + path: str, + sid_string: str, +) -> bool: + """Removes traverse ACEs for a SID using NtSetSecurityObject. + + This avoids the expensive inheritance propagation that + SetNamedSecurityInfoW would trigger on directories with many children. + + Returns True if no matching ACEs remain. + """ + try: + advapi32 = ctypes.WinDLL("advapi32.dll", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32.dll", use_last_error=True) + ntdll = ctypes.WinDLL("ntdll.dll", use_last_error=True) + except OSError: + return False + + # Convert SID string to binary + target_psid = ctypes.c_void_p() + if not advapi32.ConvertStringSidToSidW( + ctypes.c_wchar_p(sid_string), + ctypes.byref(target_psid), + ): + return False + + try: + # Open directory handle with WRITE_DAC | READ_CONTROL + handle = kernel32.CreateFileW( + ctypes.c_wchar_p(path), + READ_CONTROL | WRITE_DAC, + FILE_SHARE_ALL, + None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + None, + ) + if handle == INVALID_HANDLE_VALUE or handle is None: + # Cannot open — try fallback via SetNamedSecurityInfoW + return _remove_ace_by_sid_api(path, sid_string) + + try: + # Get security info from handle + p_dacl = ctypes.c_void_p() + p_sd = ctypes.c_void_p() + err = advapi32.GetSecurityInfo( + handle, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + ctypes.byref(p_dacl), + None, + ctypes.byref(p_sd), + ) + if err != ERROR_SUCCESS: + return False + + try: + if not p_dacl.value: + return True + + # Get ACE count + class ACL_SIZE_INFORMATION(ctypes.Structure): + _fields_ = [ + ("AceCount", ctypes.wintypes.DWORD), + ("AclBytesInUse", ctypes.wintypes.DWORD), + ("AclBytesFree", ctypes.wintypes.DWORD), + ] + + acl_info = ACL_SIZE_INFORMATION() + if not advapi32.GetAclInformation( + p_dacl, + ctypes.byref(acl_info), + ctypes.sizeof(acl_info), + 2, # AclSizeInformation + ): + return False + + # Find matching ACEs + indices_to_delete: List[int] = [] + for i in range(acl_info.AceCount): + ace_ptr = ctypes.c_void_p() + if not advapi32.GetAce(p_dacl, i, ctypes.byref(ace_ptr)): + continue + if ace_ptr.value is None: + continue + ace_sid_ptr = ctypes.c_void_p(ace_ptr.value + 8) + if advapi32.EqualSid(ace_sid_ptr, target_psid): + indices_to_delete.append(i) + + if not indices_to_delete: + return True + + # Delete in reverse order + for idx in reversed(indices_to_delete): + advapi32.DeleteAce(p_dacl, idx) + + # Build a self-relative security descriptor with the + # modified DACL and write via NtSetSecurityObject to + # avoid inheritance propagation. + sd_buf = (ctypes.c_byte * 256)() + sd_ptr = ctypes.cast(sd_buf, ctypes.c_void_p) + advapi32.InitializeSecurityDescriptor( + sd_ptr, + 1, # SECURITY_DESCRIPTOR_REVISION + ) + advapi32.SetSecurityDescriptorDacl( + sd_ptr, + True, + p_dacl, + False, + ) + + # Make self-relative + sr_size = ctypes.wintypes.DWORD(0) + advapi32.MakeSelfRelativeSD( + sd_ptr, + None, + ctypes.byref(sr_size), + ) + sr_buf = (ctypes.c_byte * sr_size.value)() + sr_ptr = ctypes.cast(sr_buf, ctypes.c_void_p) + if not advapi32.MakeSelfRelativeSD( + sd_ptr, + sr_ptr, + ctypes.byref(sr_size), + ): + # Fallback to SetNamedSecurityInfoW + err = advapi32.SetNamedSecurityInfoW( + ctypes.c_wchar_p(path), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + p_dacl, + None, + ) + return err == ERROR_SUCCESS + + # NtSetSecurityObject(Handle, SecurityInformation, SD) + ntstatus = ntdll.NtSetSecurityObject( + handle, + DACL_SECURITY_INFORMATION_NT, + sr_ptr, + ) + return ntstatus == 0 # STATUS_SUCCESS + + finally: + ctypes.windll.kernel32.LocalFree(p_sd) # type: ignore[attr-defined] + finally: + kernel32.CloseHandle(handle) + finally: + ctypes.windll.kernel32.LocalFree(target_psid) # type: ignore[attr-defined] + + +# ═══════════════════════════════════════════════════════════════════════════ +# System command helpers +# ═══════════════════════════════════════════════════════════════════════════ + + +def _run_cmd( + args: List[str], + timeout: int = 60, +) -> Optional[subprocess.CompletedProcess]: + """Runs a command synchronously. Returns result or None on failure.""" + try: + return subprocess.run( + args, + capture_output=True, + timeout=timeout, + check=False, + ) + except (subprocess.TimeoutExpired, OSError): + return None + + +def _delete_appcontainer_profile(container_name: str) -> bool: + """Deletes an AppContainer profile by name.""" + try: + userenv = ctypes.WinDLL("userenv.dll", use_last_error=True) + hr = userenv.DeleteAppContainerProfile( + ctypes.c_wchar_p(container_name), + ) + return hr == 0 + except OSError: + return False + + +def _remove_firewall_rules(username: str) -> bool: + """Removes inbound/outbound firewall block rules for a sandbox user.""" + rule_name_out = f"QwenPaw_Block_{username}_Out" + rule_name_in = f"QwenPaw_Block_{username}_In" + ok = True + for rule_name in (rule_name_out, rule_name_in): + result = _run_cmd( + [ + "netsh", + "advfirewall", + "firewall", + "delete", + "rule", + f"name={rule_name}", + ], + timeout=15, + ) + if result is None: + ok = False + return ok + + +def _delete_local_user(username: str) -> bool: + """Deletes a local Windows user account.""" + result = _run_cmd(["net", "user", username, "/delete"], timeout=30) + return result is not None and result.returncode == 0 + + +def _delete_local_group(group_name: str) -> bool: + """Deletes a local Windows group.""" + result = _run_cmd( + ["net", "localgroup", group_name, "/delete"], + timeout=30, + ) + return result is not None and result.returncode == 0 + + +def _remove_profile_dir(username: str, user_sid: str = "") -> bool: + """Removes the sandbox user's profile directory. + + Uses reg unload (to release NTUSER.DAT lock) + rd /s /q (fast + kernel-mode delete). Falls back to takeown + retry on failure. + """ + sys_drive = os.environ.get("SystemDrive", "C:") + profile_dir = os.path.join(sys_drive + os.sep, "Users", username) + if not os.path.exists(profile_dir): + return True + + # Unload the user's registry hive to release NTUSER.DAT lock + if user_sid: + _run_cmd(["reg", "unload", f"HKU\\{user_sid}"], timeout=15) + + # Fast kernel-mode recursive delete + _run_cmd(["cmd", "/c", "rd", "/s", "/q", profile_dir], timeout=60) + + if not os.path.exists(profile_dir): + return True + + # Fallback: take ownership recursively then retry + _run_cmd( + ["takeown", "/F", profile_dir, "/R", "/A", "/D", "Y"], + timeout=120, + ) + _run_cmd(["cmd", "/c", "rd", "/s", "/q", profile_dir], timeout=60) + + if os.path.exists(profile_dir): + print( + f" WARNING: Profile dir {profile_dir} could not be fully " + f"removed. Manual intervention may be required.", + ) + return False + return True + + +# ═══════════════════════════════════════════════════════════════════════════ +# AppContainer sandbox cleanup +# ═══════════════════════════════════════════════════════════════════════════ + + +def _cleanup_single_container( # pylint: disable=R0912 + meta_file: Path, +) -> None: + """Clean up a single AppContainer sandbox. + + Steps: Remove ACLs -> Delete profile -> Delete metadata. + """ + try: + meta = json.loads(meta_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + print(f"\n WARNING: Cannot read {meta_file.name}: {e}") + try: + meta_file.unlink() + except OSError: + pass + return + + container_name = meta.get("container_name", "") + sid = meta.get("sid", "") + workspace_dir = meta.get("workspace_dir", "") + acl_manifest = meta.get("acl_manifest") + + print(f"\n Container: {container_name}") + print(f" SID: {sid}") + + # Step 1: Remove ACL entries + acl_removed = 0 + acl_failed = 0 + if sid: + if acl_manifest: + all_paths = ( + acl_manifest.get("grant_paths", []) + + acl_manifest.get("deny_paths", []) + + acl_manifest.get("inheritance_broken_paths", []) + ) + for path in all_paths: + if path and os.path.exists(path): + if _remove_acl_with_retry(path, sid): + acl_removed += 1 + else: + acl_failed += 1 + print(f" FAILED to remove ACL from: {path}") + + if workspace_dir and os.path.exists(workspace_dir): + if _remove_acl_with_retry(workspace_dir, sid): + acl_removed += 1 + else: + acl_failed += 1 + print( + f" FAILED to remove ACL from workspace: {workspace_dir}", + ) + + if acl_removed or acl_failed: + print(f" ACLs: {acl_removed} removed, {acl_failed} failed") + + # Step 2: Delete the AppContainer profile + if container_name: + ok = _delete_appcontainer_profile(container_name) + print(f" Profile: {'deleted' if ok else 'not found or failed'}") + + # Step 3: Handle metadata file + if acl_failed > 0: + _move_to_failed( + meta_file, + _get_state_dir(), + f"ACL removal failed for {acl_failed} path(s)", + ) + else: + try: + meta_file.unlink() + print(" Metadata: deleted") + except OSError as e: + print(f" WARNING: Failed to delete {meta_file.name}: {e}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Elevated sandbox cleanup +# ═══════════════════════════════════════════════════════════════════════════ + + +def _cleanup_single_elevated_sandbox( # pylint: disable=R0912,R0915 + meta_file: Path, +) -> None: + """Clean up a single elevated sandbox. + + Steps: + 1. Remove ACLs (Win32 API for regular, NtSetSecurityObject for traverse) + 2. Remove firewall rules + 3. Delete local user account + 4. Remove user profile directory + 5. Delete metadata + """ + try: + meta = json.loads(meta_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + print(f"\n WARNING: Cannot read {meta_file.name}: {e}") + try: + meta_file.unlink() + except OSError: + pass + return + + sandbox_id = meta.get("sandbox_id", "") + username = meta.get("username", "") + user_sid = meta.get("user_sid", "") + cap_sid = meta.get("cap_sid", "") + network_blocked = meta.get("network_blocked", False) + acl_entries = meta.get("acl_entries", []) + + print(f"\n Elevated Sandbox: {sandbox_id}") + print(f" Username: {username}") + print(f" User SID: {user_sid}") + print(f" Cap SID: {cap_sid}") + + # Step 1: Remove ACL entries + acl_removed = 0 + acl_failed = 0 + if acl_entries: + print(f" Processing {len(acl_entries)} ACL entries...") + for entry in acl_entries: + entry_path = entry.get("path", "") + sid_type = entry.get("sid_type", "") + access_mode = entry.get("access_mode", "") + + if not entry_path or not os.path.exists(entry_path): + continue + + # Determine which SID was used + if sid_type == "cap": + sid = cap_sid + elif sid_type == "user": + sid = user_sid + elif sid_type == "group": + # QwenpawUsers group ACEs are persistent — skip + continue + else: + continue + + if not sid: + continue + + # Use appropriate removal method + if access_mode == "traverse": + ok = _remove_traverse_ace(entry_path, sid) + else: + ok = _remove_acl_with_retry(entry_path, sid) + + if ok: + acl_removed += 1 + else: + acl_failed += 1 + print(f" FAILED: {entry_path} ({sid_type}, {access_mode})") + + if acl_removed or acl_failed: + print(f" ACLs: {acl_removed} removed, {acl_failed} failed") + + # Step 2: Remove firewall rules + firewall_failed = False + if network_blocked and username: + ok = _remove_firewall_rules(username) + if not ok: + firewall_failed = True + print( + f" Firewall: {'removed' if ok else 'removal failed (may not exist)'}", + ) + + # Step 3: Delete the local user account + user_failed = False + if username: + ok = _delete_local_user(username) + if not ok: + user_failed = True + print( + f" User account: {'deleted' if ok else 'deletion failed (may not exist)'}", + ) + + # Step 4: Remove user profile directory + profile_failed = False + if username: + ok = _remove_profile_dir(username, user_sid) + if not ok: + profile_failed = True + print(f" Profile dir: {'removed' if ok else 'removal failed'}") + + # Step 5: Handle metadata file + failures: List[str] = [] + if acl_failed > 0: + failures.append(f"ACL removal failed for {acl_failed} path(s)") + if firewall_failed: + failures.append("firewall rule removal failed") + if user_failed: + failures.append("user account deletion failed") + if profile_failed: + failures.append("profile directory removal failed") + + if failures: + _move_to_failed( + meta_file, + _get_state_dir(), + "; ".join(failures), + ) + else: + try: + meta_file.unlink() + print(" Metadata: deleted") + except OSError as e: + print(f" WARNING: Failed to delete {meta_file.name}: {e}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Unelevated sandbox cleanup (no admin required) +# ═══════════════════════════════════════════════════════════════════════════ + + +def _cleanup_single_unelevated_sandbox(meta_file: Path) -> None: + """Clean up a single unelevated sandbox. + + Steps: Remove ACLs -> Delete metadata. + """ + try: + meta = json.loads(meta_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + print(f"\n WARNING: Cannot read {meta_file.name}: {e}") + try: + meta_file.unlink() + except OSError: + pass + return + + sandbox_id = meta.get("sandbox_id", "") + cap_sid = meta.get("cap_sid", "") + acl_entries = meta.get("acl_entries", []) + + print(f"\n Unelevated Sandbox: {sandbox_id}") + print(f" Cap SID: {cap_sid}") + + # Step 1: Remove ACL entries + acl_removed = 0 + acl_failed = 0 + if acl_entries and cap_sid: + for entry in acl_entries: + entry_path = entry.get("path", "") + if not entry_path or not os.path.exists(entry_path): + continue + if _remove_acl_with_retry(entry_path, cap_sid): + acl_removed += 1 + else: + acl_failed += 1 + print(f" FAILED to remove ACL from: {entry_path}") + + if acl_removed or acl_failed: + print(f" ACLs: {acl_removed} removed, {acl_failed} failed") + + # Step 2: Handle metadata file + if acl_failed > 0: + _move_to_failed( + meta_file, + _get_state_dir(), + f"ACL removal failed for {acl_failed} path(s)", + ) + else: + try: + meta_file.unlink() + print(" Metadata: deleted") + except OSError as e: + print(f" WARNING: Failed to delete {meta_file.name}: {e}") + + +def _migrate_legacy_state_file(state_dir: Path) -> None: + """Removes the legacy single unelevated sandbox state file.""" + legacy_file = state_dir / "unelevated_sandbox_state.json" + if not legacy_file.exists(): + return + print(" Migrating legacy unelevated state file...") + try: + state = json.loads(legacy_file.read_text(encoding="utf-8")) + cap_sid = state.get("cap_sid", "") + if cap_sid: + all_paths = state.get("acl_paths", []) + state.get( + "deny_paths", + [], + ) + for path in all_paths: + if os.path.exists(path): + _remove_acl_with_retry(path, cap_sid) + legacy_file.unlink(missing_ok=True) + print(" Legacy state file removed.") + except (json.JSONDecodeError, OSError) as e: + print(f" WARNING: Failed to migrate legacy state: {e}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# QwenpawUsers group cleanup (elevated only) +# ═══════════════════════════════════════════════════════════════════════════ + + +def _cleanup_sandbox_group() -> None: + """Removes the QwenpawUsers local group and associated markers.""" + print("\n Removing QwenpawUsers group...") + ok = _delete_local_group("QwenpawUsers") + if ok: + print(" Group deleted.") + else: + print(" Group deletion failed (may not exist or not empty).") + + # Remove the .qwenpaw_acl_granted marker from the Python directory. + # Without this, next sandbox creation would see the stale marker and + # skip re-granting the ACL to the (re-created) group. + python_dir = os.path.dirname(os.path.abspath(sys.executable)) + if os.path.basename(python_dir).lower() == "scripts": + python_dir = os.path.dirname(python_dir) + + marker = os.path.join(python_dir, ".qwenpaw_acl_granted") + if os.path.exists(marker): + try: + os.remove(marker) + print(f" Removed ACL marker: {marker}") + except OSError as e: + print(f" WARNING: Failed to remove marker: {e}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Failed cleanup metadata preservation +# ═══════════════════════════════════════════════════════════════════════════ + + +def _move_to_failed( + meta_file: Path, + state_dir: Path, + reason: str, +) -> None: + """Moves a metadata file to failed_cleanup/ for later retry. + + Appends a ``_cleanup_error`` field with failure reason and timestamp + so the user knows what went wrong. + """ + import datetime + + failed_dir = state_dir / "failed_cleanup" + failed_dir.mkdir(parents=True, exist_ok=True) + + dest = failed_dir / meta_file.name + # If a file with the same name already exists, append a counter + counter = 1 + while dest.exists(): + stem = meta_file.stem + dest = failed_dir / f"{stem}_{counter}.json" + counter += 1 + + # Read, annotate, and write to new location + try: + meta = json.loads(meta_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + meta = {} + + meta["_cleanup_error"] = { + "reason": reason, + "timestamp": datetime.datetime.now().isoformat(), + } + + try: + dest.write_text( + json.dumps(meta, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + except OSError as e: + print(f" ERROR: Cannot save failed metadata to {dest}: {e}") + return + + # Remove the original + try: + meta_file.unlink() + except OSError: + pass + + print(f" Metadata preserved in: {dest.name} (for retry)") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Orchestration +# ═══════════════════════════════════════════════════════════════════════════ + + +def _count_json(directory: Path) -> int: + """Count *.json files in a directory (0 if doesn't exist).""" + if directory.is_dir(): + return len(list(directory.glob("*.json"))) + return 0 + + +def _confirm_cleanup( + is_admin: bool, + appcontainer_count: int, + elevated_count: int, + unelevated_count: int, +) -> None: + """Print summary and prompt user for confirmation.""" + print("=" * 60) + print("WARNING: This will clean up ALL QwenPaw sandboxes,") + print("including any that are currently RUNNING.") + print() + print(f" AppContainer sandboxes: {appcontainer_count}") + print(f" Elevated sandboxes: {elevated_count}") + print(f" Unelevated sandboxes: {unelevated_count}") + if not is_admin and elevated_count: + print() + print( + " NOTE: Not running as administrator. Elevated sandbox", + ) + print( + " cleanup will be SKIPPED (user accounts, firewall, profiles).", + ) + print() + print("The following actions will be performed:") + print(" - Remove filesystem ACLs set by sandboxes (Win32 API)") + if is_admin: + print(" - Delete AppContainer profiles") + print(" - Delete local sandbox user accounts (qwenpaw_*)") + print(" - Remove firewall block rules") + print(" - Remove user profile directories") + print(" - Remove QwenpawUsers group (if empty)") + else: + print(" - Delete AppContainer profiles") + print(" - Delete sandbox metadata files") + print() + print("Please make sure no sandbox is currently in use.") + print("=" * 60) + print() + choice = input("Continue? (Y/N): ").strip().upper() + if choice != "Y": + print("Aborted.") + sys.exit(0) + print() + + +def _cleanup_state_dirs(state_dir: Path, *, is_admin: bool) -> None: + """Remove empty state directories after cleanup.""" + unelevated_dir = state_dir / "unelevated_sandboxes" + containers_dir = state_dir / "containers" + sandboxes_dir = state_dir / "sandboxes" + failed_dir = state_dir / "failed_cleanup" + + dirs_to_check = [unelevated_dir, containers_dir] + if is_admin: + dirs_to_check.append(sandboxes_dir) + + for d in dirs_to_check: + if d.is_dir() and not list(d.iterdir()): + try: + d.rmdir() + print(f" Removed empty dir: {d.name}/") + except OSError: + pass + + # Report failed_cleanup contents (never auto-delete) + if failed_dir.is_dir(): + failed_files = list(failed_dir.iterdir()) + if failed_files: + print( + f" WARNING: {len(failed_files)} metadata file(s) in " + f"failed_cleanup/ — re-run script to retry or " + f"delete manually.", + ) + + # Remove root state dir if completely empty + if state_dir.is_dir(): + remaining = list(state_dir.iterdir()) + if not remaining: + try: + state_dir.rmdir() + print(f" Removed empty state dir: {state_dir}") + except OSError: + pass + else: + print( + f" State dir not empty, remaining: " + f"{[e.name for e in remaining]}", + ) + + +def main() -> None: # pylint: disable=R0912,R0915 + if sys.platform != "win32": + print("ERROR: This script must run on Windows.") + sys.exit(1) + + is_admin = _is_admin() + + state_dir = _get_state_dir() + containers_dir = state_dir / "containers" + sandboxes_dir = state_dir / "sandboxes" + unelevated_dir = state_dir / "unelevated_sandboxes" + + appcontainer_count = _count_json(containers_dir) + elevated_count = _count_json(sandboxes_dir) + unelevated_count = _count_json(unelevated_dir) + + if not appcontainer_count and not elevated_count and not unelevated_count: + # Check for legacy state file + legacy = state_dir / "unelevated_sandbox_state.json" + if not legacy.exists(): + print("No QwenPaw sandbox metadata found. Nothing to clean up.") + sys.exit(0) + + _confirm_cleanup( + is_admin, + appcontainer_count, + elevated_count, + unelevated_count, + ) + + print("=" * 60) + print("QwenPaw Sandbox Cleanup") + print("=" * 60) + print(f" State directory: {state_dir}") + if not is_admin: + print(" Running without admin — elevated sandbox cleanup skipped") + print() + + # Step 1: AppContainer sandboxes (no admin required) + print(f"[1] AppContainer sandboxes ({appcontainer_count} found)") + if containers_dir.is_dir(): + for meta_file in sorted(containers_dir.glob("*.json")): + _cleanup_single_container(meta_file) + if not appcontainer_count: + print(" Nothing to clean.") + + # Step 2: Elevated sandboxes (admin required) + print(f"\n[2] Elevated sandboxes ({elevated_count} found)") + if is_admin: + if sandboxes_dir.is_dir(): + for meta_file in sorted(sandboxes_dir.glob("*.json")): + _cleanup_single_elevated_sandbox(meta_file) + if not elevated_count: + print(" Nothing to clean.") + # Clean up QwenpawUsers group after all elevated sandboxes are removed + if elevated_count > 0: + _cleanup_sandbox_group() + else: + if elevated_count: + print(" SKIPPED (requires administrator privileges)") + else: + print(" Nothing to clean.") + + # Step 3: Unelevated sandboxes (no admin required) + print(f"\n[3] Unelevated sandboxes ({unelevated_count} found)") + _migrate_legacy_state_file(state_dir) + if unelevated_dir.is_dir(): + for meta_file in sorted(unelevated_dir.glob("*.json")): + _cleanup_single_unelevated_sandbox(meta_file) + if not unelevated_count: + print(" Nothing to clean.") + + # Step 4: Clean up empty directories + print("\n[4] Cleaning up state directories...") + _cleanup_state_dirs(state_dir, is_admin=is_admin) + + print("\n" + "=" * 60) + print("Cleanup complete.") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/docker_build.sh b/scripts/docker_build.sh new file mode 100644 index 0000000..87463e1 --- /dev/null +++ b/scripts/docker_build.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build Docker image (includes console frontend build in multi-stage). +# Run from repo root: bash scripts/docker_build.sh [IMAGE_TAG] [EXTRA_ARGS...] +# Example: bash scripts/docker_build.sh qwenpaw:latest +# bash scripts/docker_build.sh myreg/qwenpaw:v1 --no-cache +# +# By default the Docker image excludes imessage (macOS-only). +# Override via: +# QWENPAW_DISABLED_CHANNELS=imessage,voice bash scripts/docker_build.sh +# QWENPAW_ENABLED_CHANNELS=discord,telegram bash scripts/docker_build.sh +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +DOCKERFILE="${DOCKERFILE:-$REPO_ROOT/deploy/Dockerfile}" +TAG="${1:-qwenpaw:latest}" +shift || true + +# Channels to exclude from the image (default: imessage). +DISABLED_CHANNELS="${QWENPAW_DISABLED_CHANNELS:-imessage}" + +echo "[docker_build] Building image: $TAG (Dockerfile: $DOCKERFILE)" +docker build -f "$DOCKERFILE" \ + --build-arg QWENPAW_DISABLED_CHANNELS="$DISABLED_CHANNELS" \ + ${QWENPAW_ENABLED_CHANNELS:+--build-arg QWENPAW_ENABLED_CHANNELS="$QWENPAW_ENABLED_CHANNELS"} \ + -t "$TAG" "$@" . +echo "[docker_build] Done." +echo "[docker_build] QwenPaw app port: 8088 (default). Override with -e QWENPAW_PORT=." +echo "[docker_build] Run: docker run -p 127.0.0.1:8088:8088 $TAG" +echo "[docker_build] Or: docker run -e QWENPAW_PORT=3000 -p 127.0.0.1:3000:3000 $TAG" diff --git a/scripts/docker_sync_latest.sh b/scripts/docker_sync_latest.sh new file mode 100644 index 0000000..136e606 --- /dev/null +++ b/scripts/docker_sync_latest.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUILDX_VERSION="v0.31.1" +PLUGINS_DIR="${DOCKER_CONFIG:-$HOME/.docker}/cli-plugins" +BUILDX_PLUGIN="$PLUGINS_DIR/docker-buildx" + +die() { echo "[docker_tag_pre_to_latest] ERROR: $*" >&2; exit 1; } +log() { echo "[docker_tag_pre_to_latest] $*"; } + +install_buildx() { + local os arch suffix url + case "$(uname -s)" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + *) die "Unsupported OS: $(uname -s)" ;; + esac + case "$(uname -m)" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) die "Unsupported arch: $(uname -m)" ;; + esac + + suffix="${os}-${arch}" + url="https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.${suffix}" + log "Installing buildx ${BUILDX_VERSION} from ${url}" + mkdir -p "$PLUGINS_DIR" + + if command -v curl &>/dev/null; then + curl -fsSL "$url" -o "$BUILDX_PLUGIN" + elif command -v wget &>/dev/null; then + wget -q "$url" -O "$BUILDX_PLUGIN" + else + die "Need curl or wget to install buildx" + fi + + chmod +x "$BUILDX_PLUGIN" + log "buildx installed at $BUILDX_PLUGIN" +} + +require_buildx_imagetools() { + command -v docker &>/dev/null || die "docker not found in PATH" + + # Check whether docker recognizes the buildx subcommand + if ! docker buildx version &>/dev/null; then + log "docker has no 'buildx' command. Will try to install buildx plugin..." + install_buildx + + # Re-check after installation + if ! docker buildx version &>/dev/null; then + die $'docker still has no buildx after plugin install.\nPossible causes:\n- You are not using official Docker CLI (e.g. podman-docker / other wrapper)\n- Docker version is too old to load CLI plugins\nFix:\n- Use Docker Desktop / official docker-ce\n- Ensure docker is the official CLI, then re-run.' + fi + fi + + # Check whether imagetools is available (and supports -t) + docker buildx imagetools create --help &>/dev/null \ + || die "buildx exists but 'imagetools create' is not available. Please upgrade Docker/buildx." +} + +require_buildx_imagetools + +ACR_REGISTRY="agentscope-registry.ap-southeast-1.cr.aliyuncs.com" +IMAGE="agentscope/qwenpaw" + +ACR_PRE="${ACR_REGISTRY}/${IMAGE}:pre" +ACR_LATEST="${ACR_REGISTRY}/${IMAGE}:latest" +DH_PRE="docker.io/${IMAGE}:pre" +DH_LATEST="docker.io/${IMAGE}:latest" + +log "ACR: ${ACR_PRE} -> ${ACR_LATEST}" +docker buildx imagetools create -t "$ACR_LATEST" "$ACR_PRE" + +log "Docker Hub: ${DH_PRE} -> ${DH_LATEST}" +docker buildx imagetools create -t "$DH_LATEST" "$DH_PRE" + +log "Done." diff --git a/scripts/gen_browser_manual.py b/scripts/gen_browser_manual.py new file mode 100644 index 0000000..a2a8e52 --- /dev/null +++ b/scripts/gen_browser_manual.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""Materialize the Browser SDK reference into packaged skill marker blocks.""" + +from pathlib import Path + +from pineagents.browser.sdk.facade import _build_manual_text + + +BEGIN = "" +END = "" +ROOT = Path(__file__).resolve().parents[1] +SKILLS = [ + ROOT / "src/pineagents/agents/skills/browser-en/SKILL.md", + ROOT / "src/pineagents/agents/skills/browser-zh/SKILL.md", +] + + +def inject(path: Path, manual: str) -> None: + """Replace exactly one generated manual block in a packaged skill.""" + text = path.read_text(encoding="utf-8") + head, begin, rest = text.partition(BEGIN) + _old, end, tail = rest.partition(END) + if not begin or not end: + raise SystemExit(f"markers missing in {path}") + block = f"{BEGIN}\n{manual.strip()}\n{END}" + path.write_text(f"{head}{block}{tail}", encoding="utf-8") + + +def main() -> None: + """Write the same generated reference into each browser skill.""" + manual = _build_manual_text() + for skill in SKILLS: + inject(skill, manual) + print(f"updated {skill}") + + +if __name__ == "__main__": + main() diff --git a/scripts/github/real_behavior_proof_check.py b/scripts/github/real_behavior_proof_check.py new file mode 100644 index 0000000..dce85ad --- /dev/null +++ b/scripts/github/real_behavior_proof_check.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +"""CI check entry point: evaluate real-behavior-proof for the current PR. + +Reads PR metadata from the GitHub Actions event payload, runs the policy +check, and exits non-zero if the PR is missing required context/evidence. + +Environment variables: + GITHUB_TOKEN — GitHub token with ``pull-requests: read`` + PR_NUMBER — Pull request number (auto-detected from event) + PR_BODY — PR body (auto-detected from event) + PR_AUTHOR_ASSOC — Author association (auto-detected) + PR_AUTHOR_TYPE — Author type (auto-detected) + PR_LABELS — Comma-separated label names (auto-detected) +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +# Add scripts/ to path so we can import the policy module. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from real_behavior_proof_policy import ( # noqa: E402 + ProofStatus, + evaluate_pull_request_context, +) + + +def _load_event() -> dict: + """Load the GitHub Actions event payload.""" + event_path = os.environ.get("GITHUB_EVENT_PATH", "") + if event_path and Path(event_path).is_file(): + return json.loads(Path(event_path).read_text("utf-8")) + return {} + + +def main() -> int: + event = _load_event() + pr = event.get("pull_request") or {} + + body: str = pr.get("body") or os.environ.get("PR_BODY", "") + author_association: str = pr.get("author_association") or os.environ.get( + "PR_AUTHOR_ASSOC", + "CONTRIBUTOR", + ) + author_type = (pr.get("user") or {}).get("type") or os.environ.get( + "PR_AUTHOR_TYPE", + "User", + ) + labels = [lbl.get("name", "") for lbl in (pr.get("labels") or [])] or [ + name.strip() + for name in os.environ.get("PR_LABELS", "").split(",") + if name.strip() + ] + + pr_number = pr.get("number") or os.environ.get("PR_NUMBER", "?") + pr_url = pr.get("html_url", "") + + print(f"Checking real-behavior-proof for PR #{pr_number}") + print(f" author_association: {author_association}") + print(f" author_type: {author_type}") + print(f" labels: {labels}") + print(f" body length: {len(body)} chars") + + evaluation = evaluate_pull_request_context( + body=body, + author_association=author_association, + author_type=author_type, + labels=labels, + ) + + print(f" status: {evaluation.status.value}") + + if evaluation.status == ProofStatus.SKIPPED: + print(" → SKIPPED (maintainer/bot/override)") + return 0 + + if evaluation.status == ProofStatus.PASSED: + print(" → PASSED ✓") + return 0 + + # MISSING + print(f" → MISSING sections: {evaluation.missing_sections}") + print() + print("This PR is from an external contributor and is missing required") + print("context or evidence. Please update the PR body to include:") + print() + for section in evaluation.missing_sections: + print(f" ## {section}") + print(" [Describe/show real behavior — not template comments]") + print() + print("See the PR template for guidance. Template HTML comments do NOT") + print("count as authored content.") + print() + print(f"PR: {pr_url}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/github/real_behavior_proof_policy.py b/scripts/github/real_behavior_proof_policy.py new file mode 100644 index 0000000..c63d115 --- /dev/null +++ b/scripts/github/real_behavior_proof_policy.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +"""Real behavior proof policy for QwenPaw PR checks. + +Ported from openclaw's ``real-behavior-proof-policy.mjs``. Parses a PR +body and determines whether an **external contributor** has provided the +two required sections: + +1. **What Problem This Solves** — a concrete user/product/operational + problem description (template comments do not count). +2. **Evidence** — real validation evidence: screenshots, terminal + transcripts, CI artifact links, test output, etc. + +Maintainer / bot PRs are auto-skipped. The ``proof: override`` label +bypasses the check. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum + +# --------------------------------------------------------------------------- +# Labels +# --------------------------------------------------------------------------- + +NEEDS_PR_CONTEXT_LABEL = "triage: needs-pr-context" +PROOF_OVERRIDE_LABEL = "proof: override" +PROOF_SUFFICIENT_LABEL = "proof: sufficient" + +# Authors with these associations skip the check entirely. +PRIVILEGED_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) + +# Values that count as "not provided" when they are the *only* content in +# a section body. +_MISSING_VALUE_RE = re.compile( + r"^(?:n/?a|none|not applicable|tbd|todo|unknown|unsure|" + r"none provided|no evidence|not tested|untested|" + r"did not test|didn't test|could not test|couldn't test|" + r"-|(?:-{3,}|\*{3,}|_{3,})|\[[^\]]*\])\.?$", + re.IGNORECASE, +) + +_SECTION_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE) + + +class ProofStatus(str, Enum): + PASSED = "passed" + MISSING = "missing" + SKIPPED = "skipped" + + +@dataclass +class ProofEvaluation: + status: ProofStatus + missing_sections: list[str] = field(default_factory=list) + labels: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _normalize_line_endings(text: str = "") -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def _mask_html_comments(text: str) -> str: + """Replace HTML comment content with spaces so commented-out template + text does not count as authored content.""" + return re.sub( + r"", + lambda m: " " * len(m.group(0)), + text, + flags=re.DOTALL, + ) + + +def _strip_code_fences(text: str) -> str: + """Remove fenced code blocks so headings/copy-pasted template text + inside them do not trick the section parser.""" + return re.sub(r"```[^\n]*\n.*?```", "", text, flags=re.DOTALL) + + +def _extract_sections(body: str) -> dict[str, str]: + """Split a PR body into a ``{heading: body}`` mapping.""" + body = _normalize_line_endings(body) + body = _mask_html_comments(body) + body = _strip_code_fences(body) + + sections: dict[str, str] = {} + matches = list(_SECTION_RE.finditer(body)) + for i, match in enumerate(matches): + heading = match.group(1).strip().lower() + start = match.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(body) + section_body = body[start:end].strip() + sections[heading] = section_body + return sections + + +def _has_real_content(text: str) -> bool: + """Return True if *text* has substantive authored content (not just + template placeholders, separators, or ``None``-like values).""" + text = text.strip() + if not text: + return False + if _MISSING_VALUE_RE.match(text): + return False + # Must have at least one alphanumeric character. + if not re.search(r"[A-Za-z0-9]", text): + return False + return True + + +# --------------------------------------------------------------------------- +# Section name aliases (support legacy + current template) +# --------------------------------------------------------------------------- + +_PROBLEM_ALIASES = [ + "what problem this solves", + "behavior or issue addressed", + "issue addressed", + "behavior addressed", + "description", +] + +_EVIDENCE_ALIASES = [ + "evidence", + "evidence after fix", + "after-fix evidence", + "evidence link or embedded proof", + "local verification evidence", + "testing", + "how to test these changes", +] + + +def _find_section(sections: dict[str, str], aliases: list[str]) -> str | None: + for alias in aliases: + for heading, body in sections.items(): + if alias in heading: + return body + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def evaluate_pull_request_context( + *, + body: str, + author_association: str = "CONTRIBUTOR", + author_type: str = "User", + labels: list[str] | None = None, +) -> ProofEvaluation: + """Evaluate whether a PR has the required context and evidence. + + Parameters + ---------- + body + The PR body text (markdown). + author_association + The GitHub ``author_association`` field — ``OWNER``, ``MEMBER``, + ``COLLABORATOR``, ``CONTRIBUTOR``, ``NONE``, etc. + author_type + The GitHub user ``type`` — ``User``, ``Bot``, etc. + labels + List of label names on the PR. + + Returns + ------- + ProofEvaluation + ``status=PASSED`` if both sections are present with real content, + ``status=MISSING`` if either is absent or empty, + ``status=SKIPPED`` for maintainer/bot PRs. + """ + labels = labels or [] + + # --- Skip rules ------------------------------------------------------- + if author_type == "Bot": + return ProofEvaluation(status=ProofStatus.SKIPPED) + + if author_association in PRIVILEGED_ASSOCIATIONS: + return ProofEvaluation(status=ProofStatus.SKIPPED) + + # The override label lets a maintainer force-skip — but it does NOT + # auto-pass; the PR still needs context unless a maintainer explicitly + # marks it proof-sufficient. + if PROOF_SUFFICIENT_LABEL in labels: + return ProofEvaluation(status=ProofStatus.SKIPPED) + + # --- Parse body ------------------------------------------------------- + sections = _extract_sections(body) + + missing: list[str] = [] + + problem_text = _find_section(sections, _PROBLEM_ALIASES) + if problem_text is None or not _has_real_content(problem_text): + missing.append("What Problem This Solves") + + evidence_text = _find_section(sections, _EVIDENCE_ALIASES) + if evidence_text is None or not _has_real_content(evidence_text): + missing.append("Evidence") + + if missing: + return ProofEvaluation( + status=ProofStatus.MISSING, + missing_sections=missing, + labels=[NEEDS_PR_CONTEXT_LABEL], + ) + + return ProofEvaluation( + status=ProofStatus.PASSED, + labels=[PROOF_SUFFICIENT_LABEL], + ) + + +def labels_for_pull_request_context(evaluation: ProofEvaluation) -> list[str]: + """Convenience: return the labels to add/remove for a given evaluation.""" + return evaluation.labels diff --git a/scripts/install.bat b/scripts/install.bat new file mode 100644 index 0000000..21ab517 --- /dev/null +++ b/scripts/install.bat @@ -0,0 +1,567 @@ +@echo off +setlocal EnableDelayedExpansion + +REM QwenPaw Installer for Windows (cmd.exe / batch) +REM Usage: install.bat [-Version X.Y.Z] [-FromSource] [-SourceDir DIR] +REM [-Extras "dev,whisper"] [-UvPath PATH] [-Help] +REM +REM Installs QwenPaw into %USERPROFILE%\.qwenpaw with a uv-managed Python environment. +REM Users do NOT need Python pre-installed -- uv handles everything. +REM +REM uv is obtained automatically (no action required from the user): +REM 1. Found on PATH or in common locations +REM 2. Downloaded via https://astral.sh/uv/install.ps1 +REM 3. Downloaded via GitHub Releases if astral.sh is unreachable (e.g. in China) + +REM ── Defaults ────────────────────────────────────────────────────────────────── +if defined QWENPAW_HOME ( + set "QWENPAW_HOME=%QWENPAW_HOME%" +) else ( + set "QWENPAW_HOME=%USERPROFILE%\.qwenpaw" +) +set "QWENPAW_VENV=%QWENPAW_HOME%\venv" +set "QWENPAW_BIN=%QWENPAW_HOME%\bin" +set "PYTHON_VERSION=3.12" +set "QWENPAW_REPO=https://github.com/agentscope-ai/QwenPaw.git" + +REM ──── Argument defaults ────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +set "ARG_VERSION=" +set "ARG_FROM_SOURCE=0" +set "ARG_SOURCE_DIR=" +set "ARG_EXTRAS=" +set "ARG_UV_PATH=" +set "ARG_PRERELEASE=0" +set "CONSOLE_COPIED=0" +set "CONSOLE_AVAILABLE=0" + +REM ──── Parse arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +:parse_args +if "%~1"=="" goto :done_args +if /i "%~1"=="-Version" goto :arg_version +if /i "%~1"=="-FromSource" goto :arg_fromsource +if /i "%~1"=="-SourceDir" goto :arg_sourcedir +if /i "%~1"=="-Extras" goto :arg_extras +if /i "%~1"=="-Prerelease" goto :arg_prerelease +if /i "%~1"=="-UvPath" goto :arg_uvpath +if /i "%~1"=="-Help" goto :show_help +shift +goto :parse_args + +:arg_version +set "ARG_VERSION=%~2" +shift & shift +goto :parse_args + +:arg_fromsource +set "ARG_FROM_SOURCE=1" +shift +goto :parse_args + +:arg_sourcedir +set "ARG_SOURCE_DIR=%~2" +shift & shift +goto :parse_args + +:arg_extras +set "ARG_EXTRAS=%~2" +shift & shift +goto :parse_args + +:arg_prerelease +set "ARG_PRERELEASE=1" +shift +goto :parse_args + +:arg_uvpath +set "ARG_UV_PATH=%~2" +shift & shift +goto :parse_args + +:done_args +goto :main + +REM ──── Help ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +:show_help +echo QwenPaw Installer for Windows +echo. +echo Usage: install.bat [OPTIONS] +echo. +echo Options: +echo -Version ^ Install a specific version (e.g. 0.0.2) +echo -FromSource Install from source (requires git, or use -SourceDir) +echo -SourceDir ^ Local source directory (used with -FromSource) +echo -Extras ^ Comma-separated optional extras to install +echo (e.g. dev, whisper) +echo -Prerelease Install the latest PyPI release, including pre-releases +echo -UvPath ^ Path to a pre-installed uv.exe (skips all auto-install) +echo -Help Show this help +echo. +echo Environment: +echo QWENPAW_HOME Installation directory (default: %%USERPROFILE%%\.qwenpaw) +exit /b 0 + +REM ──── Helper functions ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +:write_info +echo [qwenpaw] %~1 +exit /b 0 + +:write_warn +echo [qwenpaw] WARNING: %~1 +exit /b 0 + +:write_err +echo [qwenpaw] ERROR: %~1 +exit /b 0 + +:stop_with_error +echo [qwenpaw] ERROR: %~1 +exit /b 1 + +REM ──── Download uv from GitHub Releases ──────────────────────────────────────────────────────────────────────────────────── +REM Subroutine: called when astral.sh is unreachable (e.g. in China). +REM On success: uv.exe is in %LOCALAPPDATA%\uv and that dir is prepended to PATH. +:download_uv_github +if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "_DL_ARCH=aarch64" +) else ( + set "_DL_ARCH=x86_64" +) +set "_DL_URL=https://github.com/astral-sh/uv/releases/latest/download/uv-!_DL_ARCH!-pc-windows-msvc.zip" +set "_DL_DEST=%LOCALAPPDATA%\uv" +set "_DL_ZIP=%TEMP%\uv-gh-%RANDOM%.zip" + +echo [qwenpaw] Downloading uv ^(!_DL_ARCH!^) from GitHub Releases... + +REM Try curl.exe (built into Windows 10+), then fall back to PowerShell +where curl >nul 2>&1 +if not errorlevel 1 ( + curl -L --progress-bar -o "!_DL_ZIP!" "!_DL_URL!" + if not errorlevel 1 goto :download_uv_extract + echo [qwenpaw] curl failed, retrying with PowerShell... + del "!_DL_ZIP!" >nul 2>&1 +) + +powershell -NoProfile -Command "$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '!_DL_URL!' -OutFile '!_DL_ZIP!' -UseBasicParsing" +if errorlevel 1 ( + echo [qwenpaw] ERROR: GitHub download also failed. + echo [qwenpaw] Download uv manually from: https://github.com/astral-sh/uv/releases/latest + del "!_DL_ZIP!" >nul 2>&1 + exit /b 1 +) + +:download_uv_extract +if not exist "!_DL_DEST!" mkdir "!_DL_DEST!" +echo [qwenpaw] Extracting uv... +powershell -NoProfile -Command "Expand-Archive -Force -Path '!_DL_ZIP!' -DestinationPath '!_DL_DEST!'" +set "_DL_ERR=%errorlevel%" +del "!_DL_ZIP!" >nul 2>&1 +if %_DL_ERR% neq 0 ( + echo [qwenpaw] ERROR: Extraction failed. + exit /b 1 +) +if not exist "!_DL_DEST!\uv.exe" ( + echo [qwenpaw] ERROR: uv.exe not found after extraction. + exit /b 1 +) +set "PATH=!_DL_DEST!;!PATH!" +echo [qwenpaw] uv installed: !_DL_DEST!\uv.exe +exit /b 0 + +REM ──── Ensure uv ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +:ensure_uv +REM 0. User-supplied path (-UvPath) +if defined ARG_UV_PATH ( + if not exist "%ARG_UV_PATH%" ( + echo [qwenpaw] ERROR: Specified uv not found: %ARG_UV_PATH% + exit /b 1 + ) + for %%I in ("%ARG_UV_PATH%") do set "PATH=%%~dpI;!PATH!" + echo [qwenpaw] uv found: %ARG_UV_PATH% + goto :ensure_uv_done +) + +REM 1. Already on PATH +where uv >nul 2>&1 +if %errorlevel%==0 ( + for /f "delims=" %%p in ('where uv 2^>nul') do ( + echo [qwenpaw] uv found: %%p + goto :ensure_uv_done + ) +) + +REM 2. Common install locations not yet on PATH +for %%c in ("%USERPROFILE%\.local\bin\uv.exe" "%USERPROFILE%\.cargo\bin\uv.exe" "%LOCALAPPDATA%\uv\uv.exe") do ( + if exist %%c ( + set "_UV_DIR=%%~dpc" + set "PATH=!_UV_DIR!;!PATH!" + echo [qwenpaw] uv found: %%~c + goto :ensure_uv_done + ) +) + +REM 3. Try astral.sh (standard installer, fast outside China) +echo [qwenpaw] Installing uv via astral.sh... +powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 -TimeoutSec 15 | iex" +if not errorlevel 1 goto :ensure_uv_refresh + +REM 4. astral.sh failed -- fall back to GitHub Releases (works in China) +echo [qwenpaw] astral.sh unreachable, falling back to GitHub Releases... +call :download_uv_github +if errorlevel 1 ( + echo [qwenpaw] ERROR: Failed to install uv automatically. + echo [qwenpaw] Please install uv manually: https://docs.astral.sh/uv/ + exit /b 1 +) +goto :ensure_uv_done + +:ensure_uv_refresh +REM Refresh PATH after astral.sh install +for %%p in ("%USERPROFILE%\.local\bin" "%USERPROFILE%\.cargo\bin" "%LOCALAPPDATA%\uv") do ( + if exist %%p ( + echo "!PATH!" | findstr /i /c:"%%~p" >nul 2>&1 + if errorlevel 1 set "PATH=%%~p;!PATH!" + ) +) +where uv >nul 2>&1 +if errorlevel 1 ( + echo [qwenpaw] ERROR: Failed to install uv. Please install it manually: https://docs.astral.sh/uv/ + exit /b 1 +) +echo [qwenpaw] uv installed via astral.sh + +:ensure_uv_done +exit /b 0 + +REM ──── Prepare console frontend ──────────────────────────────────────────────────────────────────────────────────────────────────── +:prepare_console +REM %~1 = RepoDir +set "_REPO_DIR=%~1" +set "_CONSOLE_SRC=%_REPO_DIR%\console\dist" +set "_CONSOLE_DEST=%_REPO_DIR%\src\qwenpaw\console" + +REM Already populated +if exist "%_CONSOLE_DEST%\index.html" ( + set "CONSOLE_AVAILABLE=1" + exit /b 0 +) + +REM Copy pre-built assets if available +if exist "%_CONSOLE_SRC%\index.html" ( + echo [qwenpaw] Copying console frontend assets... + if not exist "%_CONSOLE_DEST%" mkdir "%_CONSOLE_DEST%" + xcopy /s /e /y /q "%_CONSOLE_SRC%\*" "%_CONSOLE_DEST%\" >nul + set "CONSOLE_COPIED=1" + set "CONSOLE_AVAILABLE=1" + exit /b 0 +) + +REM Try to build if npm is available +if not exist "%_REPO_DIR%\console\package.json" ( + echo [qwenpaw] WARNING: Console source not found - the web UI won't be available. + exit /b 0 +) + +where npm >nul 2>&1 +if errorlevel 1 ( + echo [qwenpaw] WARNING: npm not found - skipping console frontend build. + echo [qwenpaw] WARNING: Install Node.js from https://nodejs.org/ then re-run this installer, + echo [qwenpaw] WARNING: or run 'cd console ^&^& npm ci ^&^& npm run build' manually. + exit /b 0 +) + +echo [qwenpaw] Building console frontend (npm ci ^&^& npm run build)... +pushd "%_REPO_DIR%\console" +npm ci +if errorlevel 1 ( + popd + echo [qwenpaw] WARNING: npm ci failed - the web UI won't be available. + exit /b 0 +) +npm run build +if errorlevel 1 ( + popd + echo [qwenpaw] WARNING: npm run build failed - the web UI won't be available. + exit /b 0 +) +popd + +if exist "%_CONSOLE_SRC%\index.html" ( + if not exist "%_CONSOLE_DEST%" mkdir "%_CONSOLE_DEST%" + xcopy /s /e /y /q "%_CONSOLE_SRC%\*" "%_CONSOLE_DEST%\" >nul + set "CONSOLE_COPIED=1" + set "CONSOLE_AVAILABLE=1" + echo [qwenpaw] Console frontend built successfully + exit /b 0 +) + +echo [qwenpaw] WARNING: Console build completed but index.html not found - the web UI won't be available. +exit /b 0 + +REM ──── Cleanup console frontend ──────────────────────────────────────────────────────────────────────────────────────────────────── +:cleanup_console +REM %~1 = RepoDir +if "%CONSOLE_COPIED%"=="1" ( + set "_CLEANUP_DEST=%~1\src\qwenpaw\console" + if exist "!_CLEANUP_DEST!" rd /s /q "!_CLEANUP_DEST!" 2>nul +) +exit /b 0 + +REM ══════════════════════════════ MAIN ═════════════════════════════════════════ +:main +echo [qwenpaw] Installing QwenPaw into %QWENPAW_HOME% + +REM ──── Step 1: Ensure uv ────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +call :ensure_uv +if errorlevel 1 exit /b 1 + +REM ──── Step 2: Create / update virtual environment ────────────────────────────────────────────────────────────── +if exist "%QWENPAW_VENV%" ( + echo [qwenpaw] Existing environment found, upgrading... +) else ( + echo [qwenpaw] Creating Python %PYTHON_VERSION% environment... +) + +uv venv "%QWENPAW_VENV%" --python %PYTHON_VERSION% --quiet --clear +if errorlevel 1 ( + echo [qwenpaw] ERROR: Failed to create virtual environment + exit /b 1 +) + +set "VENV_PYTHON=%QWENPAW_VENV%\Scripts\python.exe" +if not exist "%VENV_PYTHON%" ( + echo [qwenpaw] ERROR: Failed to create virtual environment + exit /b 1 +) + +for /f "delims=" %%v in ('"%VENV_PYTHON%" --version 2^>^&1') do set "PY_VERSION=%%v" +echo [qwenpaw] Python environment ready (%PY_VERSION%) + +REM ──── Step 3: Install QwenPaw ────────────────────────────────────────────────────────────────────────────────────────────────────────── +set "EXTRAS_SUFFIX=" +if defined ARG_EXTRAS set "EXTRAS_SUFFIX=[%ARG_EXTRAS%]" + +set "VENV_QWENPAW=%QWENPAW_VENV%\Scripts\qwenpaw.exe" + +REM Use goto-based branching to avoid nested parenthesized blocks, +REM which break when %vars% expand to values containing "(" or ")". +if "%ARG_FROM_SOURCE%"=="1" goto :install_from_source +goto :install_from_pypi + +:install_from_source +if defined ARG_SOURCE_DIR goto :install_from_local +goto :install_from_github_qwenpaw + +:install_from_local +for %%I in ("%ARG_SOURCE_DIR%") do set "ARG_SOURCE_DIR=%%~fI" +echo [qwenpaw] Installing QwenPaw from local source: %ARG_SOURCE_DIR% +call :prepare_console "%ARG_SOURCE_DIR%" +echo [qwenpaw] Installing package from source... + +rem === Secure Input Validation (Prevents Argument Injection) === +rem 1. Ensure non-empty +if “%ARG_SOURCE_DIR%” == ‘’ set “ARG_SOURCE_DIR=.” +if “%EXTRAS_SUFFIX%” == ‘’ set “EXTRAS_SUFFIX=” + +rem 2. Define invalid character set (double quotes, pipe, logical AND, redirection, brackets, percent sign, caret) +rem These characters can break command structure or inject new parameters +set “INVALID_CHARS=\”|&<>()%%^" + +rem 3. Validate ARG_SOURCE_DIR +rem Logic: If the variable contains any invalid characters, findstr will match successfully (errorlevel 0) +echo %ARG_SOURCE_DIR% | findstr /R "[\"|&<>()%%^]" >nul 2>&1 +if not errorlevel 1 ( + echo [ERROR] Security Alert: ARG_SOURCE_DIR contains invalid characters. + echo [ERROR] Detected unsafe input: %ARG_SOURCE_DIR% + echo [ERROR] Installation aborted to prevent argument injection. + call :cleanup_console "%ARG_SOURCE_DIR%" + exit /b 1 +) + +rem 4. Validate EXTRAS_SUFFIX (typically formatted as [dev,test]) +rem Whitelist policy: Only letters, digits, commas, square brackets, underscores, and hyphens are permitted +rem Logic: If any non-whitelisted character is present, findstr succeeds +echo %EXTRAS_SUFFIX% | findstr /R "[^a-zA-Z0-9_,\-\[\]]" >nul 2>&1 +if not errorlevel 1 ( + echo [ERROR] Security Alert: EXTRAS_SUFFIX contains invalid characters. + echo [ERROR] Detected unsafe input: %EXTRAS_SUFFIX% + echo [ERROR] Only alphanumeric, commas, underscores, hyphens, and brackets are allowed. + call :cleanup_console "%ARG_SOURCE_DIR%" + exit /b 1 +) +rem === End Security Validation === + +rem The input has now been verified as safe and can proceed with installation. +uv pip install "%ARG_SOURCE_DIR%%EXTRAS_SUFFIX%" --python "%VENV_PYTHON%" +set "_INST_ERR=%errorlevel%" +call :cleanup_console "%ARG_SOURCE_DIR%" +if %_INST_ERR% neq 0 ( + echo [qwenpaw] ERROR: Installation from source failed + exit /b 1 +) +goto :install_verify + +:install_from_github_qwenpaw +where git >nul 2>&1 +if errorlevel 1 ( + echo [qwenpaw] ERROR: git is required for -FromSource without a local directory. + echo [qwenpaw] Please install Git from https://git-scm.com/ or pass a local path: + echo [qwenpaw] install-w-uv.bat -FromSource -SourceDir C:\path\to\QwenPaw + exit /b 1 +) +echo [qwenpaw] Installing QwenPaw from source (GitHub)... +set "CLONE_DIR=%TEMP%\qwenpaw-install-%RANDOM%" +git clone --depth 1 %QWENPAW_REPO% "%CLONE_DIR%" +if errorlevel 1 ( + if exist "%CLONE_DIR%" rd /s /q "%CLONE_DIR%" + echo [qwenpaw] ERROR: Failed to clone repository + exit /b 1 +) +call :prepare_console "%CLONE_DIR%" +echo [qwenpaw] Installing package from source... +uv pip install "%CLONE_DIR%%EXTRAS_SUFFIX%" --python "%VENV_PYTHON%" +set "_INST_ERR=%errorlevel%" +if exist "%CLONE_DIR%" rd /s /q "%CLONE_DIR%" +if %_INST_ERR% neq 0 ( + echo [qwenpaw] ERROR: Installation from source failed + exit /b 1 +) +goto :install_verify + +:install_from_pypi +set "_PACKAGE=qwenpaw" + +rem === Secure Validation for ARG_VERSION === +if defined ARG_VERSION ( + rem Version number whitelist: Only permits numbers, letters, periods, comparison symbols (=<>!), hyphens, and tilde characters + rem Prohibits spaces, quotation marks, slashes, and other characters potentially used for --index-url injection + echo %ARG_VERSION% | findstr /R "[^a-zA-Z0-9\.=<>\!\-~]" >nul 2>&1 + if not errorlevel 1 ( + echo [ERROR] Security Alert: ARG_VERSION contains invalid characters. + echo [ERROR] Detected unsafe input: %ARG_VERSION% + echo [ERROR] Installation aborted. + exit /b 1 + ) + set "_PACKAGE=qwenpaw%ARG_VERSION%" +) +rem === End Version Validation === + +echo [qwenpaw] Installing %_PACKAGE%%EXTRAS_SUFFIX% from PyPI... +rem Note: It is also recommended to validate EXTRAS_SUFFIX here. Although it may be undefined in the local scope above, +rem for safety, if ARG_EXTRAS is defined globally, it is best to reuse the validation logic from above or ensure its source is secure. +rem Assume EXTRAS_SUFFIX is generated here based on the previously validated ARG_EXTRAS, or is empty. +rem If ARG_EXTRAS is passed globally, it is recommended to validate it uniformly at the beginning of the script. + +set "PRERELEASE_ARG=" +if "%ARG_PRERELEASE%"=="1" set "PRERELEASE_ARG=--prerelease=allow" + +uv pip install "%_PACKAGE%%EXTRAS_SUFFIX%" --python "%VENV_PYTHON%" --quiet --refresh-package qwenpaw %PRERELEASE_ARG% +if errorlevel 1 ( + echo [qwenpaw] ERROR: Installation failed + exit /b 1 +) + +:install_verify + +REM Verify the CLI entry point exists +if not exist "%VENV_QWENPAW%" ( + echo [qwenpaw] ERROR: Installation failed: qwenpaw CLI not found in venv + exit /b 1 +) +echo [qwenpaw] QwenPaw installed successfully + +REM Check console availability (for PyPI installs, probe the installed package) +if "%CONSOLE_AVAILABLE%"=="0" ( + "%VENV_PYTHON%" -c "import importlib.resources, qwenpaw; p=importlib.resources.files('qwenpaw')/'console'/'index.html'; print('yes' if p.is_file() else 'no')" > "%TEMP%\_qwenpaw_console_check.tmp" 2>&1 + set /p CONSOLE_CHECK=<"%TEMP%\_qwenpaw_console_check.tmp" + del "%TEMP%\_qwenpaw_console_check.tmp" >nul 2>&1 + if "!CONSOLE_CHECK!"=="yes" set "CONSOLE_AVAILABLE=1" +) + +REM ──── Step 4: Create wrapper scripts ──────────────────────────────────────────────────────────────────────────────────────── +if not exist "%QWENPAW_BIN%" mkdir "%QWENPAW_BIN%" + +REM PowerShell wrapper +set "WRAPPER_PS1=%QWENPAW_BIN%\qwenpaw.ps1" +echo # QwenPaw CLI wrapper -- delegates to the uv-managed environment. > "%WRAPPER_PS1%" +echo $ErrorActionPreference = "Stop" >> "%WRAPPER_PS1%" +echo. >> "%WRAPPER_PS1%" +echo $QwenpawHome = if ($env:QWENPAW_HOME) { $env:QWENPAW_HOME } else { Join-Path $HOME ".qwenpaw" } >> "%WRAPPER_PS1%" +echo $RealBin = Join-Path $QwenpawHome "venv\Scripts\qwenpaw.exe" >> "%WRAPPER_PS1%" +echo. >> "%WRAPPER_PS1%" +echo if (-not (Test-Path $RealBin)) { >> "%WRAPPER_PS1%" +echo Write-Error "QwenPaw environment not found at $QwenpawHome\venv" >> "%WRAPPER_PS1%" +echo Write-Error "Please reinstall: irm ^ ^| iex" >> "%WRAPPER_PS1%" +echo exit 1 >> "%WRAPPER_PS1%" +echo } >> "%WRAPPER_PS1%" +echo. >> "%WRAPPER_PS1%" +echo ^& $RealBin @args >> "%WRAPPER_PS1%" +echo [qwenpaw] Wrapper created at %WRAPPER_PS1% + +REM CMD wrapper +set "WRAPPER_CMD=%QWENPAW_BIN%\qwenpaw.cmd" +echo @echo off > "%WRAPPER_CMD%" +echo REM QwenPaw CLI wrapper -- delegates to the uv-managed environment. >> "%WRAPPER_CMD%" +echo set "QWENPAW_HOME=%%QWENPAW_HOME%%" >> "%WRAPPER_CMD%" +echo if "%%QWENPAW_HOME%%"=="" set "QWENPAW_HOME=%%USERPROFILE%%\.qwenpaw" >> "%WRAPPER_CMD%" +echo set "REAL_BIN=%%QWENPAW_HOME%%\venv\Scripts\qwenpaw.exe" >> "%WRAPPER_CMD%" +echo if not exist "%%REAL_BIN%%" ( >> "%WRAPPER_CMD%" +echo echo Error: QwenPaw environment not found at %%QWENPAW_HOME%%\venv ^>^&2 >> "%WRAPPER_CMD%" +echo echo Please reinstall ^>^&2 >> "%WRAPPER_CMD%" +echo exit /b 1 >> "%WRAPPER_CMD%" +echo ) >> "%WRAPPER_CMD%" +echo "%%REAL_BIN%%" %%* >> "%WRAPPER_CMD%" +echo [qwenpaw] CMD wrapper created at %WRAPPER_CMD% + +REM ──── Step 5: Update PATH via user environment variable ────────────────────────────────────────────────── +set "CURRENT_USER_PATH=" +for /f "skip=2 tokens=1,2,*" %%a in ('reg query "HKCU\Environment" /v Path 2^>nul') do ( + if /i "%%a"=="Path" set "CURRENT_USER_PATH=%%c" +) + +:: === 安全检查PATH是否已存在(关键修复) === +set "path_check=;%CURRENT_USER_PATH%;" +set "check_str=;%QWENPAW_BIN%;" +if /i "%path_check%" neq "%path_check:%check_str%=%" ( + echo [qwenpaw] %QWENPAW_BIN% already in PATH +) else ( + :: === 修复1:安全传递参数(解决命令注入) === + if defined CURRENT_USER_PATH ( + powershell -NoProfile -Command "$p = $args[0]; $v = $args[1]; [Environment]::SetEnvironmentVariable('Path', $p + ';' + $v, 'User')" "%QWENPAW_BIN%" "!CURRENT_USER_PATH!" + ) else ( + powershell -NoProfile -Command "$p = $args[0]; [Environment]::SetEnvironmentVariable('Path', $p, 'User')" "%QWENPAW_BIN%" + ) + + :: === 修复2:添加关键错误检查(解决失败不报错) === + if errorlevel 1 ( + echo [error] Failed to update PATH. QWENPAW_BIN: "%QWENPAW_BIN%" + echo [error] Please verify the path is valid. + exit /b 1 + ) + + :: === 修复3:安全更新当前进程PATH === + set "PATH=%QWENPAW_BIN%;!PATH!" + echo [qwenpaw] Added %QWENPAW_BIN% to PATH +) + +REM ──── Done ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +echo. +echo QwenPaw installed successfully! +echo. +echo Install location: %QWENPAW_HOME% +echo Python: %PY_VERSION% +if "%CONSOLE_AVAILABLE%"=="1" ( + echo Console ^(web UI^): available +) else ( + echo Console ^(web UI^): not available + echo Install Node.js and re-run to enable the web UI. +) +echo. +echo To get started, open a new terminal and run: +echo. +echo pineagents init # first-time setup +echo pineagents app # start PineAgents +echo. +echo To upgrade later, re-run this installer. +echo To uninstall, run: pineagents uninstall + +exit /b 0 diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..5787c31 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,481 @@ +# QwenPaw Installer for Windows (self-contained: includes uv download via GitHub) +# Usage: irm /install.ps1 | iex +# or: .\install.ps1 [-Version X.Y.Z] [-FromSource] [-SourceDir DIR] +# [-Extras "dev,whisper"] [-UvPath PATH] +# +# Installs QwenPaw into ~/.qwenpaw with a uv-managed Python environment. +# Users do NOT need Python pre-installed — uv handles everything. +# +# uv is obtained automatically (no action required from the user): +# 1. Already on PATH or in common locations +# 2. Downloaded via https://astral.sh/uv/install.ps1 +# 3. Downloaded via GitHub Releases if astral.sh is unreachable (e.g. in China) +# +# The entire script is wrapped in & { ... } @args so that `irm | iex` works +# correctly (param() is only valid inside a scriptblock/function/file scope). + +& { +param( + [string]$Version = "", + [switch]$FromSource, + [string]$SourceDir = "", + [string]$Extras = "", + [string]$UvPath = "", + [switch]$Prerelease, + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +# ── Defaults ────────────────────────────────────────────────────────────────── +$QwenpawHome = if ($env:QWENPAW_HOME) { $env:QWENPAW_HOME } else { Join-Path $HOME ".qwenpaw" } +$QwenpawVenv = Join-Path $QwenpawHome "venv" +$QwenpawBin = Join-Path $QwenpawHome "bin" +$PythonVersion = "3.12" +$QwenpawRepo = "https://github.com/agentscope-ai/QwenPaw.git" + +# ── Colors ──────────────────────────────────────────────────────────────────── +function Write-Info { param([string]$Message) Write-Host "[qwenpaw] " -ForegroundColor Green -NoNewline; Write-Host $Message } +function Write-Warn { param([string]$Message) Write-Host "[qwenpaw] " -ForegroundColor Yellow -NoNewline; Write-Host $Message } +function Write-Err { param([string]$Message) Write-Host "[qwenpaw] " -ForegroundColor Red -NoNewline; Write-Host $Message } +function Stop-WithError { param([string]$Message) Write-Err $Message; exit 1 } + +# ── Help ────────────────────────────────────────────────────────────────────── +if ($Help) { + @" +QwenPaw Installer for Windows + +Usage: .\install.ps1 [OPTIONS] + +Options: + -Version Install a specific version (e.g. 0.0.2) + -FromSource Install from source (requires git, or use -SourceDir) + -SourceDir Local source directory (used with -FromSource) + -Extras Comma-separated optional extras to install + (e.g. dev, whisper) + -Prerelease Install the latest PyPI release, including pre-releases + -UvPath Path to a pre-installed uv.exe (skips all auto-install) + -Help Show this help + +Environment: + QWENPAW_HOME Installation directory (default: ~/.qwenpaw) +"@ + exit 0 +} + +Write-Host "[qwenpaw] " -ForegroundColor Green -NoNewline +Write-Host "Installing QwenPaw into " -NoNewline +Write-Host "$QwenpawHome" -ForegroundColor White + +# ── Execution Policy Check ──────────────────────────────────────────────────── +$policy = Get-ExecutionPolicy +if ($policy -eq "Restricted") { + Write-Info "Execution policy is 'Restricted', setting to RemoteSigned for current user..." + try { + Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force + Write-Info "Execution policy updated to RemoteSigned" + } catch { + Write-Err "PowerShell execution policy is set to 'Restricted' which prevents script execution." + Write-Err "Please run the following command and retry:" + Write-Err "" + Write-Err " Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" + Write-Err "" + exit 1 + } +} + +# ── Step 1: Ensure uv is available ─────────────────────────────────────────── + +function Invoke-UvFromGitHub { + # Downloads uv from GitHub Releases and prepends its directory to PATH. + # Used automatically when astral.sh is unreachable (e.g. in China). + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "aarch64" } else { "x86_64" } + $url = "https://github.com/astral-sh/uv/releases/latest/download/uv-$arch-pc-windows-msvc.zip" + $dest = Join-Path $env:LOCALAPPDATA "uv" + $zip = Join-Path $env:TEMP "uv-gh-$([System.IO.Path]::GetRandomFileName()).zip" + + Write-Info "Downloading uv ($arch) from GitHub Releases..." + $ProgressPreference = 'SilentlyContinue' # prevents 100x slowdown in PS 5.1 + try { + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + } catch { + throw "GitHub download failed: $_" + } + + if (-not (Test-Path $dest)) { New-Item -ItemType Directory -Path $dest -Force | Out-Null } + + Write-Info "Extracting uv..." + try { + Expand-Archive -Force -Path $zip -DestinationPath $dest + } catch { + Remove-Item $zip -ErrorAction SilentlyContinue + throw "Extraction failed: $_" + } + Remove-Item $zip -ErrorAction SilentlyContinue + + $uvExe = Join-Path $dest "uv.exe" + if (-not (Test-Path $uvExe)) { throw "uv.exe not found after extraction at $dest" } + + $env:PATH = "$dest;$env:PATH" + Write-Info "uv installed from GitHub: $uvExe" +} + +function Ensure-Uv { + # 0. User-supplied path (-UvPath) + if ($UvPath) { + if (-not (Test-Path $UvPath)) { Stop-WithError "Specified uv not found: $UvPath" } + $env:PATH = "$(Split-Path $UvPath -Parent);$env:PATH" + Write-Info "uv found: $UvPath" + return + } + + # 1. Already on PATH + if (Get-Command uv -ErrorAction SilentlyContinue) { + Write-Info "uv found: $((Get-Command uv).Source)" + return + } + + # 2. Common install locations not yet on PATH + $candidates = @( + (Join-Path $HOME ".local\bin\uv.exe"), + (Join-Path $HOME ".cargo\bin\uv.exe"), + (Join-Path $env:LOCALAPPDATA "uv\uv.exe") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + $env:PATH = "$(Split-Path $candidate -Parent);$env:PATH" + Write-Info "uv found: $candidate" + return + } + } + + # 3. Try astral.sh (standard installer, fast outside China) + Write-Warn "If automatic uv installation fails, please manually install uv first by following https://github.com/astral-sh/uv/releases, then re-run this installer." + Write-Warn "Alternatively, if Python is already installed, run: python -m pip install -U uv" + Write-Info "Installing uv via astral.sh..." + $astralOk = $false + try { + $installScript = Invoke-RestMethod https://astral.sh/uv/install.ps1 -TimeoutSec 15 + Invoke-Expression $installScript + $astralOk = $true + } catch { + Write-Warn "astral.sh unreachable, falling back to GitHub Releases..." + } + + if ($astralOk) { + # Refresh PATH after astral.sh install + $uvPaths = @( + (Join-Path $HOME ".local\bin"), + (Join-Path $HOME ".cargo\bin"), + (Join-Path $env:LOCALAPPDATA "uv") + ) + foreach ($p in $uvPaths) { + if ((Test-Path $p) -and ($env:PATH -notlike "*$p*")) { + $env:PATH = "$p;$env:PATH" + } + } + if (Get-Command uv -ErrorAction SilentlyContinue) { + Write-Info "uv installed via astral.sh" + return + } + Write-Warn "astral.sh install succeeded but uv not found on PATH, trying GitHub Releases..." + } + + # 4. GitHub Releases fallback (works in China) + try { + Invoke-UvFromGitHub + } catch { + Stop-WithError "Failed to install uv automatically: $_`nPlease install uv manually: https://docs.astral.sh/uv/" + } + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + Stop-WithError "Failed to install uv. Please install it manually: https://docs.astral.sh/uv/" + } +} + +Ensure-Uv + +# ── Step 2: Create / update virtual environment ────────────────────────────── +if (Test-Path $QwenpawVenv) { + Write-Info "Existing environment found, upgrading..." +} else { + Write-Info "Creating Python $PythonVersion environment..." +} + +uv venv $QwenpawVenv --python $PythonVersion --quiet --clear +if ($LASTEXITCODE -ne 0) { Stop-WithError "Failed to create virtual environment" } + +$VenvPython = Join-Path $QwenpawVenv "Scripts\python.exe" +if (-not (Test-Path $VenvPython)) { Stop-WithError "Failed to create virtual environment" } + +$pyVersion = & $VenvPython --version 2>&1 +Write-Info "Python environment ready ($pyVersion)" + +# ── Step 3: Install QwenPaw ──────────────────────────────────────────────────── +$ExtrasSuffix = "" +if ($Extras) { $ExtrasSuffix = "[$Extras]" } + +$script:ConsoleCopied = $false +$script:ConsoleAvailable = $false + +function Prepare-Console { + param([string]$RepoDir) + + $consoleSrc = Join-Path $RepoDir "console\dist" + $consoleDest = Join-Path $RepoDir "src\qwenpaw\console" + + # Already populated + if (Test-Path (Join-Path $consoleDest "index.html")) { $script:ConsoleAvailable = $true; return } + + # Copy pre-built assets if available + if ((Test-Path $consoleSrc) -and (Test-Path (Join-Path $consoleSrc "index.html"))) { + Write-Info "Copying console frontend assets..." + New-Item -ItemType Directory -Path $consoleDest -Force | Out-Null + Copy-Item -Path "$consoleSrc\*" -Destination $consoleDest -Recurse -Force + $script:ConsoleCopied = $true + $script:ConsoleAvailable = $true + return + } + + # Try to build if npm is available + $packageJson = Join-Path $RepoDir "console\package.json" + if (-not (Test-Path $packageJson)) { + Write-Warn "Console source not found - the web UI won't be available." + return + } + + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Warn "npm not found - skipping console frontend build." + Write-Warn "Install Node.js from https://nodejs.org/ then re-run this installer," + Write-Warn "or run 'cd console && npm ci && npm run build' manually." + return + } + + Write-Info "Building console frontend (npm ci && npm run build)..." + Push-Location (Join-Path $RepoDir "console") + try { + npm ci + if ($LASTEXITCODE -ne 0) { Write-Warn "npm ci failed - the web UI won't be available."; return } + npm run build + if ($LASTEXITCODE -ne 0) { Write-Warn "npm run build failed - the web UI won't be available."; return } + } finally { + Pop-Location + } + if (Test-Path (Join-Path $consoleSrc "index.html")) { + New-Item -ItemType Directory -Path $consoleDest -Force | Out-Null + Copy-Item -Path "$consoleSrc\*" -Destination $consoleDest -Recurse -Force + $script:ConsoleCopied = $true + $script:ConsoleAvailable = $true + Write-Info "Console frontend built successfully" + return + } + + Write-Warn "Console build completed but index.html not found - the web UI won't be available." +} + +function Cleanup-Console { + param([string]$RepoDir) + if ($script:ConsoleCopied) { + $consoleDest = Join-Path $RepoDir "src\qwenpaw\console" + if (Test-Path $consoleDest) { + Remove-Item -Path "$consoleDest\*" -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +$VenvQwenpaw = Join-Path $QwenpawVenv "Scripts\qwenpaw.exe" + +if ($FromSource) { + if ($SourceDir) { + $SourceDir = (Resolve-Path $SourceDir).Path + Write-Info "Installing QwenPaw from local source: $SourceDir" + Prepare-Console $SourceDir + Write-Info "Installing package from source..." + uv pip install "${SourceDir}${ExtrasSuffix}" --python $VenvPython + if ($LASTEXITCODE -ne 0) { Stop-WithError "Installation from source failed" } + Cleanup-Console $SourceDir + } else { + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Stop-WithError "git is required for -FromSource without a local directory. Please install Git from https://git-scm.com/ or pass a local path: .\install.ps1 -FromSource -SourceDir C:\path\to\QwenPaw" + } + Write-Info "Installing QwenPaw from source (GitHub)..." + $cloneDir = Join-Path $env:TEMP "qwenpaw-install-$(Get-Random)" + try { + git clone --depth 1 $QwenpawRepo $cloneDir + if ($LASTEXITCODE -ne 0) { Stop-WithError "Failed to clone repository" } + Prepare-Console $cloneDir + Write-Info "Installing package from source..." + uv pip install "${cloneDir}${ExtrasSuffix}" --python $VenvPython + if ($LASTEXITCODE -ne 0) { Stop-WithError "Installation from source failed" } + } finally { + if (Test-Path $cloneDir) { + Remove-Item -Path $cloneDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } +} else { + $package = "qwenpaw" + if ($Version) { $package = "qwenpaw==$Version" } + + $prereleaseArgs = @() + if ($Prerelease) { $prereleaseArgs = @("--prerelease=allow") } + + Write-Info "Installing ${package}${ExtrasSuffix} from PyPI..." + uv pip install "${package}${ExtrasSuffix}" --python $VenvPython --quiet --refresh-package qwenpaw @prereleaseArgs + if ($LASTEXITCODE -ne 0) { Stop-WithError "Installation failed" } +} + +# Verify the CLI entry point exists +if (-not (Test-Path $VenvQwenpaw)) { Stop-WithError "Installation failed: qwenpaw CLI not found in venv" } + +Write-Info "QwenPaw installed successfully" + +# Check console availability (for PyPI installs, check the installed package) +if (-not $script:ConsoleAvailable) { + $consoleCheck = & $VenvPython -c "import importlib.resources, qwenpaw; p=importlib.resources.files('qwenpaw')/'console'/'index.html'; print('yes' if p.is_file() else 'no')" 2>&1 + if ($consoleCheck -eq "yes") { $script:ConsoleAvailable = $true } +} + +# ── Step 4: Create wrapper scripts ─────────────────────────────────────────── +New-Item -ItemType Directory -Path $QwenpawBin -Force | Out-Null + +$wrapperPath = Join-Path $QwenpawBin "qwenpaw.ps1" +$wrapperContent = @' +# QwenPaw CLI wrapper — delegates to the uv-managed environment. +$ErrorActionPreference = "Stop" + +$QwenpawHome = if ($env:QWENPAW_HOME) { $env:QWENPAW_HOME } else { Join-Path $HOME ".qwenpaw" } +$RealBin = Join-Path $QwenpawHome "venv\Scripts\qwenpaw.exe" + +if (-not (Test-Path $RealBin)) { + Write-Error "QwenPaw environment not found at $QwenpawHome\venv" + Write-Error "Please reinstall: irm | iex" + exit 1 +} + +& $RealBin @args +'@ + +Set-Content -Path $wrapperPath -Value $wrapperContent -Encoding UTF8 +Write-Info "Wrapper created at $wrapperPath" + +# Also create a .cmd wrapper for use from cmd.exe +$cmdWrapperPath = Join-Path $QwenpawBin "qwenpaw.cmd" +$cmdWrapperContent = @" +@echo off +REM QwenPaw CLI wrapper — delegates to the uv-managed environment. +set "QWENPAW_HOME=%QWENPAW_HOME%" +if "%QWENPAW_HOME%"=="" set "QWENPAW_HOME=%USERPROFILE%\.qwenpaw" +set "REAL_BIN=%QWENPAW_HOME%\venv\Scripts\qwenpaw.exe" +if not exist "%REAL_BIN%" ( + echo Error: QwenPaw environment not found at %QWENPAW_HOME%\venv >&2 + echo Please reinstall: irm ^ ^| iex >&2 + exit /b 1 +) +"%REAL_BIN%" %* +"@ + +Set-Content -Path $cmdWrapperPath -Value $cmdWrapperContent -Encoding UTF8 +Write-Info "CMD wrapper created at $cmdWrapperPath" + +# ──Step 5: Update PATH via User Environment Variable ──────────────────────── +$targetPath = $QwenpawBin +$registryPath = "HKCU:\Environment" +$registryName = "Path" + +# 1. 安全获取当前的 User PATH (直接从注册表读取,避免污染 Machine PATH) +try { + $currentUserPath = (Get-ItemProperty -Path $registryPath -Name $registryName -ErrorAction SilentlyContinue).Path + if (-not $currentUserPath) { $currentUserPath = "" } +} catch { + # 如果连读都失败(极罕见),则从头开始 + $currentUserPath = "" + Write-Debug "Could not read User Path from registry, starting fresh." +} + +# 2. 精确检查是否已存在 (解决前缀匹配误判) +# 分割路径并去除空格 +$pathArray = $currentUserPath -split ';' | ForEach-Object { $_.Trim() } +$isAlreadyAdded = $pathArray -contains $targetPath + +if (-not $isAlreadyAdded) { + # 构建新的 User PATH 字符串 + if ($currentUserPath) { + $newUserPath = "$targetPath;$currentUserPath" + } else { + $newUserPath = $targetPath + } + + # 3. 核心修复:使用 Set-ItemProperty 代替 [Environment]::SetEnvironmentVariable + # 这是原生 cmdlet,在 Constrained Language Mode 下通常可用 + try { + # 确保注册表路径存在 (HKCU:\Environment 通常默认存在,但为了健壮性检查一下) + if (-not (Test-Path $registryPath)) { + # 这种情况极少见,但如果发生,尝试创建(通常需要权限,若失败则进入 catch) + New-Item -Path $registryPath -Force | Out-Null + } + + # 写入注册表 + Set-ItemProperty -Path $registryPath -Name $registryName -Value $newUserPath + + # 更新当前进程的环境变量,使当前终端立即生效 + $env:Path = "$targetPath;$env:Path" + + Write-Info "Successfully added $targetPath to User PATH (via Registry)" + + } catch { + # 如果连 Set-ItemProperty 都失败(例如注册表被组策略完全锁定) + $errorMsg = $_.Exception.Message + + Write-Host "" + Write-Host "[CRITICAL WARNING] Automatic PATH update failed." -ForegroundColor Red + Write-Host " Reason: $errorMsg" + Write-Host " Context: Your system policy strictly blocks environment modifications." + Write-Host "" + Write-Host "ACTION REQUIRED: You must manually add the path to use QwenPaw." + Write-Host " Target Path: $targetPath" + Write-Host "" + Write-Host "Manual Steps (User Variables):" + Write-Host " 1. Press Win+R, type 'sysdm.cpl' and press Enter" + Write-Host " 2. Go to [Advanced] > [Environment Variables...]" + Write-Host " 3. In the TOP section ('User variables'), select 'Path' > [Edit]" + Write-Host " (If 'Path' doesn't exist in User variables, click [New] and name it 'Path')" + Write-Host " 4. Click [New] and paste: $targetPath" + Write-Host " 5. Click [OK] everywhere to save." + Write-Host " 6. CLOSE and REOPEN your terminal." + Write-Host "" + + # 即使注册表写入失败,也尝试更新当前会话以便用户测试(如果不报错的话) + # 注意:如果策略极严,这行也可能无效,但尝试一下无害 + try { + $env:Path = "$targetPath;$env:Path" + } catch {} + } +} else { + Write-Info "$targetPath is already in your User PATH" +} + +# ── Done ────────────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "QwenPaw installed successfully!" -ForegroundColor Green +Write-Host "" + +Write-Host " Install location: " -NoNewline; Write-Host "$QwenpawHome" -ForegroundColor White +Write-Host " Python: " -NoNewline; Write-Host "$pyVersion" -ForegroundColor White +if ($script:ConsoleAvailable) { + Write-Host " Console (web UI): " -NoNewline; Write-Host "available" -ForegroundColor Green +} else { + Write-Host " Console (web UI): " -NoNewline; Write-Host "not available" -ForegroundColor Yellow + Write-Host " Install Node.js and re-run to enable the web UI." +} +Write-Host "" + +Write-Host "To get started, open a new terminal and run:" +Write-Host "" +Write-Host " pineagents init" -ForegroundColor White -NoNewline; Write-Host " # first-time setup" +Write-Host " pineagents app" -ForegroundColor White -NoNewline; Write-Host " # start PineAgents" +Write-Host "" +Write-Host "To upgrade later, re-run this installer." +Write-Host "To uninstall, run: " -NoNewline +Write-Host "pineagents uninstall" -ForegroundColor White + +} @args diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..f1f6046 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# QwenPaw Installer +# Usage: curl -fsSL /install.sh | bash +# or: bash install.sh [--version X.Y.Z] [--from-source] +# +# Installs QwenPaw into ~/.qwenpaw with a uv-managed Python environment. +# Users do NOT need Python pre-installed — uv handles everything. +set -euo pipefail + +# ── Colors ──────────────────────────────────────────────────────────────────── +if [ -t 1 ]; then + BOLD="\033[1m" + GREEN="\033[0;32m" + YELLOW="\033[0;33m" + RED="\033[0;31m" + RESET="\033[0m" +else + BOLD="" GREEN="" YELLOW="" RED="" RESET="" +fi + +info() { printf "${GREEN}[qwenpaw]${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}[qwenpaw]${RESET} %s\n" "$*"; } +error() { printf "${RED}[qwenpaw]${RESET} %s\n" "$*" >&2; } +die() { error "$@"; exit 1; } + +# ── Defaults ────────────────────────────────────────────────────────────────── +QWENPAW_HOME="${QWENPAW_HOME:-$HOME/.qwenpaw}" +QWENPAW_VENV="$QWENPAW_HOME/venv" +QWENPAW_BIN="$QWENPAW_HOME/bin" +PYTHON_VERSION="3.12" +QWENPAW_REPO="https://github.com/agentscope-ai/QwenPaw.git" + +# New: Intelligent selection of PyPI source (automatically using Alibaba Cloud mirror for domestic users, and official source for overseas users) +choose_pypi_mirror() { + # Test the connectivity of the official PyPI source (timeout 3 seconds, no output) + if curl -s --connect-timeout 3 https://pypi.org/simple/ > /dev/null 2>&1; then + echo "https://pypi.org/simple/" + info "Using official PyPI source (network is good)" >&2 + else + echo "https://mirrors.aliyun.com/pypi/simple/" + info "Using Aliyun PyPI mirror (official source is unreachable)" >&2 + fi +} +PYPI_MIRROR=$(choose_pypi_mirror) + +# New: Automatically clear old virtual environments and skip interactive prompts +export UV_VENV_CLEAR=1 + + + + +VERSION="" +FROM_SOURCE=false +SOURCE_DIR="" +EXTRAS="" +PRERELEASE=false + +# ── Parse args ──────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="$2"; shift 2 ;; + --from-source) + FROM_SOURCE=true + # Accept optional path argument (next arg that doesn't start with --) + if [[ $# -ge 2 && "$2" != --* ]]; then + SOURCE_DIR="$(cd "$2" && pwd)" || die "Directory not found: $2" + shift + fi + shift ;; + --extras) + EXTRAS="$2"; shift 2 ;; + --prerelease) + PRERELEASE=true; shift ;; + -h|--help) + cat < Install a specific version (e.g. 0.0.2) + --from-source [DIR] Install from source. If DIR is given, use that local + directory; otherwise clone from GitHub. + --extras Comma-separated optional extras to install + (e.g. dev, whisper) + --prerelease Install the latest PyPI release, including pre-releases + -h, --help Show this help + +Environment: + QWENPAW_HOME Installation directory (default: ~/.qwenpaw) +EOF + exit 0 ;; + *) + die "Unknown option: $1 (try --help)" ;; + esac +done + +# ── OS check ────────────────────────────────────────────────────────────────── +OS="$(uname -s)" +case "$OS" in + Linux|Darwin) ;; + *) die "Unsupported OS: $OS. This installer supports Linux and macOS only." ;; +esac + +printf "${GREEN}[qwenpaw]${RESET} Installing QwenPaw into ${BOLD}%s${RESET}\n" "$QWENPAW_HOME" + +# ── Step 1: Ensure uv is available ─────────────────────────────────────────── +ensure_uv() { + if command -v uv &>/dev/null; then + info "uv found: $(command -v uv)" + return + fi + + # Check common install locations not yet on PATH + for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do + if [ -x "$candidate" ]; then + export PATH="$(dirname "$candidate"):$PATH" + info "uv found: $candidate" + return + fi + done + + info "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh + + # Source the env file uv's installer creates, or add common paths + if [ -f "$HOME/.local/bin/env" ]; then + # shellcheck disable=SC1091 + . "$HOME/.local/bin/env" + fi + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" + + command -v uv &>/dev/null || die "Failed to install uv. Please install it manually: https://docs.astral.sh/uv/" + info "uv installed successfully" +} + +ensure_uv + +# ── Step 2: Create / update virtual environment ────────────────────────────── +if [ -d "$QWENPAW_VENV" ]; then + info "Existing environment found, upgrading..." +else + info "Creating Python $PYTHON_VERSION environment..." +fi + +uv venv "$QWENPAW_VENV" --python "$PYTHON_VERSION" --quiet + +# Verify the venv was created +[ -x "$QWENPAW_VENV/bin/python" ] || die "Failed to create virtual environment" +info "Python environment ready ($("$QWENPAW_VENV/bin/python" --version))" + +# ── Step 3: Install QwenPaw ──────────────────────────────────────────────────── +# Build extras suffix: "" or "[dev,whisper]" +EXTRAS_SUFFIX="" +if [ -n "$EXTRAS" ]; then + EXTRAS_SUFFIX="[$EXTRAS]" +fi + +## Ensure console frontend assets are in src/pineagents/console/ for source installs. +## Sets _CONSOLE_COPIED=1 if we populated the directory (so we can clean up). +_CONSOLE_COPIED=0 +_CONSOLE_AVAILABLE=0 +prepare_console() { + local repo_dir="$1" + local console_src="$repo_dir/console/dist" + local console_dest="$repo_dir/src/pineagents/console" + + # Already populated + if [ -f "$console_dest/index.html" ]; then + _CONSOLE_AVAILABLE=1 + return + fi + + # Copy pre-built assets if available (e.g. developer already ran npm build) + if [ -d "$console_src" ] && [ -f "$console_src/index.html" ]; then + info "Copying console frontend assets..." + mkdir -p "$console_dest" + cp -R "$console_src/"* "$console_dest/" + _CONSOLE_COPIED=1 + _CONSOLE_AVAILABLE=1 + return + fi + + # Try to build if npm is available + if [ ! -f "$repo_dir/console/package.json" ]; then + warn "Console source not found — the web UI won't be available." + return + fi + + if ! command -v npm &>/dev/null; then + warn "npm not found — skipping console frontend build." + warn "Install Node.js from https://nodejs.org/ then re-run this installer," + warn "or run 'cd console && npm ci && npm run build' manually." + return + fi + + info "Building console frontend (npm ci && npm run build)..." + (cd "$repo_dir/console" && npm ci && npm run build) + if [ -f "$console_src/index.html" ]; then + mkdir -p "$console_dest" + cp -R "$console_src/"* "$console_dest/" + _CONSOLE_COPIED=1 + _CONSOLE_AVAILABLE=1 + info "Console frontend built successfully" + return + fi + + warn "Console build completed but index.html not found — the web UI won't be available." +} + +## Remove console assets we copied into the source tree. +cleanup_console() { + local repo_dir="$1" + if [ "$_CONSOLE_COPIED" = 1 ]; then + rm -rf "$repo_dir/src/pineagents/console/"* + fi +} + +## Ensure docs are available in src/pineagents/docs/ for source installs. +_DOCS_COPIED=0 +prepare_docs() { + local repo_dir="$1" + local docs_src="$repo_dir/website/public/docs" + local docs_dest="$repo_dir/src/pineagents/docs" + + if [ -d "$docs_dest" ] && ls "$docs_dest"/*.md >/dev/null 2>&1; then + return + fi + + if [ -d "$docs_src" ] && ls "$docs_src"/*.md >/dev/null 2>&1; then + mkdir -p "$docs_dest" + cp "$docs_src/"*.md "$docs_dest/" + _DOCS_COPIED=1 + fi +} + +cleanup_docs() { + local repo_dir="$1" + if [ "$_DOCS_COPIED" = 1 ]; then + rm -rf "$repo_dir/src/pineagents/docs" + fi +} + +if [ "$FROM_SOURCE" = true ]; then + if [ -n "$SOURCE_DIR" ]; then + info "Installing QwenPaw from local source: $SOURCE_DIR" + prepare_console "$SOURCE_DIR" + prepare_docs "$SOURCE_DIR" + info "Installing package from source..." + uv pip install "${SOURCE_DIR}${EXTRAS_SUFFIX}" --python "$QWENPAW_VENV/bin/python" --index-url "$PYPI_MIRROR" + cleanup_console "$SOURCE_DIR" + cleanup_docs "$SOURCE_DIR" + else + info "Installing QwenPaw from source (GitHub)..." + CLONE_DIR="$(mktemp -d)" + trap 'rm -rf "$CLONE_DIR"' EXIT + git clone --depth 1 "$QWENPAW_REPO" "$CLONE_DIR" + prepare_console "$CLONE_DIR" + prepare_docs "$CLONE_DIR" + info "Installing package from source..." + uv pip install "${CLONE_DIR}${EXTRAS_SUFFIX}" --python "$QWENPAW_VENV/bin/python" --index-url "$PYPI_MIRROR" + # CLONE_DIR is cleaned up by trap; no need for cleanup_console/cleanup_docs + fi +else + PACKAGE="qwenpaw" + if [ -n "$VERSION" ]; then + PACKAGE="qwenpaw==$VERSION" + fi + + PRERELEASE_ARGS=() + if [ "$PRERELEASE" = true ]; then + PRERELEASE_ARGS=(--prerelease=allow) + fi + + info "Installing ${PACKAGE}${EXTRAS_SUFFIX} from PyPI..." + uv pip install "${PACKAGE}${EXTRAS_SUFFIX}" --python "$QWENPAW_VENV/bin/python" --quiet --index-url "$PYPI_MIRROR" --refresh-package qwenpaw ${PRERELEASE_ARGS[@]+"${PRERELEASE_ARGS[@]}"} +fi + +# Verify the CLI entry point exists +[ -x "$QWENPAW_VENV/bin/qwenpaw" ] || die "Installation failed: qwenpaw CLI not found in venv" +info "QwenPaw installed successfully" + +# Check console availability (for PyPI installs, check the installed package) +if [ "$_CONSOLE_AVAILABLE" = 0 ]; then + # Check if console assets were included in the installed package + CONSOLE_CHECK="$("$QWENPAW_VENV/bin/python" -c "import importlib.resources, qwenpaw; p=importlib.resources.files('qwenpaw')/'console'/'index.html'; print('yes' if p.is_file() else 'no')" 2>/dev/null || echo 'no')" + if [ "$CONSOLE_CHECK" = "yes" ]; then + _CONSOLE_AVAILABLE=1 + fi +fi + +# ── Step 4: Create wrapper script ──────────────────────────────────────────── +mkdir -p "$QWENPAW_BIN" + +cat > "$QWENPAW_BIN/qwenpaw" << 'WRAPPER' +#!/usr/bin/env bash +# QwenPaw CLI wrapper — delegates to the uv-managed environment. +set -euo pipefail + +QWENPAW_HOME="${QWENPAW_HOME:-$HOME/.qwenpaw}" +REAL_BIN="$QWENPAW_HOME/venv/bin/qwenpaw" + +if [ ! -x "$REAL_BIN" ]; then + echo "Error: QwenPaw environment not found at $QWENPAW_HOME/venv" >&2 + echo "Please reinstall: curl -fsSL | bash" >&2 + exit 1 +fi + +exec "$REAL_BIN" "$@" +WRAPPER + +chmod +x "$QWENPAW_BIN/qwenpaw" +info "Wrapper created at $QWENPAW_BIN/qwenpaw" + +# ── Step 5: Update PATH in shell profile ───────────────────────────────────── +PATH_ENTRY="export PATH=\"\$HOME/.qwenpaw/bin:\$PATH\"" + +add_to_profile() { + local profile="$1" + if [ -f "$profile" ] && grep -qF '.qwenpaw/bin' "$profile"; then + return 0 # already present + fi + if [ -f "$profile" ] || [ "$2" = "create" ]; then + printf '\n# QwenPaw\n%s\n' "$PATH_ENTRY" >> "$profile" + info "Updated $profile" + return 0 + fi + return 1 +} + +UPDATED_PROFILE=false + +case "$OS" in + Darwin) + add_to_profile "$HOME/.zshrc" "create" && UPDATED_PROFILE=true + # Also update bash profile if it exists + add_to_profile "$HOME/.bash_profile" "no-create" || true + ;; + Linux) + add_to_profile "$HOME/.bashrc" "create" && UPDATED_PROFILE=true + # Also update zshrc if it exists + add_to_profile "$HOME/.zshrc" "no-create" || true + ;; +esac + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +printf "${GREEN}${BOLD}QwenPaw installed successfully!${RESET}\n" +echo "" + +# Install summary +printf " Install location: ${BOLD}%s${RESET}\n" "$QWENPAW_HOME" +printf " Python: ${BOLD}%s${RESET}\n" "$("$QWENPAW_VENV/bin/python" --version 2>&1)" +if [ "$_CONSOLE_AVAILABLE" = 1 ]; then + printf " Console (web UI): ${GREEN}available${RESET}\n" +else + printf " Console (web UI): ${YELLOW}not available${RESET}\n" + echo " Install Node.js and re-run to enable the web UI." +fi +echo "" + +if [ "$UPDATED_PROFILE" = true ]; then + echo "To get started, open a new terminal or run:" + echo "" + printf " ${BOLD}source ~/.zshrc${RESET} # or ~/.bashrc\n" + echo "" +fi + +echo "Then run:" +echo "" +printf " ${BOLD}pineagents init${RESET} # first-time setup\n" +printf " ${BOLD}pineagents app${RESET} # start PineAgents\n" +echo "" +printf "To upgrade later, re-run this installer.\n" +printf "To uninstall, run: ${BOLD}pineagents uninstall${RESET}\n" diff --git a/scripts/pack-tauri/build_macos_pyinstaller.sh b/scripts/pack-tauri/build_macos_pyinstaller.sh new file mode 100644 index 0000000..6558b54 --- /dev/null +++ b/scripts/pack-tauri/build_macos_pyinstaller.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Build QwenPaw with Tauri for macOS (PyInstaller backend) +# Creates a self-contained desktop app with bundled Python backend +# +# Usage: +# ./scripts/pack-tauri/build_macos_pyinstaller.sh + +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +VERSION=$(sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' src/pineagents/__version__.py) + +echo "=========================================" +echo "QwenPaw Tauri Build - macOS (PyInstaller)" +echo "=========================================" +echo "Version: ${VERSION}" +echo "" + +SIGN_MACOS_BUNDLE="${REPO_ROOT}/scripts/pack-tauri/sign_macos_bundle.sh" + +# Step 0: Prerequisites +echo "== Step 0: Checking Prerequisites ==" +missing=() + +if command -v npm &>/dev/null; then + echo " [OK] npm ($(npm --version))" +else + echo " [MISSING] npm" + echo " Install Node.js: https://nodejs.org/" + missing+=("npm") +fi + +if command -v rustc &>/dev/null; then + echo " [OK] rustc ($(rustc --version))" +else + echo " [MISSING] rustc (Rust)" + echo " Install: https://rustup.rs" + missing+=("rustc") +fi + +if command -v uv &>/dev/null; then + echo " [OK] uv ($(uv --version))" +else + echo " [MISSING] uv" + echo " Install: https://docs.astral.sh/uv/getting-started/installation/" + missing+=("uv") +fi + +if [ ${#missing[@]} -gt 0 ]; then + echo "" + echo "Missing prerequisites: ${missing[*]}" + echo "Install the missing tools and re-run this script." + exit 1 +fi +echo "" + +if [ ! -f "${SIGN_MACOS_BUNDLE}" ]; then + echo "ERROR: macOS signing helper not found at ${SIGN_MACOS_BUNDLE}" + exit 1 +fi + +if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] && [ -z "${APPLE_CERTIFICATE:-}" ]; then + # The Tauri app and PyInstaller sidecar are native Mach-O executables. + # Keep their signature state consistent with ad-hoc signatures when no + # Developer ID certificate is configured. This matches the legacy desktop + # package behavior: signed enough for local loading, not notarized. + export APPLE_SIGNING_IDENTITY="-" + echo "Using ad-hoc macOS code signing" +fi +if [ -z "${PYINSTALLER_CODESIGN_IDENTITY:-}" ]; then + # PyInstaller uses the same identity as the final app for bundled Mach-O + # files; "-" means ad-hoc signing on macOS. + export PYINSTALLER_CODESIGN_IDENTITY="${APPLE_SIGNING_IDENTITY:-}" +fi +echo "" + +# Step 1: Build console static assets +echo "== Step 1: Building Console Static Assets ==" +cd console +yarn install --immutable +echo "Generating Tauri icons..." +npm exec -- tauri icon ../scripts/pack/assets/icon.svg +echo "Syncing Tauri version..." +node ../scripts/pack-tauri/sync_tauri_version.mjs +echo "Building console frontend..." +npm run build:prod +cd .. +echo "Console static assets built" +echo "" + +# Step 2: Build PyInstaller backend +echo "== Step 2: Building PyInstaller Backend ==" +bash scripts/pack-tauri/build_pyinstaller.sh +echo "PyInstaller backend built" +echo "" + +echo "== Step 2b: Signing PyInstaller Backend ==" +bash "${SIGN_MACOS_BUNDLE}" \ + "${REPO_ROOT}/console/src-tauri/binaries/qwenpaw-backend" \ + "${APPLE_SIGNING_IDENTITY}" +echo "PyInstaller backend signed" +echo "" + +# Step 3: Build Tauri app +echo "== Step 3: Building Tauri App ==" +BUNDLE_DIR="${REPO_ROOT}/console/src-tauri/target/release/bundle" +rm -rf "${BUNDLE_DIR}/dmg" "${BUNDLE_DIR}/macos" +cd console +echo "Building for macOS..." +npm exec -- tauri build \ + --config src-tauri/tauri.version.conf.json \ + --bundles app,dmg +cd .. +echo "Tauri app built" +echo "" + +APP_PATH="${BUNDLE_DIR}/macos/QwenPaw Desktop.app" +if [ ! -d "${APP_PATH}" ]; then + echo "ERROR: No Tauri macOS app found at ${APP_PATH}" + exit 1 +fi +HELPER_PATH="${APP_PATH}/Contents/MacOS/qwenpaw-computer-use-helper" +if [ ! -x "${HELPER_PATH}" ]; then + echo "ERROR: Computer Use helper was not bundled at ${HELPER_PATH}" + exit 1 +fi + +echo "== Step 3b: Signing Final macOS App ==" +bash "${SIGN_MACOS_BUNDLE}" \ + "${APP_PATH}" \ + "${APPLE_SIGNING_IDENTITY}" +echo "Final macOS app signed and verified" +echo "" + +# Step 4: Collect distribution artifacts +echo "== Step 4: Collecting Distribution Artifacts ==" +DIST="${DIST:-dist}" +if [[ "${DIST}" = /* ]]; then + DIST_ROOT="${DIST}" +else + DIST_ROOT="${REPO_ROOT}/${DIST}" +fi +DIST_DIR="${DIST_ROOT}/tauri-macos" +rm -rf "${DIST_DIR}" +mkdir -p "${DIST_DIR}" + +# Match the legacy macOS package shape: one zip containing one .app bundle. +cp -R "${APP_PATH}" "${DIST_DIR}/" +STAGED_APP_PATH="${DIST_DIR}/$(basename "${APP_PATH}")" +echo ".app copied to ${STAGED_APP_PATH}" + +# Create ZIP archive +ZIP_NAME="${DIST_ROOT}/QwenPaw-Tauri-${VERSION}-macOS.zip" +if [ -f "${ZIP_NAME}" ]; then + rm -f "${ZIP_NAME}" +fi +if command -v ditto &>/dev/null; then + ditto -c -k --sequesterRsrc --keepParent "${STAGED_APP_PATH}" "${ZIP_NAME}" +else + cd "${DIST_DIR}" + zip -r "${ZIP_NAME}" "$(basename "${STAGED_APP_PATH}")" + cd "${REPO_ROOT}" +fi + +if [ -f "${ZIP_NAME}" ]; then + SIZE=$(du -sh "${ZIP_NAME}" | cut -f1) + echo "Created ${ZIP_NAME} (${SIZE})" +else + echo "ERROR: Failed to create ZIP archive" + exit 1 +fi +echo "" + +UPDATER_NAME="${DIST_ROOT}/QwenPaw-Tauri-${VERSION}-macOS.app.tar.gz" +case "$(uname -m)" in + arm64 | aarch64) UPDATER_TARGET="darwin-aarch64" ;; + *) UPDATER_TARGET="darwin-x86_64" ;; +esac +python "${REPO_ROOT}/scripts/pack-tauri/generate_update_manifest.py" stage \ + --bundle-dir "${BUNDLE_DIR}/macos" \ + --pattern '*.app.tar.gz' \ + --target "${UPDATER_TARGET}" \ + --output "${UPDATER_NAME}" \ + --pubkey-config "${REPO_ROOT}/console/src-tauri/tauri.version.conf.json" + +echo "" +echo "=========================================" +echo "Build Complete!" +echo "=========================================" +echo "App: ${APP_PATH}" +echo "Distribution: ${DIST_DIR}" +echo "Archive: ${ZIP_NAME}" +echo "Updater: ${UPDATER_NAME}" +echo "" +echo "Test: open \"${STAGED_APP_PATH}\"" +echo "" diff --git a/scripts/pack-tauri/build_pyinstaller.ps1 b/scripts/pack-tauri/build_pyinstaller.ps1 new file mode 100644 index 0000000..80ae2e5 --- /dev/null +++ b/scripts/pack-tauri/build_pyinstaller.ps1 @@ -0,0 +1,248 @@ +# Build QwenPaw backend with PyInstaller for Tauri sidecar (Windows) +# Creates an onedir backend bundle with embedded Python runtime +# +# Usage: +# powershell ./scripts/pack-tauri/build_pyinstaller.ps1 +# +# Prerequisites: +# - Python 3.10+ with virtual environment +# - PyInstaller 6.0+ (will be installed if not present) + +param() + +$ErrorActionPreference = "Stop" +$REPO_ROOT = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location $REPO_ROOT + +$DIST = if ($env:DIST) { $env:DIST } else { "dist" } +if (-not [System.IO.Path]::IsPathRooted($DIST)) { + $DIST = Join-Path $REPO_ROOT $DIST +} +$VERSION_FILE = "src\pineagents\__version__.py" + +# Extract version +if (Test-Path $VERSION_FILE) { + $content = Get-Content $VERSION_FILE -Raw + if ($content -match '__version__\s*=\s*"([^"]+)"') { + $VERSION = $Matches[1] + } else { + throw "Failed to extract version from $VERSION_FILE" + } +} else { + throw "Version file not found: $VERSION_FILE" +} + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "QwenPaw PyInstaller Build - Windows" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Version: $VERSION" +Write-Host "Repository: $REPO_ROOT" +Write-Host "" + +# Check prerequisites +Write-Host "== Checking prerequisites ==" -ForegroundColor Yellow + +$UV_BIN = (Get-Command uv -ErrorAction SilentlyContinue).Source +$PYTHON_BIN = Join-Path $REPO_ROOT ".venv\Scripts\python.exe" +if (-not (Test-Path $PYTHON_BIN)) { + if ($UV_BIN) { + Write-Host ".venv not found, creating virtual environment with uv" -ForegroundColor Yellow + & $UV_BIN venv "$REPO_ROOT\.venv" + if ($LASTEXITCODE -ne 0) { + throw "Failed to create virtual environment with uv" + } + } else { + Write-Host ".venv not found, using system Python" -ForegroundColor Yellow + $PYTHON_BIN = (Get-Command python -ErrorAction SilentlyContinue).Source + } + if (-not $PYTHON_BIN -or -not (Test-Path $PYTHON_BIN)) { + Write-Host "ERROR: Python not found in .venv or PATH" -ForegroundColor Red + Write-Host "Please create virtual environment first: python -m venv .venv" + exit 1 + } +} + +$pythonVersion = & $PYTHON_BIN --version +Write-Host "Python: $pythonVersion" -ForegroundColor Green + +function Test-PythonImport { + param([string]$Statement) + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + & $PYTHON_BIN -c $Statement *> $null + return $LASTEXITCODE -eq 0 + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Assert-LastExit { + param([string]$Message) + if ($LASTEXITCODE -ne 0) { throw $Message } +} + +function Install-PythonPackages { + param([string[]]$Packages) + if ($UV_BIN) { + & $UV_BIN pip install --python $PYTHON_BIN @Packages + } else { + & $PYTHON_BIN -m pip install @Packages + } + Assert-LastExit "Failed to install Python packages: $($Packages -join ', ')" +} + +function Uninstall-PythonPackage { + param([string]$Package) + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + if ($UV_BIN) { + & $UV_BIN pip uninstall --python $PYTHON_BIN -y $Package *> $null + } else { + & $PYTHON_BIN -m pip uninstall -y $Package *> $null + } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +# Install PyInstaller if not present +Write-Host "== Installing PyInstaller ==" -ForegroundColor Yellow +if (Test-PythonImport "import PyInstaller") { + Write-Host "PyInstaller already installed" -ForegroundColor Green +} else { + Write-Host "Installing PyInstaller..." + Install-PythonPackages -Packages @("pyinstaller>=6.0.0") + Write-Host "PyInstaller installed" -ForegroundColor Green +} + +# Install python-dotenv if not present (required by PyInstaller collect_submodules) +if (Test-PythonImport "import dotenv") { + Write-Host "python-dotenv already installed" -ForegroundColor Green +} else { + Write-Host "Installing python-dotenv..." + Install-PythonPackages -Packages @("python-dotenv") + Write-Host "python-dotenv installed" -ForegroundColor Green +} + +Write-Host "" + +# Install project dependencies (ensures ALL runtime deps are importable) +Write-Host "== Installing project dependencies ==" -ForegroundColor Yellow +Install-PythonPackages -Packages @("-e", ".[full]") +Write-Host "Project dependencies installed with full extras" -ForegroundColor Green + +# Fix agent-client-protocol namespace collision +# PyPI has an empty 'acp' stub that shadows the real package +if (-not (Test-PythonImport "from acp import Agent")) { + Write-Host "Fixing agent-client-protocol namespace..." + Uninstall-PythonPackage "acp" + Install-PythonPackages -Packages @("agent-client-protocol>=0.9.0,<0.11.0") + Write-Host "agent-client-protocol installed" -ForegroundColor Green +} + +# Run PyInstaller +Write-Host "== Running PyInstaller ==" -ForegroundColor Yellow +Write-Host "Building onedir backend bundle..." + +$SPEC_FILE = Join-Path $REPO_ROOT "scripts\pack-tauri\pineagents.spec" +if (-not (Test-Path $SPEC_FILE)) { + Write-Host "ERROR: Spec file not found at $SPEC_FILE" -ForegroundColor Red + exit 1 +} + +& $PYTHON_BIN -m PyInstaller $SPEC_FILE ` + --distpath "${DIST}\pyinstaller" ` + --workpath "${DIST}\pyinstaller-build" ` + --clean ` + --noconfirm + +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller build failed" +} + +Write-Host "PyInstaller build complete" -ForegroundColor Green +Write-Host "" + +# Verify output +$BACKEND_DIR = Join-Path $DIST "pyinstaller\qwenpaw-backend" +$BACKEND_EXE = Join-Path $BACKEND_DIR "qwenpaw-backend.exe" +$CLI_EXE = Join-Path $BACKEND_DIR "qwenpaw.exe" +if (-not (Test-Path $BACKEND_DIR)) { + Write-Host "ERROR: Backend bundle directory not found at $BACKEND_DIR" -ForegroundColor Red + exit 1 +} +if (-not (Test-Path $BACKEND_EXE)) { + Write-Host "ERROR: Backend executable not found at $BACKEND_EXE" -ForegroundColor Red + exit 1 +} +if (-not (Test-Path $CLI_EXE)) { + Write-Host "ERROR: CLI executable not found at $CLI_EXE" -ForegroundColor Red + exit 1 +} + +Write-Host "Backend bundle created: $BACKEND_DIR" -ForegroundColor Green + +# Get size +$bundleSize = (Get-ChildItem $BACKEND_DIR -Recurse -File | Measure-Object -Property Length -Sum).Sum / 1MB +Write-Host "Bundle size: $([math]::Round($bundleSize, 2)) MB" +Write-Host "" + +# Copy to Tauri resources directory +Write-Host "== Copying to Tauri binaries directory ==" -ForegroundColor Yellow +$BINARIES_DIR = Join-Path $REPO_ROOT "console\src-tauri\binaries" +New-Item -ItemType Directory -Force -Path $BINARIES_DIR | Out-Null + +$DEST = Join-Path $BINARIES_DIR "qwenpaw-backend" +New-Item -ItemType Directory -Force -Path $DEST | Out-Null +Get-ChildItem -LiteralPath $DEST -Force | Remove-Item -Recurse -Force +Copy-Item -Recurse -Force (Join-Path $BACKEND_DIR "*") $DEST +Write-Host "Copied to: $DEST" -ForegroundColor Green +Write-Host "" + +# Stage a standalone CPython (same X.Y/arch as this build's interpreter) so the +# frozen backend can install third-party plugin dependencies at runtime. +Write-Host "== Staging bundled Python runtime ==" -ForegroundColor Yellow +& $PYTHON_BIN (Join-Path $REPO_ROOT "scripts\pack-tauri\stage_python_runtime.py") ` + --dest (Join-Path $BINARIES_DIR "python-runtime") +Assert-LastExit "Failed to stage bundled Python runtime" +Write-Host "" + +Write-Host "== Staging bundled Node runtime ==" -ForegroundColor Yellow +& $PYTHON_BIN (Join-Path $REPO_ROOT "scripts\pack-tauri\stage_node_runtime.py") ` + --dest (Join-Path $BINARIES_DIR "node-runtime") +Assert-LastExit "Failed to stage bundled Node runtime" +Write-Host "== Building Computer Use helper ==" -ForegroundColor Yellow +$CARGO_BIN = (Get-Command cargo -ErrorAction SilentlyContinue).Source +if (-not $CARGO_BIN) { + throw "cargo not found; Rust toolchain is required to build qwenpaw-computer-use-helper" +} +$TAURI_DIR = Join-Path $REPO_ROOT "console\src-tauri" +Push-Location $TAURI_DIR +try { + & $CARGO_BIN build --release --bin qwenpaw-computer-use-helper + Assert-LastExit "Failed to build qwenpaw-computer-use-helper" +} finally { + Pop-Location +} +$TARGET_DIR = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $TAURI_DIR "target" } +if (-not [System.IO.Path]::IsPathRooted($TARGET_DIR)) { + $TARGET_DIR = Join-Path $TAURI_DIR $TARGET_DIR +} +$COMPUTER_USE_HELPER_EXE = Join-Path $TARGET_DIR "release\qwenpaw-computer-use-helper.exe" +if (-not (Test-Path $COMPUTER_USE_HELPER_EXE)) { + throw "Computer Use helper executable not found at $COMPUTER_USE_HELPER_EXE" +} +$COMPUTER_USE_HELPER_DEST = Join-Path $DEST "qwenpaw-computer-use-helper.exe" +Copy-Item -Force $COMPUTER_USE_HELPER_EXE $COMPUTER_USE_HELPER_DEST +Write-Host "Computer Use helper staged: $COMPUTER_USE_HELPER_DEST" -ForegroundColor Green +Write-Host "" + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "PyInstaller Build Complete!" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Output:" +Write-Host " Bundle: $BACKEND_DIR" +Write-Host " Tauri resource: $DEST" +Write-Host "" diff --git a/scripts/pack-tauri/build_pyinstaller.sh b/scripts/pack-tauri/build_pyinstaller.sh new file mode 100644 index 0000000..565bf03 --- /dev/null +++ b/scripts/pack-tauri/build_pyinstaller.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Build QwenPaw backend with PyInstaller for Tauri sidecar +# Creates an onedir backend bundle with embedded Python runtime +# +# Usage: +# ./scripts/pack-tauri/build_pyinstaller.sh +# +# Prerequisites: +# - Python 3.10+ with virtual environment +# - PyInstaller 6.0+ (will be installed if not present) + +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +DIST="${DIST:-dist}" +VERSION=$(sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' src/pineagents/__version__.py) + +echo "=========================================" +echo "QwenPaw PyInstaller Build" +echo "=========================================" +echo "Version: ${VERSION}" +echo "Repository: ${REPO_ROOT}" +echo "" + +# Check prerequisites +echo "== Checking prerequisites ==" + +# Create venv if missing (prefer uv if available) +PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +if [ ! -f "$PYTHON_BIN" ]; then + if command -v uv &>/dev/null; then + echo "Creating virtual environment with uv..." + uv venv "${REPO_ROOT}/.venv" + else + echo "ERROR: Python not found in .venv" + echo "Please create virtual environment first: python -m venv .venv" + exit 1 + fi +fi + +echo "Python: $("$PYTHON_BIN" --version)" + +install_python_packages() { + if command -v uv &>/dev/null; then + uv pip install --python "$PYTHON_BIN" "$@" + else + "$PYTHON_BIN" -m pip install "$@" + fi +} + +uninstall_python_package() { + if command -v uv &>/dev/null; then + uv pip uninstall --python "$PYTHON_BIN" -y "$1" >/dev/null 2>&1 || true + else + "$PYTHON_BIN" -m pip uninstall -y "$1" >/dev/null 2>&1 || true + fi +} + +# Install PyInstaller if not present +echo "== Installing PyInstaller ==" +if ! "$PYTHON_BIN" -c "import PyInstaller" 2> /dev/null; then + echo "Installing PyInstaller..." + install_python_packages "pyinstaller>=6.0.0" +fi +echo "PyInstaller installed" + +# Install project dependencies (ensures ALL runtime deps are importable) +echo "== Installing project dependencies ==" +install_python_packages -e ".[full]" +echo "Project dependencies installed with full extras" + +# Fix agent-client-protocol namespace collision +# PyPI has an empty 'acp' stub that shadows the real package +if ! "$PYTHON_BIN" -c "from acp import Agent" 2> /dev/null; then + echo "Fixing agent-client-protocol namespace..." + uninstall_python_package acp + install_python_packages "agent-client-protocol>=0.9.0,<0.11.0" +fi +echo "" + +# Run PyInstaller +echo "== Running PyInstaller ==" +echo "Building onedir backend bundle..." + +SPEC_FILE="${REPO_ROOT}/scripts/pack-tauri/pineagents.spec" +if [ ! -f "$SPEC_FILE" ]; then + echo "ERROR: Spec file not found at ${SPEC_FILE}" + exit 1 +fi + +"$PYTHON_BIN" -m PyInstaller "$SPEC_FILE" \ + --distpath "${DIST}/pyinstaller" \ + --workpath "${DIST}/pyinstaller-build" \ + --clean \ + --noconfirm + +echo "PyInstaller build complete" +echo "" + +# Verify output +BACKEND_DIR="${DIST}/pyinstaller/qwenpaw-backend" +BACKEND_EXE="${BACKEND_DIR}/qwenpaw-backend" +CLI_EXE="${BACKEND_DIR}/qwenpaw" +if [ ! -d "${BACKEND_DIR}" ]; then + echo "ERROR: Backend bundle directory not found at ${BACKEND_DIR}" + exit 1 +fi +if [ ! -f "${BACKEND_EXE}" ]; then + echo "ERROR: Backend executable not found at ${BACKEND_EXE}" + exit 1 +fi +if [ ! -f "${CLI_EXE}" ]; then + echo "ERROR: CLI executable not found at ${CLI_EXE}" + exit 1 +fi + +echo "Backend bundle created: ${BACKEND_DIR}" + +# Get size +SIZE=$(du -sh "${BACKEND_DIR}" | cut -f1) +echo "Bundle size: ${SIZE}" +echo "" + +# Copy to Tauri resources directory +echo "== Copying to Tauri binaries directory ==" +BINARIES_DIR="${REPO_ROOT}/console/src-tauri/binaries" +mkdir -p "${BINARIES_DIR}" + +DEST="${BINARIES_DIR}/qwenpaw-backend" +rm -rf "${DEST}" +mkdir -p "${DEST}" +cp -R "${BACKEND_DIR}/." "${DEST}/" +chmod +x "${DEST}/qwenpaw-backend" +chmod +x "${DEST}/qwenpaw" +echo "Copied to: ${DEST}" +echo "" + +# Stage a standalone CPython (same X.Y/arch as this build's interpreter) so the +# frozen backend can install third-party plugin dependencies at runtime. +echo "== Staging bundled Python runtime ==" +"$PYTHON_BIN" "${REPO_ROOT}/scripts/pack-tauri/stage_python_runtime.py" \ + --dest "${BINARIES_DIR}/python-runtime" +echo "" + +echo "== Staging bundled Node runtime ==" +"$PYTHON_BIN" "${REPO_ROOT}/scripts/pack-tauri/stage_node_runtime.py" \ + --dest "${BINARIES_DIR}/node-runtime" +echo "" + +echo "=========================================" +echo "PyInstaller Build Complete!" +echo "=========================================" +echo "Output:" +echo " Bundle: ${BACKEND_DIR}" +echo " Tauri resource: ${DEST}" +echo "" diff --git a/scripts/pack-tauri/build_win_pyinstaller.ps1 b/scripts/pack-tauri/build_win_pyinstaller.ps1 new file mode 100644 index 0000000..fda05c5 --- /dev/null +++ b/scripts/pack-tauri/build_win_pyinstaller.ps1 @@ -0,0 +1,211 @@ +# Build QwenPaw with Tauri for Windows (PyInstaller backend) +# Creates a self-contained desktop app with bundled Python backend +# +# Usage: +# powershell ./scripts/pack-tauri/build_win_pyinstaller.ps1 + +param() + +$ErrorActionPreference = "Stop" +$REPO_ROOT = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location $REPO_ROOT + +$DIST = if ($env:DIST) { $env:DIST } else { "dist" } +if (-not [System.IO.Path]::IsPathRooted($DIST)) { + $DIST = Join-Path $REPO_ROOT $DIST +} +$VERSION_FILE = "src\qwenpaw\__version__.py" + +function Invoke-NativeWithRetry { + param( + [Parameter(Mandatory = $true)] + [string]$Description, + [Parameter(Mandatory = $true)] + [scriptblock]$Command, + [int]$MaxAttempts = 5, + [int]$DelaySeconds = 20 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + Write-Host "$Description (attempt $attempt/$MaxAttempts)..." + & $Command + $exitCode = $LASTEXITCODE + if ($exitCode -eq 0) { + return + } + + if ($attempt -eq $MaxAttempts) { + throw "$Description failed after $MaxAttempts attempts (exit code $exitCode)" + } + + Write-Host "$Description failed with exit code $exitCode; retrying in $DelaySeconds seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds $DelaySeconds + } +} + +# Extract version +if (Test-Path $VERSION_FILE) { + $content = Get-Content $VERSION_FILE -Raw + if ($content -match '__version__\s*=\s*"([^"]+)"') { + $VERSION = $Matches[1] + } else { + throw "Failed to extract version from $VERSION_FILE" + } +} else { + throw "Version file not found: $VERSION_FILE" +} + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "QwenPaw Tauri Build - Windows (PyInstaller)" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Version: $VERSION" +Write-Host "" + +# Step 0: Prerequisites +Write-Host "== Step 0: Checking Prerequisites ==" -ForegroundColor Yellow +$missing = @() + +# npm +if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Host " [MISSING] npm" -ForegroundColor Red + Write-Host " Install Node.js: https://nodejs.org/" -ForegroundColor Gray + $missing += "npm" +} else { + Write-Host " [OK] npm ($(npm --version))" -ForegroundColor Green +} + +# rustc +if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + Write-Host " [MISSING] rustc (Rust)" -ForegroundColor Red + Write-Host " Install: https://rustup.rs" -ForegroundColor Gray + $missing += "rustc" +} else { + Write-Host " [OK] rustc ($(rustc --version))" -ForegroundColor Green +} + +# Visual Studio Build Tools (MSVC) +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$hasMsvc = $false +if (Test-Path $vswhere) { + $vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + if ($vsPath) { $hasMsvc = $true } +} +if (-not $hasMsvc) { + $hostTuple = & rustc --print host-tuple 2>$null + if ($hostTuple -match "msvc") { $hasMsvc = $true } +} +if (-not $hasMsvc) { + Write-Host " [MISSING] Visual Studio Build Tools (C++ workload)" -ForegroundColor Red + Write-Host " Install: https://visualstudio.microsoft.com/visual-cpp-build-tools/" -ForegroundColor Gray + Write-Host " Required workload: 'Desktop development with C++'" -ForegroundColor Gray + $missing += "MSVC" +} else { + Write-Host " [OK] Visual Studio Build Tools (MSVC)" -ForegroundColor Green +} + +# NSIS (makensis) +if (-not (Get-Command makensis -ErrorAction SilentlyContinue)) { + Write-Host " [MISSING] makensis (NSIS)" -ForegroundColor Red + Write-Host " Install: https://nsis.sourceforge.io/Download" -ForegroundColor Gray + $missing += "makensis" +} else { + $nsisInfo = makensis /version 2>$null + Write-Host " [OK] makensis (NSIS $nsisInfo)" -ForegroundColor Green +} + +if ($missing.Count -gt 0) { + Write-Host "" + Write-Host "Missing prerequisites: $($missing -join ', ')" -ForegroundColor Red + Write-Host "Install the missing tools and re-run this script." -ForegroundColor Red + exit 1 +} +Write-Host "" + +# Step 1: Build console static assets +Write-Host "== Step 1: Building Console Static Assets ==" -ForegroundColor Yellow +Set-Location console + +Write-Host "Installing frontend dependencies..." +npm ci +if ($LASTEXITCODE -ne 0) { + throw "npm ci failed" +} + +Write-Host "Generating Tauri icons..." +npm exec -- tauri icon ../scripts/pack/assets/icon.svg +if ($LASTEXITCODE -ne 0) { + throw "Tauri icon generation failed" +} + +Write-Host "Syncing Tauri version..." +node ../scripts/pack-tauri/sync_tauri_version.mjs +if ($LASTEXITCODE -ne 0) { + throw "Tauri version sync failed" +} + +Write-Host "Building console frontend..." +npm run build:prod +if ($LASTEXITCODE -ne 0) { + throw "console frontend build failed" +} + +Set-Location $REPO_ROOT +Write-Host "Console static assets built" -ForegroundColor Green +Write-Host "" + +# Step 2: Build PyInstaller backend +Write-Host "== Step 2: Building PyInstaller Backend ==" -ForegroundColor Yellow +$PYINSTALLER_SCRIPT = Join-Path $REPO_ROOT "scripts\pack-tauri\build_pyinstaller.ps1" +& $PYINSTALLER_SCRIPT + +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller build failed" +} +Write-Host "PyInstaller backend ready" -ForegroundColor Green +Write-Host "" + +# Step 2b: Fetch Tauri Rust dependencies +Write-Host "== Step 2b: Fetching Tauri Rust Dependencies ==" -ForegroundColor Yellow +if (-not $env:CARGO_NET_RETRY) { + $env:CARGO_NET_RETRY = "10" +} +if (-not $env:CARGO_HTTP_MULTIPLEXING) { + $env:CARGO_HTTP_MULTIPLEXING = "false" +} + +$TAURI_MANIFEST = Join-Path $REPO_ROOT "console\src-tauri\Cargo.toml" +Invoke-NativeWithRetry -Description "cargo fetch for Tauri dependencies" -Command { + cargo fetch --locked --target x86_64-pc-windows-msvc --manifest-path $TAURI_MANIFEST +} +$env:CARGO_NET_OFFLINE = "true" +Write-Host "Tauri Rust dependencies fetched; Cargo offline mode enabled" -ForegroundColor Green +Write-Host "" + +# Step 3: Build Tauri app +Write-Host "== Step 3: Building Tauri App ==" -ForegroundColor Yellow +$BUNDLE_DIR = Join-Path $REPO_ROOT "console\src-tauri\target\release\bundle" +$NSIS_DIR = Join-Path $BUNDLE_DIR "nsis" +if (Test-Path $NSIS_DIR) { + Remove-Item -Recurse -Force $NSIS_DIR +} + +Set-Location console + +Write-Host "Building for Windows..." +npm exec -- tauri build --config src-tauri/tauri.version.conf.json +$tauriExit = $LASTEXITCODE + +if ($tauriExit -ne 0) { + throw "Tauri build failed" +} + +Set-Location $REPO_ROOT +Write-Host "Tauri app built" -ForegroundColor Green + +Write-Host "" +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Build Complete!" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Output:" +Write-Host " NSIS bundle directory: ${NSIS_DIR}\" +Write-Host "" diff --git a/scripts/pack-tauri/finalize_tauri_bootstrap.mjs b/scripts/pack-tauri/finalize_tauri_bootstrap.mjs new file mode 100644 index 0000000..221e1b6 --- /dev/null +++ b/scripts/pack-tauri/finalize_tauri_bootstrap.mjs @@ -0,0 +1,23 @@ +// Vite preserves the source HTML filename for multi-page builds. Tauri expects +// the bundled frontend directory to contain index.html, so rename the small +// desktop bootstrap page after the Vite build. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../.."); +const distDir = path.join(repoRoot, "console", "dist-tauri"); +const source = path.join(distDir, "tauri.html"); +const target = path.join(distDir, "index.html"); + +if (!fs.existsSync(source)) { + throw new Error(`Tauri bootstrap HTML not found: ${source}`); +} + +if (fs.existsSync(target)) { + fs.rmSync(target); +} + +fs.renameSync(source, target); +console.log(`Wrote Tauri bootstrap ${target}`); diff --git a/scripts/pack-tauri/generate_update_manifest.py b/scripts/pack-tauri/generate_update_manifest.py new file mode 100644 index 0000000..b37c457 --- /dev/null +++ b/scripts/pack-tauri/generate_update_manifest.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Tauri updater helper: stage per-platform artifacts and build the manifest. + +Subcommands: + stage Copy a Tauri-built updater archive (and its .sig) into the dist + tree, then write a small JSON sidecar describing it. + manifest Aggregate one or more stage-produced sidecar JSON files into the + unified `qwenpaw-tauri-latest.json` consumed by tauri-plugin-updater. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote + +from packaging.version import InvalidVersion, Version + + +def to_semver(version: str) -> str: + try: + parsed = Version(version) + except InvalidVersion as err: + raise SystemExit( + f"unsupported Python version for Tauri: {version}", + ) from err + + if parsed.epoch or parsed.local is not None or len(parsed.release) != 3: + raise SystemExit(f"unsupported Python version for Tauri: {version}") + + major, minor, patch = parsed.release + prerelease_map = {"a": "alpha", "b": "beta", "rc": "rc"} + labels: list[str] = [] + if parsed.pre: + prerelease, prerelease_n = parsed.pre + labels.append(f"{prerelease_map[prerelease]}.{prerelease_n}") + if parsed.dev is not None: + labels.append(f"dev.{parsed.dev}") + suffix = f"-{'.'.join(labels)}" if labels else "" + post_metadata = f"+post.{parsed.post}" if parsed.post is not None else "" + return f"{major}.{minor}.{patch}{suffix}{post_metadata}" + + +# stage + + +def _find_source(bundle_dir: Path, pattern: str) -> Path: + matches = sorted(bundle_dir.glob(pattern)) + if not matches: + raise SystemExit( + f"no artifact matching {pattern!r} under {bundle_dir}", + ) + return matches[0] + + +def cmd_stage(args: argparse.Namespace) -> None: + bundle_dir = Path(args.bundle_dir) + source = _find_source(bundle_dir, args.pattern) + sig_source = source.with_suffix(source.suffix + ".sig") + if not sig_source.is_file(): + raise SystemExit(f"no updater signature found at {sig_source}") + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, output) + shutil.copyfile(sig_source, output.with_suffix(output.suffix + ".sig")) + + metadata = { + "target": args.target, + "artifact": output.name, + "signature": output.name + ".sig", + } + sidecar = output.parent / f"tauri-{args.target}-updater.json" + sidecar.write_text( + json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if args.pubkey_config: + verify_signature_key_id( + signature_path=output.with_suffix(output.suffix + ".sig"), + pubkey_config=Path(args.pubkey_config), + ) + print(f"staged {output.name} ({args.target}); sidecar {sidecar.name}") + + +# manifest + + +def _read_metadata(path: Path) -> dict[str, str]: + with path.open("r", encoding="utf-8-sig") as f: + data = json.load(f) + required = {"target", "artifact", "signature"} + missing = required - set(data) + if missing: + raise SystemExit( + f"{path} missing required keys: {', '.join(sorted(missing))}", + ) + return {key: str(data[key]) for key in required} + + +def _signature_text(path: Path) -> str: + if not path.is_file(): + raise SystemExit(f"signature file not found: {path}") + return path.read_text(encoding="utf-8-sig").strip() + + +def _decode_base64_minisign_text(value: str, *, kind: str) -> str: + try: + text = base64.b64decode(value, validate=True).decode("utf-8") + except Exception as err: + raise SystemExit( + f"{kind} is not valid base64 minisign text: {err}", + ) from err + if "untrusted comment:" not in text: + raise SystemExit(f"{kind} is not a minisign text block") + return text + + +def _minisign_key_id(text: str, *, kind: str) -> str: + lines = [ + line.strip() for line in text.strip().splitlines() if line.strip() + ] + try: + raw = base64.b64decode(lines[1], validate=True) + key_id = raw[2:10] + if len(key_id) != 8: + raise ValueError("missing key id") + except IndexError as err: + raise SystemExit(f"{kind} is not a valid minisign text block") from err + except Exception as err: + raise SystemExit( + f"{kind} has invalid minisign key/signature data: {err}", + ) from err + return key_id.hex() + + +def _pubkey_from_config(config_path: Path) -> str: + with config_path.open("r", encoding="utf-8-sig") as f: + config = json.load(f) + try: + pubkey = config["plugins"]["updater"]["pubkey"] + except KeyError as err: + raise SystemExit( + f"{config_path} missing plugins.updater.pubkey: {err}", + ) from err + if not isinstance(pubkey, str) or not pubkey.strip(): + raise SystemExit(f"{config_path} has an empty plugins.updater.pubkey") + return _decode_base64_minisign_text(pubkey.strip(), kind=str(config_path)) + + +def verify_signature_key_id(signature_path: Path, pubkey_config: Path) -> None: + signature_text = _decode_base64_minisign_text( + _signature_text(signature_path), + kind=str(signature_path), + ) + pubkey_text = _pubkey_from_config(pubkey_config) + signature_key_id = _minisign_key_id( + signature_text, + kind=str(signature_path), + ) + pubkey_key_id = _minisign_key_id(pubkey_text, kind=str(pubkey_config)) + if signature_key_id != pubkey_key_id: + raise SystemExit( + "updater signature key id does not match configured pubkey: " + f"signature={signature_key_id} pubkey={pubkey_key_id}", + ) + print(f"verified updater signature key id: {signature_key_id}") + + +def cmd_manifest(args: argparse.Namespace) -> None: + target_overrides: dict[str, str] = {} + for entry in args.target_base or []: + target, _, url = entry.partition("=") + if not target or not url: + raise SystemExit( + f"--target-base expects 'target=URL', got {entry!r}", + ) + target_overrides[target] = url + + platforms: dict[str, dict[str, str]] = {} + for raw in args.metadata: + meta_path = Path(raw) + meta = _read_metadata(meta_path) + workdir = meta_path.parent + artifact_path = workdir / meta["artifact"] + if not artifact_path.is_file(): + raise SystemExit(f"artifact file not found: {artifact_path}") + base = target_overrides.get(meta["target"], args.base_url).rstrip( + "/", + ) + platforms[meta["target"]] = { + "url": f"{base}/{quote(meta['artifact'])}", + "signature": _signature_text(workdir / meta["signature"]), + } + if not platforms: + raise SystemExit("no updater platforms were provided") + + manifest = { + "version": to_semver(args.version), + "notes": args.notes, + "pub_date": args.pub_date, + "platforms": platforms, + } + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") + print( + f"wrote manifest {output} (platforms: {', '.join(sorted(platforms))})", + ) + + +# cli + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_stage = sub.add_parser( + "stage", + help="Copy a Tauri updater archive + .sig into dist and write a sidecar.", + ) + p_stage.add_argument( + "--bundle-dir", + required=True, + help="Tauri bundle output dir (e.g., target/release/bundle/nsis).", + ) + p_stage.add_argument( + "--pattern", + required=True, + help="Glob to find the artifact (e.g., '*-setup.exe', '*.app.tar.gz').", + ) + p_stage.add_argument( + "--target", + required=True, + help="Updater target (e.g., windows-x86_64, darwin-aarch64).", + ) + p_stage.add_argument( + "--output", + required=True, + help="Destination artifact path; .sig is staged alongside.", + ) + p_stage.add_argument( + "--pubkey-config", + help=( + "Optional tauri.conf.json path. When provided, fail if the staged " + "signature key id does not match plugins.updater.pubkey." + ), + ) + p_stage.set_defaults(func=cmd_stage) + + p_manifest = sub.add_parser( + "manifest", + help="Aggregate per-platform sidecars into the updater manifest JSON.", + ) + p_manifest.add_argument("--version", required=True) + p_manifest.add_argument( + "--base-url", + required=True, + help="Default URL prefix for platforms without --target-base override.", + ) + p_manifest.add_argument( + "--target-base", + action="append", + default=[], + help=( + "Per-target URL prefix override 'target=URL', repeatable. " + "Used when platforms live under different paths " + "(e.g., OSS lays win-tauri/ and mac-tauri/ separately)." + ), + ) + p_manifest.add_argument( + "--metadata", + action="append", + default=[], + help="Path to a sidecar JSON file (repeatable).", + ) + p_manifest.add_argument("--notes", default="") + p_manifest.add_argument( + "--pub-date", + default=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + ) + p_manifest.add_argument("--output", required=True) + p_manifest.set_defaults(func=cmd_manifest) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/pack-tauri/pineagents.spec b/scripts/pack-tauri/pineagents.spec new file mode 100644 index 0000000..e624d14 --- /dev/null +++ b/scripts/pack-tauri/pineagents.spec @@ -0,0 +1,278 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for PineAgents (Tauri sidecar). + +Shared spec for both macOS and Windows. Builds an onedir backend bundle so the +desktop startup can load Python directly without onefile extraction. The same +bundle also includes a pineagents CLI executable for the Windows installer PATH +option. +""" + +import os +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_submodules, + copy_metadata, + get_package_paths, +) + +REPO_ROOT = Path(SPECPATH).parent.parent + +SRC = REPO_ROOT / "src" / "pineagents" +if sys.platform == "darwin": + codesign_identity = os.environ.get( + "PYINSTALLER_CODESIGN_IDENTITY" + ) or os.environ.get("APPLE_SIGNING_IDENTITY") + if not codesign_identity: + codesign_identity = None +else: + codesign_identity = None + +def collect_tree(source_dir, target_dir): + return [ + (str(path), str(Path(target_dir) / path.relative_to(source_dir).parent)) + for path in source_dir.rglob("*") + if path.is_file() + ] + + +# Match the legacy desktop package: the FastAPI backend serves the web console +# from pineagents/console, so Tauri can navigate to the backend-hosted same-origin +# console after the sidecar is ready. +CONSOLE_DIST = REPO_ROOT / "console" / "dist" +if not (CONSOLE_DIST / "index.html").is_file(): + raise SystemExit( + f"console dist not found at {CONSOLE_DIST}; " + "run npm run build:prod in console/ before PyInstaller" + ) + +_data_dirs = [ + ("agents/skills", "pineagents/agents/skills"), + ("agents/md_files", "pineagents/agents/md_files"), + ("tokenizer", "pineagents/tokenizer"), + ("security/tool_guard/rules", "pineagents/security/tool_guard/rules"), + ("security/skill_scanner/rules", "pineagents/security/skill_scanner/rules"), + ("security/skill_scanner/data", "pineagents/security/skill_scanner/data"), + ("app/channels/yuanbao/proto", "pineagents/app/channels/yuanbao/proto"), +] +datas = [ + (str(SRC / src), dst) for src, dst in _data_dirs if (SRC / src).is_dir() +] +datas += collect_tree(CONSOLE_DIST, "pineagents/console") +datas.append( + ( + str(SRC / "browser/control_link/injected/engine.js"), + "pineagents/browser/control_link/injected", + ), +) + +# Include reme package data files (configs, tool yamls, etc.) +datas += collect_data_files("reme") +datas += collect_data_files("whisper") +datas += collect_data_files("agentscope") +datas += collect_data_files( + "agentscope.tool._builtin._scripts", + include_py_files=True, +) +datas += collect_data_files( + "agentscope.workspace._mcp_gateway", + include_py_files=True, +) + +# The Qoder SDK ships a platform-specific qodercli executable. Classify it as +# a binary so PyInstaller preserves executable permissions and signs it with +# the rest of the macOS bundle. +_, _qoder_sdk_dir = get_package_paths("qoder_agent_sdk") +_qoder_cli_name = "qodercli.exe" if sys.platform == "win32" else "qodercli" +_qoder_cli = Path(_qoder_sdk_dir) / "_bundled" / _qoder_cli_name +if not _qoder_cli.is_file(): + raise SystemExit( + f"Qoder SDK CLI not found at {_qoder_cli}; reinstall qoder-agent-sdk" + ) +qoder_binaries = [ + (str(_qoder_cli), "qoder_agent_sdk/_bundled"), +] + +# The official Codex Python SDK depends on a platform wheel that exposes a +# stable bundled_codex_path() API. Preserve its runtime layout because Codex +# resolves sibling hosts and resources relative to the main executable. +_, _codex_bin_dir = get_package_paths("codex_cli_bin") +_codex_bin_dir = Path(_codex_bin_dir) +_codex_executable = ( + "codex.exe" if sys.platform == "win32" else "codex" +) +_codex_cli = _codex_bin_dir / "bin" / _codex_executable +if not _codex_cli.is_file(): + raise SystemExit( + f"Codex SDK CLI not found at {_codex_cli}; reinstall openai-codex" + ) +codex_binaries = [ + ( + str(path), + str(Path("codex_cli_bin") / path.relative_to(_codex_bin_dir).parent), + ) + for directory_name in ("bin", "codex-path", "codex-resources") + for path in (_codex_bin_dir / directory_name).rglob("*") + if path.is_file() +] +datas.append( + ( + str(_codex_bin_dir / "codex-package.json"), + "codex_cli_bin", + ), +) + +# Collect package metadata for packages that use importlib.metadata at runtime. +# Keep this allowlist in sync when adding runtime dependencies that query +# importlib.metadata, otherwise packaged sidecars may fail only after install. +_metadata_pkgs = [ + "pineagents", + "fastmcp", + "mcp", + "httpx", + "httpcore", + "anyio", + "sniffio", + "starlette", + "pydantic", + "pydantic-core", + "pydantic-settings", + "uvicorn", + "openai", + "anthropic", + "tiktoken", + "agentscope", + "agentscope-runtime", + "huggingface_hub", + "modelscope", + "openai-whisper", + "openai-codex", + "openai-codex-cli-bin", + "qoder-agent-sdk", +] +for _pkg in _metadata_pkgs: + try: + datas += copy_metadata(_pkg) + except Exception: + pass + +a = Analysis( + [ + str(SRC / "tauri" / "entry.py"), + str(SRC / "tauri" / "cli_entry.py"), + ], + pathex=[str(REPO_ROOT), str(REPO_ROOT / "src")], + binaries=[*qoder_binaries, *codex_binaries], + datas=datas, + hiddenimports=[ + "codex_cli_bin", + # uvicorn internals (not auto-discovered by PyInstaller) + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on", + # All CLI sub-commands (dynamically loaded by Click) + *collect_submodules("pineagents.cli"), + # All channel adapters (imported on-demand at runtime) + *collect_submodules("pineagents.app.channels"), + # ACP runner support is lazily imported by delegate_external_agent. + *collect_submodules("pineagents.agents.acp"), + # PawApp SDK modules are imported by installed app plugins at runtime. + *collect_submodules("pineagents.pawapp"), + # ASGI app entry points + "pineagents.app._app", + "pineagents.app.multi_agent_manager", + "pineagents.app.chats", + "pineagents.app.task_tracker", + "pineagents.runtime.commands", + # Backup modules are exposed through pineagents.backup.__getattr__, which + # PyInstaller cannot discover from static imports. + *collect_submodules("pineagents.backup"), + # Third-party packages that use dynamic imports. Use + # collect_submodules() for packages that load many submodules by name; + # keep the bare package string when runtime code imports only the + # package root or when PyInstaller needs the top-level module anchor. + *collect_submodules("dotenv"), + "dotenv", + *collect_submodules("acp"), + "acp", + "psutil", + "multipart", + "websockets", + "modelscope", + "modelscope.hub.api", + "modelscope.hub.snapshot_download", + *collect_submodules("agentscope.tool._builtin._scripts"), + *collect_submodules("agentscope.workspace._mcp_gateway"), + *collect_submodules("whisper"), + *collect_submodules("chromadb"), + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +def script_entry(file_name): + for item in a.scripts: + if Path(item[1]).name == file_name: + return [item] + raise SystemExit(f"script entry not found: {file_name}") + + +backend_exe = EXE( + pyz, + script_entry("entry.py"), + [], + name="qwenpaw-backend", + debug=False, + bootloader_ignore_signals=False, + strip=False, + # UPX triggers antivirus false positives and can corrupt binaries. + upx=False, + console=False, + disable_windowed_traceback=True, + argv_emulation=False, + target_arch=None, + codesign_identity=codesign_identity, + exclude_binaries=True, +) + +cli_exe = EXE( + pyz, + script_entry("cli_entry.py"), + [], + name="qwenpaw", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=codesign_identity, + exclude_binaries=True, +) + +coll = COLLECT( + backend_exe, + cli_exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="qwenpaw-backend", +) diff --git a/scripts/pack-tauri/sign_macos_bundle.sh b/scripts/pack-tauri/sign_macos_bundle.sh new file mode 100644 index 0000000..61e5b3b --- /dev/null +++ b/scripts/pack-tauri/sign_macos_bundle.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Re-sign all Mach-O files in a macOS bundle/directory with one identity. +# +# PyInstaller collects Python frameworks and native extension libraries from +# third-party packages. Re-signing every Mach-O file after collection keeps the +# backend executable, Python runtime, and native dependencies in one signature +# state before Tauri embeds them in the final app. + +set -euo pipefail + +TARGET="${1:?Usage: sign_macos_bundle.sh [identity]}" +IDENTITY="${2:-${APPLE_SIGNING_IDENTITY:--}}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "ERROR: macOS code signing must run on Darwin" + exit 1 +fi + +if ! command -v codesign >/dev/null 2>&1; then + echo "ERROR: codesign not found" + exit 1 +fi + +if ! command -v file >/dev/null 2>&1; then + echo "ERROR: file not found" + exit 1 +fi + +if [[ ! -e "${TARGET}" ]]; then + echo "ERROR: signing target not found: ${TARGET}" + exit 1 +fi + +signing_args() { + printf '%s\n' --force --sign "${IDENTITY}" + if [[ "${IDENTITY}" == "-" ]]; then + printf '%s\n' --timestamp=none + fi +} + +is_macho() { + file -b "$1" | grep -q "Mach-O" +} + +is_inside_framework() { + [[ "$1" == *".framework/"* ]] +} + +codesign_file() { + local path="$1" + local args=() + local arg + + while IFS= read -r arg; do + args+=("${arg}") + done < <(signing_args) + + codesign "${args[@]}" "${path}" +} + +codesign_bundle() { + local path="$1" + local args=() + local arg + + while IFS= read -r arg; do + args+=("${arg}") + done < <(signing_args) + + codesign "${args[@]}" "${path}" +} + +echo "Signing macOS native files in ${TARGET}" +echo "Signing identity: ${IDENTITY}" + +signed_files=0 +while IFS= read -r -d '' path; do + if is_inside_framework "${path}"; then + continue + fi + if is_macho "${path}"; then + codesign_file "${path}" + signed_files=$((signed_files + 1)) + fi +done < <(find "${TARGET}" -type f -print0) + +# Framework directories carry their own bundle signature. Sign them after the +# contained Mach-O files, then sign the app bundle last. +signed_frameworks=0 +while IFS= read -r framework; do + if [[ -n "${framework}" ]]; then + codesign_bundle "${framework}" + signed_frameworks=$((signed_frameworks + 1)) + fi +done < <(find "${TARGET}" -type d -name "*.framework" | sort -r) + +if [[ "${TARGET}" == *.app ]]; then + codesign_bundle "${TARGET}" +fi + +echo "Signed ${signed_files} Mach-O files and ${signed_frameworks} frameworks" + +if [[ "${TARGET}" == *.app ]]; then + codesign --verify --deep --strict --verbose=2 "${TARGET}" +else + while IFS= read -r -d '' path; do + if is_inside_framework "${path}"; then + continue + fi + if is_macho "${path}"; then + codesign --verify --verbose=2 "${path}" + fi + done < <(find "${TARGET}" -type f -print0) + while IFS= read -r framework; do + if [[ -n "${framework}" ]]; then + codesign --verify --verbose=2 "${framework}" + fi + done < <(find "${TARGET}" -type d -name "*.framework" | sort -r) +fi diff --git a/scripts/pack-tauri/stage_node_runtime.py b/scripts/pack-tauri/stage_node_runtime.py new file mode 100644 index 0000000..3d2014f --- /dev/null +++ b/scripts/pack-tauri/stage_node_runtime.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Stage a Node.js runtime for the Tauri desktop bundle.""" +from __future__ import annotations + +import argparse +import os +import platform +import shutil +import tarfile +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +DEFAULT_NODE_VERSION = "v22.20.0" +NODE_DIST_URL = "https://nodejs.org/dist" + + +def _target() -> tuple[str, str, str]: + system = platform.system() + machine = platform.machine().lower() + arch = { + "amd64": "x64", + "x86_64": "x64", + "arm64": "arm64", + "aarch64": "arm64", + }.get(machine) + if arch is None: + raise SystemExit(f"unsupported machine architecture: {machine!r}") + if system == "Windows": + return "win", arch, "zip" + if system == "Darwin": + return "darwin", arch, "tar.xz" + if system == "Linux": + return "linux", arch, "tar.xz" + raise SystemExit(f"unsupported platform: {system!r}") + + +def _node_exe(dest: Path) -> Path: + if platform.system() == "Windows": + return dest / "node.exe" + return dest / "bin" / "node" + + +def _npx_exe(dest: Path) -> Path: + if platform.system() == "Windows": + return dest / "npx.cmd" + return dest / "bin" / "npx" + + +def _http_get(url: str) -> bytes: + request = urllib.request.Request(url) + request.add_header("User-Agent", "qwenpaw-build") + with urllib.request.urlopen(request, timeout=120) as response: + return response.read() + + +def _extract(archive: Path, suffix: str, workdir: Path) -> Path: + if suffix == "zip": + with zipfile.ZipFile(archive) as zip_file: + zip_file.extractall(workdir) + else: + with tarfile.open(archive, "r:xz") as tar: + try: + tar.extractall(workdir, filter="data") + except TypeError: + _validate_tar_members(tar, workdir) + tar.extractall(workdir) + + roots = [ + path + for path in workdir.iterdir() + if path.is_dir() and path.name.startswith("node-") + ] + if len(roots) != 1: + raise SystemExit("failed to locate extracted Node.js directory") + return roots[0] + + +def _validate_tar_members(tar: tarfile.TarFile, workdir: Path) -> None: + root = workdir.resolve() + for member in tar.getmembers(): + target = (root / member.name).resolve() + try: + target.relative_to(root) + except ValueError: + raise SystemExit( + f"tar member escapes target: {member.name}", + ) from None + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dest", required=True) + parser.add_argument( + "--node-version", + default=os.environ.get("QWENPAW_NODE_VERSION", DEFAULT_NODE_VERSION), + ) + args = parser.parse_args() + + version = args.node_version + platform_name, arch, suffix = _target() + target = f"{platform_name}-{arch}" + dest = Path(args.dest).resolve() + marker = dest / ".node-runtime-version" + + if ( + _node_exe(dest).is_file() + and _npx_exe(dest).is_file() + and marker.is_file() + and marker.read_text(encoding="utf-8").strip() == f"{version}-{target}" + ): + print(f"node-runtime already staged ({version}-{target}); skipping") + return + + archive_name = f"node-{version}-{target}.{suffix}" + url = f"{NODE_DIST_URL}/{version}/{archive_name}" + print(f"Staging Node.js {version} for {target}...") + print(f"Downloading {url}") + + with tempfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + archive = tmpdir / archive_name + archive.write_bytes(_http_get(url)) + extracted = _extract(archive, suffix, tmpdir) + + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + for item in extracted.iterdir(): + shutil.move(str(item), dest / item.name) + + if not _node_exe(dest).is_file() or not _npx_exe(dest).is_file(): + raise SystemExit("staging failed: node or npx missing") + marker.write_text(f"{version}-{target}", encoding="utf-8") + print(f"Staged node-runtime at {dest}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pack-tauri/stage_python_runtime.py b/scripts/pack-tauri/stage_python_runtime.py new file mode 100644 index 0000000..8df76c2 --- /dev/null +++ b/scripts/pack-tauri/stage_python_runtime.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Stage a standalone CPython runtime for the Tauri desktop bundle. + +The Tauri backend is a PyInstaller-frozen executable, so ``sys.executable`` is +not a usable Python interpreter. To install third-party *plugin* dependencies +at runtime we ship a standalone CPython (python-build-standalone) whose +``X.Y``/architecture match the frozen interpreter, and drive ``pip install`` +with it (see ``pineagents.plugins.loader``). + +This script downloads the matching ``install_only`` build and extracts it to +``/python``. Run it with the SAME interpreter used for the PyInstaller +build so the bundled runtime version matches automatically. +""" +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import sys +import tarfile +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +RELEASES_API_BASE = ( + "https://api.github.com/repos/astral-sh/" + "python-build-standalone/releases" +) +DEFAULT_RELEASE = "20260623" +RELEASE_ENV = "QWENPAW_PYTHON_BUILD_STANDALONE_RELEASE" +HTTP_ATTEMPTS = 4 +HTTP_TIMEOUT_SECONDS = 120 +RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504} + + +def _host_triple() -> str: + system = platform.system() + machine = platform.machine().lower() + arch = { + "amd64": "x86_64", + "x86_64": "x86_64", + "arm64": "aarch64", + "aarch64": "aarch64", + }.get(machine) + if arch is None: + raise SystemExit(f"unsupported machine architecture: {machine!r}") + if system == "Windows": + return f"{arch}-pc-windows-msvc" + if system == "Darwin": + return f"{arch}-apple-darwin" + if system == "Linux": + return f"{arch}-unknown-linux-gnu" + raise SystemExit(f"unsupported platform: {system!r}") + + +def _python_exe(dest: Path) -> Path: + if platform.system() == "Windows": + return dest / "python" / "python.exe" + return dest / "python" / "bin" / "python3" + + +def _http_get(url: str) -> bytes: + request = urllib.request.Request(url) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + request.add_header("User-Agent", "qwenpaw-build") + for attempt in range(1, HTTP_ATTEMPTS + 1): + try: + with urllib.request.urlopen( + request, + timeout=HTTP_TIMEOUT_SECONDS, + ) as resp: + return resp.read() + except urllib.error.HTTPError as exc: + if ( + exc.code not in RETRYABLE_HTTP_STATUS + or attempt == HTTP_ATTEMPTS + ): + raise + wait = 2 ** (attempt - 1) + print( + f"HTTP {exc.code} fetching {url}; " + f"retrying in {wait}s ({attempt}/{HTTP_ATTEMPTS})", + ) + except OSError as exc: + if attempt == HTTP_ATTEMPTS: + raise + wait = 2 ** (attempt - 1) + print( + f"{type(exc).__name__} fetching {url}: {exc}; " + f"retrying in {wait}s ({attempt}/{HTTP_ATTEMPTS})", + ) + time.sleep(wait) + raise RuntimeError(f"failed to fetch {url}") + + +def _release_url(release: str | None) -> str: + if release and release.lower() != "latest": + return f"{RELEASES_API_BASE}/tags/{release}" + return f"{RELEASES_API_BASE}/latest" + + +def _release_data(release: str | None) -> dict[str, object]: + return json.loads(_http_get(_release_url(release)).decode("utf-8")) + + +def _preferred_release() -> str: + release = os.environ.get(RELEASE_ENV, DEFAULT_RELEASE).strip() + return release or "latest" + + +def _asset_url_from_release( + data: dict[str, object], + xy: str, + triple: str, +) -> str | None: + pattern = re.compile( + rf"^cpython-{re.escape(xy)}\.\d+\+\d+-{re.escape(triple)}" + r"-install_only\.tar\.gz$", + ) + for asset in data.get("assets", []): + if not isinstance(asset, dict): + continue + if pattern.match(str(asset.get("name", ""))): + return str(asset["browser_download_url"]) + return None + + +def _find_asset_url(xy: str, triple: str, release: str) -> tuple[str, str]: + if release and release.lower() != "latest": + try: + data = _release_data(release) + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + print( + f"python-build-standalone release {release} not found; " + "falling back to latest", + ) + else: + url = _asset_url_from_release(data, xy, triple) + if url: + return url, release + print( + f"no python-build-standalone install_only asset for " + f"Python {xy} / {triple} in release {release}; " + "falling back to latest", + ) + + data = _release_data(None) + url = _asset_url_from_release(data, xy, triple) + if url: + return url, str(data.get("tag_name", "latest")) + raise SystemExit( + f"no python-build-standalone install_only asset for " + f"Python {xy} / {triple} in the latest release", + ) + + +def _is_staged(dest: Path, marker: Path, marker_value: str) -> bool: + return ( + _python_exe(dest).is_file() + and marker.is_file() + and marker.read_text(encoding="utf-8").strip() == marker_value + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--dest", + required=True, + help="Target directory (a 'python' subdir is created inside it)", + ) + parser.add_argument( + "--python-version", + default=f"{sys.version_info.major}.{sys.version_info.minor}", + help="CPython X.Y to stage (default: this interpreter's version)", + ) + args = parser.parse_args() + + xy = args.python_version + triple = _host_triple() + dest = Path(args.dest).resolve() + marker = dest / ".python-runtime-version" + + preferred_release = _preferred_release() + marker_value = f"{xy}-{triple}-{preferred_release}" + # Fast path for pinned releases; latest/fallback cache hits need resolving + # first so the marker check uses the actual release tag. + if preferred_release.lower() != "latest" and _is_staged( + dest, + marker, + marker_value, + ): + print(f"python-runtime already staged ({marker_value}); skipping") + return + + print(f"Resolving standalone CPython {xy} for {triple}...") + url, release = _find_asset_url(xy, triple, preferred_release) + marker_value = f"{xy}-{triple}-{release}" + if _is_staged(dest, marker, marker_value): + print(f"python-runtime already staged ({marker_value}); skipping") + return + + print(f"Staging standalone CPython {xy} for {triple}...") + print(f"Downloading {url}") + + if dest.exists(): + import shutil + + shutil.rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile( + suffix=".tar.gz", + delete=False, + ) as tmp: + tmp.write(_http_get(url)) + archive = tmp.name + try: + with tarfile.open(archive, "r:gz") as tar: + # ``filter="data"`` is only available on newer CPython patch + # releases (3.12+, backported to 3.10.12/3.11.4). Fall back to a + # plain extract on older interpreters; the archive comes from the + # trusted python-build-standalone release. + try: + tar.extractall(dest, filter="data") + except TypeError: + tar.extractall(dest) + finally: + os.unlink(archive) + + exe = _python_exe(dest) + if not exe.is_file(): + raise SystemExit(f"staging failed: interpreter missing at {exe}") + marker.write_text(marker_value, encoding="utf-8") + print(f"Staged python-runtime at {dest / 'python'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pack-tauri/sync_tauri_version.mjs b/scripts/pack-tauri/sync_tauri_version.mjs new file mode 100644 index 0000000..1d7646b --- /dev/null +++ b/scripts/pack-tauri/sync_tauri_version.mjs @@ -0,0 +1,131 @@ +// Sync the Python PEP 440 version from src/pineagents/__version__.py into a +// gitignored Tauri config override. Do not write the tracked tauri.conf.json: +// its version would otherwise become a stale generated value after rebases. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../.."); +const versionFile = path.join(repoRoot, "src/pineagents/__version__.py"); +const tauriConfigFile = path.join( + repoRoot, + "console/src-tauri/tauri.conf.json", +); +const tauriVersionConfigFile = path.join( + repoRoot, + "console/src-tauri/tauri.version.conf.json", +); + +function readPythonVersion() { + const text = fs.readFileSync(versionFile, "utf8"); + const match = text.match(/__version__\s*=\s*"([^"]+)"/); + if (!match) { + throw new Error(`Could not read __version__ from ${versionFile}`); + } + return match[1]; +} + +function toSemver(version) { + const match = version.match( + /^(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?(?:\.post(\d+))?(?:\.dev(\d+))?$/, + ); + if (!match) { + throw new Error(`Unsupported Python version for Tauri: ${version}`); + } + + const [, major, minor, patch, prerelease, prereleaseNumber, post, dev] = + match; + const prereleaseMap = { a: "alpha", b: "beta", rc: "rc" }; + const labels = []; + if (prerelease) + labels.push(`${prereleaseMap[prerelease]}.${prereleaseNumber}`); + if (dev) labels.push(`dev.${dev}`); + + const prereleaseSuffix = labels.length ? `-${labels.join(".")}` : ""; + const postMetadata = post ? `+post.${post}` : ""; + return `${major}.${minor}.${patch}${prereleaseSuffix}${postMetadata}`; +} + +function readBaseUpdaterConfig() { + const config = JSON.parse(fs.readFileSync(tauriConfigFile, "utf8")); + const updater = config?.plugins?.updater ?? {}; + if (!updater.pubkey) { + throw new Error( + `Could not read plugins.updater.pubkey from ${tauriConfigFile}`, + ); + } + return updater; +} + +function readUpdaterEndpoints(baseUpdater) { + const raw = process.env.TAURI_UPDATER_ENDPOINTS?.trim(); + if (!raw) return baseUpdater.endpoints; + + if (raw.startsWith("[")) { + const parsed = JSON.parse(raw); + if ( + !Array.isArray(parsed) || + parsed.some((entry) => typeof entry !== "string") + ) { + throw new Error( + "TAURI_UPDATER_ENDPOINTS JSON must be an array of strings", + ); + } + return parsed; + } + + return raw + .split(/[\n,]/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function shouldCreateUpdaterArtifacts() { + return Boolean(process.env.TAURI_SIGNING_PRIVATE_KEY?.trim()); +} + +function writeTauriVersionConfig(file, version) { + const baseUpdater = readBaseUpdaterConfig(); + const pubkey = process.env.TAURI_UPDATER_PUBKEY?.trim() || baseUpdater.pubkey; + const endpoints = readUpdaterEndpoints(baseUpdater); + const createUpdaterArtifacts = shouldCreateUpdaterArtifacts(); + const config = { + version, + ...(createUpdaterArtifacts + ? { + bundle: { + createUpdaterArtifacts: true, + }, + } + : {}), + plugins: { + updater: { + pubkey, + ...(endpoints?.length ? { endpoints } : {}), + }, + }, + }; + fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); + console.log( + `Using updater pubkey from ${ + process.env.TAURI_UPDATER_PUBKEY?.trim() + ? "TAURI_UPDATER_PUBKEY" + : "tauri.conf.json" + }`, + ); + if (process.env.TAURI_UPDATER_ENDPOINTS?.trim()) { + console.log("Using updater endpoints from TAURI_UPDATER_ENDPOINTS"); + } + console.log( + createUpdaterArtifacts + ? "Creating Tauri updater artifacts" + : "Skipping Tauri updater artifacts because TAURI_SIGNING_PRIVATE_KEY is not set", + ); +} + +const semver = toSemver(readPythonVersion()); + +writeTauriVersionConfig(tauriVersionConfigFile, semver); + +console.log(`Wrote Tauri version override ${semver}`); diff --git a/scripts/pack/README.md b/scripts/pack/README.md new file mode 100644 index 0000000..5a47ca4 --- /dev/null +++ b/scripts/pack/README.md @@ -0,0 +1,98 @@ +# PineAgents packaging scripts + +> ⚠️ **Legacy (rollback-only).** These conda-pack based packaging scripts have +> been superseded by the **Tauri** desktop build (see `console/src-tauri/` and +> `scripts/pack-tauri/`). They are kept only for short-term rollback and are no +> longer used by the release pipeline. For the current desktop app, refer to the +> [Desktop Application Guide](https://qwenpaw.agentscope.io/docs/desktop). + +One-click build: each script first builds a **wheel** via +`scripts/wheel_build.sh` (includes the console frontend), then uses a +**temporary conda environment** and **conda-pack** (no current dev env). +Dependencies follow `pyproject.toml`. + +- **Windows**: wheel → conda-pack → unpack → NSIS installer (`.exe`) +- **macOS**: wheel → conda-pack → unpack into `.app` → optional zip + +## System Requirements + +- **Windows**: Windows 10 or later +- **macOS**: macOS 14 (Sonoma) or later, Apple Silicon (M1/M2/M3/M4) recommended + +## Prerequisites + +- **conda** (Miniconda/Anaconda) on PATH +- **Node.js / npm** (for the console frontend) +- (Windows only) **NSIS**: `makensis` on PATH +- **Icons**: Pre-generated `icon.ico` (Windows) and `icon.icns` (macOS) are included in `scripts/pack/assets/` + +## One-click build + +From the **repo root**: + +**macOS** +```bash +bash ./scripts/pack/build_macos.sh +# Output: dist/QwenPaw.app + +CREATE_ZIP=1 bash ./scripts/pack/build_macos.sh # also create .zip +``` + +**Windows (PowerShell)** +```powershell +./scripts/pack/build_win.ps1 +# Output: dist/QwenPaw-Setup-.exe +# Creates two launchers: +# - PineAgents.vbs (silent, no console window) +# - PineAgents (Debug).bat (shows console for troubleshooting) +# Note: Pre-compiles all Python files to .pyc for faster startup +``` + +## Run from terminal and see logs (macOS) + +If the .app crashes on double-click, run it from Terminal to see the full error and logs: + +```bash +# From repo root; force packed env only (no system conda / PYTHONPATH). Adjust path if needed. +APP_ENV="$(pwd)/dist/QwenPaw.app/Contents/Resources/env" +PYTHONNOUSERSITE=1 PYTHONPATH= PYTHONHOME="$APP_ENV" "$APP_ENV/bin/python" -m pineagents desktop +``` + +The `PYTHONNOUSERSITE=1` prevents Python from loading packages from `~/.local/lib/pythonX.Y/site-packages`, which can conflict with the packaged environment. All stdout/stderr (including Python tracebacks) will appear in the terminal. Use this to debug startup errors or to run with `--log-level debug`. + +When you **double-click** the .app and nothing appears, the launcher writes stderr/stdout to `~/.qwenpaw/desktop.log`. Inspect that file for errors. + +On first launch macOS may ask for “Desktop” or “Files and Folders” access: click **Allow** so the app can run properly; if you click Don’t Allow, the window may close. + +## macOS: if “Apple cannot verify” / Gatekeeper blocks the app + +When users download the QwenPaw macOS app (e.g. from Releases) as a `.app` (in a zip), macOS may show: *"Apple cannot verify that 'QwenPaw' contains no malicious software"*. The app is not notarized. They can still open it as follows: + +- **Right-click to open (recommended)** + Right-click (or Control+click) the QwenPaw app → **Open** → in the dialog click **Open** again. Gatekeeper will allow it; after that double-click works as usual. + +- **Allow in System Settings** + If still blocked, go to **System Settings → Privacy & Security**, find the message like *"QwenPaw was blocked because it is from an unidentified developer"*, and click **Open Anyway** or **Allow**. + +- **Remove quarantine attribute (not recommended for most users)** + In Terminal: `xattr -cr /Applications/QwenPaw.app` (or the path to the `.app` after unzipping). This clears the download quarantine flag; less safe than right-click → Open. + +## CI + +`.github/workflows/desktop-release.yml`: + +- **Triggers**: Release publish or manual workflow_dispatch +- **Windows**: Build console → temporary conda env + conda-pack → NSIS → upload artifact +- **macOS**: Build console → temporary conda env + conda-pack → .app → zip → upload artifact +- **Release**: When triggered by a release, uploads the Windows installer and macOS zip as release assets + +## Script reference + +| File | Description | +|------|-------------| +| `build_common.py` | Create temporary conda env, install `qwenpaw[full]` from a wheel, conda-pack; produces archive. | +| `build_macos.sh` | One-click: build wheel → build_common → unpack into QwenPaw.app; optional zip. | +| `build_win.ps1` | One-click: build wheel → build_common → unpack → create VBS/BAT launchers → makensis installer. | +| `desktop.nsi` | NSIS script: pack `dist/win-unpacked`, add icons, and create shortcuts. | +| `assets/icon.ico` | Pre-generated Windows icon (installer and shortcuts). | +| `assets/icon.icns` | Pre-generated macOS icon (app bundle). | diff --git a/scripts/pack/README_zh.md b/scripts/pack/README_zh.md new file mode 100644 index 0000000..a742314 --- /dev/null +++ b/scripts/pack/README_zh.md @@ -0,0 +1,95 @@ +# PineAgents 打包脚本 + +> ⚠️ **旧版(仅用于回滚)。** 这套基于 conda-pack 的打包脚本已被 **Tauri** +> 桌面版构建取代(详见 `console/src-tauri/` 与 `scripts/pack-tauri/`),仅作 +> 短期回滚保留,发布流程已不再使用。当前桌面应用请参考 +> [桌面应用指南](https://qwenpaw.agentscope.io/docs/desktop)。 + +一键打包:脚本会先运行 `scripts/wheel_build.sh` 构建 **wheel** +(包含 console 前端产物),再用 **临时 conda 环境** + **conda-pack** +(不依赖当前开发环境)。依赖以 `pyproject.toml` 为准。 + +- **Windows**: wheel → conda-pack → 解压 → NSIS 安装包 (`.exe`) +- **macOS**: wheel → conda-pack → 解压到 `.app` → 可选打 zip + +## 系统要求 + +- **Windows**: Windows 10 或更高版本 +- **macOS**: macOS 14 (Sonoma) 或更高版本,推荐 Apple Silicon (M1/M2/M3/M4) + +## 前置 + +- **conda**(Miniconda/Anaconda)在 PATH +- **Node.js / npm**(用于构建 console 前端) +- (仅 Windows)**NSIS**:`makensis` 在 PATH +- **图标**:预生成的 `icon.ico` (Windows) 和 `icon.icns` (macOS) 已包含在 `scripts/pack/assets/` 中 + +## 一键打包 + +在**仓库根目录**执行: + +**macOS** +```bash +bash ./scripts/pack/build_macos.sh +# 产出: dist/QwenPaw.app + +CREATE_ZIP=1 bash ./scripts/pack/build_macos.sh # 同时生成 .zip +``` + +**Windows (PowerShell)** +```powershell +./scripts/pack/build_win.ps1 +# 产出: dist/QwenPaw-Setup-.exe +# 创建两个启动器: +# - PineAgents.vbs (静默启动,无终端窗口) +# - PineAgents (Debug).bat (显示终端,便于调试) +``` + +## 从终端启动并查看日志(macOS) + +如果双击 .app 会闪退,可以在终端里运行以查看完整报错和日志: + +```bash +# 在仓库根目录执行,强制只用打包环境(不用系统 conda / PYTHONPATH)。路径按需改。 +APP_ENV="$(pwd)/dist/QwenPaw.app/Contents/Resources/env" +PYTHONNOUSERSITE=1 PYTHONPATH= PYTHONHOME="$APP_ENV" "$APP_ENV/bin/python" -m pineagents desktop +``` + +`PYTHONNOUSERSITE=1` 可防止 Python 加载 `~/.local/lib/pythonX.Y/site-packages` 中的包,避免与打包环境冲突。所有标准输出和错误(包括 Python traceback)都会打在终端里;可加 `--log-level debug` 查看更详细日志。 + +若**双击** .app 没有任何窗口出现,启动器会把 stderr/stdout 写入 `~/.qwenpaw/desktop.log`,可打开该文件查看报错。 + +首次打开时,macOS 可能弹出「请求访问桌面的文件」:请点**允许**,否则部分功能可能不可用或窗口会关闭。 + +## macOS:提示「无法验证开发者」/ Gatekeeper 拦截时怎么打开 + +用户从 Release 等渠道下载的 QwenPaw macOS 应用(zip 内的 .app)未经过 Apple 公证,可能看到「Apple 无法验证“QwenPaw”是否包含可能危害 Mac 安全或泄漏隐私的恶意软件」。可按以下方式打开: + +- **右键打开(推荐)** + 在 QwenPaw 应用上 **右键(或 Control + 点击)** → 选 **「打开」** → 在弹窗里再点一次 **「打开」**。即表示你确认运行该应用,Gatekeeper 会放行,之后双击即可正常打开。 + +- **在系统设置里放行** + 若仍被拦截,进入 **系统设置 → 隐私与安全性**,往下找到类似「已阻止使用 QwenPaw,因为无法验证开发者」的提示,点 **「仍要打开」** 或 **「允许」** 即可。 + +- **用终端去掉隔离属性(不推荐普通用户)** + 在终端执行:`xattr -cr /Applications/QwenPaw.app`(或解压后 .app 的实际路径)。会去掉「从互联网下载」的隔离标记,一般就不再弹恶意软件提示,但不如「右键 → 打开」安全、可控。 + +## CI + +`.github/workflows/desktop-release.yml`: + +- **触发**: Release 发布 或 手动 workflow_dispatch +- **Windows**: 构建 console → 临时 conda 环境 + conda-pack → NSIS → 上传 artifact +- **macOS**: 构建 console → 临时 conda 环境 + conda-pack → .app → zip → 上传 artifact +- **Release**: 若由 release 触发,则把 Windows 安装包与 macOS zip 上传到该 Release 的附件 + +## 脚本说明 + +| 文件 | 说明 | +|------|------| +| `build_common.py` | 创建临时 conda 环境,从 wheel 安装 `qwenpaw[full]`,conda-pack 产出归档 | +| `build_macos.sh` | 一键:构建 wheel → build_common → 解压到 QwenPaw.app;可选打 zip | +| `build_win.ps1` | 一键:构建 wheel → build_common → 解压 → 创建 VBS/BAT 启动器 → makensis 安装包 | +| `desktop.nsi` | NSIS 脚本:打包 `dist/win-unpacked`,添加图标,创建快捷方式 | +| `assets/icon.ico` | 预生成的 Windows 图标(安装包和快捷方式使用) | +| `assets/icon.icns` | 预生成的 macOS 图标(应用包使用) | diff --git a/scripts/pack/assets/icon.icns b/scripts/pack/assets/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..3f70b92a583abec7d10cadc1eaa39a4c15be7a43 GIT binary patch literal 109861 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 \ No newline at end of file diff --git a/scripts/pack/build_common.py b/scripts/pack/build_common.py new file mode 100644 index 0000000..efd7c77 --- /dev/null +++ b/scripts/pack/build_common.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# pylint:disable=too-many-statements +""" +Create a temporary conda env, install QwenPaw from a wheel, run conda-pack. +Used by build_macos.sh and build_win.ps1. Run from repo root. +""" +from __future__ import annotations + +import argparse +import os +import random +import string +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +ENV_PREFIX = "qwenpaw_pack_" + +# Packages affected by conda-unpack bug on Windows (conda-pack Issue #154) +# conda-unpack modifies Python source files to replace path prefixes, but uses +# simple byte replacement without considering Python syntax. This corrupts +# string literals containing backslash escapes, causing SyntaxError. +# Example: "\\\\?\\" (correct) -> "\\" (SyntaxError: unterminated string) +# Solution: After conda-unpack, reinstall these packages to restore correct files +# See: issue.md and https://github.com/conda/conda-pack/issues/154 +CONDA_UNPACK_AFFECTED_PACKAGES = [ + "huggingface_hub", # file_download.py, _local_folder.py use Windows long path prefix + "discord.py", # ARG_NAME_SUBREGEX contains \\?\* which gets corrupted +] + + +def _conda_exe() -> str: + """Resolve conda executable (required on Windows where 'conda' is a batch).""" + exe = os.environ.get("CONDA_EXE") + if exe: + return exe + return "conda" + + +def _run( + cmd: list[str], + cwd: Path | None = None, + env: dict[str, str] | None = None, +) -> None: + """Run command with optional environment variable overrides.""" + run_env = os.environ.copy() + if env: + run_env.update(env) + subprocess.run(cmd, cwd=cwd or REPO_ROOT, env=run_env, check=True) + + +def _pick_wheel(wheel_arg: str | None) -> Path: + if wheel_arg: + wheel_path = Path(wheel_arg).expanduser() + if not wheel_path.is_absolute(): + wheel_path = (REPO_ROOT / wheel_path).resolve() + if not wheel_path.exists(): + raise FileNotFoundError(f"Wheel not found: {wheel_path}") + return wheel_path + + wheels = sorted( + (REPO_ROOT / "dist").glob("qwenpaw-*.whl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not wheels: + raise FileNotFoundError( + "No wheel found in dist/. Run: bash scripts/wheel_build.sh", + ) + return wheels[0] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Conda-pack QwenPaw (temp env).", + ) + parser.add_argument( + "--output", + "-o", + required=True, + help="Output archive path (e.g. .tar.gz)", + ) + parser.add_argument( + "--format", + "-f", + default="infer", + choices=["infer", "zip", "tar.gz", "tgz"], + help="Archive format (default: infer from --output extension)", + ) + parser.add_argument( + "--python", + default="3.11", + help="Python version for conda env (default: 3.11)", + ) + parser.add_argument( + "--wheel", + default=None, + help=( + "Wheel path to install. If omitted, pick the newest " + "dist/qwenpaw-*.whl." + ), + ) + parser.add_argument( + "--cache-wheels", + action="store_true", + help=( + "Download wheels for packages affected by conda-unpack bug. " + "Cached to .cache/conda_unpack_wheels/ for later reinstall." + ), + ) + args = parser.parse_args() + out_path = Path(args.output).resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + wheel_path = _pick_wheel(args.wheel) + wheel_uri = wheel_path.resolve().as_uri() + env_name = ( + f"{ENV_PREFIX}{''.join(random.choices(string.ascii_lowercase, k=8))}" + ) + + conda = _conda_exe() + try: + _run( + [ + conda, + "create", + "-n", + env_name, + f"python={args.python}", + # OpenSSL 3.5.7 has a regression (upstream commit 738688d76206 + # reworking asn1_d2i_read_bio) that breaks + # ssl.SSLContext.load_verify_locations(cadata=), which + # _load_windows_store_certs relies on. aiohttp then fails at + # import time and the desktop backend never starts. See #5086. + "openssl<3.5.7", + "pip", + "-y", + ], + ) + # Install qwenpaw with all dependencies + # Scope CMAKE_ARGS to this specific command to avoid affecting other + # CMake-based packages. Only set if we need to compile from source. + install_env = {} + + _run( + [ + conda, + "run", + "-n", + env_name, + "python", + "-m", + "pip", + "install", + f"qwenpaw[full] @ {wheel_uri}", + ], + env=install_env, + ) + print("Verifying certifi is installed (required for SSL)...") + _run( + [ + conda, + "run", + "-n", + env_name, + "python", + "-c", + "import certifi; print(f'certifi OK: {certifi.where()}')", + ], + ) + if args.cache_wheels: + # Store outside dist/ to avoid being deleted by wheel_build cleanup + wheels_cache = REPO_ROOT / ".cache" / "conda_unpack_wheels" + wheels_cache.mkdir(parents=True, exist_ok=True) + print( + f"Caching wheels for conda-unpack bug workaround to " + f"{wheels_cache}", + ) + _run( + [ + conda, + "run", + "-n", + env_name, + "python", + "-m", + "pip", + "download", + *CONDA_UNPACK_AFFECTED_PACKAGES, + "-d", + str(wheels_cache), + ], + ) + # pip may uninstall/reinstall files owned by conda while resolving + # qwenpaw[full]. Restore conda-managed packaging tools before packing. + _run( + [ + conda, + "run", + "-n", + env_name, + conda, + "install", + "-y", + "--force-reinstall", + "pip", + "setuptools", + "wheel", + ], + ) + _run( + [ + conda, + "run", + "-n", + env_name, + conda, + "install", + "-y", + "conda-pack", + ], + ) + if out_path.exists(): + out_path.unlink() + pack_cmd = [ + conda, + "run", + "-n", + env_name, + "conda-pack", + "-n", + env_name, + "-o", + str(out_path), + "-f", + ] + if args.format != "infer": + pack_cmd.extend(["--format", args.format]) + _run(pack_cmd) + print(f"Packed to {out_path}") + finally: + try: + _run([conda, "env", "remove", "-n", env_name, "-y"]) + except Exception as e: + print(f"Warning: Failed to remove temp env {env_name}: {e}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pack/build_macos.sh b/scripts/pack/build_macos.sh new file mode 100644 index 0000000..27f73ee --- /dev/null +++ b/scripts/pack/build_macos.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# One-click build: console -> conda-pack -> QwenPaw.app. Run from repo root. +# Requires: conda, node/npm (for console). Optional: icon.icns in assets/. + +set -e +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" +PACK_DIR="$(cd "$(dirname "$0")" && pwd)" +DIST="${DIST:-dist}" +ARCHIVE="${DIST}/qwenpaw-env.tar.gz" +APP_NAME="QwenPaw" +APP_DIR="${DIST}/${APP_NAME}.app" + +echo "== Building wheel (includes console frontend) ==" +# Skip wheel_build if dist already has a wheel for current version +VERSION_FILE="${REPO_ROOT}/src/pineagents/__version__.py" +CURRENT_VERSION="" +if [[ -f "${VERSION_FILE}" ]]; then + CURRENT_VERSION="$( + sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ + "${VERSION_FILE}" 2>/dev/null + )" +fi +if [[ -n "${CURRENT_VERSION}" ]]; then + shopt -s nullglob + whls=("${REPO_ROOT}/dist/qwenpaw-${CURRENT_VERSION}-"*.whl) + if [[ ${#whls[@]} -gt 0 ]]; then + echo "dist/ already has wheel for version ${CURRENT_VERSION}, skipping." + else + # Clean up old wheels to avoid confusion + old_whls=("${REPO_ROOT}/dist/qwenpaw-"*.whl) + if [[ ${#old_whls[@]} -gt 0 ]]; then + echo "Removing old wheel files: ${old_whls[*]}" + rm -f "${old_whls[@]}" + fi + bash scripts/wheel_build.sh + fi +else + bash scripts/wheel_build.sh +fi + +echo "== Building conda-packed env ==" +python "${PACK_DIR}/build_common.py" --output "$ARCHIVE" --format tar.gz + +echo "== Building .app bundle ==" +rm -rf "$APP_DIR" +mkdir -p "${APP_DIR}/Contents/MacOS" +mkdir -p "${APP_DIR}/Contents/Resources" + +# Unpack conda env into Resources/env +mkdir -p "${APP_DIR}/Contents/Resources/env" +tar -xzf "$ARCHIVE" -C "${APP_DIR}/Contents/Resources/env" --strip-components=0 + +# Fix paths for portability (required or app will crash on launch) +if [[ -x "${APP_DIR}/Contents/Resources/env/bin/conda-unpack" ]]; then + (cd "${APP_DIR}/Contents/Resources/env" && ./bin/conda-unpack) +fi + +# Launcher: force packed env; when no TTY log to ~/.qwenpaw/desktop.log (no exec so we see errors) +cat > "${APP_DIR}/Contents/MacOS/${APP_NAME}" << 'LAUNCHER' +#!/usr/bin/env bash +ENV_DIR="$(cd "$(dirname "$0")/../Resources/env" && pwd)" +LOG="$HOME/.qwenpaw/desktop.log" +unset PYTHONPATH +export PYTHONHOME="$ENV_DIR" +export PYTHONNOUSERSITE=1 +export QWENPAW_DESKTOP_APP=1 + +# Preserve system PATH for accessing system commands (e.g. imsg, brew) +# Prepend packaged env/bin so packaged Python takes precedence +export PATH="$ENV_DIR/bin:$PATH" + +# Set SSL certificate paths for packaged environment +# Query certifi path from the packaged Python interpreter +if [ -x "$ENV_DIR/bin/python" ]; then + CERT_FILE=$("$ENV_DIR/bin/python" -c \ + "import certifi; print(certifi.where())" 2>/dev/null) + if [ -n "$CERT_FILE" ] && [ -f "$CERT_FILE" ]; then + export SSL_CERT_FILE="$CERT_FILE" + export REQUESTS_CA_BUNDLE="$CERT_FILE" + export CURL_CA_BUNDLE="$CERT_FILE" + fi +fi + +cd "$HOME" || true + +# Log level: env var QWENPAW_LOG_LEVEL or default to "info" +LOG_LEVEL="${QWENPAW_LOG_LEVEL:-info}" + +if [ ! -t 2 ]; then + mkdir -p "$HOME/.qwenpaw" + { echo "=== $(date) QwenPaw starting ===" + echo "ENV_DIR=$ENV_DIR" + echo "Python: $ENV_DIR/bin/python (exists=$([ -x "$ENV_DIR/bin/python" ] && echo yes || echo no))" + echo "PATH=$PATH" + echo "LOG_LEVEL=$LOG_LEVEL" + echo "SSL_CERT_FILE=${SSL_CERT_FILE:-not set}" + if [ -n "$SSL_CERT_FILE" ] && [ -f "$SSL_CERT_FILE" ]; then + echo "SSL certificate file found at $SSL_CERT_FILE" + elif [ -n "$SSL_CERT_FILE" ]; then + echo "WARNING: SSL_CERT_FILE set but file does not exist: $SSL_CERT_FILE" + else + echo "WARNING: SSL_CERT_FILE not set, SSL connections may fail" + fi + } >> "$LOG" + exec 2>> "$LOG" + exec 1>> "$LOG" + if [ ! -x "$ENV_DIR/bin/python" ]; then + echo "ERROR: python not executable at $ENV_DIR/bin/python" + exit 1 + fi + if [ ! -f "$HOME/.qwenpaw/config.json" ]; then + "$ENV_DIR/bin/python" -u -m pineagents init --defaults --accept-security + fi + echo "Launching python with log-level=$LOG_LEVEL..." + "$ENV_DIR/bin/python" -u -m pineagents desktop --log-level "$LOG_LEVEL" + EXIT=$? + if [ $EXIT -ge 128 ]; then + SIG=$((EXIT - 128)) + echo "Exit code: $EXIT (killed by signal $SIG, e.g. 9=SIGKILL 15=SIGTERM)" + else + echo "Exit code: $EXIT" + fi + echo "--- Full log: $LOG (scroll up for Python traceback if app exited early) ---" + exit $EXIT +fi +if [ ! -f "$HOME/.qwenpaw/config.json" ]; then + "$ENV_DIR/bin/python" -u -m pineagents init --defaults --accept-security +fi +exec "$ENV_DIR/bin/python" -u -m pineagents desktop --log-level "$LOG_LEVEL" +LAUNCHER +chmod +x "${APP_DIR}/Contents/MacOS/${APP_NAME}" + +# Icon: use pre-generated icon.icns +if [[ -f "${PACK_DIR}/assets/icon.icns" ]]; then + echo "== Using pre-generated icon.icns ==" +else + echo "Warning: icon.icns not found at ${PACK_DIR}/assets/icon.icns" + echo "Generate it first: bash scripts/pack/generate_icons.sh" +fi + +# Info.plist (include icon key if icon.icns exists) +# Prioritize version from __version__.py to ensure accuracy +VERSION="${CURRENT_VERSION}" +if [[ -z "${VERSION}" ]]; then + # Fallback: try to get version from packed env metadata + VERSION="$("${APP_DIR}/Contents/Resources/env/bin/python" -c \ + "from importlib.metadata import version; print(version('qwenpaw'))" 2>/dev/null \ + || echo "0.0.0")" + echo "Using version from packed env metadata: ${VERSION}" +else + echo "Version determined from __version__.py: ${VERSION}" +fi +ICON_PLIST="" +if [[ -f "${PACK_DIR}/assets/icon.icns" ]]; then + cp "${PACK_DIR}/assets/icon.icns" "${APP_DIR}/Contents/Resources/" + ICON_PLIST="CFBundleIconFileicon.icns + " +fi +cat > "${APP_DIR}/Contents/Info.plist" << INFOPLIST + + + + + CFBundleExecutable${APP_NAME} + CFBundleIdentifiercom.qwenpaw.desktop + CFBundleName${APP_NAME} + CFBundleVersion${VERSION} + CFBundleShortVersionString${VERSION} + ${ICON_PLIST}NSHighResolutionCapable + LSMinimumSystemVersion14.0 + NSDesktopFolderUsageDescriptionQwenPaw may access files in your Desktop folder if you use file-related features. You can choose Don'\''t Allow; the app will still run with limited file access. + + +INFOPLIST + +echo "== Built ${APP_DIR} ==" +# Optional: create zip for distribution (set CREATE_ZIP=1) +if [[ -n "${CREATE_ZIP}" ]]; then + ZIP_NAME="${DIST}/QwenPaw-${VERSION}-macOS.zip" + ditto -c -k --sequesterRsrc --keepParent "${APP_DIR}" "${ZIP_NAME}" + echo "== Created ${ZIP_NAME} ==" +fi diff --git a/scripts/pack/build_win.ps1 b/scripts/pack/build_win.ps1 new file mode 100644 index 0000000..c274cbf --- /dev/null +++ b/scripts/pack/build_win.ps1 @@ -0,0 +1,394 @@ +# One-click build: console -> conda-pack -> NSIS .exe. Run from repo root. +# Requires: conda, node/npm (for console), NSIS (makensis) on PATH. + +$ErrorActionPreference = "Stop" +$RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName +Set-Location $RepoRoot +Write-Host "[build_win] REPO_ROOT=$RepoRoot" +$PackDir = $PSScriptRoot +$Dist = if ($env:DIST) { $env:DIST } else { "dist" } +$Archive = Join-Path $Dist "qwenpaw-env.zip" +$Unpacked = Join-Path $Dist "win-unpacked" +$NsiPath = Join-Path $PackDir "desktop.nsi" + +# Packages affected by conda-unpack bug on Windows (conda-pack Issue #154) +# conda-unpack corrupts Python string escaping when replacing path prefixes. +# Example: "\\\\?\\" (correct) -> "\\" (SyntaxError) +# Solution: Reinstall these packages after conda-unpack to restore correct files. +# See: issue.md, scripts/pack/WINDOWS_FIX.md +$CondaUnpackAffectedPackages = @( + "huggingface_hub" # Uses Windows extended-length path prefix (\\?\) + "discord.py" # ARG_NAME_SUBREGEX contains \\?\* which gets corrupted +) + +New-Item -ItemType Directory -Force -Path $Dist | Out-Null + +Write-Host "== Building wheel (includes console frontend) ==" +# Skip wheel_build if dist already has a wheel for current version +$VersionFile = Join-Path $RepoRoot "src\qwenpaw\__version__.py" +$CurrentVersion = "" +if (Test-Path $VersionFile) { + $m = (Get-Content $VersionFile -Raw) -match '__version__\s*=\s*"([^"]+)"' + if ($m) { $CurrentVersion = $Matches[1] } +} +$RunWheelBuild = $true +if ($CurrentVersion) { + $wheelGlob = Join-Path $Dist "qwenpaw-$CurrentVersion-*.whl" + $existingWheels = Get-ChildItem -Path $wheelGlob -ErrorAction SilentlyContinue + if ($existingWheels.Count -gt 0) { + Write-Host "dist/ already has wheel for version $CurrentVersion, skipping." + $RunWheelBuild = $false + } else { + # Clean up old wheels to avoid confusion + $oldWheels = Get-ChildItem -Path (Join-Path $Dist "qwenpaw-*.whl") -ErrorAction SilentlyContinue + if ($oldWheels.Count -gt 0) { + Write-Host "Removing old wheel files: $($oldWheels | ForEach-Object { $_.Name })" + $oldWheels | Remove-Item -Force + } + } +} +if ($RunWheelBuild) { + $WheelBuildScript = Join-Path $RepoRoot "scripts\wheel_build.ps1" + if (-not (Test-Path $WheelBuildScript)) { + throw "wheel_build.ps1 not found: $WheelBuildScript" + } + & $WheelBuildScript + if ($LASTEXITCODE -ne 0) { throw "wheel_build.ps1 failed with exit code $LASTEXITCODE" } +} + +Write-Host "== Building conda-packed env ==" +& python $PackDir\build_common.py --output $Archive --format zip --cache-wheels +if ($LASTEXITCODE -ne 0) { + throw "build_common.py failed with exit code $LASTEXITCODE" +} +if (-not (Test-Path $Archive)) { + throw "Archive not created: $Archive" +} + +Write-Host "== Unpacking env ==" +if (Test-Path $Unpacked) { Remove-Item -Recurse -Force $Unpacked } +Expand-Archive -Path $Archive -DestinationPath $Unpacked -Force +$unpackedRoot = Get-ChildItem -Path $Unpacked -ErrorAction SilentlyContinue | Measure-Object +Write-Host "[build_win] Unpacked entries in $Unpacked : $($unpackedRoot.Count)" + +# Resolve env root: conda-pack usually puts python.exe at archive root; allow one nested dir. +$EnvRoot = $Unpacked +if (-not (Test-Path (Join-Path $EnvRoot "python.exe"))) { + $found = Get-ChildItem -Path $Unpacked -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName "python.exe") } | + Select-Object -First 1 + if ($found) { $EnvRoot = $found.FullName; Write-Host "[build_win] Env root: $EnvRoot" } +} +if (-not (Test-Path (Join-Path $EnvRoot "python.exe"))) { + throw "python.exe not found in unpacked env (checked $Unpacked and one level down)." +} +if (-not [System.IO.Path]::IsPathRooted($EnvRoot)) { + $EnvRoot = Join-Path $RepoRoot $EnvRoot +} +Write-Host "[build_win] python.exe found at env root: $EnvRoot" + +# Rewrite prefix in packed env so paths point to current location (required after move). +$CondaUnpack = Join-Path $EnvRoot "Scripts\conda-unpack.exe" +if (Test-Path $CondaUnpack) { + Write-Host "[build_win] Running conda-unpack..." + & $CondaUnpack + if ($LASTEXITCODE -ne 0) { throw "conda-unpack failed with exit code $LASTEXITCODE" } + + # Fix conda-unpack bug: it corrupts Python string escaping on Windows + # See: issue.md and https://github.com/conda/conda-pack/issues/154 + # Solution: Reinstall affected packages using cached wheels + Write-Host "[build_win] Fixing conda-unpack corruption by reinstalling affected packages..." + $WheelsCache = Join-Path $RepoRoot ".cache\conda_unpack_wheels" + if (Test-Path $WheelsCache) { + $pythonExe = Join-Path $EnvRoot "python.exe" + + foreach ($pkg in $CondaUnpackAffectedPackages) { + Write-Host " Reinstalling $pkg..." + & $pythonExe -m pip install --force-reinstall --no-deps ` + --find-links $WheelsCache --no-index $pkg + if ($LASTEXITCODE -ne 0) { + Write-Host " WARN: Failed to reinstall $pkg (exit code: $LASTEXITCODE)" -ForegroundColor Yellow + } + } + + # Verify the fix worked + Write-Host "[build_win] Verifying fix..." + + # Create a verification script that handles SSL certificate store issues on Windows + $verifyScript = @" +import sys +import ssl +import os + +# Set SSL certificate paths before importing anything that uses SSL +try: + import certifi + cert_path = certifi.where() + os.environ['SSL_CERT_FILE'] = cert_path + os.environ['REQUESTS_CA_BUNDLE'] = cert_path + os.environ['CURL_CA_BUNDLE'] = cert_path +except ImportError: + print("WARNING: certifi not available, using system certificates") + +# Monkey-patch ssl.SSLContext.load_default_certs to handle Windows certificate store issues +# This prevents SSL errors when the Windows certificate store contains corrupted certificates +_original_load_default_certs = ssl.SSLContext.load_default_certs + +def _safe_load_default_certs(self, purpose=ssl.Purpose.SERVER_AUTH): + try: + # Try loading from Windows certificate store first + _original_load_default_certs(self, purpose) + except ssl.SSLError as e: + # If Windows store fails, fall back to certifi CA bundle + print(f"WARNING: Windows certificate store load failed ({e}), using certifi CA bundle") + try: + import certifi + self.load_verify_locations(cafile=certifi.where()) + except Exception as cert_err: + print(f"ERROR: Failed to load certifi CA bundle: {cert_err}") + raise + +ssl.SSLContext.load_default_certs = _safe_load_default_certs + +# Now verify imports +try: + from huggingface_hub import file_download + print('✓ huggingface_hub import OK') +except Exception as e: + print(f'✗ huggingface_hub import failed: {e}') + sys.exit(1) + +try: + import discord + print('✓ discord.py import OK') +except Exception as e: + print(f'✗ discord.py import failed: {e}') + sys.exit(1) +"@ + + $verifyScriptPath = Join-Path $EnvRoot "verify_imports.py" + Set-Content -Path $verifyScriptPath -Value $verifyScript -Encoding UTF8 + + $pythonExe = Join-Path $EnvRoot "python.exe" + & $pythonExe $verifyScriptPath + $verifyExitCode = $LASTEXITCODE + + # Clean up verification script + Remove-Item -Path $verifyScriptPath -Force -ErrorAction SilentlyContinue + + if ($verifyExitCode -ne 0) { + throw "CRITICAL: Package verification failed after reinstall. See output above for details." + } + Write-Host "[build_win] ✓ conda-unpack corruption fixed successfully." + } else { + Write-Host "[build_win] WARN: wheels_cache not found at $WheelsCache" -ForegroundColor Yellow + Write-Host "[build_win] WARN: Cannot fix conda-unpack corruption. App may fail to start." -ForegroundColor Yellow + } +} else { + Write-Host "[build_win] WARN: conda-unpack.exe not found at $CondaUnpack, skipping." +} + +Write-Host "== Pre-compiling Python bytecode for faster startup ==" +$pythonExe = Join-Path $EnvRoot "python.exe" +if (Test-Path $pythonExe) { + Write-Host "[build_win] Compiling all .py files to .pyc..." + $compileStart = Get-Date + + # Compile all Python files to bytecode + # -q: quiet mode (only show errors) + # -j 0: use all CPU cores for parallel compilation + & $pythonExe -m compileall -q -j 0 $EnvRoot + + if ($LASTEXITCODE -eq 0) { + $compileEnd = Get-Date + $compileTime = ($compileEnd - $compileStart).TotalSeconds + Write-Host "[build_win] ✓ Bytecode compilation completed in $($compileTime.ToString('F1')) seconds" + + # Count compiled files for reporting + $pycCount = (Get-ChildItem -Path $EnvRoot -Recurse -Filter "*.pyc" -ErrorAction SilentlyContinue | Measure-Object).Count + Write-Host "[build_win] Generated $pycCount .pyc files (these will be included in installer)" + } else { + Write-Host "[build_win] WARN: Bytecode compilation had some errors (exit code: $LASTEXITCODE)" -ForegroundColor Yellow + Write-Host "[build_win] This is usually not critical - app will compile on first run" -ForegroundColor Yellow + } +} else { + Write-Host "[build_win] WARN: python.exe not found at $pythonExe, skipping bytecode compilation" -ForegroundColor Yellow +} + +# Main launcher .bat (will be hidden by VBS) +$LauncherBat = Join-Path $EnvRoot "PineAgents.bat" +@" +@echo off +cd /d "%~dp0" + +REM Isolate packaged Python from user site-packages to prevent conflicts +set "PYTHONNOUSERSITE=1" + +REM Preserve system PATH for accessing system commands +REM Prepend packaged env to PATH so packaged Python takes precedence +set "PATH=%~dp0;%~dp0Scripts;%PATH%" + +REM Log level: env var QWENPAW_LOG_LEVEL or default to "info" +if not defined QWENPAW_LOG_LEVEL set "QWENPAW_LOG_LEVEL=info" + +REM Set SSL certificate paths for packaged environment +REM Use temp file to avoid for /f blocking issue in bat scripts +set "CERT_TMP=%TEMP%\qwenpaw_cert_%RANDOM%.txt" +"%~dp0python.exe" -u -c "import certifi; print(certifi.where())" > "%CERT_TMP%" 2>nul +set /p CERT_FILE=<"%CERT_TMP%" +del "%CERT_TMP%" 2>nul +if defined CERT_FILE ( + if exist "%CERT_FILE%" ( + set "SSL_CERT_FILE=%CERT_FILE%" + set "REQUESTS_CA_BUNDLE=%CERT_FILE%" + set "CURL_CA_BUNDLE=%CERT_FILE%" + ) +) + +if not exist "%USERPROFILE%\.qwenpaw\config.json" ( + "%~dp0python.exe" -u -m pineagents init --defaults --accept-security +) +"%~dp0python.exe" -u -m pineagents desktop --log-level %QWENPAW_LOG_LEVEL% +"@ | Set-Content -Path $LauncherBat -Encoding ASCII + +# Debug launcher .bat (shows console) +$DebugBat = Join-Path $EnvRoot "PineAgents (Debug).bat" +@" +@echo off +cd /d "%~dp0" + +REM Isolate packaged Python from user site-packages to prevent conflicts +set "PYTHONNOUSERSITE=1" + +REM Preserve system PATH for accessing system commands +REM Prepend packaged env to PATH so packaged Python takes precedence +set "PATH=%~dp0;%~dp0Scripts;%PATH%" + +REM Debug mode: use debug log level by default (can override with QWENPAW_LOG_LEVEL) +if not defined QWENPAW_LOG_LEVEL set "QWENPAW_LOG_LEVEL=debug" + +REM Set SSL certificate paths for packaged environment +REM Use temp file to avoid for /f blocking issue in bat scripts +set "CERT_TMP=%TEMP%\qwenpaw_cert_%RANDOM%.txt" +"%~dp0python.exe" -u -c "import certifi; print(certifi.where())" > "%CERT_TMP%" 2>nul +set /p CERT_FILE=<"%CERT_TMP%" +del "%CERT_TMP%" 2>nul +if defined CERT_FILE ( + if exist "%CERT_FILE%" ( + set "SSL_CERT_FILE=%CERT_FILE%" + set "REQUESTS_CA_BUNDLE=%CERT_FILE%" + set "CURL_CA_BUNDLE=%CERT_FILE%" + ) +) + +echo ==================================== +echo PineAgents - Debug Mode +echo ==================================== +echo Working Directory: %cd% +echo Python: "%~dp0python.exe" +echo PATH: %PATH% +echo PYTHONNOUSERSITE: %PYTHONNOUSERSITE% +echo Log Level: %QWENPAW_LOG_LEVEL% +echo SSL_CERT_FILE: %SSL_CERT_FILE% +echo REQUESTS_CA_BUNDLE: %REQUESTS_CA_BUNDLE% +echo CURL_CA_BUNDLE: %CURL_CA_BUNDLE% +echo. +if not exist "%USERPROFILE%\.qwenpaw\config.json" ( + echo [Init] Creating config... + "%~dp0python.exe" -u -m pineagents init --defaults --accept-security +) +echo [Launch] Starting PineAgents with log-level=%QWENPAW_LOG_LEVEL%... +echo Press Ctrl+C to stop +echo. +"%~dp0python.exe" -u -m pineagents desktop --log-level %QWENPAW_LOG_LEVEL% +echo. +echo [Exit] PineAgents closed +pause +"@ | Set-Content -Path $DebugBat -Encoding ASCII + +# VBScript launcher (no console window) +$LauncherVbs = Join-Path $EnvRoot "PineAgents.vbs" +@" +Set WshShell = CreateObject("WScript.Shell") +batPath = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName) & "\PineAgents.bat" +WshShell.Run Chr(34) & batPath & Chr(34), 0, False +Set WshShell = Nothing +"@ | Set-Content -Path $LauncherVbs -Encoding ASCII + +# Create qwenpaw.cmd wrapper in env root so "qwenpaw" resolves to this +# instead of Scripts\qwenpaw.exe whose embedded Python path may be stale +# after conda-pack/unpack. +$QwenpawCmd = Join-Path $EnvRoot "qwenpaw.cmd" +@" +@"%~dp0python.exe" -u -m pineagents %* +"@ | Set-Content -Path $QwenpawCmd -Encoding ASCII + +# Copy icon.ico to env root so NSIS can find it +$IconSrc = Join-Path $PackDir "assets\icon.ico" +if (Test-Path $IconSrc) { + Copy-Item $IconSrc -Destination $EnvRoot -Force + Write-Host "[build_win] Copied icon.ico to env root" +} else { + Write-Host "[build_win] WARN: icon.ico not found at $IconSrc" +} + +Write-Host "== Building NSIS installer ==" + +# Debug: Print EnvRoot directory contents +Write-Host "=== EnvRoot=$EnvRoot ===" +Write-Host "=== EnvRoot top files ===" +Get-ChildItem -LiteralPath $EnvRoot -Force | Select-Object -First 50 | ForEach-Object { Write-Host $_.FullName } + +# Prioritize version from __version__.py to ensure accuracy +$Version = $CurrentVersion +if (-not $Version) { + # Fallback: try to get version from packed env metadata + try { + $Version = (& (Join-Path $EnvRoot "python.exe") -c "from importlib.metadata import version; print(version('qwenpaw'))" 2>&1) -replace '\s+$', '' + Write-Host "[build_win] Using version from packed env metadata: $Version" + } catch { + Write-Host "[build_win] version from packed env failed: $_" + } +} +if (-not $Version) { $Version = "0.0.0"; Write-Host "[build_win] WARN: Using fallback version 0.0.0" } +Write-Host "[build_win] Version determined: $Version" +Write-Host "[build_win] QWENPAW_VERSION=$Version OUTPUT_EXE will be under $Dist" +$OutInstaller = Join-Path (Join-Path $RepoRoot $Dist) "PineAgents-Setup-$Version.exe" +# Pass absolute paths to NSIS (keep backslashes). +$UnpackedFull = (Resolve-Path $EnvRoot).Path +$OutputExeNsi = [System.IO.Path]::GetFullPath($OutInstaller) +$nsiArgs = @( + "/DQWENPAW_VERSION=$Version", + "/DOUTPUT_EXE=$OutputExeNsi", + "/DUNPACKED=$UnpackedFull", + $NsiPath +) + +# Debug: Check if makensis is available +Write-Host "=== Checking makensis availability ===" +try { + $makensisPath = (Get-Command makensis -ErrorAction Stop).Source + Write-Host "[build_win] makensis found at: $makensisPath" +} catch { + throw "makensis not found in PATH. Please install NSIS and ensure makensis.exe is in PATH." +} + +Write-Host "[build_win] Running: makensis $($nsiArgs -join ' ')" +Write-Host "=== NSIS will compile from: $NsiPath ===" +Write-Host "=== NSIS unpacked source: $UnpackedFull ===" +Write-Host "=== NSIS output installer: $OutputExeNsi ===" +$nsisOutput = & makensis @nsiArgs 2>&1 | Out-String +Write-Host "=== NSIS Output Begin ===" +Write-Host $nsisOutput +Write-Host "=== NSIS Output End ===" +$makensisExit = $LASTEXITCODE +Write-Host "[build_win] makensis exit code: $makensisExit" +if ($makensisExit -ne 0) { + Write-Host "ERROR: makensis compilation failed!" + Write-Host "Check the NSIS output above for specific errors." + throw "makensis failed with exit code $makensisExit" +} +if (-not (Test-Path $OutInstaller)) { + throw "NSIS did not create installer: $OutInstaller" +} +Write-Host "== Built $OutInstaller ==" diff --git a/scripts/pack/desktop.nsi b/scripts/pack/desktop.nsi new file mode 100644 index 0000000..65c5643 --- /dev/null +++ b/scripts/pack/desktop.nsi @@ -0,0 +1,56 @@ +; PineAgents NSIS installer. Run makensis from repo root after +; building dist/win-unpacked (see scripts/pack/build_win.ps1). +; Usage: makensis /DQWENPAW_VERSION=1.2.3 /DOUTPUT_EXE=dist\PineAgents-Setup-1.2.3.exe scripts\pack\desktop.nsi + +!include "MUI2.nsh" +!define MUI_ABORTWARNING +; Use custom icon from unpacked env (copied by build_win.ps1) +!define MUI_ICON "${UNPACKED}\icon.ico" +!define MUI_UNICON "${UNPACKED}\icon.ico" + +!ifndef QWENPAW_VERSION + !define QWENPAW_VERSION "0.0.0" +!endif +!ifndef OUTPUT_EXE + !define OUTPUT_EXE "dist\PineAgents-Setup-${QWENPAW_VERSION}.exe" +!endif + +Name "PineAgents" +OutFile "${OUTPUT_EXE}" +InstallDir "$LOCALAPPDATA\PineAgents" +InstallDirRegKey HKCU "Software\PineAgents" "InstallPath" +RequestExecutionLevel user + +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "SimpChinese" + +; Pass /DUNPACKED=full_path from build_win.ps1 so path works when cwd != repo root +!ifndef UNPACKED + !define UNPACKED "dist\win-unpacked" +!endif + +Section "PineAgents" SEC01 + SetOutPath "$INSTDIR" + File /r "${UNPACKED}\*.*" + WriteRegStr HKCU "Software\PineAgents" "InstallPath" "$INSTDIR" + WriteUninstaller "$INSTDIR\Uninstall.exe" + + ; Main shortcut - uses VBS to hide console window + CreateShortcut "$SMPROGRAMS\PineAgents.lnk" "$INSTDIR\PineAgents.vbs" "" "$INSTDIR\icon.ico" 0 + CreateShortcut "$DESKTOP\PineAgents.lnk" "$INSTDIR\PineAgents.vbs" "" "$INSTDIR\icon.ico" 0 + + ; Debug shortcut - shows console window for troubleshooting + CreateShortcut "$SMPROGRAMS\PineAgents (Debug).lnk" "$INSTDIR\PineAgents (Debug).bat" "" "$INSTDIR\icon.ico" 0 +SectionEnd + +Section "Uninstall" + Delete "$SMPROGRAMS\PineAgents.lnk" + Delete "$SMPROGRAMS\PineAgents (Debug).lnk" + Delete "$DESKTOP\PineAgents.lnk" + RMDir /r "$INSTDIR" + DeleteRegKey HKCU "Software\PineAgents" +SectionEnd diff --git a/scripts/pack/generate_oss_metadata.py b/scripts/pack/generate_oss_metadata.py new file mode 100644 index 0000000..31cab29 --- /dev/null +++ b/scripts/pack/generate_oss_metadata.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Generate OSS metadata JSON files for release artifacts. + +Usage: + python generate_oss_metadata.py \ + --file dist/QwenPaw-Setup-1.0.0.exe \ + --product desktop \ + --platform win \ + --version 1.0.0 \ + --output metadata.json +""" + +import argparse +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def calculate_sha256(filepath: str) -> str: + """Calculate SHA256 hash of a file.""" + sha256_hash = hashlib.sha256() + with open(filepath, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + +def format_file_size(size_bytes: int) -> str: + """Format file size to human-readable format.""" + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + +def get_file_type(filename: str) -> str: + """Extract file type from filename extension.""" + ext = Path(filename).suffix.lower() + return ext[1:] if ext else "unknown" + + +def generate_metadata( + filepath: str, + product: str, + platform: str, + version: str, +) -> dict: + """Generate metadata for a single artifact file.""" + file_path = Path(filepath) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + + filename = file_path.name + file_size = file_path.stat().st_size + sha256 = calculate_sha256(filepath) + file_type = get_file_type(filename) + + file_id = f"{product}-{platform}-{version}" + + platform_names = { + "win-tauri": { + "zh-CN": "Windows Tauri", + "en-US": "for Windows (Tauri)", + }, + "mac-tauri": { + "zh-CN": "macOS Tauri", + "en-US": "for macOS (Tauri)", + }, + "win": {"zh-CN": "Windows 版", "en-US": "for Windows"}, + "mac": {"zh-CN": "macOS 版", "en-US": "for macOS"}, + "linux": {"zh-CN": "Linux 版", "en-US": "for Linux"}, + } + + product_names = { + "desktop": {"zh-CN": "桌面客户端", "en-US": "Desktop Client"}, + "cli": {"zh-CN": "命令行工具", "en-US": "CLI Tool"}, + } + + platform_suffix = platform_names.get( + platform, {"zh-CN": platform, "en-US": platform} + ) + product_name = product_names.get( + product, {"zh-CN": product, "en-US": product} + ) + + oss_path = f"/files/apps/{product}/{platform}/{filename}" + + metadata = { + "id": file_id, + "name": { + "zh-CN": f"{product_name['zh-CN']} {platform_suffix['zh-CN']}", + "en-US": f"{product_name['en-US']} {platform_suffix['en-US']}", + }, + "description": { + "zh-CN": f"适用于 {platform_suffix['zh-CN']}的{product_name['zh-CN']}安装包", + "en-US": f"{product_name['en-US']} installer {platform_suffix['en-US']}", + }, + "product": product, + "platform": platform, + "version": version, + "filename": filename, + "url": oss_path, + "size": format_file_size(file_size), + "size_bytes": file_size, + "sha256": sha256, + "updated_at": datetime.now(timezone.utc).isoformat(), + "type": file_type, + } + + return metadata + + +def merge_desktop_index( + existing_index_path: str, + new_metadata: dict, + platform: str, +) -> dict: + """Merge new metadata into desktop/index.json.""" + if os.path.exists(existing_index_path): + with open(existing_index_path, "r", encoding="utf-8") as f: + index = json.load(f) + else: + index = { + "product": "desktop", + "updated_at": datetime.now(timezone.utc).isoformat(), + "platforms": {}, + "files": {}, + } + + if "platforms" not in index: + index["platforms"] = {} + if "files" not in index: + index["files"] = {} + if "product" not in index: + index["product"] = "desktop" + + index["updated_at"] = datetime.now(timezone.utc).isoformat() + + if platform not in index["platforms"]: + index["platforms"][platform] = {"latest": "", "versions": []} + + file_id = new_metadata["id"] + index["platforms"][platform]["latest"] = file_id + + if file_id not in index["platforms"][platform]["versions"]: + index["platforms"][platform]["versions"].insert(0, file_id) + + index["files"][file_id] = new_metadata + + return index + + +def main(): + parser = argparse.ArgumentParser( + description="Generate OSS metadata for release artifacts" + ) + parser.add_argument( + "--file", required=True, help="Path to the artifact file" + ) + parser.add_argument( + "--product", required=True, help="Product name (e.g., desktop, cli)" + ) + parser.add_argument( + "--platform", + required=True, + help="Platform name (e.g., win, mac, linux)", + ) + parser.add_argument( + "--version", required=True, help="Version string (e.g., 1.0.0)" + ) + parser.add_argument( + "--output", + default="metadata.json", + help="Output metadata JSON file path", + ) + parser.add_argument( + "--merge-index", + help="Path to existing desktop/index.json to merge into", + ) + parser.add_argument( + "--output-index", + help="Output path for merged desktop/index.json", + ) + + args = parser.parse_args() + + print(f"Generating metadata for: {args.file}") + metadata = generate_metadata( + args.file, args.product, args.platform, args.version + ) + + with open(args.output, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + print(f"✓ Metadata written to: {args.output}") + print(f" ID: {metadata['id']}") + print(f" Size: {metadata['size']}") + print(f" SHA256: {metadata['sha256'][:16]}...") + + # Merge into desktop/index.json if requested + if args.merge_index and args.output_index: + print(f"\nMerging into desktop index: {args.merge_index}") + merged_index = merge_desktop_index( + args.merge_index, metadata, args.platform + ) + with open(args.output_index, "w", encoding="utf-8") as f: + json.dump(merged_index, f, indent=2, ensure_ascii=False) + print(f"✓ Desktop index written to: {args.output_index}") + print( + f" Latest {args.platform}: {merged_index['platforms'][args.platform]['latest']}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/pack/generate_plugin_metadata.py b/scripts/pack/generate_plugin_metadata.py new file mode 100644 index 0000000..c4d7116 --- /dev/null +++ b/scripts/pack/generate_plugin_metadata.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Scan local plugins, build distributable zips, and emit OSS metadata. + +Mirrors the shape of ``generate_oss_metadata.py`` so the resulting +``metadata/plugins/index.json`` is a drop-in product entry for the existing +Downloads page (it iterates ``mainIndex.products`` regardless of product type). + +Layout produced under ``--dist``:: + + dist/plugins/ + bundle//-.zip + tool//-.zip + index.json + +Each plugin is stored under its own directory on the CDN +(``/files/plugins/{kind}/{plugin_id}/…``), not flat under ``{kind}/``. + +Each plugin source tree is full-rebuild zipped: this means deletions in the +repo propagate through to OSS on the next run (no stale entries left behind). +Rebuilding the same version overwrites the zip at a stable URL; bump the +plugin version in ``plugin.json`` when you need a distinct release artifact. + +A plugin can opt out of publishing by setting ``"publish": false`` in its +``plugin.json``. + +Pass ``--only `` (repeatable) to pack a subset of plugins, e.g. +for a standalone release of a single plugin driven by its own version bump. +Conversely, ``--exclude `` (repeatable) skips plugins that are +released through their own dedicated pipeline. +""" + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import shutil +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +KIND_DIRS = ("bundle", "tool", "apps") + +EXCLUDE_PATTERNS = ( + "__pycache__", + "*.pyc", + "*.pyo", + ".DS_Store", + ".git", + ".gitignore", + "node_modules", + ".venv", + "venv", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "*.log", +) + + +def _is_excluded(name: str) -> bool: + return any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_PATTERNS) + + +def _format_size(size_bytes: int) -> str: + size = float(size_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + + +def _sha256_of_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _iter_tree_relpaths(plugin_dir: Path) -> list[str]: + rels: list[str] = [] + for root, dirs, files in os.walk(plugin_dir): + dirs[:] = [d for d in dirs if not _is_excluded(d)] + for fname in files: + if _is_excluded(fname): + continue + rels.append(str((Path(root) / fname).relative_to(plugin_dir))) + rels.sort() + return rels + + +def _zip_plugin(plugin_dir: Path, out_zip: Path) -> None: + out_zip.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zf: + for rel in _iter_tree_relpaths(plugin_dir): + zf.write(plugin_dir / rel, f"{plugin_dir.name}/{rel}") + + +def _read_manifest(plugin_dir: Path) -> dict[str, Any] | None: + manifest_path = plugin_dir / "plugin.json" + if not manifest_path.is_file(): + return None + try: + with manifest_path.open("r", encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print( + f"WARNING: skipping {plugin_dir} - cannot read plugin.json: {exc}", + file=sys.stderr, + ) + return None + + +def _localized_field(value: Any) -> dict[str, str]: + """Normalize manifest name/description to ``{zh-CN, en-US}``. + + Accepts a plain string (duplicated to both locales) or a mapping with + ``zh-CN`` / ``en-US`` (also accepts ``zh`` / ``en`` aliases). + """ + if isinstance(value, dict): + zh = str(value.get("zh-CN") or value.get("zh") or "").strip() + en = str(value.get("en-US") or value.get("en") or "").strip() + fallback = zh or en + return { + "zh-CN": zh or en or fallback, + "en-US": en or zh or fallback, + } + text = str(value or "").strip() + return {"zh-CN": text, "en-US": text} + + +def _localized_description(manifest: dict[str, Any]) -> dict[str, str]: + """CDN metadata description. + Prefer ``description_i18n``, else ``description``. + """ + i18n = manifest.get("description_i18n") + if i18n is not None: + return _localized_field(i18n) + return _localized_field(manifest.get("description") or "") + + +def _normalize_ver(raw: str) -> str: + """Strip leading 'v' and surrounding whitespace from a version string.""" + s = raw.strip() + if s.lower().startswith("v"): + s = s[1:] + return s + + +def get_version(manifest: dict[str, Any]) -> dict[str, str] | None: + """Return a normalized ``qwenpaw_version`` for CDN metadata. + + Strategy: + 1. If the manifest already provides the structured + ``qwenpaw_version`` field, return it directly — the plugin + explicitly declares its compatibility. + 2. For legacy plugins that only declare ``min_version`` / + ``max_version``, synthesize a proper ``qwenpaw_version`` dict + with ``min`` and/or ``max`` keys so downstream consumers + (e.g. ``_is_entry_compatible``) always see a consistent + structure. + + Version strings are sanitized (leading 'v' and whitespace removed). + Returns ``None`` when no version constraint is declared. + """ + # --- Case 1: structured field available, use directly --- + qwenpaw_version = manifest.get("qwenpaw_version") + if isinstance(qwenpaw_version, dict): + return { + k: _normalize_ver(str(v)) + for k, v in qwenpaw_version.items() + if k in ("min", "max") + } + + # --- Case 2: legacy min/max, synthesize structured dict --- + min_ver_str = _normalize_ver(str(manifest.get("min_version") or "")) + max_ver_str = _normalize_ver(str(manifest.get("max_version") or "")) + if not min_ver_str and not max_ver_str: + return None + + result: dict[str, str] = {} + if min_ver_str: + result["min"] = min_ver_str + if max_ver_str: + result["max"] = max_ver_str + return result + + +def _build_metadata( + manifest: dict[str, Any], + *, + file_id: str, + plugin_id: str, + version: str, + kind: str, + zip_path: Path, + cdn_path: str, +) -> dict[str, Any]: + size_bytes = zip_path.stat().st_size + metadata: dict[str, Any] = { + "id": file_id, + "plugin_id": plugin_id, + "name": _localized_field(manifest.get("name") or plugin_id), + "description": _localized_description(manifest), + "product": "plugins", + "platform": kind, + "version": version, + "author": str(manifest.get("author") or ""), + "filename": zip_path.name, + "url": cdn_path, + "size": _format_size(size_bytes), + "size_bytes": size_bytes, + "sha256": _sha256_of_file(zip_path), + "updated_at": datetime.now(timezone.utc).isoformat(), + "type": "zip", + } + + version_constraint = get_version(manifest) + if version_constraint: + metadata["qwenpaw_version"] = version_constraint + + return metadata + + +def _matches_only( + only: list[str] | None, + plugin_id: str, + dir_name: str, +) -> bool: + """True when *only* is unset or matches the plugin id / directory name.""" + if not only: + return True + return plugin_id in only or dir_name in only + + +def _selected( + only: list[str] | None, + exclude: list[str] | None, + plugin_id: str, + dir_name: str, +) -> bool: + """Apply ``--only`` / ``--exclude`` selection to a plugin.""" + if exclude and (plugin_id in exclude or dir_name in exclude): + return False + return _matches_only(only, plugin_id, dir_name) + + +def discover_and_pack( + plugins_root: Path, + dist_root: Path, + cdn_prefix: str, + only: list[str] | None = None, + exclude: list[str] | None = None, +) -> dict[str, Any]: + """Scan, zip, and assemble the plugins index. Always full-rebuild.""" + index: dict[str, Any] = { + "product": "plugins", + "updated_at": datetime.now(timezone.utc).isoformat(), + "platforms": {}, + "files": {}, + } + + if not plugins_root.is_dir(): + print( + f"WARNING: plugins root does not exist: {plugins_root}", + file=sys.stderr, + ) + return index + + for kind in KIND_DIRS: + kind_dir = plugins_root / kind + if not kind_dir.is_dir(): + continue + for plugin_dir in sorted(p for p in kind_dir.iterdir() if p.is_dir()): + manifest = _read_manifest(plugin_dir) + if manifest is None: + continue + if manifest.get("publish") is False: + print(f" - skip {plugin_dir.name} (publish=false)") + continue + + plugin_id = str(manifest.get("id") or plugin_dir.name) + if not _selected(only, exclude, plugin_id, plugin_dir.name): + continue + version = str(manifest.get("version") or "0.0.0") + zip_name = f"{plugin_id}-{version}.zip" + zip_path = dist_root / kind / plugin_id / zip_name + + print( + f" + pack {kind}/{plugin_dir.name} -> " + f"{plugin_id}/{zip_path.name}", + ) + _zip_plugin(plugin_dir, zip_path) + + cdn_path = ( + f"{cdn_prefix.rstrip('/')}/{kind}/{plugin_id}/{zip_name}" + ) + file_id = f"{plugin_id}-{version}" + metadata = _build_metadata( + manifest, + file_id=file_id, + plugin_id=plugin_id, + version=version, + kind=kind, + zip_path=zip_path, + cdn_path=cdn_path, + ) + index["files"][file_id] = metadata + kind_entry = index["platforms"].setdefault(kind, {"versions": []}) + if file_id not in kind_entry["versions"]: + kind_entry["versions"].insert(0, file_id) + + return index + + +def _dry_run_scan( + plugins_root: Path, + only: list[str] | None, + exclude: list[str] | None, +) -> None: + """List what would be packed without writing anything.""" + for kind in KIND_DIRS: + kind_dir = plugins_root / kind + if not kind_dir.is_dir(): + continue + for plugin_dir in sorted( + p for p in kind_dir.iterdir() if p.is_dir() + ): + manifest = _read_manifest(plugin_dir) + if manifest is None: + continue + if manifest.get("publish") is False: + print(f" - skip {plugin_dir.name} (publish=false)") + continue + plugin_id = str(manifest.get("id") or plugin_dir.name) + if not _selected(only, exclude, plugin_id, plugin_dir.name): + continue + print( + f" ~ would pack {kind}/{plugin_dir.name} " + f"(id={manifest.get('id')}, " + f"version={manifest.get('version')})" + ) + print("Dry run complete.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--plugins-root", + default="plugins", + help="Path to the plugins/ directory (default: plugins)", + ) + parser.add_argument( + "--dist", + default="dist/plugins", + help="Output directory for zip artifacts (default: dist/plugins)", + ) + parser.add_argument( + "--metadata-out", + default=None, + help=( + "Where to write the assembled plugins index JSON. " + "Defaults to /index.json." + ), + ) + parser.add_argument( + "--cdn-prefix", + default="/files/plugins", + help="OSS path prefix used in metadata URLs (default: /files/plugins)", + ) + parser.add_argument( + "--only", + action="append", + default=None, + metavar="PLUGIN_ID", + help=( + "Pack only the plugin(s) whose manifest id or directory name " + "matches. Repeatable. Default: pack all plugins." + ), + ) + parser.add_argument( + "--exclude", + action="append", + default=None, + metavar="PLUGIN_ID", + help=( + "Skip the plugin(s) whose manifest id or directory name " + "matches (e.g. plugins with a dedicated release pipeline). " + "Repeatable." + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Scan + plan only; do not write zips or index.", + ) + args = parser.parse_args() + + plugins_root = Path(args.plugins_root).resolve() + dist_root = Path(args.dist).resolve() + metadata_out = Path( + args.metadata_out + if args.metadata_out is not None + else dist_root / "index.json" + ).resolve() + + print(f"Scanning plugins under: {plugins_root}") + if args.dry_run: + _dry_run_scan(plugins_root, args.only, args.exclude) + return 0 + + if dist_root.exists(): + # Wipe stale zips/dirs so deletions propagate cleanly. + for kind in KIND_DIRS: + kind_root = dist_root / kind + if not kind_root.is_dir(): + continue + for child in kind_root.iterdir(): + if child.is_file(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child) + dist_root.mkdir(parents=True, exist_ok=True) + + index = discover_and_pack( + plugins_root, + dist_root, + args.cdn_prefix, + only=args.only, + exclude=args.exclude, + ) + + metadata_out.parent.mkdir(parents=True, exist_ok=True) + with metadata_out.open("w", encoding="utf-8") as f: + json.dump(index, f, indent=2, ensure_ascii=False) + + print() + print(f"Wrote index: {metadata_out}") + n_files = len(index["files"]) + n_kinds = len(index["platforms"]) + print(f" {n_files} plugin(s) across {n_kinds} kind(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pack/merge_plugin_index.py b/scripts/pack/merge_plugin_index.py new file mode 100644 index 0000000..9e1ccb4 --- /dev/null +++ b/scripts/pack/merge_plugin_index.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Merge a newly built plugin index with a historical one from OSS. + +Used by the ``plugins-release.yml`` workflow to preserve old plugin +versions on the CDN while adding new ones. + +Merge rules: +- ``files``: keyed by file_id (``{plugin_id}-{version}``). New entries + overwrite same-id old entries; different ids from old are preserved. +- ``platforms.{kind}.versions``: union of new and old version lists, + new versions first, old versions appended with deduplication. + +Usage:: + + python scripts/pack/merge_plugin_index.py \ + --new dist/plugins/index.json \ + --old existing-index.json \ + --out dist/plugins/index.json +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def merge_indexes(new_index: dict, old_index: dict) -> dict: + """Merge *old_index* into *new_index* (mutates and returns *new_index*).""" + # files: same file_id is overwritten by new; different ids preserved. + old_files = old_index.get("files", {}) + new_files = new_index.get("files", {}) + new_index["files"] = {**old_files, **new_files} + + # platforms.versions: union, new first, old appended with dedup. + old_platforms = old_index.get("platforms", {}) + all_kinds = set( + list(new_index.get("platforms", {}).keys()) + + list(old_platforms.keys()), + ) + for kind in all_kinds: + old_versions = old_platforms.get(kind, {}).get("versions", []) + new_versions = ( + new_index.get("platforms", {}).get(kind, {}).get("versions", []) + ) + seen = set(new_versions) + merged = list(new_versions) + for v in old_versions: + if v not in seen: + merged.append(v) + seen.add(v) + new_index.setdefault("platforms", {}) + new_index["platforms"].setdefault(kind, {}) + new_index["platforms"][kind]["versions"] = merged + + return new_index + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Merge new and historical plugin indexes.", + ) + parser.add_argument( + "--new", + required=True, + type=Path, + help="Path to the newly generated index.json", + ) + parser.add_argument( + "--old", + required=True, + type=Path, + help="Path to the existing (historical) index.json", + ) + parser.add_argument( + "--out", + required=True, + type=Path, + help="Output path for the merged index.json", + ) + args = parser.parse_args(argv) + + with open(args.new, encoding="utf-8") as f: + new_index = json.load(f) + with open(args.old, encoding="utf-8") as f: + old_index = json.load(f) + + merged = merge_indexes(new_index, old_index) + + with open(args.out, "w", encoding="utf-8") as f: + json.dump(merged, f, indent=2, ensure_ascii=False) + + +if __name__ == "__main__": + main() diff --git a/scripts/pack/patch_main_index.py b/scripts/pack/patch_main_index.py new file mode 100644 index 0000000..754d34b --- /dev/null +++ b/scripts/pack/patch_main_index.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Patch the main OSS metadata index to advertise the plugins product. + +Ensures the top-level ``metadata/index.json`` has a ``products.plugins`` +entry pointing to the plugins sub-index. + +Usage:: + + python scripts/pack/patch_main_index.py \ + --index main-index.json \ + --out main-index.json +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + + +def patch_index(index: dict) -> dict: + """Add/update the ``plugins`` product entry (mutates *index*).""" + index.setdefault("products", {}) + index["products"]["plugins"] = { + "name": {"zh-CN": "插件", "en-US": "Plugins"}, + "index_url": "/metadata/plugins/index.json", + } + index["updated_at"] = datetime.now(timezone.utc).isoformat() + return index + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Patch main metadata index with plugins product entry.", + ) + parser.add_argument( + "--index", + required=True, + type=Path, + help="Path to the main index.json", + ) + parser.add_argument( + "--out", + required=True, + type=Path, + help="Output path for the patched index.json", + ) + args = parser.parse_args(argv) + + with open(args.index, encoding="utf-8") as f: + index = json.load(f) + + patched = patch_index(index) + + with open(args.out, "w", encoding="utf-8") as f: + json.dump(patched, f, indent=2, ensure_ascii=False) + + +if __name__ == "__main__": + main() diff --git a/scripts/review-bot/prompts.py b/scripts/review-bot/prompts.py new file mode 100644 index 0000000..26d48c4 --- /dev/null +++ b/scripts/review-bot/prompts.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Review prompt templates for QwenPaw AI Review Bot. + +The review methodology, coding standards, and anti-pattern checklist +live in the workspace persona files (SOUL.md, AGENTS.md) written by +setup_review_workspace.py. This module only builds the *task* prompt +that tells the agent which PR to review and what output format to use. +""" + + +def build_review_prompt(pr_number: int, repo: str) -> str: + """Build a task-oriented review prompt. + + Instead of embedding the full diff in the prompt, we tell QwenPaw + to fetch the PR data itself using ``gh`` CLI commands. + + Args: + pr_number: The pull request number to review. + repo: The full repository name (owner/repo). + """ + return f"""\ +Please perform a thorough yet precise code review for \ +**PR #{pr_number}** in the **{repo}** repository. + +## Step 1: Fetch PR Information + +Use the following commands to retrieve PR data: + +1. Fetch PR metadata: + `gh pr view {pr_number} --repo {repo} --json \ +number,title,body,author,baseRefName,headRefName,\ +additions,deletions,files` + +2. Fetch the full diff: + `gh pr diff {pr_number} --repo {repo}` + +## Step 2: Analyze and Review + +Follow the review methodology in AGENTS.md to perform a \ +dimension-based analysis of the diff. + +## Step 3: Output the Review Report + +Please strictly follow this structure: + +### 1. Overview + +| Item | Details | +|------|---------| +| PR Number | (from gh) | +| Author | @username format, e.g. @lalaliat | +| Changes | (from gh) | +| Merge Target | (from gh) | +| Related Issue | (extract from PR body, if any) | + +### 2. Background + +Describe the problem this PR solves and the motivation. + +### 3. Core Changes + +Summarize what this PR does (in list form). + +### 4. Strengths + +List what was done well, with specific file and code details. + +### 5. Issues and Suggestions + +Output by severity: + +#### High +#### Medium +#### Low + +Each issue should include: +- **Code reference**: Show the problematic code snippet +- **Explanation**: Why this is an issue + +If no issues at a given level, write "None". + +### 6. Summary + +- One-sentence qualitative assessment +- N items that must be addressed before merge (if any) +- Items that can be followed up later + +Finally, output a JSON code block with the conclusion \ +(include issue counts per severity): + +```json +{{ + "verdict": "APPROVE or REQUEST_CHANGES", + "high_count": 0, + "medium_count": 0, + "low_count": 0, + "summary": "One-sentence summary of the review conclusion" +}} +``` + +## Key Principles + +- **Focus on changes**: Only review code in the diff +- **Distinguish blockers from suggestions**: Be clear about \ +what must change vs. what can be improved later +- **Provide concrete fixes**: Include improvement code examples \ +for each issue +- **Acknowledge strengths**: Explicitly praise good design decisions +- **Do not assume**: Use "consider verifying" for uncertain cases +""" diff --git a/scripts/review-bot/review_runner.py b/scripts/review-bot/review_runner.py new file mode 100644 index 0000000..8e74cbd --- /dev/null +++ b/scripts/review-bot/review_runner.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""QwenPaw AI Review Bot - Main runner script. + +This script runs inside GitHub Actions to: +1. Read PR number and repo from environment variables +2. Send a task prompt to the local QwenPaw instance +3. QwenPaw autonomously fetches PR data via `gh` CLI +4. Parse the response and output verdict + review text +""" +import json +import os +import re +import sys +import time + +import httpx + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# pylint: disable=wrong-import-position +from prompts import build_review_prompt # noqa: E402 +from pineagents.agents.tools.agent_management import ( # noqa: E402 + extract_agent_text_content, + parse_agent_sse_line, +) + +# pylint: enable=wrong-import-position + +QWENPAW_URL = "http://localhost:8088" +CHAT_ENDPOINT = f"{QWENPAW_URL}/api/console/chat" +MAX_RETRIES = 3 +TIMEOUT_SECONDS = 300 + + +def _extract_stream_text(evt: dict) -> str: + """Extract text from a single SSE payload (streaming or final).""" + text = extract_agent_text_content(evt) + if text: + return text + + content = evt.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(item.get("text", "")) + elif isinstance(item, dict) and "text" in item: + parts.append(str(item["text"])) + return "".join(parts) + + fallback = evt.get("text") + return fallback if isinstance(fallback, str) else "" + + +def call_qwenpaw(prompt: str, session_id: str) -> str: + """Send prompt to QwenPaw console chat API and collect SSE response.""" + payload = { + "channel": "console", + "user_id": "review-bot", + "session_id": session_id, + "input": [{"content": [{"type": "text", "text": prompt}]}], + } + + for attempt in range(1, MAX_RETRIES + 1): + try: + print(f"[attempt {attempt}/{MAX_RETRIES}] Calling QwenPaw...") + final_event = None + stream_errors = [] + + with httpx.Client(timeout=TIMEOUT_SECONDS) as client: + with client.stream( + "POST", + CHAT_ENDPOINT, + json=payload, + ) as resp: + if resp.status_code != 200: + print(f" HTTP {resp.status_code}, retrying...") + time.sleep(5) + continue + + for line in resp.iter_lines(): + if not line or not line.startswith("data: "): + continue + if line[6:] == "[DONE]": + break + + parsed = parse_agent_sse_line(line) + if not parsed: + continue + if parsed.get("error"): + stream_errors.append(str(parsed["error"])) + if parsed.get("type") == "turn_usage": + continue + final_event = parsed + + if stream_errors: + print(f" Stream errors: {'; '.join(stream_errors)}") + + response = _extract_stream_text(final_event or {}) + if response.strip(): + return response + + print(" Empty response, retrying...") + time.sleep(5) + + except (httpx.TimeoutException, httpx.ConnectError) as e: + print(f" Error: {e}, retrying...") + time.sleep(5) + + return "" + + +def validate_response(response: str, pr_number: int) -> list[str]: + """Check that the response contains signs of real PR data. + + Returns a list of warning messages (empty = all checks passed). + """ + warnings = [] + if f"#{pr_number}" not in response and str(pr_number) not in response: + warnings.append( + f"Response does not mention PR #{pr_number} — " + f"agent may not have fetched PR data", + ) + structure_markers = ["### 1.", "### 2.", "### 3."] + missing = [m for m in structure_markers if m not in response] + if missing: + warnings.append( + f"Missing expected sections: {', '.join(missing)}", + ) + return warnings + + +def parse_verdict(response: str) -> dict: + """Extract verdict and issue counts from the Summary section. + + Scopes the search to ``### 6. Summary`` to avoid matching + unrelated JSON code blocks elsewhere in the review. + """ + default = { + "verdict": "REQUEST_CHANGES", + "high_count": -1, + "medium_count": -1, + "low_count": -1, + } + summary_match = re.search(r"###\s*6[.\s]", response) + search_text = ( + response[summary_match.start() :] if summary_match else response + ) + + match = re.search( + r"```json\s*(\{[\s\S]*?\})\s*```", + search_text, + ) + if not match: + return default + try: + result = json.loads(match.group(1)) + except json.JSONDecodeError: + return default + + verdict = result.get("verdict", "REQUEST_CHANGES") + if verdict not in ("APPROVE", "REQUEST_CHANGES"): + verdict = "REQUEST_CHANGES" + + return { + "verdict": verdict, + "high_count": int(result.get("high_count", -1)), + "medium_count": int(result.get("medium_count", -1)), + "low_count": int(result.get("low_count", -1)), + } + + +def _strip_summary_verdict_json(text: str) -> str: + """Strip the verdict JSON block from the '### 6. Summary' section only. + + Matches a ```json ... ``` block that contains a "verdict" key + and appears after the '### 6' heading. Other JSON blocks + elsewhere in the review (e.g. code examples) are preserved. + """ + summary_match = re.search(r"(###\s*6[.\s])", text) + if not summary_match: + return text + + before = text[: summary_match.start()] + summary_section = text[summary_match.start() :] + + cleaned = re.sub( + r"\n*```json\s*\{[\s\S]*?\"verdict\"[\s\S]*?\}\s*```\n*", + "\n", + summary_section, + ) + return (before + cleaned).rstrip() + + +_FENCE_RE = re.compile(r"^(`{3,})(.*)") + + +def _scan_fence_block( + lines: list[str], + start: int, + tick_len: int, +) -> tuple[list[str], int]: + """Find the matching closer for a code fence. + + Tracks open/close depth so that LLM-produced + pseudo-nested fences are handled correctly. + + Returns ``(body_lines, close_index)``. + ``close_index`` is ``-1`` if no closer is found. + """ + depth = 1 + body: list[str] = [] + for j in range(start, len(lines)): + fm = re.match(rf"^`{{{tick_len},}}", lines[j]) + if fm: + rest = lines[j][len(fm.group(0)) :].strip() + if rest: + depth += 1 + else: + depth -= 1 + if depth == 0: + return body, j + body.append(lines[j]) + return body, -1 + + +def _fix_nested_code_fences(text: str) -> str: + """Bump outer fence width when content has inner fences. + + LLMs often produce pseudo-nested fences where inner + ````` ``` ````` markers break the outer block. This + function uses depth tracking to find the intended + closer, then increases the outer fence length so + inner fences become harmless content. + """ + lines = text.split("\n") + out: list[str] = [] + i = 0 + while i < len(lines): + m = _FENCE_RE.match(lines[i]) + if not m: + out.append(lines[i]) + i += 1 + continue + + info = m.group(2).strip() + n = len(m.group(1)) + body, close = _scan_fence_block(lines, i + 1, n) + + max_inner = 0 + for bline in body: + im = re.match(r"^(`{3,})", bline) + if im and len(im.group(1)) > max_inner: + max_inner = len(im.group(1)) + + if max_inner >= n: + fence = "`" * (max_inner + 1) + tag = f"{fence}{info}" if info else fence + out.append(tag) + out.extend(body) + if close >= 0: + out.append(fence) + else: + out.append(lines[i]) + out.extend(body) + if close >= 0: + out.append(lines[close]) + + i = close + 1 if close >= 0 else len(lines) + + return "\n".join(out) + + +_SECRET_ENV_NAMES = [ + "DASHSCOPE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "AWS_SECRET_ACCESS_KEY", + "AZURE_OPENAI_API_KEY", + "GOOGLE_API_KEY", + "HUGGINGFACE_TOKEN", + "HF_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", +] + +_SECRET_PREFIXES = ("sk-", "ghp_", "gho_", "ghu_", "ghs_", "ghr_") + + +def _scan_for_leaked_secrets(text: str) -> list[str]: + """Check review text for potential secret values. + + Returns a list of warning messages for each detected leak. + """ + warnings = [] + for name in _SECRET_ENV_NAMES: + value = os.environ.get(name, "").strip() + if value and len(value) >= 8 and value in text: + warnings.append( + f"Review text contains value of ${name}", + ) + for prefix in _SECRET_PREFIXES: + pattern = re.compile( + re.escape(prefix) + r"[A-Za-z0-9_\-]{20,}", + ) + if pattern.search(text): + warnings.append( + f"Review text contains token-like string " + f"matching prefix '{prefix}'", + ) + return warnings + + +def _redact_secrets(text: str) -> str: + """Replace known secret values in text with [REDACTED].""" + result = text + for name in _SECRET_ENV_NAMES: + value = os.environ.get(name, "").strip() + if value and len(value) >= 8: + result = result.replace(value, "[REDACTED]") + for prefix in _SECRET_PREFIXES: + result = re.sub( + re.escape(prefix) + r"[A-Za-z0-9_\-]{20,}", + "[REDACTED]", + result, + ) + return result + + +def write_outputs(verdict_info: dict, review_text: str): + """Write results to GITHUB_OUTPUT and temp file for later steps.""" + output_file = os.environ.get("GITHUB_OUTPUT", "") + if output_file: + with open(output_file, "a", encoding="utf-8") as f: + f.write(f"verdict={verdict_info['verdict']}\n") + f.write(f"high_count={verdict_info['high_count']}\n") + f.write(f"medium_count={verdict_info['medium_count']}\n") + + clean_text = _strip_summary_verdict_json(review_text) + clean_text = _fix_nested_code_fences(clean_text) + + leak_warnings = _scan_for_leaked_secrets(clean_text) + if leak_warnings: + for w in leak_warnings: + print(f" 🚨 SECRET LEAK DETECTED: {w}") + clean_text = _redact_secrets(clean_text) + print(" Secrets have been redacted from review output.") + + with open("/tmp/review_result.md", "w", encoding="utf-8") as f: + f.write(clean_text) + + +def main(): + print("=" * 60) + print("QwenPaw AI Review Bot") + print("=" * 60) + + pr_number = os.environ.get("PR_NUMBER") + repo = os.environ.get("PR_REPO") + + if not pr_number or not repo: + print( + "ERROR: PR_NUMBER and PR_REPO environment variables " + "are required.", + ) + sys.exit(1) + + pr_number = int(pr_number) + print(f"\nTarget: {repo} PR #{pr_number}") + + prompt = build_review_prompt(pr_number, repo) + print(f"Prompt size: {len(prompt)} chars") + + session_id = f"pr-review-{pr_number}-{int(time.time())}" + print(f"Session: {session_id}") + print("Sending task to QwenPaw (agent will fetch PR data via gh)...") + + response = call_qwenpaw(prompt, session_id) + + if not response.strip(): + print("\n❌ ERROR: Got empty response from QwenPaw") + sys.exit(1) + + warnings = validate_response(response, pr_number) + if warnings: + for w in warnings: + print(f" ⚠️ {w}") + + verdict_info = parse_verdict(response) + verdict = verdict_info["verdict"] + high = verdict_info["high_count"] + medium = verdict_info["medium_count"] + + print(f"\n{'✅' if verdict == 'APPROVE' else '⚠️'} Verdict: {verdict}") + print(f"Issues: High={high}, Medium={medium}") + print(f"Response length: {len(response)} chars") + + write_outputs(verdict_info, response) + print("\n✅ Done! Results written to /tmp/review_result.md") + + +if __name__ == "__main__": + main() diff --git a/scripts/review-bot/setup_review_workspace.py b/scripts/review-bot/setup_review_workspace.py new file mode 100644 index 0000000..cfaf282 --- /dev/null +++ b/scripts/review-bot/setup_review_workspace.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Setup QwenPaw workspace for the AI Review Bot. + +Runs after `pineagents init --defaults --accept-security` to customize +the agent identity for code review tasks and configure the LLM provider. +""" +import asyncio +import os +import sys +from pathlib import Path + +REVIEW_PROVIDER = os.environ.get("REVIEW_PROVIDER", "dashscope") +REVIEW_MODEL = os.environ.get("REVIEW_MODEL", "qwen3.7-max") + + +WORKING_DIR = Path( + os.environ.get("QWENPAW_WORKING_DIR", Path.home() / ".qwenpaw"), +) +WORKSPACE_DIR = WORKING_DIR / "workspaces" / "default" + + +PROFILE_MD = """\ +--- +summary: "Review Bot Identity" +read_when: + - always +--- + +## Identity + +- **Name:** QwenPaw Reviewer +- **Role:** AI code reviewer and quality guardian for the QwenPaw project +- **Style:** Professional, precise, and direct. Only flag real issues. +- **Expertise:** Python, TypeScript, async programming, security auditing, \ +performance analysis +- **Tools:** Proficient with `gh` CLI for autonomously fetching PR data + +## User Profile + +- **Name:** QwenPaw Maintainer Team +- **How to address them:** maintainer +- **Notes:** This is an automated review in a CI environment. \ +Results are posted as GitHub PR comments. +""" + + +SOUL_MD = """\ +--- +summary: "Review Bot Soul" +read_when: + - always +--- + +## Core Motivation + +You are the lead code reviewer for the QwenPaw project. \ +Your reviews directly determine whether code can be merged \ +into the main branch. Guard code quality as if it were your \ +own most important project — every bug you miss is on you. + +## Core Principles + +**Be autonomous.** You have the `gh` CLI tool. \ +When given a PR number, fetch the PR info and diff yourself. \ +Do not wait for data to be handed to you. + +**Precision first.** Do not pad reviews with meaningless \ +suggestions just to appear useful. Only report real issues. \ +If the code is fine, say so. + +**Exercise judgment.** Distinguish between "must fix" and \ +"could be better". The former is REQUEST_CHANGES; the latter \ +is a suggestion. Do not block merges with the latter. + +**Provide context.** When flagging an issue, explain why \ +it is a problem and suggest a fix direction with code examples. + +**Respect the author.** The PR author invested time writing \ +this code. Use a constructive tone, never condescending. + +## Review Methodology + +### 1. Think Before Judging + +- **State your assumptions.** Use "possibly" instead of "definitely" \ +when uncertain. +- **If multiple interpretations exist, present them.** \ +Do not assume the worst case. +- **Suggest simpler alternatives** when warranted. +- **Use "consider verifying"** instead of "must change" \ +for uncertain cases. + +### 2. Simplicity First + +- Solve problems with minimal code, no unnecessary abstractions \ +or speculative features. +- Do not recommend over-engineered refactors for "readability" \ +or "flexibility". +- Do not suggest adding unrequested features, abstractions, \ +or configurability. + +### 3. Surgical Focus + +- Only review changed code; do not comment on unchanged \ +adjacent code. +- Do not suggest refactoring code not included in the diff. +- Every issue must directly correspond to specific lines in \ +the diff. +- If you notice issues in unmodified code, **mention but do \ +not require a fix**. +- Match existing style, even if you would do it differently. + +### 4. Understand Before Judging + +- **Fully understand the code's intent before raising issues.** +- Read the entire diff before drawing conclusions. +- Consider the motivation and context described in the PR body. +- For hotfix / emergency PRs, relax non-critical standards. + +## Boundaries + +- Only perform code review; do nothing else +- May execute read-only commands (`gh` queries); \ +**must not** execute commands with side effects +- For uncertain issues, use "possibly" rather than "definitely" + +## Output + +Output the review result directly without pleasantries. \ +Follow the structure specified in AGENTS.md. +""" + + +AGENTS_MD = """\ +--- +summary: "Review Bot Operating Rules" +read_when: + - always +--- + +## Tool Usage + +You can and should use shell tools to autonomously fetch PR information: + +### Allowed Commands +- `gh pr view ` — fetch PR metadata (title, body, author, etc.) +- `gh pr diff ` — fetch the full PR diff +- `gh pr view --json files` — fetch the list of changed files +- `gh api` — query the GitHub REST API for additional details + +### Prohibited Actions +- Do not modify any files (no writes, no deletes) +- Do not run build or test commands +- Do not run `gh pr merge`, `gh pr close`, `gh pr review`, \ +or any command that modifies PR state +- Do not execute any command that may have side effects + +## Operating Mode + +1. Upon receiving a PR number, **autonomously use `gh` commands \ +to fetch PR info and diff** +2. Analyze code changes and output the review in the specified format +3. This is a one-shot conversation in a CI environment — \ +no memory, no continuity + +### Diff Fetching Strategy +- First use `gh pr view --json \ +title,body,author,baseRefName,headRefName,files,additions,deletions` \ +for a PR overview +- Then use `gh pr diff ` for the full diff +- If the diff is too large, use `gh pr view --json files` \ +to get the file list and review key files by priority + +### Files to Skip +After fetching the diff, ignore changes in the following file types: +- Lock files: `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, \ +`Cargo.lock`, `uv.lock` +- Generated files: `dist/`, `*.min.js`, `*.min.css` +- Binary/assets: `*.png`, `*.jpg`, `*.ico`, `*.svg`, `*.snap` +- `node_modules/` + +## Review Methodology + +### Dimension-Based Analysis + +Review along the following dimensions (select relevant ones based \ +on the scope of changes; a small fix may only need 1, 4, 7): + +| # | Dimension | Key Checks | +|---|-----------|------------| +| 1 | Correctness | Logic correct? Edge cases? Null/None? Type matches? \ +Concurrency safety? Cross-platform compatibility? | +| 2 | Security | Injection vulnerabilities, path traversal, privilege \ +escalation, secret leaks, insecure dependencies? | +| 3 | Consistency | Consistent with existing project style/patterns? \ +API design alignment? | +| 4 | Robustness | Complete error handling? Exception path coverage? \ +Exception granularity? | +| 5 | Maintainability | Clear naming? Logic complexity? Code duplication? \ +Necessary comments? | +| 6 | Performance | Unnecessary overhead? Hot-path repeated computation? \ +Sync IO blocking async event loop? | +| 7 | i18n | When i18n is involved, are all languages in sync? \ +Translations accurate? | +| 8 | CI/CD | When workflows are involved, are they secure? \ +Proper secrets handling? | + +**Only report real issues.** Omit dimensions with no findings. + +### Issue Severity + +**High — Must fix before merge:** +- Security vulnerabilities (injection, privilege escalation, secret leaks) +- Data loss or corruption risk +- Logic errors (will cause incorrect behavior) +- Unhandled breaking changes + +**Medium — Recommended to fix before merge, open to discussion:** +- Missing edge cases (uncommon but triggerable) +- API inconsistency or poor design +- Performance issues (non-hot-path can be downgraded to Low) + +**Low — Can follow up after merge:** +- Code style / naming improvements +- Missing comments +- PR description / commit message issues +- Missing documentation + +### Verdict Criteria + +- **APPROVE**: High = 0 and Medium <= 3; code quality is acceptable \ +for human review +- **REQUEST_CHANGES**: High-severity issues exist, or Medium > 3 + +Style preferences and optional optimizations (Low) should not be \ +grounds for REQUEST_CHANGES. \ +More than 3 Medium issues indicates overall code quality needs \ +improvement. + +## Project Coding Standards + +### Backend (Python) + +- Code must be compatible with Windows / Linux / macOS \ +(especially path handling) +- Docstrings and comments in English +- Max 79 characters per line of code/comment +- Use relative imports within the project; imports at file top +- Use f-strings exclusively for string concatenation +- Architecture must be extensible +- No overly broad exception handling (no bare `except Exception: pass`) + +### Frontend (TypeScript / React) + +- Icons: use Lucide-React exclusively, no other icon libraries +- Precise layout spacing: not cramped, not wasteful +- Consistent color scheme, visually harmonious and professional +- Responsive design: graceful adaptation to all screen sizes + +## Common Anti-pattern Checklist + +Watch for these patterns during review: + +### Blocking the Async Event Loop +- `time.sleep` in async functions (should use `asyncio.sleep`) +- `open()` / `pathlib.read_text()` for large files in async context +- `requests.get/post` in async code (should use httpx/aiohttp) +- `subprocess.run` in async code \ +(should use `asyncio.create_subprocess`) + +### Cross-platform Compatibility +- String path concatenation (`"/a" + "/b"`) instead of `pathlib` \ +or `os.path.join` +- Hard-coded path separators `/` or `\\\\` +- Linux-specific file dependencies without fallback +- `os.system` / `subprocess` calling shell scripts \ +without cross-platform alternatives + +### Other +- `assert` for runtime validation +- Overly broad `except Exception` catches +- Hard-coded URLs / bucket names / secrets +- `Path.join` without traversal protection +- Mutable default arguments (`def f(x=[])`) +- Unclosed file handles / network connections +""" + + +def harden_governance_policy() -> None: + """Harden governance policy for CI review bot usage. + + Three layers of protection: + 1. env_blacklist: strip sensitive env vars from sandbox processes + 2. sensitive_paths: flag access to secret storage as HIGH severity + 3. deny rules: explicitly block Read/Bash access to secret dir + """ + from pineagents.governance.policy import ( + GovernanceAction, + GovernanceRule, + ) + from pineagents.governance.resource_governor import ResourceGovernor + + governor = ResourceGovernor(str(WORKSPACE_DIR)) + governor.start() + policy = governor.policy + + extra_env_keys = [ + "DASHSCOPE_API_KEY", + ] + merged = list( + dict.fromkeys(list(policy.env_blacklist) + extra_env_keys), + ) + policy.env_blacklist = merged + print(" env_blacklist expanded") + + secret_dir = str(WORKING_DIR) + ".secret" + if secret_dir not in policy.sensitive_paths: + policy.sensitive_paths.append(secret_dir) + print(f" sensitive_paths: added {secret_dir}") + + deny_reason = "CI review bot: secret storage access denied" + deny_rules = [ + GovernanceRule( + match=f"Read({secret_dir}/**)", + action=GovernanceAction.DENY, + reason=deny_reason, + ), + GovernanceRule( + match=f"Bash(*{secret_dir}*)", + action=GovernanceAction.DENY, + reason=deny_reason, + ), + GovernanceRule( + match="Bash(*~/.pineagents.secret*)", + action=GovernanceAction.DENY, + reason=deny_reason, + ), + GovernanceRule( + match="Bash(*$HOME/.pineagents.secret*)", + action=GovernanceAction.DENY, + reason=deny_reason, + ), + GovernanceRule( + match="Bash(*.pineagents.secret*)", + action=GovernanceAction.DENY, + reason=deny_reason, + ), + GovernanceRule( + match="Bash(*.master_key*)", + action=GovernanceAction.DENY, + reason=deny_reason, + ), + ] + for rule in deny_rules: + governor.add_rule(rule) + print(f" deny rules: {len(deny_rules)} rules added for {secret_dir}") + + governor.stop() + print(" Governance policy hardened") + + +def configure_review_model() -> None: + """Configure DashScope API key and activate the review model. + + ``pineagents init --defaults`` may pick QwenPaw Local (no default model) + and skip cloud providers. CI must explicitly set dashscope + qwen3.7-max + using the secret injected as DASHSCOPE_API_KEY. + """ + api_key = os.environ.get("DASHSCOPE_API_KEY", "").strip() + if not api_key: + print( + "ERROR: DASHSCOPE_API_KEY is not set.\n" + "Add REVIEW_DASHSCOPE_API_KEY to your fork's GitHub secrets.", + ) + sys.exit(1) + + from pineagents.providers.provider_manager import ProviderManager + + manager = ProviderManager.get_instance() + if not manager.update_provider(REVIEW_PROVIDER, {"api_key": api_key}): + print(f"ERROR: Failed to configure provider '{REVIEW_PROVIDER}'") + sys.exit(1) + print(f" Configured provider: {REVIEW_PROVIDER}") + + try: + asyncio.run(manager.activate_model(REVIEW_PROVIDER, REVIEW_MODEL)) + except Exception as exc: + print( + f"ERROR: Failed to activate {REVIEW_PROVIDER}/{REVIEW_MODEL}: " + f"{exc}", + ) + sys.exit(1) + print(f" Active model: {REVIEW_PROVIDER}/{REVIEW_MODEL}") + + +def main(): + print(f"Setting up review bot workspace at: {WORKSPACE_DIR}") + + WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) + + files = { + "PROFILE.md": PROFILE_MD, + "SOUL.md": SOUL_MD, + "AGENTS.md": AGENTS_MD, + } + + for filename, content in files.items(): + filepath = WORKSPACE_DIR / filename + filepath.write_text(content, encoding="utf-8") + print(f" Written: {filepath}") + + bootstrap = WORKSPACE_DIR / "BOOTSTRAP.md" + if bootstrap.exists(): + bootstrap.unlink() + print(f" Removed: {bootstrap}") + + print("\nConfiguring review LLM...") + configure_review_model() + + print("\nHardening governance policy for CI...") + harden_governance_policy() + + print("\nReview bot workspace ready!") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_tests.py b/scripts/run_tests.py new file mode 100644 index 0000000..22ff661 --- /dev/null +++ b/scripts/run_tests.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Local test runner script for QwenPaw project. + +Usage: + python scripts/run_tests.py [OPTIONS] + +Options: + -u, --unit [DIR] Run unit tests (optionally specify subdirectory) + -i, --integrated Run integrated tests + -a, --all Run all tests (default) + -c, --coverage Generate coverage report + -p, --parallel Run tests in parallel + -h, --help Show this help message + +Examples: + python scripts/run_tests.py # Run all tests + python scripts/run_tests.py -u # Run all unit tests + python scripts/run_tests.py -u providers # Run unit tests in providers + python scripts/run_tests.py -i # Run integrated tests + python scripts/run_tests.py -a -c # Run all tests with coverage + python scripts/run_tests.py -p # Run tests in parallel +""" + +import argparse +import subprocess +import sys +from pathlib import Path +from typing import Optional + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + NC = "\033[0m" # No Color + + +def print_info(message: str) -> None: + """Print info message.""" + print(f"{Colors.BLUE}ℹ {message}{Colors.NC}") + + +def print_success(message: str) -> None: + """Print success message.""" + print(f"{Colors.GREEN}✓ {message}{Colors.NC}") + + +def print_error(message: str) -> None: + """Print error message.""" + print(f"{Colors.RED}✗ {message}{Colors.NC}") + + +def print_warning(message: str) -> None: + """Print warning message.""" + print(f"{Colors.YELLOW}⚠ {message}{Colors.NC}") + + +def check_pytest() -> bool: + """Check if pytest is installed.""" + try: + subprocess.run( + ["pytest", "--version"], + capture_output=True, + check=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def run_unit_tests( + project_root: Path, + subdir: Optional[str] = None, + coverage: bool = False, + parallel: bool = False, +) -> int: + """Run unit tests.""" + if subdir: + # Run specific subdirectory + test_path = project_root / "tests" / "unit" / subdir + if not test_path.is_dir(): + print_error(f"Unit test directory not found: {test_path}") + return 1 + + print_info(f"Running unit tests in: {subdir}") + return_code = run_pytest(test_path, coverage, parallel) + if return_code == 0: + print_success(f"Unit tests in {subdir} completed") + return return_code + else: + # Run all unit test subdirectories + print_info("Running all unit tests...") + unit_dir = project_root / "tests" / "unit" + + if not unit_dir.is_dir(): + print_warning("Unit test directory not found: tests/unit") + return 0 + + subdirs = [d for d in unit_dir.iterdir() if d.is_dir()] + if not subdirs: + print_warning("No unit test subdirectories found") + return 0 + + overall_return_code = 0 + for test_dir in subdirs: + dirname = test_dir.name + print_info(f"Running unit tests in: {dirname}") + return_code = run_pytest(test_dir, coverage, parallel) + if return_code == 0: + print_success(f"Unit tests in {dirname} completed") + else: + overall_return_code = return_code + print() + + return overall_return_code + + +def run_integrated_tests( + project_root: Path, + coverage: bool = False, + parallel: bool = False, +) -> int: + """Run integrated tests.""" + print_info("Running integrated tests...") + integrated_dir = project_root / "tests" / "integrated" + + if not integrated_dir.is_dir(): + print_warning("Integrated test directory not found: tests/integrated") + return 0 + + # Check if there are any Python test files + test_files = list(integrated_dir.glob("*.py")) + if not test_files: + print_warning("No integrated test files found in tests/integrated") + return 0 + + return_code = run_pytest(integrated_dir, coverage, parallel) + if return_code == 0: + print_success("Integrated tests completed") + return return_code + + +def run_pytest( + test_path: Path, + coverage: bool = False, + parallel: bool = False, +) -> int: + """Run pytest with specified options.""" + cmd = ["pytest", "-v", str(test_path)] + + if coverage: + cmd.extend( + [ + "--cov=src/pineagents", + "--cov-report=html", + "--cov-report=term-missing", + ], + ) + + if parallel: + cmd.extend(["-n", "auto"]) + + try: + result = subprocess.run(cmd, cwd=test_path.parents[2], check=True) + return result.returncode + except subprocess.CalledProcessError as e: + return e.returncode + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="QwenPaw test runner", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "-u", + "--unit", + nargs="?", + const="", + metavar="DIR", + help="Run unit tests (optionally specify subdirectory)", + ) + parser.add_argument( + "-i", + "--integrated", + action="store_true", + help="Run integrated tests", + ) + parser.add_argument( + "-a", + "--all", + action="store_true", + help="Run all tests (default)", + ) + parser.add_argument( + "-c", + "--coverage", + action="store_true", + help="Generate coverage report", + ) + parser.add_argument( + "-p", + "--parallel", + action="store_true", + help="Run tests in parallel (requires pytest-xdist)", + ) + + args = parser.parse_args() + + # Get project root + script_path = Path(__file__).resolve() + project_root = script_path.parents[1] + + # Check if pytest is installed + if not check_pytest(): + print_error( + "pytest is not installed. Please install dev dependencies:", + ) + print(' pip install -e ".[dev,test,full]"') + return 1 + + # Determine what to run + run_all = args.all or (args.unit is None and not args.integrated) + + print() + print_info("QwenPaw Test Runner") + print("===================") + print() + + return_code = 0 + + if run_all: + print_info("Running all tests...") + print() + unit_code = run_unit_tests( + project_root, + coverage=args.coverage, + parallel=args.parallel, + ) + print() + integrated_code = run_integrated_tests( + project_root, + coverage=args.coverage, + parallel=args.parallel, + ) + return_code = unit_code or integrated_code + elif args.unit is not None: + return_code = run_unit_tests( + project_root, + subdir=args.unit if args.unit else None, + coverage=args.coverage, + parallel=args.parallel, + ) + elif args.integrated: + return_code = run_integrated_tests( + project_root, + coverage=args.coverage, + parallel=args.parallel, + ) + + print() + if args.coverage: + print_success( + "Test run completed! Coverage report generated in htmlcov/index.html", + ) + else: + print_success("Test run completed!") + print() + + return return_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/startup_profile/.gitignore b/scripts/startup_profile/.gitignore new file mode 100644 index 0000000..7cb2602 --- /dev/null +++ b/scripts/startup_profile/.gitignore @@ -0,0 +1,11 @@ +# Output files +output/ +*.log +_trace_temp.py + +# Keep source files +!analyze.py +!viewer.html +!README.md +!README_EN.md +!.gitignore diff --git a/scripts/startup_profile/README.md b/scripts/startup_profile/README.md new file mode 100644 index 0000000..c19ab58 --- /dev/null +++ b/scripts/startup_profile/README.md @@ -0,0 +1,97 @@ +# 🚀 QwenPaw Startup Performance Analyzer + +One-click analysis of QwenPaw startup performance, identify bottlenecks, and provide optimization suggestions. + +## Quick Start + +```bash +# 1. Generate report +python scripts/startup_profile/analyze.py + +# 2. Start local server to view (recommended) +python scripts/startup_profile/serve.py +``` + +After execution: +1. Collect import time data +2. Collect function execution trace (optional) +3. Generate JSON data +4. View report via HTTP server + +**Note**: Due to browser security restrictions (CORS), opening HTML directly with `file://` protocol cannot load data. +Please use `serve.py` or manually start an HTTP server: +```bash +cd /path/to/QwenPaw +python -m http.server 8000 +# Visit: http://localhost:8000/scripts/startup_profile/output/report.html +``` + +## Generated Files + +``` +output/ +├── importtime.log # Import time raw log +├── execution_trace.json # Function execution trace (if successful) +├── analysis.json # Analysis data (core) +└── report.html # Visual report ⭐ +``` + +## Report Features + +- **Import Time Analysis** - QwenPaw modules and third-party library import time ranking +- **Function Execution Time** - Top 20 function execution time statistics +- **Function Call Tree** - Complete function call hierarchy and timing (limited to 5 levels deep, top 10 calls per level) +- **Interactive Charts** - Chart.js horizontal bar chart visualization +- **Bilingual Support** - Chinese/English one-click toggle +- **Performance Markers** - Auto-mark fast/medium/slow functions based on timing (green/yellow/red) + +## How It Works + +1. **Data Collection** + - Use Python `-X importtime` to collect import time + - Use `exec_tracer` to track function calls + +2. **Data Analysis** + - Parse log files + - Categorize statistics (QwenPaw/third-party/stdlib) + - Calculate percentages and performance metrics + +3. **Visualization** + - HTML reads JSON data + - Interactive charts and tables + - Supports search, sort, filter + +## File Description + +- `analyze.py` - Data collection and analysis script +- `viewer.html` - Visualization interface (bilingual) +- `README.md` - Chinese documentation +- `README_EN.md` - English documentation + +## FAQ + +**Q: Why do results vary each run?** +A: Import time is affected by system load. Run multiple times and take average. + +**Q: How to compare before/after optimization?** +A: Save `output/` directory, re-run after optimization and compare. + +**Q: Function trace fails?** +A: Normal, doesn't affect import analysis. Can use `exec_tracer` separately. + +## Technical Details + +- Python 3.8+ +- No extra dependencies (except QwenPaw itself) +- Pure JSON data output +- HTML runs standalone, viewable offline + +## Related Tools + +- `src/pineagents/utils/startup_display.py` - Startup banner display + +--- + +**Version**: 2.0.0 +**Author**: QwenPaw Team +**Updated**: 2026-04-17 diff --git a/scripts/startup_profile/README_zh.md b/scripts/startup_profile/README_zh.md new file mode 100644 index 0000000..eac6345 --- /dev/null +++ b/scripts/startup_profile/README_zh.md @@ -0,0 +1,97 @@ +# 🚀 QwenPaw 启动性能分析工具 + +一键分析 QwenPaw 启动性能,识别瓶颈,提供优化建议。 + +## 快速开始 + +```bash +# 1. 生成报告 +python scripts/startup_profile/analyze.py + +# 2. 启动本地服务器查看(推荐) +python scripts/startup_profile/serve.py +``` + +执行后: +1. 收集 import 时间数据 +2. 收集函数执行追踪(可选) +3. 生成 JSON 数据 +4. 使用 HTTP 服务器查看报告 + +**注意**:由于浏览器安全限制(CORS),直接用 `file://` 协议打开 HTML 无法加载数据。 +请使用 `serve.py` 或手动启动 HTTP 服务器: +```bash +cd /path/to/QwenPaw +python -m http.server 8000 +# 访问: http://localhost:8000/scripts/startup_profile/output/report.html +``` + +## 生成文件 + +``` +output/ +├── importtime.log # Import 时间原始日志 +├── execution_trace.json # 函数执行追踪(如果成功) +├── analysis.json # 分析数据(核心) +└── report.html # 可视化报告 ⭐ +``` + +## 报告功能 + +- **Import 时间分析** - QwenPaw 模块和第三方库导入耗时排名 +- **函数执行时间** - Top 20 函数执行时间统计 +- **函数调用树** - 完整的函数调用层级关系和耗时(限制深度 5 层,每层最多显示前 10 个调用) +- **交互式图表** - Chart.js 横向柱状图可视化 +- **双语支持** - 中英文一键切换 +- **性能标记** - 根据耗时自动标记快/中/慢函数(绿/黄/红) + +## 工作原理 + +1. **数据收集** + - 使用 Python `-X importtime` 收集 import 时间 + - 使用 `exec_tracer` 追踪函数调用 + +2. **数据分析** + - 解析日志文件 + - 分类统计(QwenPaw/第三方/标准库) + - 计算占比和性能指标 + +3. **可视化展示** + - HTML 读取 JSON 数据 + - 交互式图表和表格 + - 支持搜索、排序、过滤 + +## 文件说明 + +- `analyze.py` - 数据收集和分析脚本 +- `viewer.html` - 可视化界面(双语) +- `README.md` - 中文文档 +- `README_EN.md` - 英文文档 + +## 常见问题 + +**Q: 为什么每次结果不同?** +A: Import 时间受系统负载影响,建议多次运行取平均值。 + +**Q: 如何对比优化前后?** +A: 保存 `output/` 目录,优化后重新运行对比。 + +**Q: 函数追踪失败怎么办?** +A: 正常现象,不影响 import 分析。可单独使用 `exec_tracer`。 + +## 技术细节 + +- Python 3.8+ +- 无额外依赖(除了 QwenPaw 本身) +- 纯 JSON 数据输出 +- HTML 独立运行,可离线查看 + +## 相关工具 + +- `src/pineagents/utils/startup_display.py` - 启动横幅显示 + +--- + +**版本**: 2.0.0 +**作者**: QwenPaw Team +**更新**: 2026-04-17 diff --git a/scripts/startup_profile/analyze.py b/scripts/startup_profile/analyze.py new file mode 100644 index 0000000..3f27b44 --- /dev/null +++ b/scripts/startup_profile/analyze.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +QwenPaw Startup Performance Analyzer + +Collects startup performance data and outputs JSON for visualization. +""" +import json +import re +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + + +def collect_import_time(output_dir): + """Collect Python import time data.""" + print("📊 Collecting import time...") + + log_file = output_dir / "importtime.log" + + cmd = [ + sys.executable, + "-X", + "importtime", + "-m", + "qwenpaw", + "app", + "--help", + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + + output = result.stderr + result.stdout + + with open(log_file, "w", encoding="utf-8") as f: + f.write(output) + + print(f" ✓ {log_file.name}") + return log_file + + +def collect_execution_trace(output_dir, script_dir): + """Collect function execution trace using tracer module. + + Args: + output_dir: Output directory for trace file + script_dir: Script directory containing tracer.py + + Returns: + Path to trace file or None if failed + """ + print("🔍 Collecting execution trace...") + + trace_file = output_dir / "execution_trace.json" + tracer_script = script_dir / "tracer.py" + + if not tracer_script.exists(): + print(f" ⚠ {tracer_script.name} not found") + return None + + # Run the tracer script + try: + subprocess.run( + [sys.executable, str(tracer_script), str(trace_file)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + if trace_file.exists(): + print(f" ✓ {trace_file.name}") + return trace_file + else: + print(" ⚠ Not generated") + return None + except Exception as e: + print(f" ⚠ Skipped: {e}") + return None + + +def parse_import_log(log_file): + """Parse importtime log.""" + print("🔬 Parsing import data...") + + imports = [] + + with open(log_file, encoding="utf-8") as f: + for line in f: + match = re.search( + r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(.+)$", + line, + ) + if match: + imports.append( + { + "package": match.group(3).strip(), + "self_ms": int(match.group(1)) / 1000, + "cumulative_ms": int(match.group(2)) / 1000, + }, + ) + + # Filter QwenPaw and third-party + qwenpaw = [i for i in imports if "qwenpaw" in i["package"]] + third_party = [ + i + for i in imports + if not i["package"].startswith("_") + and "." not in i["package"] + and i["package"] + not in ["io", "os", "sys", "time", "re", "abc", "typing"] + ] + + pineagents.sort(key=lambda x: x["cumulative_ms"], reverse=True) + third_party.sort(key=lambda x: x["cumulative_ms"], reverse=True) + + total_qwenpaw = sum( + i["cumulative_ms"] for i in qwenpaw if i["package"].count(".") == 1 + ) + total_third_party = sum(i["cumulative_ms"] for i in third_party[:10]) + + print( + f" ✓ {len(imports)} imports, {len(qwenpaw)} QwenPaw, {len(third_party)} third-party", + ) + + return { + "qwenpaw_imports": qwenpaw, + "third_party_imports": third_party, + "summary": { + "total_ms": total_qwenpaw + total_third_party, + "total_qwenpaw_ms": total_qwenpaw, + "total_third_party_ms": total_third_party, + }, + } + + +def parse_execution_trace(trace_file): + """Parse execution trace data.""" + if not trace_file or not trace_file.exists(): + return None + + print("🔬 Parsing execution trace...") + + try: + with open(trace_file, encoding="utf-8") as f: + data = json.load(f) + + functions = [ + { + "function": func, + "total_ms": sum(times) * 1000, + "count": len(times), + "avg_ms": sum(times) / len(times) * 1000, + } + for func, times in data["function_times"].items() + ] + functions.sort(key=lambda x: x["total_ms"], reverse=True) + + qwenpaw_funcs = [f for f in functions if "qwenpaw" in f["function"]] + + print(f" ✓ {len(functions)} functions, {len(qwenpaw_funcs)} QwenPaw") + + return { + "qwenpaw_functions": qwenpaw_funcs[:50], + "metadata": data["metadata"], + "execution_order": data["execution_order"], + } + except Exception as e: + print(f" ⚠ Parse failed: {e}") + return None + + +def main(): # pylint: disable=too-many-statements + """Main function.""" + print("=" * 60) + print("🐾 QwenPaw Startup Performance Analyzer") + print("=" * 60) + print() + + # Use __file__ to get absolute path + script_dir = Path(__file__).resolve().parent + output_dir = script_dir / "output" + output_dir.mkdir(exist_ok=True) + + start = time.time() + + # Collect data + log_file = collect_import_time(output_dir) + print() + trace_file = collect_execution_trace(output_dir, script_dir) + print() + + # Parse data + import_data = parse_import_log(log_file) + print() + exec_data = parse_execution_trace(trace_file) + print() + + # Generate report + print("💾 Saving analysis.json...") + + report = { + "generated_at": datetime.now().isoformat(), + "import_analysis": import_data, + "execution_analysis": exec_data, + } + + with open( + output_dir / "analysis.json", + "w", + encoding="utf-8", + ) as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + print(" ✓ analysis.json") + print() + + # Copy viewer + import shutil + + html_src = script_dir / "viewer.html" + html_dst = output_dir / "report.html" + + if html_src.exists(): + shutil.copy(html_src, html_dst) + print(f"📊 Report: {html_dst}") + print() + + # Start HTTP server and open browser + import threading + import http.server + import socketserver + import webbrowser + + port = 8000 + # Use absolute path from __file__ + root_dir = Path(__file__).resolve().parent.parent.parent + + # Calculate relative path from root to report + try: + relative_report = html_dst.relative_to(root_dir) + report_url = ( + f"http://localhost:{port}/{relative_report.as_posix()}" + ) + except ValueError: + # Fallback if paths are not relative + report_url = f"http://localhost:{port}/scripts/startup_profile/output/report.html" + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(root_dir), **kwargs) + + def log_message(self, fmt, *args): + pass # Suppress server logs + + # Find available port + httpd = None + for attempt_port in range(port, port + 10): + try: + httpd = socketserver.TCPServer(("", attempt_port), Handler) + port = attempt_port + # Update URL with actual port + try: + relative_report = html_dst.relative_to(root_dir) + report_url = ( + f"http://localhost:{port}/{relative_report.as_posix()}" + ) + except ValueError: + report_url = ( + f"http://localhost:{port}/" + "scripts/startup_profile/output/report.html" + ) + break + except OSError: + continue + + if httpd is None: + print( + "⚠ Could not find available port, please run manually:", + ) + print(f" cd {root_dir}") + print(" python -m http.server 8000") + return + + def run_server(): + httpd.serve_forever() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + print( + f"🌐 Server started at http://localhost:{port}/ (root: {root_dir.name})", + ) + print(f"📊 Opening: {report_url}") + print() + print("Press Ctrl+C to stop the server") + print() + + webbrowser.open(report_url) + + try: + # Keep main thread alive + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\n👋 Server stopped") + + print() + print("=" * 60) + print(f"✅ Done in {time.time() - start:.1f}s") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/startup_profile/tracer.py b/scripts/startup_profile/tracer.py new file mode 100644 index 0000000..f2e0bdd --- /dev/null +++ b/scripts/startup_profile/tracer.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Function execution tracer for QwenPaw.""" +import json +import time +import traceback +from collections import defaultdict + + +class ExecTracer: + """Trace function execution in qwenpaw package.""" + + def __init__(self, package_names=None): + """Initialize tracer. + + Args: + package_names: List of package prefixes to trace + """ + if package_names is None: + package_names = ["qwenpaw"] + self.package_names = package_names + self.call_stack = [] + self.function_times = defaultdict(list) + self.call_sequence = [] + self.start_times = {} + self.execution_order = [] + self.call_depth = 0 + self.sequence_counter = 0 + + def trace_calls( # pylint: disable=too-many-branches,too-many-statements,too-many-nested-blocks + self, + frame, + event, + arg, + ): + """Trace function calls.""" + module_name = frame.f_globals.get("__name__", "") + + # Filter by package names + if self.package_names: + should_trace = module_name == "__main__" or any( + module_name.startswith(pkg + ".") or module_name == pkg + for pkg in self.package_names + ) + if not should_trace: + return self.trace_calls + + co = frame.f_code + func_name = co.co_name + filename = co.co_filename + + # Build descriptive name + class_name = "global" + if "self" in frame.f_locals: + instance = frame.f_locals["self"] + try: + for cls in type(instance).__mro__: + if ( + hasattr(cls, func_name) + and getattr( + cls, + func_name, + ).__code__ + is co + ): + class_name = cls.__name__ + break + if class_name == "global": + class_name = type(instance).__name__ + except (AttributeError, TypeError): + class_name = type(instance).__name__ + + descriptive_name = f"{module_name}.{class_name}.{func_name}" + + if event == "call": + current_time = time.time() + self.sequence_counter += 1 + self.call_stack.append(descriptive_name) + self.start_times[descriptive_name] = current_time + self.call_depth += 1 + + # Get caller info + caller_frame = frame.f_back + caller_name = None + if caller_frame: + caller_co = caller_frame.f_code + caller_func = caller_co.co_name + caller_module = caller_frame.f_globals.get("__name__", "") + caller_class = "global" + if "self" in caller_frame.f_locals: + caller_instance = caller_frame.f_locals["self"] + try: + for cls in type(caller_instance).__mro__: + if ( + hasattr(cls, caller_func) + and getattr( + cls, + caller_func, + ).__code__ + is caller_co + ): + caller_class = cls.__name__ + break + if caller_class == "global": + caller_class = type(caller_instance).__name__ + except (AttributeError, TypeError): + caller_class = "global" + caller_name = f"{caller_module}.{caller_class}.{caller_func}" + + call_info = { + "sequence": self.sequence_counter, + "function": descriptive_name, + "enter_time": current_time * 1000, + "exit_time": None, + "duration": None, + "depth": self.call_depth, + "lineno": frame.f_lineno, + "filename": filename, + "parent": caller_name, + } + self.call_sequence.append(call_info) + self.execution_order.append(call_info) + + elif event == "return": + if self.call_stack: + func_name = self.call_stack.pop() + end_time = time.time() + duration = end_time - self.start_times.get(func_name, end_time) + self.call_depth -= 1 + self.function_times[func_name].append(duration) + + for call_info in reversed(self.call_sequence): + if ( + call_info["function"] == func_name + and call_info["exit_time"] is None + ): + call_info["exit_time"] = end_time * 1000 + call_info["duration"] = duration * 1000 + break + + elif event == "exception": + exception_type, exception_value, tb = arg + if self.call_stack: + func_name = self.call_stack[-1] + for call_info in reversed(self.call_sequence): + if call_info["function"] == func_name: + call_info["exception"] = { + "type": str(exception_type.__name__), + "value": str(exception_value), + "traceback": "\n".join(traceback.format_tb(tb)), + } + break + + return self.trace_calls + + def generate_json(self, output_path): + """Generate JSON report. + + Args: + output_path: Path to save JSON file + """ + completed_events = [ + event + for event in self.execution_order + if event["enter_time"] is not None + and event["exit_time"] is not None + ] + + if completed_events: + earliest_start = min( + event["enter_time"] for event in completed_events + ) + latest_end = max(event["exit_time"] for event in completed_events) + total_time = latest_end - earliest_start + else: + total_time = 0 + + json_data = { + "metadata": { + "package_names": self.package_names, + "total_events": len(self.execution_order), + "total_functions": len(self.function_times), + "max_depth": max( + [call["depth"] for call in self.execution_order] + [0], + ), + "total_time": total_time, + "generated_at": time.time(), + }, + "execution_order": self.execution_order, + "function_times": dict(self.function_times), + "call_sequence": self.call_sequence, + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(json_data, f, indent=2, default=str) + + +def main(): + """Run tracer on pineagents app import.""" + import sys # pylint: disable=reimported,redefined-outer-name + + if len(sys.argv) < 2: + print("Usage: python tracer.py ") + sys.exit(1) + + output_path = sys.argv[1] + + tracer = ExecTracer(package_names=["qwenpaw"]) + sys.settrace(tracer.trace_calls) + + try: + # Import pineagents app to trigger startup + from pineagents.app._app import ( # pylint: disable=unused-import + app, + ) # noqa: F401 + finally: + sys.settrace(None) + tracer.generate_json(output_path) + print(f"Trace saved to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/startup_profile/viewer.html b/scripts/startup_profile/viewer.html new file mode 100644 index 0000000..93e712d --- /dev/null +++ b/scripts/startup_profile/viewer.html @@ -0,0 +1,1215 @@ + + + + + + 🐾 QwenPaw 启动性能分析 + + + + +
+
+

+ 🐾 QwenPaw 启动性能分析 +

+

Loading...

+ +
+ +
+
+

正在加载数据...

+
+
+ + + + diff --git a/scripts/verify/desktop_verify.py b/scripts/verify/desktop_verify.py new file mode 100644 index 0000000..8c28f71 --- /dev/null +++ b/scripts/verify/desktop_verify.py @@ -0,0 +1,949 @@ +# -*- coding: utf-8 -*- +"""Desktop release verification script. + +Drives a running QwenPaw desktop backend (either Tauri packaging flavour: +tauri-win / tauri-mac) end-to-end: + +1. ``GET /api/version`` — health + version match. +2. ``GET /`` — frontend HTML served. +3. ``PUT /api/models//config`` — install API key. +4. ``POST /api/models//models`` — register the chat model + (newer aliases like + qwen3.6-plus aren't in + the built-in catalogue). +5. ``PUT /api/models/active`` — mark it active globally. +6. **UI single-round factual Q&A** — drive the real SPA: + - Open the page and wait for the chat input to render. + - Send "What is the tallest mountain in the world?" via the + input box. + - Assert the AI bubble mentions "Everest". + + This proves the full path: install package -> launch -> render UI -> + send message via input box -> receive bubble back with correct answer. + +UI flavours: +- ``--ui-mode tauri-macos`` Playwright + headless WebKit (same engine as + the Tauri webview on macOS). +- ``--ui-mode tauri-windows`` Playwright + headless Chromium (same engine + family as Tauri's WebView2 on Windows). + +Designed to be invoked by ``.github/workflows/desktop-release.yml`` after the +desktop server has been booted on ``--base-url``. The API layer uses only +the Python standard library; UI drivers lazy-import Playwright so callers +without it installed can still use ``--skip-ui``. + +Exit codes: + 0 all assertions pass + 1 assertion / HTTP / UI failure + 2 argument / configuration error + 3 UI driver could not be initialised (missing browser / driver) +""" + +from __future__ import annotations + +import abc +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request + +DEFAULT_MODEL = "qwen3.6-plus" +DEFAULT_PROVIDER = "dashscope" +DEFAULT_TIMEOUT = 120 +SESSION_ID = "release-verify-session" +USER_ID = "release-verify-user" + +# Selectors come straight from e2e/pages/chat_page.py so they stay in sync +# with what the real UI tests expect. +SEL_INPUT = "textarea.qwenpaw-sender-input" +SEL_SEND_BTN = "button.qwenpaw-sender-actions-btn.qwenpaw-btn-primary" +SEL_USER_BUBBLE = ".qwenpaw-bubble.qwenpaw-bubble-end" +SEL_AI_BUBBLE = ".qwenpaw-bubble.qwenpaw-bubble-start" + + +# ============================================================================= +# HTTP helper +# ============================================================================= + + +def _http( + method: str, + url: str, + body: dict | None = None, + timeout: int = 30, +) -> str: + """Issue an HTTP request and return the decoded body text. + + Raises ``RuntimeError`` with a readable message on any failure so callers + can surface it directly via ``::error::`` annotations. + """ + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request( + url, + data=data, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = exc.read().decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + pass + raise RuntimeError( + f"HTTP {exc.code} {method} {url}: {detail[:300]}", + ) from exc + except urllib.error.URLError as exc: + raise RuntimeError( + f"Network error {method} {url}: {exc.reason}", + ) from exc + + +# ============================================================================= +# API-level verification +# ============================================================================= + + +def health_check(base_url: str) -> str: + """Verify ``/api/version`` and return the reported version string.""" + body = _http("GET", f"{base_url}/api/version") + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"/api/version returned non-JSON: {body[:200]}", + ) from exc + version = payload.get("version") or "" + if not version: + raise RuntimeError( + f"/api/version missing 'version' field: {body[:200]}", + ) + print(f"PASS /api/version -> {version}") + return version + + +def verify_frontend(base_url: str) -> None: + """Verify the bundled console frontend is served at ``/``.""" + body = _http("GET", f"{base_url}/") + lower = body.lower() + if " frontend HTML served") + + +def configure_provider( + base_url: str, + provider_id: str, + api_key: str, +) -> None: + """Write the DashScope API key into ProviderManager.""" + _http( + "PUT", + f"{base_url}/api/models/{provider_id}/config", + body={"api_key": api_key}, + ) + print(f"PASS configured provider '{provider_id}'") + + +def ensure_model( + base_url: str, + provider_id: str, + model: str, +) -> None: + """Register ``model`` on ``provider_id`` if it isn't already known. + + DashScope ships only a few model ids in the built-in catalogue + (``qwen3-max`` / ``deepseek-v3.2`` / ...), so verifying against newer + aliases like ``qwen3.6-plus`` requires an explicit add first. The + endpoint returns 201 on first add; later runs may 4xx because the model + already exists — both outcomes are fine for our purposes. + """ + try: + _http( + "POST", + f"{base_url}/api/models/{provider_id}/models", + body={"id": model, "name": model}, + ) + print(f"PASS registered model '{model}' on '{provider_id}'") + except RuntimeError as exc: + # 4xx (e.g. 409 already-registered) is expected and downgraded + # to info; 5xx and others are likely real failures and surface + # as warnings so they show up in CI logs. + msg = str(exc) + is_4xx = ( + any( + f" {code} " in f" {msg} " or f"HTTP {code}" in msg + for code in (400, 401, 403, 404, 409, 422) + ) + or "already" in msg.lower() + ) + if is_4xx: + print(f"INFO add-model: {exc}") + else: + print(f"WARN add-model unexpected: {exc}", file=sys.stderr) + + +def set_active_model( + base_url: str, + provider_id: str, + model: str, +) -> None: + """Mark ``provider_id/model`` as the global active LLM.""" + _http( + "PUT", + f"{base_url}/api/models/active", + body={ + "provider_id": provider_id, + "model": model, + "scope": "global", + }, + ) + print(f"PASS active model -> {provider_id}/{model}") + + +# ============================================================================= +# UI driver abstraction +# ============================================================================= + + +class UIDriverInitError(RuntimeError): + """Raised when a UI driver cannot start (missing browser / driver).""" + + +class UIDriver(abc.ABC): + """High-level interface implemented by each platform-specific driver.""" + + @abc.abstractmethod + def open(self, url: str) -> None: + """Navigate to ``url`` and wait until the chat input is visible.""" + + @abc.abstractmethod + def chat_one_round(self, message: str, timeout: int) -> str: + """Send ``message`` and return the resulting AI bubble's full text.""" + + @abc.abstractmethod + def close(self) -> None: + """Tear down browser / webdriver resources (best effort).""" + + +class PlaywrightDriver(UIDriver): + """Headless browser driver backed by Playwright. + + Supports both Chromium (for Legacy desktop) and WebKit (for Tauri + macOS — same engine as the Tauri webview). The ``browser`` arg + selects which backend to launch. + """ + + INPUT_VISIBLE_TIMEOUT_MS = 60_000 + NAVIGATE_TIMEOUT_MS = 60_000 + + def __init__( + self, + browser: str = "chromium", + screenshot_dir: str | None = None, + headless: bool = True, + cdp_url: str = "", + ) -> None: + self._screenshot_dir = screenshot_dir + if screenshot_dir: + os.makedirs(screenshot_dir, exist_ok=True) + + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: + raise UIDriverInitError( + "playwright is not installed; " + "run 'pip install -r scripts/verify/" + "requirements-verify.txt'", + ) from exc + + try: + self._pw = sync_playwright().start() + if cdp_url: + self._browser = self._pw.chromium.connect_over_cdp(cdp_url) + for i in range(60): + if ( + self._browser.contexts + and self._browser.contexts[0].pages + ): + break + if i and i % 10 == 0: + print( + f" CDP: waiting for page " + f"({i * 0.5:.0f}s elapsed)...", + ) + time.sleep(0.5) + if ( + not self._browser.contexts + or not self._browser.contexts[0].pages + ): + raise UIDriverInitError( + "CDP connected but no page appeared within 30s", + ) + self._context = self._browser.contexts[0] + self._page = self._context.pages[0] + else: + launcher = getattr(self._pw, browser, None) + if launcher is None: + raise UIDriverInitError( + f"playwright has no browser '{browser}'", + ) + self._browser = launcher.launch(headless=headless) + self._context = self._browser.new_context() + self._page = self._context.new_page() + except UIDriverInitError: + raise + except Exception as exc: # noqa: BLE001 + raise UIDriverInitError( + f"failed to start {browser}: {exc}", + ) from exc + + def _screenshot(self, name: str) -> None: + """Best-effort screenshot. Never raises.""" + if not self._screenshot_dir: + return + try: + path = os.path.join(self._screenshot_dir, f"{name}.png") + self._page.screenshot(path=path, full_page=True) + print(f" [screenshot] {path}") + except Exception: # noqa: BLE001 + pass + + def open(self, url: str) -> None: + self._page.goto(url, timeout=self.NAVIGATE_TIMEOUT_MS) + self._page.locator(SEL_INPUT).first.wait_for( + state="visible", + timeout=self.INPUT_VISIBLE_TIMEOUT_MS, + ) + self._screenshot("01-page-loaded") + + def wait_for_input(self) -> None: + """Wait for chat input on the current page (no navigation).""" + self._page.locator(SEL_INPUT).first.wait_for( + state="visible", + timeout=self.INPUT_VISIBLE_TIMEOUT_MS, + ) + self._screenshot("01-page-loaded") + + # Same 4-channel disabled detection as e2e/pages/chat_page.py: + # 1. button.disabled property + # 2. disabled attribute + # 3. aria-disabled="true" + # 4. framework-injected disabled / loading class + _JS_SEND_DISABLED = """() => { + const btn = document.querySelector( + 'button.qwenpaw-sender-actions-btn.qwenpaw-btn-primary' + ); + if (!btn) return true; + if (btn.disabled === true) return true; + if (btn.hasAttribute('disabled')) return true; + if (btn.getAttribute('aria-disabled') === 'true') return true; + const cls = btn.className || ''; + if (/qwenpaw-btn-disabled|qwenpaw-btn-loading|is-disabled|is-loading/.test(cls)) { + return true; + } + return false; + }""" + + def _wait_for_send_enabled(self, timeout: int) -> None: + """Block until the send button is clickable (or timeout). + + The chat UI throttles the button while a previous round is still + streaming. Trying to fill+click during that window produces a + no-op and leaves the verifier waiting on a bubble that never + comes. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + disabled = self._page.evaluate(self._JS_SEND_DISABLED) + if not disabled: + return + time.sleep(0.5) + raise RuntimeError( + f"Send button never became enabled within {timeout}s", + ) + + # End-of-streaming detection JS shared with e2e/pages/chat_page.py. + # Two paths, whichever fires first releases the wait: + # Path A: send button went through a disabled -> enabled transition + # (we must have seen disabled at least once during this + # round, then seen it come back to enabled) AND the last + # AI bubble has at least 2 real characters after stripping + # "Thinking" / "Loading" placeholders. + # Path B: last AI bubble content has been unchanged for >= 2500ms + # and has at least 2 real characters (fallback for the + # known "button stays forever disabled" bug). With + # bootstrap pre-skipped (see verify_ui_chat docstring), + # rounds are short single-step replies; 2.5s is plenty. + _JS_BUBBLE_READY = """(expectedCount) => { + const btn = document.querySelector( + 'button.qwenpaw-sender-actions-btn.qwenpaw-btn-primary' + ); + let btnDisabled = true; + if (btn) { + const cls = btn.className || ''; + const disabledByCls = /qwenpaw-btn-disabled|qwenpaw-btn-loading|is-disabled|is-loading/.test(cls); + const disabledByAttr = btn.disabled === true + || btn.hasAttribute('disabled') + || btn.getAttribute('aria-disabled') === 'true'; + btnDisabled = disabledByAttr || disabledByCls; + } + + // Track whether we have ever seen the button in the disabled + // state during this round. Path A only fires after a full + // disabled -> enabled transition, not when the button simply + // hasn't been disabled yet (which looks the same as "enabled"). + const stateKey = '__qwenpaw_btn_was_disabled__'; + if (btnDisabled) { + window[stateKey] = true; + } + const sawDisabled = !!window[stateKey]; + const btnRecovered = sawDisabled && !btnDisabled; + + const aiMsgs = document.querySelectorAll( + '.qwenpaw-bubble.qwenpaw-bubble-start' + ); + if (aiMsgs.length <= expectedCount) { + return false; + } + const last = aiMsgs[aiMsgs.length - 1]; + const raw = (last.innerText || '').trim(); + const stripped = raw + .replace(/Thinking/gi, '') + .replace(/Loading/gi, '') + .trim(); + const hasRealText = stripped.length >= 2; + + let contentStable = false; + if (hasRealText) { + const key = '__qwenpaw_ai_stable_cache__'; + const now = Date.now(); + const cache = window[key] || {}; + if (cache.text !== raw) { + window[key] = { text: raw, since: now }; + } else if ((now - cache.since) >= 2500) { + // 2500ms is empirical — long enough to ride out SSE chunk + // gaps on a busy CI runner, short enough to avoid extending + // the verify step. Revisit if streaming cadence changes. + contentStable = true; + } + } + + if (btnRecovered && hasRealText) { + return true; + } + if (contentStable) { + return true; + } + return false; + }""" + + def _wait_previous_round_idle(self) -> None: + """Wait for any prior round's streaming to finish. + + Runs the same dual-path JS as + ``e2e/pages/chat_page.py::send_message``: button recovered to + enabled **or** last AI bubble content stable for 1500ms. Uses + a separate cache key so it doesn't clobber Gate 2's cache. + Best-effort: timeouts (8s) are swallowed. + """ + if self._page.locator(SEL_USER_BUBBLE).count() == 0: + return + try: + _idle_js = """() => { + const btn = document.querySelector( + 'button.qwenpaw-sender-actions-btn.qwenpaw-btn-primary', + ); + if (btn) { + const cls = btn.className || ''; + const disabledByCls = + /qwenpaw-btn-disabled|qwenpaw-btn-loading|is-disabled|is-loading/.test(cls); + const disabledByAttr = btn.disabled === true + || btn.hasAttribute('disabled') + || btn.getAttribute('aria-disabled') === 'true'; + if (!disabledByAttr && !disabledByCls) return true; + } + const aiMsgs = document.querySelectorAll( + '.qwenpaw-bubble.qwenpaw-bubble-start', + ); + if (aiMsgs.length === 0) return true; + const last = aiMsgs[aiMsgs.length - 1]; + const raw = (last.innerText || '').trim(); + const key = '__qwenpaw_send_idle_cache__'; + const now = Date.now(); + const cache = window[key] || {}; + if (cache.text !== raw) { + window[key] = { text: raw, since: now }; + return false; + } + // 1500ms is empirical — tuned against real CI runners. May need + // adjustment if SSE chunk cadence changes. + return (now - cache.since) >= 1500; +}""" + self._page.wait_for_function(_idle_js, timeout=8000) + except Exception: # noqa: BLE001 + pass + finally: + try: + self._page.evaluate( + "() => { try { delete window." + "__qwenpaw_send_idle_cache__; } catch(e) {} }", + ) + except Exception: # noqa: BLE001 + pass + + def chat_one_round(self, message: str, timeout: int) -> str: + self._wait_previous_round_idle() + + ai_count_before = self._page.locator(SEL_AI_BUBBLE).count() + user_count_before = self._page.locator(SEL_USER_BUBBLE).count() + + # Defensive input flow borrowed from e2e/pages/chat_page.py: + # focus the textarea, clear any leftover text, fill the new + # message, then click send (or fall back to Enter). + input_box = self._page.locator(SEL_INPUT).first + input_box.click() + time.sleep(0.2) + input_box.fill("") + time.sleep(0.2) + input_box.fill(message) + time.sleep(0.5) + + # Reset Gate 2 state-machine caches so a fresh round starts + # from a clean slate (prior round's disabled-flag / content + # stable cache must not carry over). + try: + self._page.evaluate( + "() => { delete window.__qwenpaw_btn_was_disabled__;" + " delete window.__qwenpaw_ai_stable_cache__; }", + ) + except Exception: # noqa: BLE001 + pass + + send_btn = self._page.locator(SEL_SEND_BTN).first + if send_btn.is_visible() and send_btn.is_enabled(): + send_btn.click() + else: + input_box.press("Enter") + + # Gold-standard "message actually sent" check: a new user bubble + # must appear. Treating button-disabled as the signal turns out + # to be racy (e2e/ tried it and dropped it); the user bubble + # showing up is what the SPA actually does once the request was + # accepted. + send_timeout_ms = min(30, timeout) * 1000 + try: + self._page.wait_for_function( + """(expected) => { + const msgs = document.querySelectorAll( + '.qwenpaw-bubble.qwenpaw-bubble-end' + ); + return msgs.length > expected; + }""", + arg=user_count_before, + timeout=send_timeout_ms, + ) + except Exception: # noqa: BLE001 + # Fall back to pressing Enter — some layouts ignore the + # send button click but accept Enter on the textarea. + try: + input_box.click() + time.sleep(0.2) + input_box.press("Enter") + self._page.wait_for_function( + """(expected) => { + const msgs = document.querySelectorAll( + '.qwenpaw-bubble.qwenpaw-bubble-end' + ); + return msgs.length > expected; + }""", + arg=user_count_before, + timeout=send_timeout_ms, + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + f"User bubble never appeared for: {message!r}", + ) from exc + + self._screenshot("02-message-sent") + timeout_ms = timeout * 1000 + + # Gate 1: a new AI bubble appears in the DOM. + try: + self._page.wait_for_function( + """(expectedCount) => { + const aiMsgs = document.querySelectorAll( + '.qwenpaw-bubble.qwenpaw-bubble-start' + ); + return aiMsgs.length > expectedCount; + }""", + arg=ai_count_before, + timeout=timeout_ms, + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + f"No new AI bubble within {timeout}s for: {message!r}", + ) from exc + + # Gate 2: streaming actually finished. Path A (button enabled + + # real text) or Path B (content stable >= 2500ms + real text) + # may release first; whichever fires accepts the round. + try: + self._page.wait_for_function( + self._JS_BUBBLE_READY, + arg=ai_count_before, + timeout=timeout_ms, + ) + except Exception: # noqa: BLE001 + # Don't fail outright — return whatever text we have so the + # caller's substring assertion can still succeed when most + # of the streaming arrived but the end-of-stream signal was + # lost (a known SPA quirk e2e/ also tolerates). + pass + + self._screenshot("03-reply-received") + last_locator = self._page.locator(SEL_AI_BUBBLE).last + try: + raw = (last_locator.inner_text() or "").strip() + except Exception: # noqa: BLE001 + return "" + # Strip placeholders so callers don't accidentally satisfy a + # substring assertion on the loading indicator. + return raw.replace("Thinking", "").replace("Loading", "").strip() + + def close(self) -> None: + for closer in ( + getattr(self, "_page", None), + getattr(self, "_context", None), + getattr(self, "_browser", None), + ): + if closer is None: + continue + try: + closer.close() + except Exception: # noqa: BLE001 + pass + pw = getattr(self, "_pw", None) + if pw is not None: + try: + pw.stop() + except Exception: # noqa: BLE001 + pass + + +UI_MODES = ("tauri-macos", "tauri-windows") + + +def make_driver( + ui_mode: str, + screenshot_dir: str | None = None, + headless: bool = True, + cdp_url: str = "", +) -> UIDriver: + """Build a concrete ``UIDriver`` for the requested mode.""" + if ui_mode == "tauri-macos": + return PlaywrightDriver("webkit", screenshot_dir, headless) + if ui_mode == "tauri-windows": + return PlaywrightDriver( + "chromium", + screenshot_dir, + headless, + cdp_url, + ) + raise UIDriverInitError(f"unknown ui-mode: {ui_mode!r}") + + +# ============================================================================= +# UI-level verification (three-round conversation) +# ============================================================================= + + +def verify_ui_loaded( + driver: UIDriver, + base_url: str, + skip_navigate: bool = False, +) -> None: + """Verify the SPA loads and the chat input becomes visible. + + Runs without an API key — proves the desktop bundle's frontend + is wired up correctly. Catches broken Vite bundles, missing + asset paths, CSP misconfigurations, and Tauri webview load + failures even when LLM credentials are unavailable. + """ + if skip_navigate: + print("--> CDP mode: waiting for SPA on existing page") + driver.wait_for_input() + else: + print(f"--> opening UI at {base_url}") + driver.open(base_url) + print("PASS UI loaded, chat input visible") + + +def verify_ui_chat( + driver: UIDriver, + timeout: int, +) -> None: + """Drive the loaded SPA with one factual question to prove LLM works. + + Assumes the SPA is already loaded by ``verify_ui_loaded``. Uses a + single-round factual question ("tallest mountain") to avoid + multi-turn SPA timing races. Any of Everest / 珠穆朗玛 / 8848 in + the reply proves the full path: + textarea filled -> send clicked -> backend received -> LLM + invoked -> SSE streamed -> AI bubble rendered with real content. + """ + expected_any = ( + "Everest", + "everest", + "珠穆朗玛", + "Chomolungma", + "8848", + "8849", + ) + + question = "What is the tallest mountain in the world?" + print(f"--> sending: {question!r}") + reply = driver.chat_one_round(question, timeout) + preview = reply.replace("\n", " ")[:200] + print(f"<-- agent: {preview}...") + + if not any(kw in reply for kw in expected_any): + raise RuntimeError( + f"LLM reply does not mention Everest / 珠穆朗玛 / 8848. " + f"Got: {reply[:500]}", + ) + print("PASS LLM responded with correct factual answer") + + +def _run_llm_with_retry( + driver: UIDriver, + timeout: int, + retries: int, + allow_flaky: bool, +) -> int: + """Run the LLM chat round with retries; return process exit code.""" + attempts = max(1, retries + 1) + last_err: BaseException | None = None + for attempt in range(1, attempts + 1): + try: + verify_ui_chat(driver, timeout) + return 0 + except Exception as exc: # noqa: BLE001 + last_err = exc + print( + f"WARN LLM round attempt {attempt}/{attempts} " + f"failed: {exc}", + file=sys.stderr, + ) + if attempt < attempts: + backoff = 5 * (2 ** (attempt - 1)) + print(f" retrying in {backoff}s...") + time.sleep(backoff) + if allow_flaky: + print( + "::warning::LLM verification failed after " + f"{attempts} attempts but --allow-flaky-llm is set; " + f"continuing. Last error: {last_err}", + ) + return 0 + print( + f"FAIL LLM verification failed after {attempts} attempts: " + f"{last_err}", + file=sys.stderr, + ) + return 1 + + +# ============================================================================= +# main +# ============================================================================= + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Verify a running QwenPaw desktop backend end-to-end: API " + "health + provider config + single-round UI chat." + ), + ) + parser.add_argument( + "--base-url", + required=True, + help="Base URL of the running desktop backend, e.g. " + "http://127.0.0.1:8088", + ) + parser.add_argument( + "--ui-mode", + choices=UI_MODES, + required=True, + help="UI driver flavour. 'tauri-macos' uses Playwright + WebKit; " + "'tauri-windows' uses Playwright + Chromium over CDP.", + ) + parser.add_argument( + "--api-key", + default=os.environ.get("QWENPAW_DASHSCOPE_API_KEY", ""), + help="DashScope API key. Falls back to env " + "QWENPAW_DASHSCOPE_API_KEY. Empty value -> auto skip-chat.", + ) + parser.add_argument( + "--provider", + default=DEFAULT_PROVIDER, + help=f"Provider id (default: {DEFAULT_PROVIDER})", + ) + parser.add_argument( + "--model", + default=DEFAULT_MODEL, + help=f"Model id (default: {DEFAULT_MODEL})", + ) + parser.add_argument( + "--skip-chat", + action="store_true", + help="Skip the entire LLM chain (provider config + UI chat). " + "Implied when no API key is available.", + ) + parser.add_argument( + "--skip-ui", + action="store_true", + help="Skip the UI driver portion entirely (no SPA load " + "check, no chat round). API-level checks still run. " + "Useful for environments without a browser.", + ) + parser.add_argument( + "--timeout", + type=int, + default=DEFAULT_TIMEOUT, + help=f"Per-chat timeout in seconds (default: {DEFAULT_TIMEOUT})", + ) + parser.add_argument( + "--screenshot-dir", + default=os.environ.get("RUNNER_TEMP", ""), + help="Directory to save UI screenshots. Defaults to " + "env RUNNER_TEMP (set by GitHub Actions). Empty = no " + "screenshots.", + ) + parser.add_argument( + "--headed", + action="store_true", + help="Run the browser in headed mode (visible window) " + "instead of headless. Requires a display server.", + ) + parser.add_argument( + "--cdp-url", + default="", + help="CDP endpoint URL (e.g. http://127.0.0.1:9222). " + "When set, connects to the existing WebView2 via CDP " + "instead of launching a new Playwright browser.", + ) + parser.add_argument( + "--llm-retries", + type=int, + default=3, + help="Retries for the LLM round on transient failures " + "(DashScope 5xx / SSE jitter). Uses exponential backoff " + "(5s, 10s, 20s, ...). Default: 3.", + ) + parser.add_argument( + "--allow-flaky-llm", + action="store_true", + help="If all LLM retries fail, emit a warning and exit 0 " + "instead of failing. Use for fork CI where a flaky LLM " + "should not block release. Release pipelines should NOT " + "set this — they need the assertion.", + ) + args = parser.parse_args() + + base_url = args.base_url.rstrip("/") + skip_chat = args.skip_chat or not args.api_key + + started = time.monotonic() + driver: UIDriver | None = None + try: + # ---- API-level checks (always run, no key needed) ---- + health_check(base_url) + verify_frontend(base_url) + + # ---- UI load (always run unless --skip-ui, no key needed) ---- + # This catches broken Vite bundles, missing assets, CSP issues, + # and Tauri webview load failures even without LLM credentials. + if args.skip_ui: + print("SKIP UI verification (--skip-ui)") + else: + try: + ss_dir = ( + os.path.join( + args.screenshot_dir, + "verify-screenshots", + ) + if args.screenshot_dir + else None + ) + driver = make_driver( + args.ui_mode, + ss_dir, + headless=not args.headed, + cdp_url=args.cdp_url, + ) + except UIDriverInitError as exc: + print(f"FAIL UI driver init: {exc}", file=sys.stderr) + return 3 + verify_ui_loaded( + driver, + base_url, + skip_navigate=bool(args.cdp_url), + ) + + # ---- LLM chat round (only when key is available) ---- + if skip_chat: + reason = ( + "explicit --skip-chat" + if args.skip_chat + else "no DashScope API key provided" + ) + print(f"SKIP LLM verification ({reason})") + elif driver is None: + # --skip-ui was set; nothing to drive. + print("SKIP LLM verification (--skip-ui)") + else: + configure_provider(base_url, args.provider, args.api_key) + ensure_model(base_url, args.provider, args.model) + set_active_model(base_url, args.provider, args.model) + rc = _run_llm_with_retry( + driver, + args.timeout, + args.llm_retries, + args.allow_flaky_llm, + ) + if rc != 0: + return rc + except RuntimeError as exc: + print(f"FAIL {exc}", file=sys.stderr) + return 1 + finally: + if driver is not None: + driver.close() + + elapsed = time.monotonic() - started + print(f"OK desktop verification completed in {elapsed:.1f}s") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify/launch_tauri_macos.sh b/scripts/verify/launch_tauri_macos.sh new file mode 100644 index 0000000..d90c28c --- /dev/null +++ b/scripts/verify/launch_tauri_macos.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Unpack, launch the Tauri macOS shell, and wait for the backend to be ready. +# Outputs BASE_URL to $GITHUB_ENV for subsequent steps. +set -euo pipefail + +# 1. Unpack the freshly built Tauri zip. +echo "[launch_tauri_macos] Unpacking zip..." +mkdir -p dist/verify-tauri +unzip -q dist/QwenPaw-Tauri-*-macOS.zip -d dist/verify-tauri +APP="$(find dist/verify-tauri -maxdepth 3 -name '*.app' -type d | head -1)" +if [ -z "$APP" ]; then + echo "::error::Tauri .app not found inside zip" + exit 1 +fi +echo "[launch_tauri_macos] Found app: $APP" + +# 2. Remove macOS quarantine (CI download marks it). +xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true + +# 3. Launch the full Tauri shell (matches real user double-click). +echo "[launch_tauri_macos] Launching Tauri shell..." +open "$APP" +echo "[launch_tauri_macos] open exit=$?" +sleep 3 +echo "[launch_tauri_macos] Process snapshot after launch:" +ps -ef | grep -iE "qwenpaw|tauri" | grep -v grep || echo " (no matching processes)" + +# 4. Wait for the sidecar to write the port file and respond. +# The sidecar writes desktop_port at WORKING_DIR root (~/.qwenpaw), +# not inside the workspace dir. +PORT_FILE="$HOME/.qwenpaw/desktop_port" +PORT="" +for i in $(seq 1 60); do + if [ -f "$PORT_FILE" ]; then + PORT="$(cat "$PORT_FILE" | tr -d '[:space:]')" + if [ -n "$PORT" ] && curl -sf "http://127.0.0.1:$PORT/api/version" >/dev/null; then + echo "[launch_tauri_macos] Tauri app ready on port $PORT after ~$((i*2))s" + break + fi + fi + if [ "$i" = "60" ]; then + echo "::error::Tauri app did not start within 120s" + echo "[debug] PORT_FILE=$PORT_FILE exists=$([ -f "$PORT_FILE" ] && echo yes || echo no)" + echo "[debug] WORKING_DIR (~/.qwenpaw) contents:" + ls -la "$HOME/.qwenpaw/" 2>/dev/null || echo " (missing)" + echo "[debug] All qwenpaw-related files under HOME (top 30):" + find "$HOME/.qwenpaw" -maxdepth 4 -type f 2>/dev/null | head -30 || true + echo "[debug] desktop.log tail (if exists):" + tail -50 "$HOME/.qwenpaw/desktop.log" 2>/dev/null || echo " (no desktop.log)" + echo "[debug] Process list:" + ps -ef | grep -iE "qwenpaw|tauri" | grep -v grep || echo " (no matching processes)" + exit 1 + fi + sleep 2 +done + +# 5. Auto-init creates BOOTSTRAP.md during startup. Remove it afterwards so +# the verifier can drive the agent in normal QA mode. +rm -f "$HOME/.qwenpaw/workspaces/default/BOOTSTRAP.md" + +export BASE_URL="http://127.0.0.1:$PORT" +echo "BASE_URL=$BASE_URL" >> "$GITHUB_ENV" +echo "$BASE_URL" diff --git a/scripts/verify/launch_tauri_windows.ps1 b/scripts/verify/launch_tauri_windows.ps1 new file mode 100644 index 0000000..4a3a8a0 --- /dev/null +++ b/scripts/verify/launch_tauri_windows.ps1 @@ -0,0 +1,148 @@ +# Install Tauri via NSIS, launch the shell, and wait for the backend. +# Outputs BASE_URL to $env:GITHUB_ENV for subsequent steps. +$ErrorActionPreference = "Stop" + +# 1. Run NSIS silent install (matches real user installer). +# /S = silent, run the installer to completion before continuing. +$installer = Get-ChildItem dist/QwenPaw-Tauri-*-Windows-setup.exe | + Select-Object -First 1 +if (-not $installer) { throw "NSIS installer not found in dist/" } +Write-Host "Installing $($installer.Name) silently..." +$proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S" ` + -Wait -PassThru -NoNewWindow +Write-Host "Installer exited with code $($proc.ExitCode)" +if ($proc.ExitCode -ne 0) { + throw "NSIS installer failed (exit $($proc.ExitCode))" +} +# Tauri NSIS spawns elevated child + finishes immediately; allow time for +# files to settle. +Start-Sleep -Seconds 5 + +# 2. Locate the installed Tauri exe. +# Priority: registry InstallLocation (canonical) → known candidate dirs. +$tauriExe = $null + +# Try registry first — Tauri NSIS always writes InstallLocation. +foreach ($hive in @("HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall")) { + $reg = Get-ChildItem $hive -ErrorAction SilentlyContinue | + Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).DisplayName -match "QwenPaw" } | + Select-Object -First 1 + if ($reg) { + $loc = (Get-ItemProperty $reg.PSPath).InstallLocation + if ($loc -and (Test-Path $loc)) { + $found = Get-ChildItem -Path $loc -Filter "qwenpaw-desktop.exe" ` + -Recurse -Depth 3 -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($found) { $tauriExe = $found.FullName; break } + } + } +} + +# Fallback: search known install candidate directories. +if (-not $tauriExe) { + $candidateRoots = @( + (Join-Path $env:LOCALAPPDATA "PineAgents"), + (Join-Path $env:LOCALAPPDATA "Programs\PineAgents"), + (Join-Path $env:ProgramFiles "PineAgents"), + (Join-Path ${env:ProgramFiles(x86)} "PineAgents") + ) + foreach ($root in $candidateRoots) { + if (Test-Path $root) { + $found = Get-ChildItem -Path $root -Filter "qwenpaw-desktop.exe" ` + -Recurse -Depth 3 -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($found) { $tauriExe = $found.FullName; break } + } + } +} + +if (-not $tauriExe) { + Write-Host "=== DEBUG: install location not found ===" + Write-Host "Registry entries matching QwenPaw:" + foreach ($hive in @("HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")) { + Get-ChildItem $hive -ErrorAction SilentlyContinue | + Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).DisplayName -match "QwenPaw" } | + ForEach-Object { Write-Host " $((Get-ItemProperty $_.PSPath).InstallLocation)" } + } + throw "Tauri exe not found after NSIS install" +} +Write-Host "Installed at: $tauriExe" + +# 2b. Verify WebView2 bootstrapper is bundled in the install. +$installRoot = Split-Path $tauriExe -Parent +$wv2Files = Get-ChildItem -Path $installRoot -Filter "*WebView2*" ` + -Recurse -Depth 3 -ErrorAction SilentlyContinue +if ($wv2Files) { + Write-Host "WebView2 bootstrapper present: $($wv2Files[0].Name)" +} else { + Write-Host "::warning::WebView2 bootstrapper not found in install dir" +} + +# 3. Launch the full Tauri shell with CDP debugging enabled. +# This makes WebView2 expose a Chrome DevTools Protocol port so +# Playwright can connect_over_cdp() to the real embedded webview. +$cdpPort = 9222 +$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=$cdpPort" +Start-Process -FilePath $tauriExe + +# 4. Wait for the sidecar to write the port file and respond. +# The sidecar writes desktop_port at WORKING_DIR root (~/.qwenpaw), +# not inside the workspace dir. +$portFile = Join-Path $env:USERPROFILE ".qwenpaw\desktop_port" +$port = $null +$deadline = (Get-Date).AddSeconds(120) +while ((Get-Date) -lt $deadline) { + if (Test-Path $portFile) { + $port = (Get-Content $portFile -ErrorAction SilentlyContinue).Trim() + if ($port) { + try { + $r = Invoke-WebRequest -Uri "http://127.0.0.1:$port/api/version" ` + -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($r.StatusCode -eq 200) { + Write-Host "Tauri app ready on port $port" + break + } + } catch {} + } + } + Start-Sleep -Seconds 2 +} +if (-not $port) { + Write-Host "::error::Tauri app did not start within 120s" + exit 1 +} + +# 5. Auto-init creates BOOTSTRAP.md during startup. Remove it afterwards so +# the verifier can drive the agent in normal QA mode. +$bootstrapMd = Join-Path $env:USERPROFILE ".qwenpaw\workspaces\default\BOOTSTRAP.md" +if (Test-Path $bootstrapMd) { Remove-Item -Force $bootstrapMd } + +# 6. Wait for CDP endpoint to become available. +$cdpUrl = "http://127.0.0.1:$cdpPort" +$cdpReady = $false +for ($i = 1; $i -le 30; $i++) { + try { + $r = Invoke-WebRequest -Uri "$cdpUrl/json/version" ` + -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop + if ($r.StatusCode -eq 200) { + Write-Host "CDP ready at $cdpUrl" + $cdpReady = $true + break + } + } catch { Start-Sleep -Seconds 2 } +} +if (-not $cdpReady) { + Write-Host "::warning::CDP not available, falling back to standalone browser" + $cdpUrl = "" +} + +$baseUrl = "http://127.0.0.1:$port" +$env:BASE_URL = $baseUrl +$env:CDP_URL = $cdpUrl +"BASE_URL=$baseUrl" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append +"CDP_URL=$cdpUrl" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append +Write-Host "BASE_URL=$baseUrl" +Write-Host "CDP_URL=$cdpUrl" diff --git a/scripts/verify/requirements-verify.txt b/scripts/verify/requirements-verify.txt new file mode 100644 index 0000000..4c788c8 --- /dev/null +++ b/scripts/verify/requirements-verify.txt @@ -0,0 +1,9 @@ +# Dependencies for scripts/verify/desktop_verify.py UI drivers. +# Installed by .github/workflows/desktop-release.yml on each runner before +# the verify step runs. The verifier itself only needs stdlib for the API +# layer; the drivers below are imported lazily so callers using --skip-ui +# don't have to install them. + +# All platforms: Playwright drives Chromium (Legacy + Tauri Win) or +# WebKit (Tauri macOS) for headed/headless browser automation. +playwright>=1.40 diff --git a/scripts/website_build.sh b/scripts/website_build.sh new file mode 100644 index 0000000..9fac803 --- /dev/null +++ b/scripts/website_build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the website (Vite). Run from repo root: bash scripts/website_build.sh +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WEBSITE_DIR="$REPO_ROOT/website" +cd "$WEBSITE_DIR" + +echo "[website_build] Installing dependencies..." +if command -v pnpm &>/dev/null; then + if ! pnpm install --frozen-lockfile 2>/dev/null; then + pnpm install + fi +else + if ! npm ci 2>/dev/null; then + npm install + fi +fi + +echo "[website_build] Building..." +if command -v pnpm &>/dev/null; then + pnpm run build +else + npm run build +fi + +echo "[website_build] Done. Output: $WEBSITE_DIR/dist/" diff --git a/scripts/wheel_build.ps1 b/scripts/wheel_build.ps1 new file mode 100644 index 0000000..d170c26 --- /dev/null +++ b/scripts/wheel_build.ps1 @@ -0,0 +1,47 @@ +# Build a full wheel package including the latest console frontend. +# Run from repo root: pwsh -File scripts/wheel_build.ps1 + +$ErrorActionPreference = "Stop" +$RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName +Set-Location $RepoRoot + +$ConsoleDir = Join-Path $RepoRoot "console" +$ConsoleDest = Join-Path $RepoRoot "src\qwenpaw\console" + +Write-Host "[wheel_build] Building console frontend..." +Push-Location $ConsoleDir +try { + npm ci + if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE" } + npm run build + if ($LASTEXITCODE -ne 0) { throw "npm run build failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location +} + +Write-Host "[wheel_build] Copying console/dist/* -> src/pineagents/console/..." +if (Test-Path $ConsoleDest) { + Remove-Item -Path (Join-Path $ConsoleDest "*") -Recurse -Force -ErrorAction SilentlyContinue +} else { + New-Item -ItemType Directory -Force -Path $ConsoleDest | Out-Null +} +$ConsoleDist = Join-Path $ConsoleDir "dist" +Copy-Item -Path (Join-Path $ConsoleDist "*") -Destination $ConsoleDest -Recurse -Force + +Write-Host "[wheel_build] Bundling website docs into package..." +$DocsSrc = Join-Path $RepoRoot "website\public\docs" +$DocsDest = Join-Path $RepoRoot "src\qwenpaw\docs" +if (Test-Path $DocsDest) { Remove-Item -Recurse -Force $DocsDest } +New-Item -ItemType Directory -Force -Path $DocsDest | Out-Null +Copy-Item -Path (Join-Path $DocsSrc "*.md") -Destination $DocsDest -Force + +Write-Host "[wheel_build] Building wheel + sdist..." +python -m pip install --quiet build +$DistDir = Join-Path $RepoRoot "dist" +if (Test-Path $DistDir) { + Remove-Item -Path (Join-Path $DistDir "*") -Force -ErrorAction SilentlyContinue +} +python -m build --outdir dist . +if ($LASTEXITCODE -ne 0) { throw "python -m build failed with exit code $LASTEXITCODE" } + +Write-Host "[wheel_build] Done. Wheel(s) in: $RepoRoot\dist\" diff --git a/scripts/wheel_build.sh b/scripts/wheel_build.sh new file mode 100644 index 0000000..6e23811 --- /dev/null +++ b/scripts/wheel_build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build a full wheel package including the latest console frontend. +# Run from repo root: bash scripts/wheel_build.sh +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +CONSOLE_DIR="$REPO_ROOT/console" +CONSOLE_DEST="$REPO_ROOT/src/pineagents/console" + +echo "[wheel_build] Building console frontend..." +(cd "$CONSOLE_DIR" && npm ci) +(cd "$CONSOLE_DIR" && npm run build) + +echo "[wheel_build] Copying console/dist/* -> src/pineagents/console/..." +rm -rf "$CONSOLE_DEST"/* + +mkdir -p "$CONSOLE_DEST" +cp -R "$CONSOLE_DIR/dist/"* "$CONSOLE_DEST/" + +echo "[wheel_build] Bundling website docs into package..." +DOCS_SRC="$REPO_ROOT/website/public/docs" +DOCS_DEST="$REPO_ROOT/src/pineagents/docs" +rm -rf "$DOCS_DEST" +mkdir -p "$DOCS_DEST" +cp "$DOCS_SRC/"*.md "$DOCS_DEST/" + +echo "[wheel_build] Building wheel + sdist..." +python3 -m pip install --quiet build +rm -rf dist/* +python3 -m build --outdir dist . + +echo "[wheel_build] Done. Wheel(s) in: $REPO_ROOT/dist/"