diff --git a/.github/workflows/ci-bot.yml b/.github/workflows/ci-bot.yml deleted file mode 100644 index d2f0b4cf4..000000000 --- a/.github/workflows/ci-bot.yml +++ /dev/null @@ -1,178 +0,0 @@ -name: CI Bot -on: - issue_comment: - types: - - created - - pull_request_review_comment: - types: - - created - - issues: - types: - - opened - - pull_request_target: - types: - - opened - - synchronize - -env: - PLUGINS: |- - assign - auto-cc - cc - - AUTHOR_PLUGINS: |- - label-bug - label-documentation - label-enhancement - label-question - retest - - MEMBERS_PLUGINS: |- - label-duplicate - label-good-first-issue - label-help-wanted - label-invalid - label-kind - label-wontfix - lifecycle - retest - - REVIEWERS_PLUGINS: |- - label-lgtm - retitle - - APPROVERS_PLUGINS: |- - label-approve - label - merge - base - rebase - cherry-pick - - MAINTAINERS_PLUGINS: '' - - REVIEWERS: |- - samzong - yankay - - APPROVERS: |- - samzong - yankay - - MAINTAINERS: '' - - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPOSITORY: ${{ github.repository }} - DETAILS: |- - -
- Details - - Instructions for interacting with me using comments are available here. - If you have questions or suggestions related to my behavior, please file an issue against the [gh-ci-bot](https://github.com/wzshiming/gh-ci-bot) repository. - -
- -permissions: - actions: write - checks: read - contents: write - deployments: none - id-token: none - issues: write - discussions: read - packages: none - pages: none - pull-requests: write - repository-projects: read - security-events: none - statuses: read - -jobs: - bot: - name: Bot - runs-on: ubuntu-latest - steps: - - name: Issue Opened - uses: wzshiming/gh-ci-bot@v1.5.0 - if: ${{ github.event_name == 'issues' }} - env: - LOGIN: ${{ github.event.issue.user.login }} - AUTHOR: ${{ github.event.issue.user.login }} - MESSAGE: ${{ github.event.issue.body }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - AUTHOR_ASSOCIATION: ${{ github.event.issue.author_association }} - ISSUE_KIND: issue - TYPE: created - GREETING: |- - Hi @${{ github.event.issue.user.login }}, - Thanks for opening an issue! - We will look into it as soon as possible. - - - name: PR Opened - uses: wzshiming/gh-ci-bot@v1.5.0 - if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' }} - env: - LOGIN: ${{ github.event.pull_request.user.login }} - AUTHOR: ${{ github.event.pull_request.user.login }} - MESSAGE: ${{ github.event.pull_request.body }} - ISSUE_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} - ISSUE_KIND: pr - TYPE: created - GREETING: |- - Hi @${{ github.event.pull_request.user.login }}, - Thanks for your pull request! - If the PR is ready, use the `/auto-cc` command to assign Reviewer to Review. - We will review it shortly. - - - name: PR Synchronize - uses: wzshiming/gh-ci-bot@v1.5.0 - if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'synchronize' }} - env: - LOGIN: ${{ github.event.pull_request.user.login }} - AUTHOR: ${{ github.event.pull_request.user.login }} - MESSAGE: '' - ISSUE_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} - ISSUE_KIND: pr - TYPE: synchronize - - - name: Issue Commented - uses: wzshiming/gh-ci-bot@v1.5.0 - if: ${{ github.event_name == 'issue_comment' && !github.event.issue.pull_request }} - env: - LOGIN: ${{ github.event.comment.user.login }} - AUTHOR: ${{ github.event.issue.user.login }} - MESSAGE: ${{ github.event.comment.body }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} - ISSUE_KIND: issue - TYPE: comment - - - name: PR Review Commented - uses: wzshiming/gh-ci-bot@v1.5.0 - if: ${{ github.event_name == 'pull_request_review_comment' }} - env: - LOGIN: ${{ github.event.comment.user.login }} - AUTHOR: ${{ github.event.pull_request.user.login }} - MESSAGE: ${{ github.event.comment.body }} - ISSUE_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} - ISSUE_KIND: pr - TYPE: comment - - - name: PR Commented - uses: wzshiming/gh-ci-bot@v1.5.0 - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} - env: - LOGIN: ${{ github.event.comment.user.login }} - AUTHOR: ${{ github.event.issue.user.login }} - MESSAGE: ${{ github.event.comment.body }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} - ISSUE_KIND: pr - TYPE: comment diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 560dc4827..000000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,194 +0,0 @@ -name: E2E Tests - -on: - pull_request: - branches: [main] - -concurrency: - group: e2e-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest - permissions: - pull-requests: read - outputs: - smoke: ${{ steps.plan.outputs.smoke }} - gateway: ${{ steps.plan.outputs.gateway }} - steps: - - uses: actions/checkout@v6 - - - id: filter - uses: dorny/paths-filter@v4 - with: - filters: | - global: - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - 'tsconfig.base.json' - - '.github/workflows/e2e.yml' - - '.github/workflows/pr-check.yml' - - 'e2e/playwright.config.ts' - - 'e2e/helpers/**' - shared: - - 'packages/shared/**' - core: - - 'packages/core/**' - desktop_renderer: - - 'packages/desktop/src/renderer/**' - desktop_main: - - 'packages/desktop/src/main/**' - - 'packages/desktop/src/preload/**' - desktop_package: - - 'packages/desktop/package.json' - - 'packages/desktop/electron.vite.config.ts' - - 'packages/desktop/scripts/**' - smoke_specs: - - 'e2e/smoke/**' - gateway_specs: - - 'e2e/gateway/**' - - 'e2e/docker-compose.yml' - - - id: plan - env: - GLOBAL: ${{ steps.filter.outputs.global }} - SHARED: ${{ steps.filter.outputs.shared }} - CORE: ${{ steps.filter.outputs.core }} - DESKTOP_RENDERER: ${{ steps.filter.outputs.desktop_renderer }} - DESKTOP_MAIN: ${{ steps.filter.outputs.desktop_main }} - DESKTOP_PACKAGE: ${{ steps.filter.outputs.desktop_package }} - SMOKE_SPECS: ${{ steps.filter.outputs.smoke_specs }} - GATEWAY_SPECS: ${{ steps.filter.outputs.gateway_specs }} - run: | - set_flag() { - local name="$1" - shift - local value=false - for item in "$@"; do - if [ "$item" = "true" ]; then - value=true - break - fi - done - echo "$name=$value" >> "$GITHUB_OUTPUT" - } - - set_flag smoke "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$SMOKE_SPECS" - set_flag gateway "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$GATEWAY_SPECS" - - smoke: - needs: [changes] - if: needs.changes.outputs.smoke == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build shared package - run: pnpm --filter @clawwork/desktop exec tsc -b ../../packages/shared/tsconfig.json - - - name: Build Electron app - run: pnpm --filter @clawwork/desktop exec electron-vite build - - - name: Install Xvfb - run: sudo apt-get install -y xvfb - - - name: Run smoke tests - run: xvfb-run --auto-servernum -- npx playwright test --config=e2e/playwright.config.ts --project=smoke - - - name: Upload smoke report - if: always() - uses: actions/upload-artifact@v7 - with: - name: e2e-smoke-report - path: playwright-report/ - retention-days: 7 - - gateway: - needs: [changes] - if: needs.changes.outputs.gateway == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build shared package - run: pnpm --filter @clawwork/desktop exec tsc -b ../../packages/shared/tsconfig.json - - - name: Build Electron app - run: pnpm --filter @clawwork/desktop exec electron-vite build - - - name: Install Xvfb - run: sudo apt-get install -y xvfb - - - name: Pull OpenClaw image - run: docker pull ghcr.io/openclaw/openclaw@$OPENCLAW_DIGEST - env: - OPENCLAW_DIGEST: sha256:a5a4c83b773aca85a8ba99cf155f09afa33946c0aa5cc6a9ccb6162738b5da02 - - - name: Start OpenClaw Gateway - run: docker compose -f e2e/docker-compose.yml up -d --wait - - - name: Run gateway tests - run: xvfb-run --auto-servernum -- npx playwright test --config=e2e/playwright.config.ts --project=gateway - - - name: Stop OpenClaw Gateway - if: always() - run: docker compose -f e2e/docker-compose.yml down - - - name: Upload gateway report - if: always() - uses: actions/upload-artifact@v7 - with: - name: e2e-gateway-report - path: playwright-report/ - retention-days: 7 - - e2e-passed: - needs: [changes, smoke, gateway] - if: always() - runs-on: ubuntu-latest - steps: - - name: Verify aggregate status - env: - NEEDS: ${{ toJson(needs) }} - run: | - echo "$NEEDS" - failed=$(jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")' <<< "$NEEDS") - if [ -n "$failed" ]; then - echo "::error::Required gates failed or cancelled: $failed" - exit 1 - fi - echo "All required gates passed or skipped cleanly." diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml deleted file mode 100644 index cec9fac6b..000000000 --- a/.github/workflows/pr-check.yml +++ /dev/null @@ -1,307 +0,0 @@ -name: PR Check - -on: - pull_request: - branches: [main] - -concurrency: - group: pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest - permissions: - pull-requests: read - outputs: - quality: ${{ steps.plan.outputs.quality }} - architecture: ${{ steps.plan.outputs.architecture }} - ui_contract: ${{ steps.plan.outputs.ui_contract }} - renderer_copy: ${{ steps.plan.outputs.renderer_copy }} - i18n: ${{ steps.plan.outputs.i18n }} - dead_code: ${{ steps.plan.outputs.dead_code }} - typecheck_shared: ${{ steps.plan.outputs.typecheck_shared }} - typecheck_core: ${{ steps.plan.outputs.typecheck_core }} - typecheck_desktop: ${{ steps.plan.outputs.typecheck_desktop }} - typecheck_pwa: ${{ steps.plan.outputs.typecheck_pwa }} - typecheck_website: ${{ steps.plan.outputs.typecheck_website }} - test_shared: ${{ steps.plan.outputs.test_shared }} - test_core: ${{ steps.plan.outputs.test_core }} - test_desktop: ${{ steps.plan.outputs.test_desktop }} - test_pwa: ${{ steps.plan.outputs.test_pwa }} - build_desktop: ${{ steps.plan.outputs.build_desktop }} - test_job: ${{ steps.plan.outputs.test_job }} - steps: - - uses: actions/checkout@v6 - - - id: filter - uses: dorny/paths-filter@v4 - with: - filters: | - global: - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - 'tsconfig.base.json' - - 'eslint.config.mjs' - - 'knip.json' - - 'scripts/**' - - '.github/workflows/pr-check.yml' - - '.github/workflows/e2e.yml' - shared: - - 'packages/shared/**' - core: - - 'packages/core/**' - desktop_renderer: - - 'packages/desktop/src/renderer/**' - - 'packages/desktop/test/**/*.tsx' - desktop_main: - - 'packages/desktop/src/main/**' - - 'packages/desktop/src/preload/**' - - 'packages/desktop/test/**/*.ts' - desktop_package: - - 'packages/desktop/package.json' - - 'packages/desktop/electron.vite.config.ts' - - 'packages/desktop/scripts/**' - pwa: - - 'packages/pwa/**' - website: - - 'website/**' - - '.github/workflows/website.yml' - keynote: - - 'keynote/**' - - - id: plan - env: - GLOBAL: ${{ steps.filter.outputs.global }} - SHARED: ${{ steps.filter.outputs.shared }} - CORE: ${{ steps.filter.outputs.core }} - DESKTOP_RENDERER: ${{ steps.filter.outputs.desktop_renderer }} - DESKTOP_MAIN: ${{ steps.filter.outputs.desktop_main }} - DESKTOP_PACKAGE: ${{ steps.filter.outputs.desktop_package }} - PWA: ${{ steps.filter.outputs.pwa }} - WEBSITE: ${{ steps.filter.outputs.website }} - KEYNOTE: ${{ steps.filter.outputs.keynote }} - run: | - set_flag() { - local name="$1" - shift - local value=false - for item in "$@"; do - if [ "$item" = "true" ]; then - value=true - break - fi - done - echo "$name=$value" >> "$GITHUB_OUTPUT" - } - - set_flag quality "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$PWA" "$WEBSITE" - set_flag architecture "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_MAIN" - set_flag ui_contract "$GLOBAL" "$DESKTOP_RENDERER" "$PWA" - set_flag renderer_copy "$GLOBAL" "$DESKTOP_RENDERER" - set_flag i18n "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$PWA" - set_flag dead_code "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$PWA" "$WEBSITE" - set_flag typecheck_shared "$GLOBAL" "$SHARED" - set_flag typecheck_core "$GLOBAL" "$SHARED" "$CORE" - set_flag typecheck_desktop "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" - set_flag typecheck_pwa "$GLOBAL" "$SHARED" "$CORE" "$PWA" - set_flag typecheck_website "$GLOBAL" "$WEBSITE" - set_flag test_shared "$GLOBAL" "$SHARED" - set_flag test_core "$GLOBAL" "$SHARED" "$CORE" - set_flag test_desktop "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" - set_flag test_pwa "$GLOBAL" "$SHARED" "$CORE" "$PWA" - set_flag build_desktop "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" - set_flag test_job "$GLOBAL" "$SHARED" "$CORE" "$DESKTOP_RENDERER" "$DESKTOP_MAIN" "$DESKTOP_PACKAGE" "$PWA" "$WEBSITE" - - quality: - needs: [changes] - if: needs.changes.outputs.quality == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - - name: Format check - run: pnpm format:check - - - name: Architecture guardrails - if: needs.changes.outputs.architecture == 'true' - run: pnpm check:architecture - - - name: UI contract - if: needs.changes.outputs.ui_contract == 'true' - run: pnpm check:ui-contract - - - name: Renderer copy contract - if: needs.changes.outputs.renderer_copy == 'true' - run: pnpm check:renderer-copy - - - name: i18n key parity - if: needs.changes.outputs.i18n == 'true' - run: pnpm check:i18n - - - name: Dead code detection - if: needs.changes.outputs.dead_code == 'true' - run: pnpm check:dead-code - - test: - needs: [changes] - if: needs.changes.outputs.test_job == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Type check shared - if: needs.changes.outputs.typecheck_shared == 'true' || needs.changes.outputs.typecheck_core == 'true' || needs.changes.outputs.typecheck_desktop == 'true' || needs.changes.outputs.typecheck_pwa == 'true' - run: pnpm --filter @clawwork/shared exec tsc -b - - - name: Type check core - if: needs.changes.outputs.typecheck_core == 'true' || needs.changes.outputs.typecheck_desktop == 'true' || needs.changes.outputs.typecheck_pwa == 'true' - run: pnpm --filter @clawwork/core exec tsc -b - - - name: Type check desktop - if: needs.changes.outputs.typecheck_desktop == 'true' - run: pnpm --filter @clawwork/desktop exec tsc --noEmit - - - name: Type check PWA - if: needs.changes.outputs.typecheck_pwa == 'true' - run: pnpm --filter @clawwork/pwa exec tsc --noEmit - - - name: Type check website - if: needs.changes.outputs.typecheck_website == 'true' - run: pnpm --filter @clawwork/website exec tsc --noEmit - - - name: Type check shared tests - if: needs.changes.outputs.test_shared == 'true' - run: pnpm --filter @clawwork/shared exec tsc --noEmit -p tsconfig.test.json - - - name: Type check core tests - if: needs.changes.outputs.test_core == 'true' - run: pnpm --filter @clawwork/core exec tsc --noEmit -p tsconfig.test.json - - - name: Type check desktop tests - if: needs.changes.outputs.test_desktop == 'true' - run: pnpm --filter @clawwork/desktop exec tsc --noEmit -p tsconfig.test.json - - - name: Type check PWA tests - if: needs.changes.outputs.test_pwa == 'true' - run: pnpm --filter @clawwork/pwa exec tsc --noEmit -p tsconfig.test.json - - - name: Test shared - if: needs.changes.outputs.test_shared == 'true' - run: pnpm --filter @clawwork/shared test - - - name: Test core - if: needs.changes.outputs.test_core == 'true' - run: pnpm --filter @clawwork/core test - - - name: Test desktop - if: needs.changes.outputs.test_desktop == 'true' - run: pnpm --filter @clawwork/desktop test - - - name: Test PWA - if: needs.changes.outputs.test_pwa == 'true' - run: pnpm --filter @clawwork/pwa test - - build: - needs: [changes, quality, test] - if: needs.changes.outputs.build_desktop == 'true' && needs.quality.result != 'failure' && needs.test.result != 'failure' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build Electron app - run: pnpm --filter @clawwork/desktop build - - - name: Package Linux smoke build - run: pnpm --filter @clawwork/desktop exec electron-builder --linux --x64 --publish never - - secrets-scan: - runs-on: ubuntu-latest - permissions: - contents: read - env: - GITLEAKS_VERSION: 8.30.1 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Install gitleaks - run: | - curl -fsSL \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | tar -xz -C /usr/local/bin gitleaks - gitleaks version - - - name: Scan PR diff - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: gitleaks detect --source . --log-opts "${BASE_SHA}..${HEAD_SHA}" --redact --verbose --no-banner - - ci-passed: - needs: [changes, quality, test, build, secrets-scan] - if: always() - runs-on: ubuntu-latest - steps: - - name: Verify aggregate status - env: - NEEDS: ${{ toJson(needs) }} - run: | - echo "$NEEDS" - failed=$(jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")' <<< "$NEEDS") - if [ -n "$failed" ]; then - echo "::error::Required gates failed or cancelled: $failed" - exit 1 - fi - echo "All required gates passed or skipped cleanly." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index fff091485..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,263 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: 'Release tag (for example: v1.0.0 or v1.0.0-beta.1)' - required: true - type: string - -permissions: - contents: write - actions: write - -jobs: - prepare-release: - runs-on: ubuntu-latest - env: - RELEASE_TAG: ${{ inputs.tag || github.ref_name }} - outputs: - release_tag: ${{ steps.meta.outputs.release_tag }} - is_prerelease: ${{ steps.meta.outputs.is_prerelease }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - - - name: Verify tag format and version consistency - id: meta - run: | - if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-((alpha|beta|rc)\.[0-9]+))?$ ]]; then - echo "::error::Unsupported release tag format: $RELEASE_TAG" - exit 1 - fi - - DESKTOP_VERSION=$(node -p "require('./packages/desktop/package.json').version") - TAG_VERSION="${RELEASE_TAG#v}" - if [ "$DESKTOP_VERSION" != "$TAG_VERSION" ]; then - echo "::error::Version mismatch: package.json=${DESKTOP_VERSION}, tag=${TAG_VERSION}" - exit 1 - fi - - echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" - if [[ "$RELEASE_TAG" == *-* ]]; then - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - fi - - release: - needs: prepare-release - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - platform: mac - arch: arm64 - build_args: '--mac --arm64' - - os: macos-latest - platform: mac - arch: x64 - build_args: '--mac --x64' - - os: windows-latest - platform: win - arch: x64 - build_args: '--win --x64' - - os: ubuntu-latest - platform: linux - arch: x64 - build_args: '--linux --x64' - - runs-on: ${{ matrix.os }} - env: - RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }} - IS_PRERELEASE: ${{ needs.prepare-release.outputs.is_prerelease }} - defaults: - run: - shell: bash - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Import Apple signing certificate (macOS) - if: matrix.platform == 'mac' - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - if [ -z "$APPLE_CERTIFICATE" ]; then - echo "⚠️ Apple certificate not configured, skipping code signing" - exit 0 - fi - - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - - echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $RUNNER_TEMP/certificate.p12 - - security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH - security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security list-keychain -d user -s $KEYCHAIN_PATH - - echo "✅ Certificate imported successfully" - - - name: Build Electron app - run: pnpm --filter @clawwork/desktop build - - - name: Package and publish (${{ matrix.platform }}) - if: matrix.platform != 'mac' - run: pnpm --filter @clawwork/desktop exec electron-builder ${{ matrix.build_args }} --publish always -c.buildVersion="$BUILD_VERSION" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_VERSION: ${{ github.run_number }} - EP_PRE_RELEASE: ${{ env.IS_PRERELEASE }} - - - name: Package and publish (mac, with adhoc fallback) - if: matrix.platform == 'mac' - run: | - LOG=$(mktemp) - set +e - pnpm --filter @clawwork/desktop exec electron-builder ${{ matrix.build_args }} --publish always -c.buildVersion="$BUILD_VERSION" 2>&1 | tee "$LOG" - status=${PIPESTATUS[0]} - set -e - if [ "$status" -eq 0 ]; then - exit 0 - fi - if ! grep -q "Failed to notarize" "$LOG"; then - echo "::error::electron-builder failed for a non-notarization reason — not falling back to ad-hoc." - exit "$status" - fi - echo "::warning::Notarization failed — falling back to ad-hoc signing for ${{ matrix.arch }}." - rm -rf packages/desktop/dist - unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID - export CSC_IDENTITY_AUTO_DISCOVERY=false - pnpm --filter @clawwork/desktop exec electron-builder ${{ matrix.build_args }} --publish always -c.buildVersion="$BUILD_VERSION" -c.mac.identity=null -c.mac.notarize=false - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_VERSION: ${{ github.run_number }} - EP_PRE_RELEASE: ${{ env.IS_PRERELEASE }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - - name: Upload arch-specific update metadata (macOS arm64) - if: matrix.platform == 'mac' && matrix.arch == 'arm64' - run: | - cp packages/desktop/dist/latest-mac.yml packages/desktop/dist/latest-mac-arm64.yml - gh release upload "$RELEASE_TAG" packages/desktop/dist/latest-mac-arm64.yml --clobber --repo clawwork-ai/clawwork - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - verify-release: - needs: [prepare-release, release] - runs-on: ubuntu-latest - env: - RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }} - steps: - - name: Verify update metadata files - run: | - ASSETS=$(gh release view "$RELEASE_TAG" --repo clawwork-ai/clawwork --json assets -q '.assets[].name') - echo "Release assets:" - echo "$ASSETS" - echo "$ASSETS" | grep -q "latest-mac-arm64.yml" || { echo "::error::latest-mac-arm64.yml missing from release"; exit 1; } - echo "$ASSETS" | grep -q "latest-mac.yml" || { echo "::error::latest-mac.yml (x64) missing from release"; exit 1; } - echo "$ASSETS" | grep -q "latest.yml" || { echo "::error::latest.yml missing from release"; exit 1; } - echo "$ASSETS" | grep -q "latest-linux.yml" || { echo "::error::latest-linux.yml missing from release"; exit 1; } - echo "$ASSETS" | grep -q "\.AppImage$" || { echo "::error::AppImage missing from release"; exit 1; } - echo "$ASSETS" | grep -q "\.deb$" || { echo "::error::deb package missing from release"; exit 1; } - echo "All update metadata files present" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-release-notes: - needs: [prepare-release, verify-release] - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: read - env: - RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - - - name: Generate release notes draft - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node scripts/generate-release-notes.mjs "$RELEASE_TAG" > /tmp/release-notes.md - { - echo "## Draft release notes for $RELEASE_TAG" - echo '' - cat /tmp/release-notes.md - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload draft as artifact - uses: actions/upload-artifact@v7 - with: - name: release-notes-${{ env.RELEASE_TAG }} - path: /tmp/release-notes.md - - - name: Fill Release body only if empty - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if ! current=$(gh release view "$RELEASE_TAG" --repo ${{ github.repository }} --json body -q '.body' 2>/dev/null); then - echo "::warning::Could not read current Release body — skipping notes fill to avoid overwriting hand-written content" - exit 0 - fi - stripped=$(printf '%s' "$current" | tr -d '[:space:]') - if [ -z "$stripped" ]; then - echo "Release body is empty — writing draft" - gh release edit "$RELEASE_TAG" --repo ${{ github.repository }} --notes-file /tmp/release-notes.md - else - echo "Release body already populated — leaving it alone" - fi - - update-homebrew: - needs: [prepare-release, release, verify-release] - if: ${{ needs.prepare-release.outputs.is_prerelease != 'true' }} - runs-on: ubuntu-latest - steps: - - name: Trigger Homebrew tap update - run: | - gh workflow run update-homebrew.yml \ - --repo clawwork-ai/clawwork \ - --field tag="$RELEASE_TAG" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }} diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml deleted file mode 100644 index dde04a6f0..000000000 --- a/.github/workflows/update-homebrew.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Update Homebrew Tap - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: 'Git tag to publish to Homebrew tap (for example: v0.0.2)' - required: true - type: string - -permissions: - contents: read - -jobs: - update-homebrew-tap: - if: ${{ github.event_name != 'release' || github.event.release.prerelease != true }} - runs-on: ubuntu-latest - env: - CLAWWORK_REPO: clawwork-ai/clawwork - TAP_DIR: homebrew-clawwork - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} - - steps: - - name: Checkout source repository - uses: actions/checkout@v6 - - - name: Checkout tap repository - uses: actions/checkout@v6 - with: - repository: clawwork-ai/homebrew-clawwork - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-clawwork - - - name: Update clawwork cask - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CLAWWORK_REPO: ${{ env.CLAWWORK_REPO }} - TAP_DIR: ${{ env.TAP_DIR }} - RELEASE_TAG: ${{ env.RELEASE_TAG }} - run: | - bash scripts/update-homebrew-tap.sh - - - name: Configure git identity - working-directory: homebrew-clawwork - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Commit and push tap update - working-directory: homebrew-clawwork - run: | - if git diff --quiet; then - echo "No Homebrew tap changes to commit." - exit 0 - fi - - git add Casks/clawwork.rb - git commit -m "chore: update clawwork cask for ${RELEASE_TAG}" - git push origin HEAD:main diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml deleted file mode 100644 index 670d0507b..000000000 --- a/.github/workflows/website.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Website - -on: - push: - branches: [main] - paths: - - 'website/**' - - 'keynote/**' - - '.github/workflows/website.yml' - pull_request: - paths: - - 'website/**' - - 'keynote/**' - - '.github/workflows/website.yml' - workflow_dispatch: - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v5 - with: - version: 10 - - - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm --filter @clawwork/website exec tsc --noEmit - - - name: Build website - run: pnpm --filter @clawwork/website build - env: - VITE_BASE: /ClawWork/ - - - name: Build keynote - run: pnpm --filter @clawwork/keynote build --base /ClawWork/keynote/ - - - name: Patch keynote SPA redirect - run: node -e "const f='keynote/dist/index.html';const fs=require('fs');const h=fs.readFileSync(f,'utf8');fs.writeFileSync(f,h.replace('',''))" - - - name: Merge outputs - run: cp -r keynote/dist website/dist/keynote - - - name: Upload Pages artifact - if: github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v5 - with: - path: website/dist - - deploy: - if: github.ref == 'refs/heads/main' - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/packages/desktop/src/main/workspace/init.ts b/packages/desktop/src/main/workspace/init.ts index 26374adee..f25b42158 100644 --- a/packages/desktop/src/main/workspace/init.ts +++ b/packages/desktop/src/main/workspace/init.ts @@ -12,7 +12,7 @@ export async function migrateWorkspace(oldPath: string, newPath: string): Promis if (!existsSync(oldPath)) throw new Error(`Source workspace does not exist: ${oldPath}`); const resolvedOld = resolve(oldPath); const resolvedNew = resolve(newPath); - if (resolvedNew.startsWith(resolvedOld + '/') || resolvedNew === resolvedOld) { + if (resolvedNew.startsWith(resolvedOld + sep) || resolvedNew === resolvedOld) { throw new Error('New workspace path must not be inside or equal to the current workspace'); } await cp(resolvedOld, resolvedNew, { diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx index 86dcf2162..9bdb8d99b 100644 --- a/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx +++ b/packages/desktop/src/renderer/layouts/Settings/sections/GatewaysSection.tsx @@ -480,34 +480,46 @@ export default function GatewaysSection() { return; } setTesting(true); - const auth = { - token: form.token || undefined, - password: form.password || undefined, - }; - const res = await window.clawwork.testGateway(form.url, auth); - setTesting(false); - if (res.ok) { - toast.success(t('settings.testSuccess')); - } else if (res.pairingRequired) { - setShowPairingDialog(true); - } else { - toast.error(t('settings.testFailed'), { description: res.error }); + try { + const auth = { + token: form.token || undefined, + password: form.password || undefined, + }; + const res = await window.clawwork.testGateway(form.url, auth); + if (res.ok) { + toast.success(t('settings.testSuccess')); + } else if (res.pairingRequired) { + setShowPairingDialog(true); + } else { + toast.error(t('settings.testFailed'), { description: res.error }); + } + } catch (err) { + console.error('[GatewaysSection] test failed:', err); + toast.error(t('errors.failed')); + } finally { + setTesting(false); } }, [form, t]); const handlePairingRetry = useCallback(async () => { setPairingRetrying(true); - const auth = { - token: form.token || undefined, - password: form.password || undefined, - }; - const res = await window.clawwork.testGateway(form.url, auth); - setPairingRetrying(false); - if (res.ok) { - setShowPairingDialog(false); - toast.success(t('pairing.approved')); - } else { - toast.error(t('pairing.stillPending'), { description: res.error }); + try { + const auth = { + token: form.token || undefined, + password: form.password || undefined, + }; + const res = await window.clawwork.testGateway(form.url, auth); + if (res.ok) { + setShowPairingDialog(false); + toast.success(t('pairing.approved')); + } else { + toast.error(t('pairing.stillPending'), { description: res.error }); + } + } catch (err) { + console.error('[GatewaysSection] pairing retry failed:', err); + toast.error(t('errors.failed')); + } finally { + setPairingRetrying(false); } }, [form.url, form.token, form.password, t]); @@ -527,43 +539,49 @@ export default function GatewaysSection() { } setSaving(true); - if (editingId) { - const res = await window.clawwork.updateGateway(editingId, { - name: form.name.trim(), - url: form.url.trim(), - token: form.token.trim() || undefined, - password: form.password.trim() || undefined, - pairingCode: form.pairingCode.trim() || undefined, - authMode, - }); - if (res.ok) { - toast.success(t('settings.gatewayUpdated')); - closeForm(); - await loadGateways(); - } else { - toast.error(res.error ?? t('errors.failed')); - } - } else { - const newGw: GatewayServerConfig = { - id: crypto.randomUUID(), - name: form.name.trim(), - url: form.url.trim(), - token: form.token.trim() || undefined, - password: form.password.trim() || undefined, - pairingCode: form.pairingCode.trim() || undefined, - authMode, - type: 'openclaw', - }; - const res = await window.clawwork.addGateway(newGw); - if (res.ok) { - toast.success(t('settings.gatewayAdded')); - closeForm(); - await loadGateways(); + try { + if (editingId) { + const res = await window.clawwork.updateGateway(editingId, { + name: form.name.trim(), + url: form.url.trim(), + token: form.token.trim() || undefined, + password: form.password.trim() || undefined, + pairingCode: form.pairingCode.trim() || undefined, + authMode, + }); + if (res.ok) { + toast.success(t('settings.gatewayUpdated')); + closeForm(); + await loadGateways(); + } else { + toast.error(res.error ?? t('errors.failed')); + } } else { - toast.error(res.error ?? t('errors.failed')); + const newGw: GatewayServerConfig = { + id: crypto.randomUUID(), + name: form.name.trim(), + url: form.url.trim(), + token: form.token.trim() || undefined, + password: form.password.trim() || undefined, + pairingCode: form.pairingCode.trim() || undefined, + authMode, + type: 'openclaw', + }; + const res = await window.clawwork.addGateway(newGw); + if (res.ok) { + toast.success(t('settings.gatewayAdded')); + closeForm(); + await loadGateways(); + } else { + toast.error(res.error ?? t('errors.failed')); + } } + } catch (err) { + console.error('[GatewaysSection] save failed:', err); + toast.error(t('errors.failed')); + } finally { + setSaving(false); } - setSaving(false); }, [form, editingId, closeForm, loadGateways, t]); const handleRemove = useCallback( diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx index 9a10960a0..720a6b53d 100644 --- a/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx +++ b/packages/desktop/src/renderer/layouts/Settings/sections/GeneralSection.tsx @@ -101,7 +101,8 @@ export default function GeneralSection() { const handleNotificationToggle = useCallback( (key: 'taskComplete' | 'approvalRequest' | 'gatewayDisconnect', value: boolean) => { - void updateSettings({ notifications: { ...notifyState, [key]: value } }).catch((err: unknown) => { + const next = { ...notifyState, [key]: value }; + void updateSettings({ notifications: next }).catch((err: unknown) => { console.error('[GeneralSection] updateSettings failed:', err); }); }, diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx index 1bc24c22a..93a13d0f4 100644 --- a/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx +++ b/packages/desktop/src/renderer/layouts/Settings/sections/SkillsSection.tsx @@ -583,18 +583,24 @@ function ClawHubTab({ gatewayId, onInstalled }: { gatewayId: string; onInstalled const handleInstall = useCallback( async (slug: string) => { setInstallingSlugs((prev) => new Set(prev).add(slug)); - const res = await window.clawwork.installSkill(gatewayId, { source: 'clawhub', slug }); - if (res.ok && res.result?.ok) { - toast.success(t('settings.skillHubInstalled')); - onInstalled(); - } else { - toast.error(res.error ?? res.result?.message ?? t('settings.skillHubInstallFailed')); + try { + const res = await window.clawwork.installSkill(gatewayId, { source: 'clawhub', slug }); + if (res.ok && res.result?.ok) { + toast.success(t('settings.skillHubInstalled')); + onInstalled(); + } else { + toast.error(res.error ?? res.result?.message ?? t('settings.skillHubInstallFailed')); + } + } catch (err) { + console.error('[SkillsSection] install failed:', err); + toast.error(t('settings.skillHubInstallFailed')); + } finally { + setInstallingSlugs((prev) => { + const next = new Set(prev); + next.delete(slug); + return next; + }); } - setInstallingSlugs((prev) => { - const next = new Set(prev); - next.delete(slug); - return next; - }); }, [gatewayId, onInstalled, t], ); @@ -738,22 +744,28 @@ export default function SkillsSection() { async (skill: SkillStatusEntry) => { if (!selectedGatewayId) return; setTogglingKeys((prev) => new Set(prev).add(skill.skillKey)); - const newEnabled = skill.disabled; - const res = await window.clawwork.updateSkill(selectedGatewayId, { - skillKey: skill.skillKey, - enabled: newEnabled, - }); - if (res.ok) { - toast.success(newEnabled ? t('settings.skillEnabled') : t('settings.skillDisabledToast')); - await refreshSkills(); - } else { + try { + const newEnabled = skill.disabled; + const res = await window.clawwork.updateSkill(selectedGatewayId, { + skillKey: skill.skillKey, + enabled: newEnabled, + }); + if (res.ok) { + toast.success(newEnabled ? t('settings.skillEnabled') : t('settings.skillDisabledToast')); + await refreshSkills(); + } else { + toast.error(t('settings.skillUpdateFailed')); + } + } catch (err) { + console.error('[SkillsSection] toggle failed:', err); toast.error(t('settings.skillUpdateFailed')); + } finally { + setTogglingKeys((prev) => { + const next = new Set(prev); + next.delete(skill.skillKey); + return next; + }); } - setTogglingKeys((prev) => { - const next = new Set(prev); - next.delete(skill.skillKey); - return next; - }); }, [selectedGatewayId, refreshSkills, t], ); diff --git a/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx b/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx index 7cc0dc60c..05a4416cc 100644 --- a/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx +++ b/packages/desktop/src/renderer/layouts/Settings/sections/SystemSection.tsx @@ -65,20 +65,33 @@ export default function SystemSection() { ); const handleChangeWorkspace = useCallback(async () => { - const selected = await window.clawwork.browseWorkspace(); + let selected: string | null = null; + try { + selected = await window.clawwork.browseWorkspace(); + } catch (err) { + console.error('[SystemSection] browseWorkspace failed:', err); + toast.error(t('errors.failed')); + return; + } if (!selected || selected === workspacePath) return; const oldPath = workspacePath; setChangingWorkspace(true); - const result = await window.clawwork.changeWorkspace(selected); - setChangingWorkspace(false); - if (result.ok) { - await refreshSettings().catch(() => {}); - toast.success(t('settings.workspaceChanged'), { - description: t('settings.workspaceOldPathHint', { path: oldPath }), - duration: 8000, - }); - } else { - toast.error(t('settings.workspaceChangeFailed', { error: result.error })); + try { + const result = await window.clawwork.changeWorkspace(selected); + if (result.ok) { + await refreshSettings().catch(() => {}); + toast.success(t('settings.workspaceChanged'), { + description: t('settings.workspaceOldPathHint', { path: oldPath }), + duration: 8000, + }); + } else { + toast.error(t('settings.workspaceChangeFailed', { error: result.error })); + } + } catch (err) { + console.error('[SystemSection] changeWorkspace failed:', err); + toast.error(t('settings.workspaceChangeFailed', { error: String(err) })); + } finally { + setChangingWorkspace(false); } }, [refreshSettings, workspacePath, t]); diff --git a/packages/desktop/test/workspace-init.test.ts b/packages/desktop/test/workspace-init.test.ts new file mode 100644 index 000000000..c9eda6a69 --- /dev/null +++ b/packages/desktop/test/workspace-init.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdirSync, existsSync } from 'fs'; +import { cp } from 'fs/promises'; +import { resolve } from 'path'; + +vi.mock('fs', () => ({ + mkdirSync: vi.fn(), + existsSync: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + cp: vi.fn(), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockCp = vi.mocked(cp); +const mockMkdirSync = vi.mocked(mkdirSync); + +describe('migrateWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function loadMigrateWorkspace() { + const mod = await import('../src/main/workspace/init.js'); + return mod.migrateWorkspace; + } + + it('throws when new path is inside old path', async () => { + mockExistsSync.mockReturnValue(true); + const migrateWorkspace = await loadMigrateWorkspace(); + const oldPath = resolve('/workspace'); + const newPath = resolve('/workspace', 'sub'); + + await expect(migrateWorkspace(oldPath, newPath)).rejects.toThrow( + 'New workspace path must not be inside or equal to the current workspace', + ); + expect(mockCp).not.toHaveBeenCalled(); + }); + + it('throws when new path equals old path', async () => { + mockExistsSync.mockReturnValue(true); + const migrateWorkspace = await loadMigrateWorkspace(); + const path = resolve('/workspace'); + + await expect(migrateWorkspace(path, path)).rejects.toThrow( + 'New workspace path must not be inside or equal to the current workspace', + ); + expect(mockCp).not.toHaveBeenCalled(); + }); + + it('throws when source workspace does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const migrateWorkspace = await loadMigrateWorkspace(); + + await expect(migrateWorkspace('/old', '/new')).rejects.toThrow('Source workspace does not exist: /old'); + }); + + it('copies workspace when paths are valid', async () => { + mockExistsSync.mockReturnValue(true); + mockCp.mockResolvedValue(undefined); + const migrateWorkspace = await loadMigrateWorkspace(); + const oldPath = resolve('/workspace/old'); + const newPath = resolve('/workspace/new'); + + await migrateWorkspace(oldPath, newPath); + + expect(mockCp).toHaveBeenCalledWith(oldPath, newPath, { + recursive: true, + errorOnExist: false, + force: true, + }); + }); +}); + +describe('initWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function loadInitWorkspace() { + const mod = await import('../src/main/workspace/init.js'); + return mod.initWorkspace; + } + + it('creates directory when it does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const initWorkspace = await loadInitWorkspace(); + + await initWorkspace('/workspace'); + + expect(mockMkdirSync).toHaveBeenCalledWith('/workspace', { recursive: true }); + }); + + it('does nothing when directory already exists', async () => { + mockExistsSync.mockReturnValue(true); + const initWorkspace = await loadInitWorkspace(); + + await initWorkspace('/workspace'); + + expect(mockMkdirSync).not.toHaveBeenCalled(); + }); +});