diff --git a/.eslintrc.json b/.eslintrc.json index 13ec039a5e..e69de29bb2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,28 +0,0 @@ -{ - "extends": ["plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "react-hooks", "import"], - "rules": { - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - } - ], - "@typescript-eslint/no-explicit-any": "warn", - - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-empty-function": "off", - "prefer-const": "warn", - "react-hooks/set-state-in-effect": "warn" - }, - "overrides": [], - "env": { - "browser": true, - "es2020": true, - "node": true - }, - "ignorePatterns": ["**/_*/**"] -} diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml new file mode 100644 index 0000000000..2e6c8cae79 --- /dev/null +++ b/.github/actions/setup-build/action.yml @@ -0,0 +1,64 @@ +name: Setup build environment +description: Install toolchain, deps, and write production env + +inputs: + posthog-key: + description: PostHog project API key + required: false + default: '' + posthog-host: + description: PostHog host + required: false + default: '' + build-variant: + description: Build variant to embed (canary | prod) + required: false + default: 'prod' + windows-native: + description: Set to true on Windows to pass MSVC/gyp flags to pnpm install + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: pnpm + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python build tools + shell: bash + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install dependencies (Windows) + if: inputs.windows-native == 'true' + shell: bash + env: + npm_config_msvs_version: '2022' + GYP_MSVS_VERSION: '2022' + run: pnpm install --frozen-lockfile + + - name: Install dependencies + if: inputs.windows-native != 'true' + shell: bash + run: pnpm install --frozen-lockfile + + - name: Write production env + shell: bash + env: + PH_KEY: ${{ inputs.posthog-key }} + PH_HOST: ${{ inputs.posthog-host }} + BUILD_VARIANT: ${{ inputs.build-variant }} + run: | + echo "VITE_POSTHOG_KEY=$PH_KEY" >> .env.production + echo "VITE_POSTHOG_HOST=$PH_HOST" >> .env.production + echo "VITE_BUILD=$BUILD_VARIANT" >> .env.production diff --git a/.github/actions/upload-r2/action.yml b/.github/actions/upload-r2/action.yml new file mode 100644 index 0000000000..142d653086 --- /dev/null +++ b/.github/actions/upload-r2/action.yml @@ -0,0 +1,40 @@ +name: Upload to R2 +description: Upload release artifacts to Cloudflare R2 + +inputs: + r2-account-id: + required: true + description: Cloudflare R2 account ID + r2-access-key-id: + required: true + description: Cloudflare R2 access key ID + r2-secret-access-key: + required: true + description: Cloudflare R2 secret access key + r2-bucket: + required: true + description: Cloudflare R2 bucket + channel: + description: Override update channel for manifest discovery (default uses stable channel) + required: false + default: '' + prefix: + description: Override artifact prefix for installer discovery (default uses stable prefix) + required: false + default: '' + +runs: + using: composite + steps: + - name: Upload to R2 + shell: bash + env: + R2_ACCOUNT_ID: ${{ inputs.r2-account-id }} + R2_ACCESS_KEY_ID: ${{ inputs.r2-access-key-id }} + R2_SECRET_ACCESS_KEY: ${{ inputs.r2-secret-access-key }} + R2_BUCKET: ${{ inputs.r2-bucket }} + run: | + ARGS="" + if [ -n "${{ inputs.channel }}" ]; then ARGS="$ARGS --channel ${{ inputs.channel }}"; fi + if [ -n "${{ inputs.prefix }}" ]; then ARGS="$ARGS --prefix ${{ inputs.prefix }}"; fi + node --experimental-strip-types scripts/release/upload-r2.ts $ARGS diff --git a/.github/workflows/code-consistency-check.yml b/.github/workflows/code-consistency-check.yml index 8126031c53..5c470ef066 100644 --- a/.github/workflows/code-consistency-check.yml +++ b/.github/workflows/code-consistency-check.yml @@ -43,48 +43,8 @@ jobs: - name: Check formatting run: pnpm run format:check - # TODO: add this once fixed across all files - # - name: Check linting - # run: pnpm run lint - - name: Type check run: pnpm run typecheck - vitest: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' - - - name: Install Linux native deps - run: | - sudo apt-get update - sudo apt-get install -y build-essential libsecret-1-0 libsecret-1-dev - - - name: Install dependencies - # Tests import Electron/keytar paths, so lifecycle scripts must run. - run: | - for attempt in 1 2 3; do - pnpm install --frozen-lockfile && break - if [ "$attempt" -eq 3 ]; then - echo "pnpm install failed after 3 attempts" - exit 1 - fi - echo "Install failed (attempt $attempt). Retrying in 10s..." - sleep 10 - done - - - name: Run Vitest - run: pnpm exec vitest run + - name: Check linting + run: pnpm run lint diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml new file mode 100644 index 0000000000..d255285983 --- /dev/null +++ b/.github/workflows/release-canary.yml @@ -0,0 +1,168 @@ +name: Release (Canary) + +on: + workflow_dispatch: + inputs: + arch: + description: 'Architecture to build (arm64 | x64 | both)' + required: false + default: 'both' + +permissions: + contents: read + +jobs: + release-linux: + runs-on: ubuntu-latest + permissions: + contents: read + environment: release + steps: + - uses: actions/checkout@v4 + - name: Install system build dependencies + run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config libsecret-1-dev rpm + - uses: ./.github/actions/setup-build + with: + posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary + + - run: pnpm run build + + - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version + + - name: Build Linux packages + run: > + node --experimental-strip-types scripts/release/build.ts + --platform linux --arch x64 + --config electron-builder.canary.config.ts + + - uses: ./.github/actions/upload-r2 + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + channel: v1-canary + prefix: emdash-canary + + release-win: + runs-on: windows-2022 + permissions: + contents: read + environment: release + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-build + with: + posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary + windows-native: 'true' + - name: Export Python path for native modules + shell: bash + run: echo "python=$(which python)" >> "$GITHUB_ENV" + + - name: Build app + shell: bash + run: pnpm run build + + - shell: bash + run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version + + - name: Check Azure Trusted Signing secrets + id: signing + shell: bash + env: + AZ_TENANT: ${{ secrets.AZURE_TENANT_ID }} + AZ_CLIENT: ${{ secrets.AZURE_CLIENT_ID }} + AZ_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: | + if [ -n "$AZ_TENANT" ] && [ -n "$AZ_CLIENT" ] && [ -n "$AZ_SECRET" ]; then + echo "has_signing=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing=false" >> "$GITHUB_OUTPUT" + echo "::warning::Azure Trusted Signing secrets not configured. Windows build will be unsigned." + fi + + - name: Build Windows packages + shell: bash + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: > + node --experimental-strip-types scripts/release/build.ts + --platform win --arch x64 + --config electron-builder.canary.config.ts + + - name: Verify Windows code signature + if: ${{ steps.signing.outputs.has_signing == 'true' }} + shell: bash + run: node --experimental-strip-types scripts/release/verify-win.ts + + - name: Upload to R2 + uses: ./.github/actions/upload-r2 + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + channel: v1-canary + prefix: emdash-canary + + release-mac: + runs-on: macos-latest + permissions: + contents: read + environment: release + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-build + with: + posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary + + - name: Import Apple signing certificate + uses: apple-actions/import-codesign-certs@v2 + with: + p12-file-base64: ${{ secrets.CERTIFICATE_P12 }} + p12-password: ${{ secrets.CERTIFICATE_PASSWORD }} + + - run: pnpm run build + + - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version + + - name: Build signed DMGs + ZIPs + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'true' + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: > + node --experimental-strip-types scripts/release/build.ts + --platform mac --arch ${{ github.event.inputs.arch || 'both' }} --targets dmg,zip + --config electron-builder.canary.config.ts + + - name: Verify macOS bundle + run: > + node --experimental-strip-types scripts/release/verify-mac.ts + --expected-team-id ${{ secrets.APPLE_TEAM_ID }} + + - name: Notarize and staple + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node --experimental-strip-types scripts/release/notarize-mac.ts --app-bundle "Emdash Canary.app" + + - name: Upload to R2 + uses: ./.github/actions/upload-r2 + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + channel: v1-canary + prefix: emdash-canary diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml new file mode 100644 index 0000000000..5e6bee27d6 --- /dev/null +++ b/.github/workflows/release-prod.yml @@ -0,0 +1,159 @@ +name: Release (Production) + +on: + workflow_dispatch: + inputs: + arch: + description: 'Architecture to build (arm64 | x64 | both)' + required: false + default: 'both' + +permissions: + contents: read + +jobs: + release-linux: + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + environment: release + steps: + - uses: actions/checkout@v4 + - name: Install system build dependencies + run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config libsecret-1-dev rpm + - uses: ./.github/actions/setup-build + with: + posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} + + - run: pnpm run build + + - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version + + - name: Build Linux packages + run: > + node --experimental-strip-types scripts/release/build.ts + --platform linux --arch x64 + + - uses: ./.github/actions/upload-r2 + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + + release-win: + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + runs-on: windows-2022 + permissions: + contents: read + environment: release + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-build + with: + posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} + windows-native: 'true' + - name: Export Python path for native modules + shell: bash + run: echo "python=$(which python)" >> "$GITHUB_ENV" + + - name: Build app + shell: bash + run: pnpm run build + + - shell: bash + run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version + + - name: Check Azure Trusted Signing secrets + id: signing + shell: bash + env: + AZ_TENANT: ${{ secrets.AZURE_TENANT_ID }} + AZ_CLIENT: ${{ secrets.AZURE_CLIENT_ID }} + AZ_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: | + if [ -n "$AZ_TENANT" ] && [ -n "$AZ_CLIENT" ] && [ -n "$AZ_SECRET" ]; then + echo "has_signing=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing=false" >> "$GITHUB_OUTPUT" + echo "::warning::Azure Trusted Signing secrets not configured. Windows build will be unsigned." + fi + + - name: Build Windows packages + shell: bash + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + run: > + node --experimental-strip-types scripts/release/build.ts + --platform win --arch x64 + + - name: Verify Windows code signature + if: ${{ steps.signing.outputs.has_signing == 'true' }} + shell: bash + run: node --experimental-strip-types scripts/release/verify-win.ts + + - name: Upload to R2 + uses: ./.github/actions/upload-r2 + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + + release-mac: + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + runs-on: macos-latest + permissions: + contents: read + environment: release + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-build + with: + posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} + + - name: Import Apple signing certificate + uses: apple-actions/import-codesign-certs@v2 + with: + p12-file-base64: ${{ secrets.CERTIFICATE_P12 }} + p12-password: ${{ secrets.CERTIFICATE_PASSWORD }} + + - run: pnpm run build + + - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version + + - name: Build signed DMGs + ZIPs + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'true' + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: > + node --experimental-strip-types scripts/release/build.ts + --platform mac --arch ${{ github.event.inputs.arch || 'both' }} --targets dmg,zip + + - name: Verify macOS bundle + run: > + node --experimental-strip-types scripts/release/verify-mac.ts + --expected-team-id ${{ secrets.APPLE_TEAM_ID }} + + - name: Notarize and staple + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node --experimental-strip-types scripts/release/notarize-mac.ts --app-bundle "Emdash.app" + + - name: Upload to R2 + uses: ./.github/actions/upload-r2 + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index da99edbd03..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,329 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v1.*' - workflow_dispatch: - inputs: - arch: - description: 'Architecture to build (arm64 | x64 | both)' - required: false - default: 'both' - dry_run: - description: 'Build signed+stapled but DO NOT publish (artifacts only)' - required: false - default: 'true' - -permissions: - contents: read - -jobs: - build-mac: - runs-on: macos-latest - steps: - - name: Init flags - id: init - run: | - set -euo pipefail - DRY=${{ inputs.dry_run || '' }} - if [ -z "$DRY" ]; then DRY=${{ github.event.inputs.dry_run || '' }}; fi - DRY=$(printf "%s" "$DRY" | tr '[:upper:]' '[:lower:]') - case "$DRY" in - true|1|yes) DRY=true ;; - *) DRY=false ;; - esac - echo "dry_run=$DRY" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - run: python -m pip install --upgrade pip setuptools wheel - - run: pnpm install --frozen-lockfile - - run: pnpm run build - - - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version - - - name: Inject telemetry - env: - PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - PH_HOST: ${{ secrets.POSTHOG_HOST }} - run: node --experimental-strip-types scripts/release/inject-telemetry.ts - - - name: Build unsigned DMGs - env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: > - node --experimental-strip-types scripts/release/build.ts - --platform mac --arch ${{ github.event.inputs.arch || 'both' }} --targets dmg - - - name: Verify macOS bundle - run: node --experimental-strip-types scripts/release/verify-mac.ts - - release-linux: - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - contents: read - environment: release - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - run: python -m pip install --upgrade pip setuptools wheel - - name: Install system build dependencies - run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config libsecret-1-dev rpm - - run: pnpm install --frozen-lockfile - - run: pnpm run build - - - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version - - - name: Inject telemetry - env: - PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - PH_HOST: ${{ secrets.POSTHOG_HOST }} - run: node --experimental-strip-types scripts/release/inject-telemetry.ts - - - name: Build Linux packages - run: > - node --experimental-strip-types scripts/release/build.ts - --platform linux --arch x64 - - - name: Upload to R2 - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - run: node --experimental-strip-types scripts/release/upload-r2.ts - - release-win: - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - runs-on: windows-2022 - permissions: - contents: read - environment: release - steps: - - name: Init flags - id: init - shell: bash - run: | - set -euo pipefail - DRY=${{ inputs.dry_run || '' }} - if [ -z "$DRY" ]; then DRY=${{ github.event.inputs.dry_run || '' }}; fi - if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${GITHUB_REF_TYPE:-}" = "branch" ]; then - DRY=true - fi - DRY=$(printf "%s" "$DRY" | tr '[:upper:]' '[:lower:]') - case "$DRY" in - true|1|yes) DRY=true ;; - *) DRY=false ;; - esac - echo "dry_run=$DRY" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install Python build deps - shell: bash - run: | - python -m pip install --upgrade pip setuptools wheel - echo "python=$(which python)" >> "$GITHUB_ENV" - - - name: Install dependencies - shell: bash - env: - npm_config_python: ${{ env.python }} - npm_config_msvs_version: 2022 - GYP_MSVS_VERSION: 2022 - run: pnpm install --frozen-lockfile - - - name: Build app - shell: bash - run: pnpm run build - - - shell: bash - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version - - - name: Inject telemetry - shell: bash - env: - PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - PH_HOST: ${{ secrets.POSTHOG_HOST }} - run: node --experimental-strip-types scripts/release/inject-telemetry.ts - - - name: Check Azure Trusted Signing secrets - id: signing - shell: bash - env: - AZ_TENANT: ${{ secrets.AZURE_TENANT_ID }} - AZ_CLIENT: ${{ secrets.AZURE_CLIENT_ID }} - AZ_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - run: | - if [ -n "$AZ_TENANT" ] && [ -n "$AZ_CLIENT" ] && [ -n "$AZ_SECRET" ]; then - echo "has_signing=true" >> "$GITHUB_OUTPUT" - else - echo "has_signing=false" >> "$GITHUB_OUTPUT" - echo "::warning::Azure Trusted Signing secrets not configured. Windows build will be unsigned." - fi - - - name: Build Windows packages - shell: bash - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - run: > - node --experimental-strip-types scripts/release/build.ts - --platform win --arch x64 - - - name: Verify Windows code signature - if: ${{ steps.signing.outputs.has_signing == 'true' }} - shell: bash - run: node --experimental-strip-types scripts/release/verify-win.ts - - - name: Upload to R2 - if: ${{ steps.init.outputs.dry_run != 'true' }} - shell: bash - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - run: node --experimental-strip-types scripts/release/upload-r2.ts - - - name: Upload artifacts (dry-run) - if: ${{ steps.init.outputs.dry_run == 'true' }} - uses: actions/upload-artifact@v4 - with: - name: WINDOWS-ARTIFACTS - path: | - release/emdash-*.exe - release/emdash-*.msi - release/*.blockmap - release/v1-stable*.yml - if-no-files-found: error - - release-mac: - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - runs-on: macos-latest - permissions: - contents: read - environment: release - steps: - - name: Init flags - id: init - run: | - set -euo pipefail - DRY=${{ inputs.dry_run || '' }} - if [ -z "$DRY" ]; then DRY=${{ github.event.inputs.dry_run || '' }}; fi - DRY=$(printf "%s" "$DRY" | tr '[:upper:]' '[:lower:]') - case "$DRY" in - true|1|yes) DRY=true ;; - *) DRY=false ;; - esac - echo "dry_run=$DRY" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'pnpm' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - run: | - python -m pip install --upgrade pip setuptools wheel - echo "python=$(which python3)" >> $GITHUB_ENV - - run: pnpm install --frozen-lockfile - - - name: Import Apple signing certificate - uses: apple-actions/import-codesign-certs@v2 - with: - p12-file-base64: ${{ secrets.CERTIFICATE_P12 }} - p12-password: ${{ secrets.CERTIFICATE_PASSWORD }} - - - run: pnpm run build - - - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version - - - name: Inject telemetry - env: - PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - PH_HOST: ${{ secrets.POSTHOG_HOST }} - run: node --experimental-strip-types scripts/release/inject-telemetry.ts - - - name: Build signed DMGs + ZIPs - env: - CSC_IDENTITY_AUTO_DISCOVERY: 'true' - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - run: > - node --experimental-strip-types scripts/release/build.ts - --platform mac --arch ${{ github.event.inputs.arch || 'both' }} --targets dmg,zip - - - name: Verify macOS bundle - run: > - node --experimental-strip-types scripts/release/verify-mac.ts - --expected-team-id ${{ secrets.APPLE_TEAM_ID }} - - - name: Notarize and staple - if: ${{ steps.init.outputs.dry_run != 'true' }} - env: - APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - run: node --experimental-strip-types scripts/release/notarize-mac.ts - - - name: Upload to R2 - if: ${{ steps.init.outputs.dry_run != 'true' }} - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - run: node --experimental-strip-types scripts/release/upload-r2.ts - - - name: Upload artifacts (dry-run) - if: ${{ steps.init.outputs.dry_run == 'true' }} - uses: actions/upload-artifact@v4 - with: - name: SIGNED-STAPLED-DMGS - path: | - release/emdash-*.dmg - release/emdash-*.zip - release/*.blockmap - release/v1-stable*.yml - if-no-files-found: error diff --git a/.gitignore b/.gitignore index ff7dafab98..0cbf69ae59 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ jspm_packages/ .env.development.local .env.test.local .env.production.local +.env.production # IDE .vscode/ @@ -69,4 +70,5 @@ Thumbs.db .cursor .codex/config.toml -src/main/appConfig.json \ No newline at end of file +src/main/appConfig.json +.pi/extensions/emdash-hook.ts diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 5ee7abd87c..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm exec lint-staged diff --git a/.node-version b/.node-version index 57c15c67c7..d845d9d88d 100644 --- a/.node-version +++ b/.node-version @@ -1,2 +1 @@ -22.20.0 - +24.14.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96e9dae4af..77acf9c23b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thanks for your interest in contributing! We favor small, focused PRs and clear Prerequisites -- **Node.js 24.0.0+ (recommended: 24.14.0)** and Git +- **Node.js 24.0.0+ (recommended: 24.14.0)**, **pnpm 10.28.0+**, and Git - Optional (recommended for end‑to‑end testing): - GitHub CLI (`brew install gh`; then `gh auth login`) - At least one supported coding agent CLI (see docs for list) @@ -28,10 +28,11 @@ pnpm run d pnpm install pnpm run dev -# Type checking, lint, build - pnpm run typecheck - pnpm run lint - pnpm run build +# Format, lint, type check, and test +pnpm run format +pnpm run lint +pnpm run typecheck +pnpm run test ``` Tip: During development, the renderer hot‑reloads. Changes to the Electron main process (files in `src/main`) require a restart of the dev app. @@ -61,13 +62,13 @@ Tip: During development, the renderer hot‑reloads. Changes to the Electron mai 3. Run checks locally ``` -pnpm run format # Format code with Prettier (required) +pnpm run format # Format code with Prettier (required) +pnpm run lint # ESLint pnpm run typecheck # TypeScript type checking -pnpm run lint # ESLint -pnpm run build # Build both main and renderer +pnpm run test # Vitest test suite ``` -Pre-commit hooks run automatically via Husky + lint-staged. On each commit, staged files are auto-formatted with Prettier and linted with ESLint. You don't need to remember to run these manually. Type checking and tests run in CI only since they need the full project context and are slower to execute. +Pre-commit hooks run automatically via Husky + lint-staged. On each commit, staged files are auto-formatted with Prettier and linted with ESLint. Run the full local gate before opening or merging a PR. If you need to skip the hook for a work-in-progress commit, use `git commit --no-verify`. The checks will still run in CI when you open a PR. @@ -98,9 +99,9 @@ TypeScript + ESLint + Prettier Pre-commit hooks handle formatting and linting automatically on staged files. For full-project checks you can run them manually: - `pnpm run format` -- format all files with Prettier -- `pnpm run typecheck` -- TypeScript type checking (whole project) - `pnpm run lint` -- ESLint across all files -- `pnpm exec vitest run` -- run the test suite +- `pnpm run typecheck` -- TypeScript type checking (whole project) +- `pnpm run test` -- run the test suite Electron main (Node side) @@ -159,19 +160,23 @@ This automatically: 2. Creates a git commit with the version number (e.g., `"0.2.10"`) 3. Creates a git tag (e.g., `v0.2.10`) -Then push to trigger the CI/CD pipeline. +Then push the commit and tag. Production release builds are dispatched from GitHub Actions. ### What happens next -Two GitHub Actions workflows trigger on version tags: +The release pipeline is split across these GitHub Actions workflows: -**macOS Release** (`.github/workflows/release.yml`): -1. Builds the TypeScript and Vite bundles -2. Signs the app with Apple Developer ID -3. Notarizes via Apple's notary service -4. Creates a GitHub Release with DMG artifacts for arm64 and x64 +**Production Release** (`.github/workflows/release-prod.yml`): +1. Builds Linux, Windows, and macOS packages +2. Signs Windows builds when Azure Trusted Signing secrets are configured +3. Signs, verifies, notarizes, and staples macOS DMGs and ZIPs +4. Uploads release artifacts to Cloudflare R2 **Linux/Nix Build** (`.github/workflows/nix-build.yml`): 1. Computes the correct dependency hash from `pnpm-lock.yaml` 2. Builds the x86_64-linux package via Nix flake -3. Pushes build artifacts to Cachix +3. Pushes build artifacts to Cachix and uploads the Nix artifact when available + +**Canary Release** (`.github/workflows/release-canary.yml`): +1. Builds Linux, Windows, and macOS packages with the canary config +2. Publishes artifacts to the `v1-canary` R2 channel diff --git a/LICENSE.md b/LICENSE.md index b020b7b722..52225c98d0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,204 @@ -MIT License - -Copyright (c) 2025 General Action, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Copyright 2026 General Action, Inc. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index c19c640a4a..4b03758de2 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,13 @@ Download for Linux -

- Download Emdash v1 -

-

- Stable v1 is now available for macOS, Windows, and Linux · - Read the launch post -

-
Emdash is a provider-agnostic desktop app that lets you run multiple coding agents in parallel, each isolated in its own git worktree, either locally or over SSH on a remote machine. We call it an Agentic Development Environment (ADE). -Emdash supports 23 CLI agents, including Claude Code, Qwen Code, Hermes Agent, Amp, and Codex. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. +Emdash supports 24 CLI agents, including Claude Code, Codex, OpenCode, Gemini and Amp. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. **Develop on remote servers via SSH** @@ -58,16 +50,17 @@ Connect to remote machines via SSH/SFTP to work with remote codebases. Emdash su # Installation ### macOS -- Apple Silicon: https://github.com/generalaction/emdash/releases/latest/download/emdash-arm64.dmg -- Intel x64: https://github.com/generalaction/emdash/releases/latest/download/emdash-x64.dmg +- Homebrew: `brew install --cask emdash` +- Apple Silicon: https://releases.emdash.sh/emdash-arm64.dmg +- Intel x64: https://releases.emdash.sh/emdash-x64.dmg ### Windows -- Installer (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-x64.msi -- Portable (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-x64.exe +- Installer (x64): https://releases.emdash.sh/emdash-x64.msi +- Portable (x64): https://releases.emdash.sh/emdash-x64.exe ### Linux -- AppImage (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-x86_64.AppImage -- Debian package (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-amd64.deb +- AppImage (x64): https://releases.emdash.sh/emdash-x86_64.AppImage +- Debian package (x64): https://releases.emdash.sh/emdash-amd64.deb ### Release Overview @@ -79,7 +72,7 @@ Connect to remote machines via SSH/SFTP to work with remote codebases. Emdash su ### Supported CLI Providers -Emdash currently supports 23 CLI providers, and we are adding new ones regularly. If you miss one, let us know or create a PR. +Emdash currently supports 26 CLI providers, and we are adding new ones regularly. If you miss one, let us know or create a PR. | CLI Provider | Status | Install | | ----------- | ------ | ----------- | @@ -93,14 +86,15 @@ Emdash currently supports 23 CLI providers, and we are adding new ones regularly | [Codex](https://github.com/openai/codex) | ✅ Supported | npm install -g @openai/codex | | [Continue](https://docs.continue.dev/guides/cli) | ✅ Supported | npm i -g @continuedev/cli | | [Cursor](https://cursor.com/cli) | ✅ Supported | curl https://cursor.com/install -fsS | bash | +| [Devin](https://cli.devin.ai/docs) | ✅ Supported | curl -fsSL https://cli.devin.ai/install.sh | bash | | [Droid](https://docs.factory.ai/cli/getting-started/quickstart) | ✅ Supported | curl -fsSL https://app.factory.ai/cli | sh | | [Gemini](https://github.com/google-gemini/gemini-cli) | ✅ Supported | npm install -g @google/gemini-cli | | [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) | ✅ Supported | npm install -g @github/copilot | | [Goose](https://block.github.io/goose/docs/quickstart/) | ✅ Supported | curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash | -| [Hermes Agent](https://hermes-agent.nousresearch.com/docs/) | ✅ Supported | curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash | | [Kilocode](https://kilo.ai/docs/cli) | ✅ Supported | npm install -g @kilocode/cli | | [Kimi](https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html) | ✅ Supported | uv tool install kimi-cli | | [Kiro (AWS)](https://kiro.dev/docs/cli/) | ✅ Supported | curl -fsSL https://cli.kiro.dev/install | bash | +| [Letta](https://docs.letta.com/letta-code/cli) | ✅ Supported | npm install -g @letta-ai/letta-code | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ Supported | curl -LsSf https://mistral.ai/vibe/install.sh | bash | | [OpenCode](https://opencode.ai/docs/cli/) | ✅ Supported | npm install -g opencode-ai | | [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | ✅ Supported | npm install -g @mariozechner/pi-coding-agent | @@ -109,13 +103,16 @@ Emdash currently supports 23 CLI providers, and we are adding new ones regularly ### Issues -Emdash allows you to pass tickets straight from Linear, GitHub, or Jira to your coding agent. +Emdash allows you to pass issues, tickets, and support threads straight to your coding agent. | Tool | Status | Authentication | | ----------- | ------ | ----------- | | [Linear](https://linear.app) | ✅ Supported | Connect with a Linear API key. | | [Jira](https://www.atlassian.com/software/jira) | ✅ Supported | Provide your site URL, email, and Atlassian API token. | -| [GitHub Issues](https://docs.github.com/en/issues) | ✅ Supported | Authenticate via GitHub CLI (`gh auth login`). | +| [GitHub Issues](https://docs.github.com/en/issues) | ✅ Supported | Connect your GitHub account or authenticate via GitHub CLI (`gh auth login`). | +| [GitLab Issues](https://docs.gitlab.com/user/project/issues/) | ✅ Supported | Provide your GitLab instance URL and a personal access token with `read_api` scope. | +| [Forgejo Issues](https://forgejo.org/) | ✅ Supported | Provide your Forgejo instance URL and API token. | +| [Plain Threads](https://www.plain.com/) | ✅ Supported | Connect with a Plain API key. | # Contributing diff --git a/agents/integrations/providers.md b/agents/integrations/providers.md index eabd67a7d9..b7c1bb87fb 100644 --- a/agents/integrations/providers.md +++ b/agents/integrations/providers.md @@ -6,9 +6,9 @@ - `src/main/core/dependencies/dependency-manager.ts` - `src/main/core/pty/` -## Current Providers (22) +## Current Providers (26) -codex, claude, qwen, droid, gemini, cursor, copilot, amp, opencode, charm, auggie, goose, kimi, kilocode, kiro, rovo, cline, continue, codebuff, mistral, pi, autohand +codex, claude, devin, qwen, droid, gemini, cursor, copilot, amp, opencode, hermes, charm, auggie, goose, kimi, kilocode, kiro, rovo, cline, continue, codebuff, mistral, junie, pi, autohand, letta ## Provider Metadata Includes diff --git a/agents/quickstart.md b/agents/quickstart.md index 22643e52f9..b962f42731 100644 --- a/agents/quickstart.md +++ b/agents/quickstart.md @@ -25,7 +25,7 @@ pnpm run reset pnpm run format pnpm run lint pnpm run typecheck -pnpm test run +pnpm run test ``` ## Docs Commands diff --git a/agents/risky-areas/updater.md b/agents/risky-areas/updater.md index 7830a7f268..cf360acd0e 100644 --- a/agents/risky-areas/updater.md +++ b/agents/risky-areas/updater.md @@ -6,7 +6,8 @@ - `src/main/core/updates/controller.ts` - `build/` - `package.json` -- `.github/workflows/release.yml` +- `.github/workflows/release-prod.yml` +- `.github/workflows/release-canary.yml` - `.github/workflows/windows-beta-build.yml` - `.github/workflows/nix-build.yml` diff --git a/agents/workflows/testing.md b/agents/workflows/testing.md index 39b7c270f5..dd40ee4d99 100644 --- a/agents/workflows/testing.md +++ b/agents/workflows/testing.md @@ -31,8 +31,8 @@ pnpm run test - `.github/workflows/code-consistency-check.yml` currently enforces: - `pnpm run format:check` - `pnpm run typecheck` - - `pnpm exec vitest run` -- Lint is still expected locally even though it is not enabled in that workflow yet. + - `pnpm run lint` +- Tests are still expected locally before merging even though they are not enabled in that workflow yet. ## Focused Validation diff --git a/dev-app-update.canary.yml b/dev-app-update.canary.yml new file mode 100644 index 0000000000..1b2322e9b7 --- /dev/null +++ b/dev-app-update.canary.yml @@ -0,0 +1,3 @@ +provider: generic +url: https://releases.emdash.sh +channel: v1-canary diff --git a/drizzle/0007_bent_spitfire.sql b/drizzle/0007_bent_spitfire.sql new file mode 100644 index 0000000000..444b07d462 --- /dev/null +++ b/drizzle/0007_bent_spitfire.sql @@ -0,0 +1,3 @@ +ALTER TABLE `tasks` ADD `workspace_provider` text;--> statement-breakpoint +ALTER TABLE `tasks` ADD `workspace_id` text;--> statement-breakpoint +ALTER TABLE `tasks` ADD `workspace_provider_data` text; \ No newline at end of file diff --git a/drizzle/0008_past_shinko_yamashiro.sql b/drizzle/0008_past_shinko_yamashiro.sql new file mode 100644 index 0000000000..1a5d70b42a --- /dev/null +++ b/drizzle/0008_past_shinko_yamashiro.sql @@ -0,0 +1 @@ +ALTER TABLE `conversations` ADD `last_interacted_at` text; \ No newline at end of file diff --git a/drizzle/0009_lean_goblin_queen.sql b/drizzle/0009_lean_goblin_queen.sql new file mode 100644 index 0000000000..b7cc68cbfc --- /dev/null +++ b/drizzle/0009_lean_goblin_queen.sql @@ -0,0 +1 @@ +ALTER TABLE `conversations` ADD `is_initial_conversation` integer; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..cd92f77abb --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1291 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "68b5cc95-67aa-4078-94fa-c10caad8d9eb", + "prevId": "79109ea2-737d-48d0-b5c6-ec2ceb4b0d3c", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000000..4449954ab1 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1298 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f7518b53-ae00-412e-a92c-68667522bca3", + "prevId": "68b5cc95-67aa-4078-94fa-c10caad8d9eb", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000000..96c7378f56 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1305 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "44fce29b-43c5-4a52-98b0-22f4c2a840dd", + "prevId": "f7518b53-ae00-412e-a92c-68667522bca3", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_initial_conversation": { + "name": "is_initial_conversation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e9a35926ba..5bb2bcc089 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,27 @@ "when": 1776959681864, "tag": "0006_bumpy_gamma_corps", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1777399834705, + "tag": "0007_bent_spitfire", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1778075084353, + "tag": "0008_past_shinko_yamashiro", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1778239261398, + "tag": "0009_lean_goblin_queen", + "breakpoints": true } ] } diff --git a/electron-builder.canary.config.ts b/electron-builder.canary.config.ts new file mode 100644 index 0000000000..06c8de3c49 --- /dev/null +++ b/electron-builder.canary.config.ts @@ -0,0 +1,79 @@ +import type { Configuration } from 'electron-builder'; +import { + APP_ID, + ARTIFACT_PREFIX, + PRODUCT_NAME, + R2_BASE_URL, + UPDATE_CHANNEL, +} from './src/shared/app-identity.canary'; + +const config: Configuration = { + appId: APP_ID, + productName: PRODUCT_NAME, + directories: { output: 'release' }, + artifactName: `${ARTIFACT_PREFIX}-\${arch}.\${ext}`, + publish: [ + { + provider: 'generic', + url: R2_BASE_URL, + channel: UPDATE_CHANNEL, + }, + ], + generateUpdatesFilesForAllChannels: false, + files: ['out/**/*', 'node_modules/**/*', 'drizzle/**/*'], + asarUnpack: [ + 'node_modules/better-sqlite3/**', + 'node_modules/node-pty/**', + 'node_modules/@parcel/watcher/**', + '**/*.node', + ], + mac: { + category: 'public.app-category.developer-tools', + hardenedRuntime: true, + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.plist', + target: [ + { target: 'dmg', arch: ['arm64'] }, + { target: 'zip', arch: ['arm64'] }, + ], + icon: 'src/assets/images/emdash/emdash-canary.icns', + notarize: false, + }, + dmg: { + icon: 'src/assets/images/emdash/emdash-canary.icns', + }, + linux: { + category: 'Development', + target: [ + { target: 'AppImage', arch: ['x64'] }, + { target: 'deb', arch: ['x64'] }, + { target: 'rpm', arch: ['x64'] }, + ], + }, + win: { + icon: 'src/assets/images/emdash/app-icon-canary.png', + target: [ + { target: 'nsis', arch: ['x64'] }, + { target: 'msi', arch: ['x64'] }, + ], + azureSignOptions: { + publisherName: 'General Action, Inc.', + endpoint: 'https://eus.codesigning.azure.net/', + certificateProfileName: 'emdash-public', + codeSigningAccountName: 'emdash', + }, + }, + msi: { + oneClick: false, + perMachine: false, + }, + nsis: { + differentialPackage: true, + oneClick: false, + allowToChangeInstallationDirectory: true, + perMachine: false, + }, + npmRebuild: false, +}; + +export default config; diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000000..4584ee0751 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,63 @@ +import eslint from '@eslint/js'; +import reactHooks from 'eslint-plugin-react-hooks'; +import { globalIgnores } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + globalIgnores(['dist/**', 'out/**', 'build/**', 'node_modules/**', '**/_*/**']), + + eslint.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + + // Non-type-aware rules for all TS/TSX files + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + globals: { ...globals.browser, ...globals.node, ...globals.es2020 }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + 'prefer-const': 'error', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-control-regex': 'off', + }, + }, + + // Type-aware rules scoped to src/ only (config files like vitest.config.ts are not in tsconfig) + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-floating-promises': 'warn', + // Allow async functions as React event handler attributes (onClick={asyncFn} is idiomatic) + '@typescript-eslint/no-misused-promises': [ + 'warn', + { checksVoidReturn: { attributes: false } }, + ], + '@typescript-eslint/await-thenable': 'warn', + }, + }, + + // Relax rules for test files + { + files: ['**/*.test.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + } +); diff --git a/flake.nix b/flake.nix index 37917e95c4..b39da79770 100644 --- a/flake.nix +++ b/flake.nix @@ -179,7 +179,7 @@ EOF meta = { description = "Emdash – multi-agent orchestration desktop app"; homepage = "https://emdash.sh"; - license = lib.licenses.mit; + license = lib.licenses.asl20; platforms = [ "x86_64-linux" ]; }; } diff --git a/package.json b/package.json index 39fb53662e..5e558c67c4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "emdash", - "version": "1.1.3", + "version": "1.1.13", "description": "A cross-platform Electron app that orchestrates multiple coding agents in parallel", + "license": "Apache-2.0", "type": "module", "main": "./out/main/index.js", "packageManager": "pnpm@10.28.2", @@ -28,7 +29,7 @@ "run:docker-ssh": "docker compose up --build -d", "clean": "rm -rf node_modules dist", "reset": "pnpm run clean && pnpm install", - "lint": "eslint . --ext .ts,.tsx", + "lint": "eslint . --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit && tsc --noEmit -p scripts/release/tsconfig.json", @@ -53,25 +54,24 @@ }, "devDependencies": { "@electron/rebuild": "^4.0.1", + "@eslint/js": "^9.0.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@tailwindcss/vite": "^4.2.1", "@types/node": "^20.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/react-syntax-highlighter": "^15.5.13", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/browser": "^4.1.0", "@vitest/browser-playwright": "^4.1.0", "@vitest/browser-preview": "^4.1.0", "concurrently": "^8.2.2", + "dotenv": "^17.4.2", "drizzle-kit": "^0.24.2", "electron": "^40.7.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", - "eslint": "^8.55.0", - "eslint-plugin-import": "^2.32.0", + "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^7.0.0", "husky": "^9.1.7", "jsdom": "^29.0.2", @@ -82,6 +82,7 @@ "s3mini": "^0.9.4", "tailwindcss": "^4.2.1", "typescript": "^6.0.2", + "typescript-eslint": "^8.0.0", "vite": "^6.4.1", "vitest": "^4.1.0", "vitest-browser-react": "^2.1.0" @@ -156,7 +157,6 @@ "motion": "^12.23.12", "nbranch": "^0.1.0", "node-pty": "1.1.0", - "posthog-js": "^1.297.2", "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a1ef8aba..7119b1e0a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,9 +215,6 @@ importers: node-pty: specifier: 1.1.0 version: 1.1.0 - posthog-js: - specifier: ^1.297.2 - version: 1.342.1 react: specifier: ^19.2.0 version: 19.2.4 @@ -276,6 +273,9 @@ importers: '@electron/rebuild': specifier: ^4.0.1 version: 4.0.3 + '@eslint/js': + specifier: ^9.0.0 + version: 9.39.4 '@ianvs/prettier-plugin-sort-imports': specifier: ^4.7.1 version: 4.7.1(prettier@3.6.2) @@ -294,12 +294,6 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 - '@typescript-eslint/eslint-plugin': - specifier: ^6.14.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/parser': - specifier: ^6.14.0 - version: 6.21.0(eslint@8.57.1)(typescript@6.0.2) '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) @@ -315,6 +309,9 @@ importers: concurrently: specifier: ^8.2.2 version: 8.2.2 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-kit: specifier: ^0.24.2 version: 0.24.2 @@ -328,14 +325,11 @@ importers: specifier: ^5.0.0 version: 5.0.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) eslint: - specifier: ^8.55.0 - version: 8.57.1 - eslint-plugin-import: - specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1) + specifier: ^9.0.0 + version: 9.39.4(jiti@2.6.1) eslint-plugin-react-hooks: specifier: ^7.0.0 - version: 7.0.1(eslint@8.57.1) + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -363,6 +357,9 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.2 + typescript-eslint: + specifier: ^8.0.0 + version: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) vite: specifier: ^6.4.1 version: 6.4.1(@types/node@20.19.32)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) @@ -1113,13 +1110,33 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} @@ -1180,18 +1197,25 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} '@ianvs/prettier-plugin-sort-imports@4.7.1': resolution: {integrity: sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw==} @@ -1282,18 +1306,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@npmcli/agent@3.0.0': resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1374,78 +1386,10 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@opentelemetry/api-logs@0.208.0': - resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} - engines: {node: '>=8.0.0'} - '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/core@2.2.0': - resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/core@2.5.0': - resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/exporter-logs-otlp-http@0.208.0': - resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/otlp-exporter-base@0.208.0': - resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/otlp-transformer@0.208.0': - resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/resources@2.2.0': - resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/resources@2.5.0': - resolution: {integrity: sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/sdk-logs@0.208.0': - resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.4.0 <1.10.0' - - '@opentelemetry/sdk-metrics@2.2.0': - resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.9.0 <1.10.0' - - '@opentelemetry/sdk-trace-base@2.2.0': - resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/semantic-conventions@1.39.0': - resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} - engines: {node: '>=14'} - '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1541,45 +1485,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@posthog/core@1.20.1': - resolution: {integrity: sha512-uoTmWkYCtLYFpiK37/JCq+BuCA/OZn1qQZn5cPv1EEKt3ni3Zgg48xWCnSEyGFl5KKSXlfCruiRTwnbAtCgrBA==} - - '@posthog/types@1.342.1': - resolution: {integrity: sha512-bcyBdO88FWTkd5AVTa4Nu8T7RfY0WJrG7WMCXum/rcvNjYhS3DmOfKf8o/Bt56vA3J3yeU0vbgrmltYVoTAfaA==} - '@preact/signals-core@1.14.1': resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} - - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2264,9 +2172,6 @@ packages: cpu: [x64] os: [win32] - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@shikijs/core@3.22.0': resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} @@ -2662,9 +2567,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} @@ -2686,63 +2588,64 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@6.21.0': - resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/eslint-plugin@8.59.0': + resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser': ^8.59.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@6.21.0': - resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/parser@8.59.0': + resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.0': + resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@6.21.0': - resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/scope-manager@8.59.0': + resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@6.21.0': - resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/tsconfig-utils@8.59.0': + resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@6.21.0': - resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/type-utils@8.59.0': + resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.0': + resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@6.21.0': - resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/typescript-estree@8.59.0': + resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@6.21.0': - resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/utils@8.59.0': + resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@6.21.0': - resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/visitor-keys@8.59.0': + resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -2857,6 +2760,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + allotment@1.20.5: resolution: {integrity: sha512-7i4NT7ieXEyAd5lBrXmE7WHz/e7hRuo97+j+TwrPE85ha6kyFURoc76nom0dWSZ1pTKVEAMJy/+f3/Isfu/41A==} peerDependencies: @@ -2937,34 +2843,6 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -2984,10 +2862,6 @@ packages: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2998,10 +2872,6 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - axios@1.14.0: resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} @@ -3125,10 +2995,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -3333,9 +3199,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - core-js@3.48.0: - resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} - core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -3535,18 +3398,6 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -3557,14 +3408,6 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3646,10 +3489,6 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dmg-builder@26.8.1: resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} @@ -3659,14 +3498,6 @@ packages: os: [darwin] hasBin: true - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3687,6 +3518,10 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dotenv@9.0.2: resolution: {integrity: sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==} engines: {node: '>=10'} @@ -3874,10 +3709,6 @@ packages: err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3897,14 +3728,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -3940,63 +3763,41 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint-plugin-react-hooks@7.0.1: resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} @@ -4056,19 +3857,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fault@1.0.4: resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} @@ -4084,12 +3878,9 @@ packages: picomatch: optional: true - fflate@0.4.8: - resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} - - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -4109,9 +3900,9 @@ packages: resolution: {integrity: sha512-g31GX207Tt+psI53ZSaB1egprYbEN0ZYl90aKcO22A2LmCNnFsSq3b5YpoKp3E/QEiWByTXGJOkFQG4S07Bc1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -4125,10 +3916,6 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4198,22 +3985,11 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4250,20 +4026,12 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -4285,18 +4053,14 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4312,9 +4076,6 @@ packages: resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==} engines: {node: '>=8.0.0'} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - graphql@16.13.1: resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -4322,10 +4083,6 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4333,10 +4090,6 @@ packages: has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -4489,6 +4242,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4517,10 +4274,6 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -4544,42 +4297,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-ci@3.0.1: resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} hasBin: true - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - is-decimal@1.0.4: resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} @@ -4590,10 +4311,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -4602,10 +4319,6 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -4623,26 +4336,10 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -4650,56 +4347,17 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -4947,9 +4605,6 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5078,10 +4733,6 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - mermaid@11.12.2: resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} @@ -5245,14 +4896,13 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} - minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -5467,22 +5117,6 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -5511,10 +5145,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -5572,9 +5202,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5583,10 +5210,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -5639,20 +5262,10 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - posthog-js@1.342.1: - resolution: {integrity: sha512-mMnQhWuKj4ejFicLtFzr52InmqploOyW1eInqXBkaVqE1DPhczBDmwsd9MSggY8kv0EXm8zgK+2tzBJUKcX5yg==} - - preact@10.28.3: - resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} - prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -5772,10 +5385,6 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} - engines: {node: '>=12.0.0'} - proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -5791,12 +5400,6 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} - query-selector-shadow-dom@1.0.1: - resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -5904,10 +5507,6 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - refractor@3.6.0: resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} @@ -5920,10 +5519,6 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - rehype-harden@1.1.7: resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} @@ -5999,11 +5594,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} @@ -6019,10 +5609,6 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -6046,9 +5632,6 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -6059,24 +5642,12 @@ packages: resolution: {integrity: sha512-lA6p6JY0+lbcz/NJL8O/BKU8q96iA3f+wDO/QCg4QxOooEJwe0fomHZoeDJhs8TDDRTA40OVNY2E9wOQgAoAVw==} engines: {node: '>=20'} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6117,18 +5688,6 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6192,10 +5751,6 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -6281,10 +5836,6 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - stopword@3.1.5: resolution: {integrity: sha512-OgLYGVFCNa430WOrj9tYZhQge5yg6vd6JsKredveAqEhdLVQkfrpnQIGjx0L9lLqzL4Kq4J8yNTcfQR/MpBwhg==} @@ -6313,18 +5864,6 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -6382,10 +5921,6 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -6424,9 +5959,6 @@ packages: temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -6491,11 +6023,11 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} peerDependencies: - typescript: '>=4.2.0' + typescript: '>=4.8.4' ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} @@ -6524,25 +6056,12 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} + typescript-eslint@8.59.0: + resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -6557,10 +6076,6 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -6805,9 +6320,6 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-vitals@5.1.0: - resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} - webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -6820,22 +6332,6 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7348,7 +6844,7 @@ snapshots: debug: 4.4.3 dir-compare: 3.3.0 fs-extra: 9.1.0 - minimatch: 3.1.2 + minimatch: 3.1.5 plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -7588,28 +7084,51 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 8.57.1 + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.2': dependencies: - ajv: 6.12.6 + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 + espree: 10.4.0 + globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 '@exodus/bytes@1.15.0': {} @@ -7672,17 +7191,21 @@ snapshots: dependencies: graphql: 16.13.1 - '@humanwhocodes/config-array@0.13.0': + '@humanfs/core@0.19.2': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.4.3': {} '@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.6.2)': dependencies: @@ -7785,18 +7308,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@npmcli/agent@3.0.0': dependencies: agent-base: 7.1.4 @@ -7902,81 +7413,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api-logs@0.208.0': - dependencies: - '@opentelemetry/api': 1.9.0 - - '@opentelemetry/api@1.9.0': {} - - '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 - - '@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 - - '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - protobufjs: 7.5.4 - - '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - - '@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - - '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - - '@opentelemetry/semantic-conventions@1.39.0': {} + '@opentelemetry/api@1.9.0': + optional: true '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -8043,37 +7481,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@posthog/core@1.20.1': - dependencies: - cross-spawn: 7.0.6 - - '@posthog/types@1.342.1': {} - '@preact/signals-core@1.14.1': {} - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.4': {} - - '@protobufjs/eventemitter@1.1.0': {} - - '@protobufjs/fetch@1.1.0': - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/inquire@1.1.0': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.0': {} - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -8710,8 +8119,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@rtsao/scc@1.1.0': {} - '@shikijs/core@3.22.0': dependencies: '@shikijs/types': 3.22.0 @@ -9137,8 +8544,6 @@ snapshots: dependencies: '@types/node': 20.19.32 - '@types/semver@7.7.1': {} - '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 @@ -9160,91 +8565,96 @@ snapshots: '@types/node': 20.19.32 optional: true - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.59.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 natural-compare: 1.4.0 - semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@6.0.2) - optionalDependencies: + ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.59.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.2) + '@typescript-eslint/types': 8.59.0 debug: 4.4.3 - eslint: 8.57.1 - optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@6.21.0': + '@typescript-eslint/scope-manager@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 + + '@typescript-eslint/tsconfig-utils@8.59.0(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 + typescript: 6.0.2 - '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/typescript-estree': 6.21.0(typescript@6.0.2) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@6.0.2) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@6.0.2) - optionalDependencies: + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@6.21.0': {} + '@typescript-eslint/types@8.59.0': {} - '@typescript-eslint/typescript-estree@6.21.0(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/project-service': 8.59.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.2) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.3 + minimatch: 10.2.4 semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@6.0.2) - optionalDependencies: + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@6.0.2) - eslint: 8.57.1 - semver: 7.7.4 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + eslint: 9.39.4(jiti@2.6.1) + typescript: 6.0.2 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/visitor-keys@6.21.0': + '@typescript-eslint/visitor-keys@8.59.0': dependencies: - '@typescript-eslint/types': 6.21.0 - eslint-visitor-keys: 3.4.3 + '@typescript-eslint/types': 8.59.0 + eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} @@ -9396,6 +8806,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + allotment@1.20.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: classnames: 2.5.1 @@ -9559,58 +8976,6 @@ snapshots: dependencies: dequal: 2.0.3 - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array-union@2.1.0: {} - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -9625,18 +8990,12 @@ snapshots: async-exit-hook@2.0.1: {} - async-function@1.0.0: {} - async@3.2.6: {} asynckit@0.4.0: {} at-least-node@1.0.0: {} - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - axios@1.14.0: dependencies: follow-redirects: 1.15.11 @@ -9842,13 +9201,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10037,8 +9389,6 @@ snapshots: convert-source-map@2.0.0: {} - core-js@3.48.0: {} - core-util-is@1.0.2: optional: true @@ -10274,24 +9624,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - date-fns@2.30.0: dependencies: '@babel/runtime': 7.28.6 @@ -10300,10 +9632,6 @@ snapshots: dayjs@1.11.19: {} - debug@3.2.7: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -10335,12 +9663,14 @@ snapshots: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + optional: true define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 + optional: true delaunator@5.0.1: dependencies: @@ -10369,17 +9699,13 @@ snapshots: dir-compare@3.3.0: dependencies: buffer-equal: 1.0.1 - minimatch: 3.1.2 + minimatch: 3.1.5 dir-compare@4.2.0: dependencies: minimatch: 3.1.2 p-limit: 3.1.0 - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - dmg-builder@26.8.1(electron-builder-squirrel-windows@24.13.3): dependencies: app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@24.13.3) @@ -10405,14 +9731,6 @@ snapshots: verror: 1.10.1 optional: true - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - dom-accessibility-api@0.5.16: {} dompurify@3.2.7: @@ -10431,6 +9749,8 @@ snapshots: dotenv@16.6.1: {} + dotenv@17.4.2: {} + dotenv@9.0.2: {} drizzle-kit@0.24.2: @@ -10581,63 +9901,6 @@ snapshots: err-code@2.0.3: {} - es-abstract@1.24.1: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -10655,16 +9918,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - es6-error@4.1.1: optional: true @@ -10761,119 +10014,74 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-import-resolver-node@0.3.9: + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + eslint-scope@8.4.0: dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color + esrecurse: 4.3.0 + estraverse: 5.3.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-react-hooks@7.0.1(eslint@8.57.1): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 8.57.1 - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color + eslint-visitor-keys@3.4.3: {} - eslint-scope@7.2.2: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 + eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@5.0.1: {} - eslint@8.57.1: + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 - ajv: 6.12.6 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 - doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: acorn: 8.15.0 acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 3.4.3 + eslint-visitor-keys: 4.2.1 esquery@1.7.0: dependencies: @@ -10932,22 +10140,10 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fault@1.0.4: dependencies: format: 0.2.2 @@ -10960,11 +10156,9 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fflate@0.4.8: {} - - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 file-uri-to-path@1.0.0: {} @@ -10985,20 +10179,15 @@ snapshots: dependencies: shell-path: 3.1.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 - rimraf: 3.0.2 flatted@3.3.3: {} follow-redirects@1.15.11: {} - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -11068,17 +10257,6 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - gauge@4.0.4: dependencies: aproba: 2.1.0 @@ -11091,8 +10269,6 @@ snapshots: wide-align: 1.1.5 optional: true - generator-function@2.0.1: {} - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -11127,22 +10303,12 @@ snapshots: get-stream@6.0.1: {} - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 github-from-package@0.0.0: {} - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -11181,23 +10347,13 @@ snapshots: serialize-error: 7.0.1 optional: true - globals@13.24.0: - dependencies: - type-fest: 0.20.2 + globals@14.0.0: {} globalthis@1.0.4: dependencies: define-properties: 1.2.1 gopd: 1.2.0 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + optional: true gopd@1.2.0: {} @@ -11219,23 +10375,16 @@ snapshots: grad-school@0.0.5: {} - graphemer@1.4.0: {} - graphql@16.13.1: {} hachure-fill@0.5.2: {} - has-bigints@1.1.0: {} - has-flag@4.0.0: {} has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 + optional: true has-symbols@1.1.0: {} @@ -11482,6 +10631,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -11506,12 +10657,6 @@ snapshots: inline-style-parser@0.2.7: {} - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - internmap@1.0.1: {} internmap@2.0.3: {} @@ -11532,74 +10677,22 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-callable@1.2.7: {} - is-ci@3.0.1: dependencies: ci-info: 3.9.0 - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-decimal@1.0.4: {} is-decimal@2.0.1: {} is-extglob@2.1.1: {} - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -11613,70 +10706,18 @@ snapshots: is-lambda@1.0.1: optional: true - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-number@7.0.0: {} - is-path-inside@3.0.3: {} - is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - is-stream@2.0.1: {} - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - is-unicode-supported@0.1.0: {} - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - isarray@1.0.0: {} - isarray@2.0.5: {} - isbinaryfile@4.0.10: {} isbinaryfile@5.0.7: {} @@ -11906,8 +10947,6 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 - long@5.3.2: {} - longest-streak@3.1.0: {} lowercase-keys@2.0.0: {} @@ -12166,8 +11205,6 @@ snapshots: merge-stream@2.0.0: {} - merge2@1.4.1: {} - mermaid@11.12.2: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -12457,11 +11494,11 @@ snapshots: dependencies: brace-expansion: 1.1.12 - minimatch@5.1.6: + minimatch@3.1.5: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 1.1.12 - minimatch@9.0.3: + minimatch@5.1.6: dependencies: brace-expansion: 2.0.2 @@ -12676,36 +11713,8 @@ snapshots: object-inspect@1.13.4: {} - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + object-keys@1.1.1: + optional: true obug@2.1.1: {} @@ -12750,12 +11759,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - p-cancelable@2.1.1: {} p-limit@3.1.0: @@ -12816,8 +11819,6 @@ snapshots: path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -12828,8 +11829,6 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-type@4.0.0: {} - pathe@2.0.3: {} pe-library@0.4.1: {} @@ -12873,32 +11872,12 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - possible-typed-array-names@1.1.0: {} - postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-js@1.342.1: - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - '@posthog/core': 1.20.1 - '@posthog/types': 1.342.1 - core-js: 3.48.0 - dompurify: 3.3.1 - fflate: 0.4.8 - preact: 10.28.3 - query-selector-shadow-dom: 1.0.1 - web-vitals: 5.1.0 - - preact@10.28.3: {} - prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -12960,21 +11939,6 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.5.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 20.19.32 - long: 5.3.2 - proxy-from-env@2.1.0: {} pump@3.0.3: @@ -12988,10 +11952,6 @@ snapshots: dependencies: side-channel: 1.1.0 - query-selector-shadow-dom@1.0.1: {} - - queue-microtask@1.2.3: {} - quick-lru@5.1.1: {} rate-limiter-flexible@8.3.0: {} @@ -13120,17 +12080,6 @@ snapshots: readdirp@5.0.0: {} - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - refractor@3.6.0: dependencies: hastscript: 6.0.0 @@ -13147,15 +12096,6 @@ snapshots: dependencies: regex-utilities: 2.3.0 - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - rehype-harden@1.1.7: dependencies: unist-util-visit: 5.1.0 @@ -13262,12 +12202,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - responselike@2.0.1: dependencies: lowercase-keys: 2.0.0 @@ -13284,13 +12218,12 @@ snapshots: retry@0.12.0: {} - reusify@1.1.0: {} - rfdc@1.4.1: {} rimraf@3.0.2: dependencies: glob: 7.2.3 + optional: true roarr@2.15.4: dependencies: @@ -13342,10 +12275,6 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - rw@1.3.3: {} rxjs@7.8.2: @@ -13354,29 +12283,10 @@ snapshots: s3mini@0.9.4: {} - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - safer-buffer@2.1.2: {} sanitize-filename@1.6.3: @@ -13408,28 +12318,6 @@ snapshots: set-blocking@2.0.0: optional: true - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -13511,8 +12399,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slash@3.0.0: {} - slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -13612,11 +12498,6 @@ snapshots: std-env@4.0.0: {} - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - stopword@3.1.5: {} streamdown@1.6.11(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4): @@ -13676,29 +12557,6 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -13754,8 +12612,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: {} tabbable@6.4.0: {} @@ -13805,8 +12661,6 @@ snapshots: async-exit-hook: 2.0.1 fs-extra: 10.1.0 - text-table@0.2.0: {} - tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -13860,7 +12714,7 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@1.4.3(typescript@6.0.2): + ts-api-utils@2.5.0(typescript@6.0.2): dependencies: typescript: 6.0.2 @@ -13890,40 +12744,16 @@ snapshots: type-fest@0.13.1: optional: true - type-fest@0.20.2: {} - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: + typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2): dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 + '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + eslint: 9.39.4(jiti@2.6.1) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color typescript@5.9.3: {} @@ -13931,13 +12761,6 @@ snapshots: ufo@1.6.3: {} - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - undici-types@5.26.5: {} undici-types@6.21.0: {} @@ -14157,8 +12980,6 @@ snapshots: web-namespaces@2.0.1: {} - web-vitals@5.1.0: {} - webidl-conversions@8.0.1: {} whatwg-mimetype@5.0.0: {} @@ -14171,47 +12992,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/scripts/postinstall.ts b/scripts/postinstall.ts index dcea87cf92..47c0001205 100644 --- a/scripts/postinstall.ts +++ b/scripts/postinstall.ts @@ -28,7 +28,6 @@ function runElectronRebuild(onlyModules) { : spawnSync(electronRebuildBin, args, { stdio: 'inherit' }); if (result.error) { - // eslint-disable-next-line no-console console.error('postinstall: failed to run electron-rebuild:', result.error); } @@ -39,7 +38,7 @@ function runElectronRebuild(onlyModules) { const disablePty = process.env.EMDASH_DISABLE_PTY === '1'; const disableNativeDb = process.env.EMDASH_DISABLE_NATIVE_DB === '1'; -const nativeModules = []; +const nativeModules: string[] = []; if (!disableNativeDb) nativeModules.push('better-sqlite3'); if (!disablePty) nativeModules.push('node-pty'); diff --git a/scripts/release/build.ts b/scripts/release/build.ts index 68db2f4b63..9a2bff567e 100644 --- a/scripts/release/build.ts +++ b/scripts/release/build.ts @@ -7,13 +7,16 @@ const { values } = parseArgs({ platform: { type: 'string' }, arch: { type: 'string', default: 'both' }, targets: { type: 'string' }, + config: { type: 'string', default: 'electron-builder.config.ts' }, }, strict: true, }); const platform = values.platform; if (!platform || !['mac', 'linux', 'win'].includes(platform)) { - fail('Usage: build.ts --platform mac|linux|win [--arch arm64|x64|both] [--targets dmg,zip]'); + fail( + 'Usage: build.ts --platform mac|linux|win [--arch arm64|x64|both] [--targets dmg,zip] [--config electron-builder.config.ts]' + ); } const archInput = values.arch ?? 'both'; @@ -41,7 +44,7 @@ for (const arch of archs) { targets, archFlag, '--publish always', - '--config electron-builder.config.ts', + `--config ${values.config}`, '--config.npmRebuild=false', ].join(' '); diff --git a/scripts/release/inject-telemetry.ts b/scripts/release/inject-telemetry.ts deleted file mode 100644 index 90b1fdfb0a..0000000000 --- a/scripts/release/inject-telemetry.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { info, step } from './lib/log.ts'; - -const phKey = process.env.PH_KEY ?? ''; -const phHost = process.env.PH_HOST ?? ''; - -if (!phKey || !phHost) { - console.log('PostHog secrets not set; skipping telemetry injection.'); - process.exit(0); -} - -step('Inject PostHog config'); -mkdirSync('dist/main', { recursive: true }); -writeFileSync( - 'dist/main/appConfig.json', - JSON.stringify({ posthogHost: phHost, posthogKey: phKey }, null, 2) -); -info('Wrote dist/main/appConfig.json'); diff --git a/scripts/release/lib/artifacts.ts b/scripts/release/lib/artifacts.ts index dacd6dec96..dffe1f9215 100644 --- a/scripts/release/lib/artifacts.ts +++ b/scripts/release/lib/artifacts.ts @@ -12,12 +12,12 @@ function matchFiles(pattern: RegExp): string[] { } } -export function findManifests(): string[] { - return matchFiles(new RegExp(`^${UPDATE_CHANNEL}.*\\.yml$`)); +export function findManifests(channel = UPDATE_CHANNEL): string[] { + return matchFiles(new RegExp(`^${channel}.*\\.yml$`)); } -export function findInstallers(): string[] { - return matchFiles(new RegExp(`^${ARTIFACT_PREFIX}-.*\\.(dmg|zip|exe|msi|AppImage|deb|rpm)$`)); +export function findInstallers(prefix = ARTIFACT_PREFIX): string[] { + return matchFiles(new RegExp(`^${prefix}-.*\\.(dmg|zip|exe|msi|AppImage|deb|rpm)$`)); } export function findBlockmaps(): string[] { diff --git a/scripts/release/lib/config.ts b/scripts/release/lib/config.ts index 958d87624f..806d8ebb05 100644 --- a/scripts/release/lib/config.ts +++ b/scripts/release/lib/config.ts @@ -1,5 +1,3 @@ -import { PRODUCT_NAME } from '../../../src/shared/app-identity.ts'; - export { APP_ID, APP_NAME_LOWER, @@ -9,8 +7,6 @@ export { UPDATE_CHANNEL, } from '../../../src/shared/app-identity.ts'; -export const APP_BUNDLE = `${PRODUCT_NAME}.app`; -export const APP_BINARY = PRODUCT_NAME; export const RELEASE_DIR = 'release'; export const NATIVE_MODULES = ['better-sqlite3', 'node-pty', '@parcel/watcher']; diff --git a/scripts/release/notarize-mac.ts b/scripts/release/notarize-mac.ts index 8c868be954..a439842b65 100644 --- a/scripts/release/notarize-mac.ts +++ b/scripts/release/notarize-mac.ts @@ -1,7 +1,8 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { APP_BUNDLE, RELEASE_DIR } from './lib/config.ts'; +import { parseArgs } from 'node:util'; +import { RELEASE_DIR } from './lib/config.ts'; import { exec } from './lib/exec.ts'; import { fail, info, step, warn } from './lib/log.ts'; @@ -10,6 +11,19 @@ if (process.platform !== 'darwin') { process.exit(0); } +const { values } = parseArgs({ + options: { + 'app-bundle': { type: 'string' }, + }, + strict: true, +}); + +if (!values['app-bundle']) { + fail('--app-bundle is required (e.g. --app-bundle "Emdash.app")'); +} + +const appBundle = values['app-bundle']; + const apiKeyPath = process.env.APPLE_API_KEY ?? process.env.APPLE_API_KEY_CONTENT; const apiKeyId = process.env.APPLE_API_KEY_ID; const apiIssuer = process.env.APPLE_API_ISSUER; @@ -50,7 +64,7 @@ for (const dmg of dmgs) { step('Staple app bundles'); const macDirs = readdirSync(RELEASE_DIR) .filter((d) => d.startsWith('mac')) - .map((d) => join(RELEASE_DIR, d, APP_BUNDLE)) + .map((d) => join(RELEASE_DIR, d, appBundle)) .filter((p) => existsSync(p)); for (const appDir of macDirs) { @@ -68,9 +82,9 @@ for (const dmg of dmgs) { const mnt = mkdtempSync(join(tmpdir(), 'dmg-')); try { exec(`hdiutil attach "${dmg}" -mountpoint "${mnt}" -nobrowse -quiet`, { echo: true }); - const appPath = join(mnt, APP_BUNDLE); + const appPath = join(mnt, appBundle); if (!existsSync(appPath)) { - fail(`No ${APP_BUNDLE} found inside ${dmg}`); + fail(`No ${appBundle} found inside ${dmg}`); } exec(`spctl -a -vv --type execute "${appPath}"`, { echo: true }); info(`Gatekeeper passed for ${dmg}`); diff --git a/scripts/release/upload-r2.ts b/scripts/release/upload-r2.ts index afa8b1345d..1b3f803af5 100644 --- a/scripts/release/upload-r2.ts +++ b/scripts/release/upload-r2.ts @@ -1,10 +1,19 @@ import { readFileSync } from 'node:fs'; import { basename } from 'node:path'; +import { parseArgs } from 'node:util'; import { S3mini } from 's3mini'; import { findBlockmaps, findInstallers, findManifests } from './lib/artifacts.ts'; import { r2Endpoint, requireEnv } from './lib/config.ts'; import { fail, info, step } from './lib/log.ts'; +const { values } = parseArgs({ + options: { + channel: { type: 'string' }, + prefix: { type: 'string' }, + }, + strict: true, +}); + const s3 = new S3mini({ accessKeyId: requireEnv('R2_ACCESS_KEY_ID'), secretAccessKey: requireEnv('R2_SECRET_ACCESS_KEY'), @@ -12,7 +21,11 @@ const s3 = new S3mini({ region: 'auto', }); -const files = [...findManifests(), ...findInstallers(), ...findBlockmaps()]; +const files = [ + ...findManifests(values.channel), + ...findInstallers(values.prefix), + ...findBlockmaps(), +]; if (files.length === 0) { fail('No artifacts found to upload'); diff --git a/scripts/release/verify-mac.ts b/scripts/release/verify-mac.ts index 26a92b43a5..1d8290a508 100644 --- a/scripts/release/verify-mac.ts +++ b/scripts/release/verify-mac.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { parseArgs } from 'node:util'; -import { APP_BUNDLE, APP_ID, PRODUCT_NAME, RELEASE_DIR } from './lib/config.ts'; +import { RELEASE_DIR } from './lib/config.ts'; import { exec, execOrNull } from './lib/exec.ts'; import { fail, info, step, warn } from './lib/log.ts'; @@ -12,34 +12,39 @@ if (process.platform !== 'darwin') { const { values } = parseArgs({ options: { - 'smoke-test': { type: 'boolean', default: false }, 'expected-team-id': { type: 'string' }, }, strict: true, }); -const smokeTest = values['smoke-test'] ?? false; const expectedTeamId = values['expected-team-id']; -const macDirs = readdirSync(RELEASE_DIR) +const appBundles = readdirSync(RELEASE_DIR) .filter((d) => d.startsWith('mac')) - .map((d) => join(RELEASE_DIR, d, APP_BUNDLE)) + .flatMap((d) => { + const dir = join(RELEASE_DIR, d); + return readdirSync(dir) + .filter((f) => f.endsWith('.app')) + .map((f) => join(dir, f)); + }) .filter((p) => existsSync(p)); -if (macDirs.length === 0) { +if (appBundles.length === 0) { fail('No app bundles found to verify'); } let verified = 0; -for (const appDir of macDirs) { +for (const appDir of appBundles) { const archDir = appDir.split('/').at(-2)!; const expectedArch = archDir === 'mac-arm64' ? 'arm64' : archDir.startsWith('mac') ? 'x86_64' : null; + const productName = basename(appDir, '.app'); + step(`Verifying ${appDir} (expected: ${expectedArch ?? 'unknown'})`); - const electronBin = join(appDir, 'Contents', 'MacOS', PRODUCT_NAME); + const electronBin = join(appDir, 'Contents', 'MacOS', productName); const sqliteNode = join( appDir, 'Contents', @@ -70,14 +75,6 @@ for (const appDir of macDirs) { } } - if (smokeTest && archDir === 'mac-arm64') { - step('Smoke test sqlite3 (arm64)'); - exec( - `ELECTRON_RUN_AS_NODE=1 NODE_PATH="${appDir}/Contents/Resources/app.asar.unpacked/node_modules" "${electronBin}" -e "require('sqlite3'); console.log('sqlite3 OK')"`, - { echo: true } - ); - } - const plist = join(appDir, 'Contents', 'Info.plist'); if (existsSync(plist)) { const bid = @@ -86,9 +83,6 @@ for (const appDir of macDirs) { `plutil -extract CFBundleIdentifier xml1 -o - "${plist}" | sed -n 's/.*\\(.*\\)<\\/string>.*/\\1/p' | head -n1` ); info(`CFBundleIdentifier: ${bid}`); - if (bid !== APP_ID) { - fail(`CFBundleIdentifier mismatch (got '${bid}', expected '${APP_ID}')`); - } } exec(`codesign --verify --deep --strict --verbose=2 "${appDir}"`, { echo: true }); diff --git a/src/assets/images/android-studio.svg b/src/assets/images/android-studio.svg new file mode 100644 index 0000000000..85429ef1b4 --- /dev/null +++ b/src/assets/images/android-studio.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/src/assets/images/devin.png b/src/assets/images/devin.png new file mode 100644 index 0000000000..cdd3f34577 Binary files /dev/null and b/src/assets/images/devin.png differ diff --git a/src/assets/images/emdash/app-icon-canary-blue.png b/src/assets/images/emdash/app-icon-canary-blue.png new file mode 100644 index 0000000000..e074dc5f34 Binary files /dev/null and b/src/assets/images/emdash/app-icon-canary-blue.png differ diff --git a/src/assets/images/emdash/app-icon-canary.png b/src/assets/images/emdash/app-icon-canary.png new file mode 100644 index 0000000000..b9f6bce38a Binary files /dev/null and b/src/assets/images/emdash/app-icon-canary.png differ diff --git a/src/assets/images/emdash/emdash-canary.icns b/src/assets/images/emdash/emdash-canary.icns new file mode 100644 index 0000000000..f04b1ec202 Binary files /dev/null and b/src/assets/images/emdash/emdash-canary.icns differ diff --git a/src/assets/images/hermesagent.jpg b/src/assets/images/hermesagent.jpg new file mode 100644 index 0000000000..5edec869f1 Binary files /dev/null and b/src/assets/images/hermesagent.jpg differ diff --git a/src/assets/images/jules.svg b/src/assets/images/jules.svg new file mode 100644 index 0000000000..87fc5fb540 --- /dev/null +++ b/src/assets/images/jules.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/junie-color.png b/src/assets/images/junie-color.png new file mode 100644 index 0000000000..8762bd9ed0 Binary files /dev/null and b/src/assets/images/junie-color.png differ diff --git a/src/assets/images/kitty.png b/src/assets/images/kitty.png new file mode 100644 index 0000000000..a4cdcf8cf4 Binary files /dev/null and b/src/assets/images/kitty.png differ diff --git a/src/assets/images/letta.svg b/src/assets/images/letta.svg new file mode 100644 index 0000000000..568a549143 --- /dev/null +++ b/src/assets/images/letta.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/trae.png b/src/assets/images/trae.png new file mode 100644 index 0000000000..1a1f23f6fe Binary files /dev/null and b/src/assets/images/trae.png differ diff --git a/src/assets/images/vscodium.png b/src/assets/images/vscodium.png new file mode 100644 index 0000000000..c567f17cdd Binary files /dev/null and b/src/assets/images/vscodium.png differ diff --git a/src/main/app/menu.ts b/src/main/app/menu.ts index 09d72fdc60..77268d2467 100644 --- a/src/main/app/menu.ts +++ b/src/main/app/menu.ts @@ -119,11 +119,15 @@ export function setupApplicationMenu(): void { submenu: [ { label: 'Docs', - click: () => shell.openExternal(EMDASH_DOCS_URL), + click: () => { + void shell.openExternal(EMDASH_DOCS_URL); + }, }, { label: 'Changelog', - click: () => shell.openExternal(EMDASH_RELEASES_URL), + click: () => { + void shell.openExternal(EMDASH_RELEASES_URL); + }, }, ...(!isMac ? [ diff --git a/src/main/app/window.ts b/src/main/app/window.ts index a9c525a083..9c44b32cd9 100644 --- a/src/main/app/window.ts +++ b/src/main/app/window.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { BrowserWindow } from 'electron'; import appIcon from '@/assets/images/emdash/emdash_logo.png?asset'; import { PRODUCT_NAME } from '@shared/app-identity'; -import { capture, checkAndReportDailyActiveUser } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { registerExternalLinkHandlers } from '@main/utils/externalLinks'; import { APP_ORIGIN } from './protocol'; @@ -29,15 +29,19 @@ export function createMainWindow(): BrowserWindow { preload: join(__dirname, '../preload/index.mjs'), }, ...(process.platform === 'darwin' - ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 10, y: 10 } } + ? { + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 10, y: 10 }, + acceptFirstMouse: true, + } : {}), show: false, }); if (import.meta.env.DEV) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL!); + void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL!); } else { - mainWindow.loadURL(`${APP_ORIGIN}/index.html`); + void mainWindow.loadURL(`${APP_ORIGIN}/index.html`); } // Route external links to the user’s default browser @@ -50,15 +54,15 @@ export function createMainWindow(): BrowserWindow { // Track window focus for telemetry mainWindow.on('focus', () => { - capture('app_window_focused'); + telemetryService.capture('app_window_focused'); if (typeof mainWindow?.setWindowButtonVisibility === 'function') { mainWindow.setWindowButtonVisibility(true); } - checkAndReportDailyActiveUser(); + void telemetryService.checkAndReportDailyActiveUser(); }); mainWindow.on('blur', () => { - capture('app_window_unfocused'); + telemetryService.capture('app_window_unfocused'); }); // Cleanup reference on close diff --git a/src/main/core/account/controller.ts b/src/main/core/account/controller.ts index 617365b6aa..502a346740 100644 --- a/src/main/core/account/controller.ts +++ b/src/main/core/account/controller.ts @@ -1,6 +1,6 @@ import { createRPCController } from '@shared/ipc/rpc'; import { log } from '@main/lib/logger'; -import { capture, identify as telemetryIdentify } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { emdashAccountService } from './services/emdash-account-service'; export const accountController = createRPCController({ @@ -16,8 +16,7 @@ export const accountController = createRPCController({ signIn: async (provider?: string) => { try { const result = await emdashAccountService.signIn(provider); - telemetryIdentify(result.user.username, result.user.userId); - capture('user_signed_in'); + telemetryService.capture('user_signed_in'); return { success: true, user: result.user }; } catch (error) { log.error('Account sign-in failed:', error); @@ -31,7 +30,7 @@ export const accountController = createRPCController({ signOut: async () => { try { await emdashAccountService.signOut(); - capture('user_signed_out'); + telemetryService.capture('user_signed_out'); return { success: true }; } catch (error) { log.error('Account sign-out failed:', error); diff --git a/src/main/core/account/services/emdash-account-service.ts b/src/main/core/account/services/emdash-account-service.ts index 8a66f2debc..e6f25ea103 100644 --- a/src/main/core/account/services/emdash-account-service.ts +++ b/src/main/core/account/services/emdash-account-service.ts @@ -1,5 +1,7 @@ import { executeOAuthFlow } from '@main/core/shared/oauth-flow'; import { KV } from '@main/db/kv'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { log } from '@main/lib/logger'; import { ACCOUNT_CONFIG } from '../config'; import { providerTokenRegistry } from '../provider-token-registry'; import { accountCredentialStore } from './credential-store'; @@ -36,12 +38,24 @@ interface AccountKVSchema extends Record { profile: CachedProfile; } +type AccountServiceHooks = { + accountChanged: (username: string, userId: string, email: string) => void | Promise; + accountCleared: () => void | Promise; +}; + const accountKV = new KV('account'); -export class EmdashAccountService { +export class EmdashAccountService implements Hookable { + private readonly _hooks = new HookCore((name, e) => + log.error(`EmdashAccountService: ${String(name)} hook error`, e) + ); private cachedProfile: CachedProfile | null = null; private sessionToken: string | null = null; + on(name: K, handler: AccountServiceHooks[K]) { + return this._hooks.on(name, handler); + } + async getSession(): Promise { this.cachedProfile = await accountKV.get('profile'); const hasAccount = this.cachedProfile?.hasAccount === true; @@ -62,7 +76,18 @@ export class EmdashAccountService { } async loadSessionToken(): Promise { - this.sessionToken = await accountCredentialStore.get(); + [this.sessionToken, this.cachedProfile] = await Promise.all([ + accountCredentialStore.get(), + accountKV.get('profile'), + ]); + if (this.sessionToken && this.cachedProfile?.hasAccount) { + this._hooks.callHookBackground( + 'accountChanged', + this.cachedProfile.username, + this.cachedProfile.userId, + this.cachedProfile.email + ); + } } /** make provider optional and remove default in case emdash starts supporting more providers */ @@ -110,6 +135,8 @@ export class EmdashAccountService { await providerTokenRegistry.dispatch(providerId, accessToken); } + this._hooks.callHookBackground('accountChanged', user.username, user.userId, user.email); + return { providerToken: accessToken || undefined, provider: providerId || undefined, @@ -124,6 +151,7 @@ export class EmdashAccountService { this.cachedProfile.hasAccount = true; await accountKV.set('profile', this.cachedProfile); } + this._hooks.callHookBackground('accountCleared'); } async checkServerHealth(): Promise { diff --git a/src/main/core/agent-hooks/agent-hook-service.ts b/src/main/core/agent-hooks/agent-hook-service.ts index 71962a2f4d..b263a25eaa 100644 --- a/src/main/core/agent-hooks/agent-hook-service.ts +++ b/src/main/core/agent-hooks/agent-hook-service.ts @@ -1,13 +1,14 @@ import { agentEventChannel } from '@shared/events/agentEvents'; import { events } from '@main/lib/events'; +import type { IDisposable, IInitializable } from '@main/lib/lifecycle'; import { enrichEvent } from './event-enricher'; import { HookServer } from './hook-server'; import { isAppFocused, maybeShowNotification } from './notification'; -class AgentHookService { +class AgentHookService implements IInitializable, IDisposable { private server = new HookServer(); - async start(): Promise { + async initialize(): Promise { await this.server.start(async (raw) => { const event = await enrichEvent(raw); event.source = 'hook'; @@ -17,7 +18,7 @@ class AgentHookService { }); } - stop(): void { + dispose(): void { this.server.stop(); } getPort(): number { diff --git a/src/main/core/agent-hooks/agent-notify-command.test.ts b/src/main/core/agent-hooks/agent-notify-command.test.ts new file mode 100644 index 0000000000..a7b5082880 --- /dev/null +++ b/src/main/core/agent-hooks/agent-notify-command.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { makeCodexNotifyCommand, makeOpenCodePluginContent } from './agent-notify-command'; + +describe('makeCodexNotifyCommand', () => { + it('writes the Windows notify script only once per script path', () => { + const writeFile = vi.fn(); + const mkdir = vi.fn(); + const scriptPath = 'C:\\Temp\\emdash-codex-notify.ps1'; + + makeCodexNotifyCommand({ platform: 'win32', scriptPath, mkdir, writeFile }); + makeCodexNotifyCommand({ platform: 'win32', scriptPath, mkdir, writeFile }); + + expect(mkdir).toHaveBeenCalledTimes(1); + expect(writeFile).toHaveBeenCalledTimes(1); + }); +}); + +describe('makeOpenCodePluginContent', () => { + it('posts OpenCode session events to the Emdash hook server', () => { + const content = makeOpenCodePluginContent(); + + expect(content).toContain('EMDASH_HOOK_PORT'); + expect(content).toContain("event.type === 'session.idle'"); + expect(content).toContain("event.type === 'session.error'"); + expect(content).toContain("'X-Emdash-Event-Type': payload.type"); + }); +}); diff --git a/src/main/core/agent-hooks/agent-notify-command.ts b/src/main/core/agent-hooks/agent-notify-command.ts new file mode 100644 index 0000000000..0b60ea4181 --- /dev/null +++ b/src/main/core/agent-hooks/agent-notify-command.ts @@ -0,0 +1,100 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, win32 } from 'node:path'; +import { log } from '@main/lib/logger'; +import openCodePluginContent from './opencode-notifications-plugin.js?raw'; + +export type CodexNotifyCommandOptions = { + platform?: NodeJS.Platform; + writeFile?: (path: string, content: string) => void; + mkdir?: (path: string) => void; + scriptPath?: string; +}; + +const ensuredWindowsCodexNotifyScriptPaths = new Set(); + +export function makeClaudeHookCommand(eventType: string): string { + return ( + 'curl -sf -X POST ' + + '-H "Content-Type: application/json" ' + + '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + + '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + + `-H "X-Emdash-Event-Type: ${eventType}" ` + + '-d @- ' + + '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true' + ); +} + +export function makeOpenCodePluginContent(): string { + return openCodePluginContent; +} + +function makePosixCodexNotifyCommand(): string[] { + return [ + 'bash', + '-c', + 'curl -sf -X POST ' + + "-H 'Content-Type: application/json' " + + '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + + '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + + '-H "X-Emdash-Event-Type: notification" ' + + '-d "$1" ' + + '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true', + '_', + ]; +} + +function windowsCodexNotifyScript(): string { + return [ + 'param([string]$payload)', + 'try {', + ' Invoke-WebRequest -UseBasicParsing -Method POST ' + + "-Uri ('http://127.0.0.1:' + $env:EMDASH_HOOK_PORT + '/hook') " + + '-Headers @{ ' + + "'Content-Type' = 'application/json'; " + + "'X-Emdash-Token' = $env:EMDASH_HOOK_TOKEN; " + + "'X-Emdash-Pty-Id' = $env:EMDASH_PTY_ID; " + + "'X-Emdash-Event-Type' = 'notification' " + + '} -Body $payload | Out-Null', + '} catch {', + ' exit 0', + '}', + '', + ].join('\n'); +} + +function ensureWindowsCodexNotifyScript(options: CodexNotifyCommandOptions): string { + const platform = options.platform ?? process.platform; + const scriptPath = options.scriptPath ?? join(tmpdir(), 'emdash-codex-notify.ps1'); + if (ensuredWindowsCodexNotifyScriptPaths.has(scriptPath)) { + return scriptPath; + } + + const scriptDir = platform === 'win32' ? win32.dirname(scriptPath) : dirname(scriptPath); + const mkdir = options.mkdir ?? ((path: string) => mkdirSync(path, { recursive: true })); + const writeFile = options.writeFile ?? writeFileSync; + + try { + mkdir(scriptDir); + writeFile(scriptPath, windowsCodexNotifyScript()); + ensuredWindowsCodexNotifyScriptPaths.add(scriptPath); + } catch (err) { + log.warn('CodexNotifyCommand: failed to write Windows notify script', { + path: scriptPath, + error: String(err), + }); + } + + return scriptPath; +} + +function makeWindowsCodexNotifyCommand(options: CodexNotifyCommandOptions): string[] { + return ['powershell.exe', '-NoProfile', '-File', ensureWindowsCodexNotifyScript(options)]; +} + +export function makeCodexNotifyCommand(options: CodexNotifyCommandOptions = {}): string[] { + const platform = options.platform ?? process.platform; + return platform === 'win32' + ? makeWindowsCodexNotifyCommand(options) + : makePosixCodexNotifyCommand(); +} diff --git a/src/main/core/agent-hooks/classifier-wiring.ts b/src/main/core/agent-hooks/classifier-wiring.ts index e09b619b3b..2828d0853a 100644 --- a/src/main/core/agent-hooks/classifier-wiring.ts +++ b/src/main/core/agent-hooks/classifier-wiring.ts @@ -1,8 +1,8 @@ import { BrowserWindow } from 'electron'; -import { AgentProviderId } from '@shared/agent-provider-registry'; +import { type AgentProviderId } from '@shared/agent-provider-registry'; import { agentEventChannel, type AgentEvent } from '@shared/events/agentEvents'; import { makePtyId } from '@shared/ptyId'; -import { Pty } from '@main/core/pty/pty'; +import { type Pty } from '@main/core/pty/pty'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { createClassifier } from './classifiers'; diff --git a/src/main/core/agent-hooks/classifiers/devin.ts b/src/main/core/agent-hooks/classifiers/devin.ts new file mode 100644 index 0000000000..abe3686ce3 --- /dev/null +++ b/src/main/core/agent-hooks/classifiers/devin.ts @@ -0,0 +1,50 @@ +import { createProviderClassifier, type ClassificationResult } from './base'; + +export function createDevinClassifier() { + return createProviderClassifier((text: string): ClassificationResult => { + const tail = text.slice(-500); + + if (/approve|reject|permission|allow|confirm|accept edits|bypass/i.test(tail)) { + return { + type: 'notification', + notificationType: 'permission_prompt', + }; + } + + if (/Type @|\/help|\/mode|\/plan|\/ask|What would you like|Start coding/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + if (/Ready|Awaiting|Press Enter|Next command/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + if (/Successfully authenticated|Login successful|Authenticated as/i.test(text)) { + return { + type: 'notification', + notificationType: 'auth_success', + }; + } + + if (/What.*\?|How.*\?|Which.*\?|Please (provide|specify|clarify)/i.test(tail)) { + return { + type: 'notification', + notificationType: 'elicitation_dialog', + }; + } + + if (/error:|fatal:|exception|failed/i.test(text)) { + return { + type: 'error', + }; + } + + return undefined; + }); +} diff --git a/src/main/core/agent-hooks/classifiers/index.ts b/src/main/core/agent-hooks/classifiers/index.ts index a35dd63cec..e74c908fae 100644 --- a/src/main/core/agent-hooks/classifiers/index.ts +++ b/src/main/core/agent-hooks/classifiers/index.ts @@ -9,13 +9,17 @@ import { createCodebuffClassifier } from './codebuff'; import { createContinueClassifier } from './continue'; import { createCopilotClassifier } from './copilot'; import { createCursorClassifier } from './cursor'; +import { createDevinClassifier } from './devin'; import { createDroidClassifier } from './droid'; import { createGeminiClassifier } from './gemini'; import { createGenericClassifier } from './generic'; import { createGooseClassifier } from './goose'; +import { createJulesClassifier } from './jules'; +import { createJunieClassifier } from './junie'; import { createKilocodeClassifier } from './kilocode'; import { createKimiClassifier } from './kimi'; import { createKiroClassifier } from './kiro'; +import { createLettaClassifier } from './letta'; import { createMistralClassifier } from './mistral'; import { createOpenCodeClassifier } from './opencode'; import { createPiClassifier } from './pi'; @@ -34,12 +38,16 @@ const classifierFactories: Partial ProviderClassif continue: createContinueClassifier, copilot: createCopilotClassifier, cursor: createCursorClassifier, + devin: createDevinClassifier, droid: createDroidClassifier, gemini: createGeminiClassifier, goose: createGooseClassifier, + jules: createJulesClassifier, + junie: createJunieClassifier, kilocode: createKilocodeClassifier, kimi: createKimiClassifier, kiro: createKiroClassifier, + letta: createLettaClassifier, mistral: createMistralClassifier, opencode: createOpenCodeClassifier, pi: createPiClassifier, diff --git a/src/main/core/agent-hooks/classifiers/jules.ts b/src/main/core/agent-hooks/classifiers/jules.ts new file mode 100644 index 0000000000..2a9cb4a635 --- /dev/null +++ b/src/main/core/agent-hooks/classifiers/jules.ts @@ -0,0 +1,43 @@ +import { createProviderClassifier, type ClassificationResult } from './base'; + +export function createJulesClassifier() { + return createProviderClassifier((text: string): ClassificationResult => { + const tail = text.slice(-500); + + if (/approve|reject|permission|allow|confirm/i.test(tail)) { + return { + type: 'notification', + notificationType: 'permission_prompt', + }; + } + + if (/Ready|Awaiting|Press Enter|Jules/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + if (/Successfully authenticated|Login successful|Signed in/i.test(text)) { + return { + type: 'notification', + notificationType: 'auth_success', + }; + } + + if (/What.*\?|How.*\?|Which.*\?|Please (provide|specify|clarify)/i.test(tail)) { + return { + type: 'notification', + notificationType: 'elicitation_dialog', + }; + } + + if (/error:|fatal:|exception|failed/i.test(text)) { + return { + type: 'error', + }; + } + + return undefined; + }); +} diff --git a/src/main/core/agent-hooks/classifiers/junie.ts b/src/main/core/agent-hooks/classifiers/junie.ts new file mode 100644 index 0000000000..cb80a52b1c --- /dev/null +++ b/src/main/core/agent-hooks/classifiers/junie.ts @@ -0,0 +1,48 @@ +import { createProviderClassifier, type ClassificationResult } from './base'; + +export function createJunieClassifier() { + return createProviderClassifier((text: string): ClassificationResult => { + const tail = text.slice(-500); + + // Permission/approval prompts + if (/approve|reject|permission|allow|confirm|brave mode/i.test(tail)) { + return { + type: 'notification', + notificationType: 'permission_prompt', + }; + } + + // Idle/ready prompts + if (/Ready|Awaiting|Press Enter|Next command|Junie/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + // Auth success + if (/Successfully authenticated|Login successful|authenticated with Junie/i.test(text)) { + return { + type: 'notification', + notificationType: 'auth_success', + }; + } + + // Questions/elicitation + if (/What.*\?|How.*\?|Which.*\?|Please (provide|specify|clarify)/i.test(tail)) { + return { + type: 'notification', + notificationType: 'elicitation_dialog', + }; + } + + // Error detection + if (/error:|fatal:|exception|failed/i.test(text)) { + return { + type: 'error', + }; + } + + return undefined; + }); +} diff --git a/src/main/core/agent-hooks/classifiers/letta.ts b/src/main/core/agent-hooks/classifiers/letta.ts new file mode 100644 index 0000000000..016460a58e --- /dev/null +++ b/src/main/core/agent-hooks/classifiers/letta.ts @@ -0,0 +1,55 @@ +import { createProviderClassifier, type ClassificationResult } from './base'; + +export function createLettaClassifier() { + return createProviderClassifier((text: string): ClassificationResult => { + const tail = text.slice(-500); + + // Permission/approval prompts + if (/approve|reject|permission|allow|confirm/i.test(tail)) { + return { + type: 'notification', + notificationType: 'permission_prompt', + }; + } + + // Idle/ready prompts (slash-command hint shows in Letta's input footer) + if (/Press\s+Tab|\/help|\/connect|\/model|\/agents/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + if (/letta\s*>|^>\s*$/im.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + // Auth success (e.g. /connect flow) + if (/Successfully authenticated|Successfully connected|Login successful/i.test(text)) { + return { + type: 'notification', + notificationType: 'auth_success', + }; + } + + // Questions/elicitation + if (/What.*\?|How.*\?|Which.*\?|Please (provide|specify|clarify)/i.test(tail)) { + return { + type: 'notification', + notificationType: 'elicitation_dialog', + }; + } + + // Error detection + if (/error:|fatal:|exception|failed/i.test(text)) { + return { + type: 'error', + }; + } + + return undefined; + }); +} diff --git a/src/main/core/agent-hooks/classifiers/pi.test.ts b/src/main/core/agent-hooks/classifiers/pi.test.ts new file mode 100644 index 0000000000..af04ce012e --- /dev/null +++ b/src/main/core/agent-hooks/classifiers/pi.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { createPiClassifier } from './pi'; + +describe('createPiClassifier', () => { + it('recognizes Pi JSON agent_end events as completion', () => { + const classifier = createPiClassifier(); + + expect(classifier.classify('{"type":"agent_end","messages":[]}')).toEqual({ + type: 'stop', + message: 'Task completed', + }); + }); + + it('recognizes agent_end when buried beyond the 500-char tail', () => { + const classifier = createPiClassifier(); + const padding = 'x'.repeat(800); + + expect(classifier.classify(`{"type":"agent_end","messages":[]}\n${padding}`)).toEqual({ + type: 'stop', + message: 'Task completed', + }); + }); + + it('does not classify agent_start as a completion event', () => { + const classifier = createPiClassifier(); + + expect(classifier.classify('{"type":"agent_start"}')).toBeUndefined(); + }); +}); diff --git a/src/main/core/agent-hooks/classifiers/pi.ts b/src/main/core/agent-hooks/classifiers/pi.ts index da1038dbd6..f3dbdbaec4 100644 --- a/src/main/core/agent-hooks/classifiers/pi.ts +++ b/src/main/core/agent-hooks/classifiers/pi.ts @@ -36,6 +36,13 @@ export function createPiClassifier() { }; } + if (/"type"\s*:\s*"agent_end"/i.test(text)) { + return { + type: 'stop', + message: 'Task completed', + }; + } + // Error detection if (/error:|fatal:|exception|failed/i.test(text)) { return { diff --git a/src/main/core/agent-hooks/claude-trust-service.test.ts b/src/main/core/agent-hooks/claude-trust-service.test.ts index 2c11e2787f..fd1c33e340 100644 --- a/src/main/core/agent-hooks/claude-trust-service.test.ts +++ b/src/main/core/agent-hooks/claude-trust-service.test.ts @@ -1,11 +1,11 @@ import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { FileSystemError, FileSystemErrorCodes, type FileSystemProvider, } from '@main/core/fs/types'; -import type { ExecFn } from '@main/core/utils/exec'; import { ClaudeTrustService } from './claude-trust-service'; const mockReadFile = vi.hoisted(() => vi.fn()); @@ -224,9 +224,10 @@ describe('ClaudeTrustService', () => { realPath: vi.fn().mockResolvedValue('/remote/worktree'), }); - const exec: ExecFn = vi - .fn() - .mockImplementation(async (command: string, args: string[] = []) => { + const ctx: IExecutionContext = { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn().mockImplementation(async (command: string, args: string[] = []) => { if (command === 'sh') { return { stdout: '/home/remote-user', stderr: '' }; } @@ -236,12 +237,15 @@ describe('ClaudeTrustService', () => { return { stdout: '', stderr: '' }; } return { stdout: '', stderr: '' }; - }); + }), + execStreaming: vi.fn(), + dispose: vi.fn(), + }; await service.maybeAutoTrustSsh({ providerId: 'claude', cwd: '/remote/worktree', - exec, + ctx, remoteFs, }); @@ -257,6 +261,6 @@ describe('ClaudeTrustService', () => { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true, }); - expect(exec).toHaveBeenCalledWith('mv', [tmpPath, '/home/remote-user/.claude.json']); + expect(ctx.exec).toHaveBeenCalledWith('mv', [tmpPath, '/home/remote-user/.claude.json']); }); }); diff --git a/src/main/core/agent-hooks/claude-trust-service.ts b/src/main/core/agent-hooks/claude-trust-service.ts index 745fec9ec2..15612f6209 100644 --- a/src/main/core/agent-hooks/claude-trust-service.ts +++ b/src/main/core/agent-hooks/claude-trust-service.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { AgentProviderId } from '@shared/agent-provider-registry'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { FileSystemError, FileSystemErrorCodes, @@ -9,7 +10,6 @@ import { } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { resolveRemoteHome } from '@main/core/ssh/utils'; -import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; const CLAUDE_PROVIDER_ID: AgentProviderId = 'claude'; @@ -49,25 +49,25 @@ export class ClaudeTrustService { async maybeAutoTrustSsh({ providerId, cwd, - exec, + ctx, remoteFs, }: { providerId: AgentProviderId; cwd?: string; - exec: ExecFn; + ctx: IExecutionContext; remoteFs: Pick; }): Promise { if (!cwd) return; if (!(await this.shouldAutoTrust(providerId))) return; const normalizedPath = await remoteFs.realPath(cwd).catch(() => path.posix.resolve('/', cwd)); - const homeDir = await resolveRemoteHome(exec); + const homeDir = await resolveRemoteHome(ctx); const configPath = path.posix.join(homeDir, CLAUDE_CONFIG_NAME); await this.withLock(configPath, () => this.ensureTrusted(normalizedPath, { readConfig: () => readRemoteConfig(remoteFs, configPath), - writeConfig: (content) => writeRemoteConfigAtomic(remoteFs, exec, configPath, content), + writeConfig: (content) => writeRemoteConfigAtomic(remoteFs, ctx, configPath, content), }) ); } @@ -190,17 +190,17 @@ async function readRemoteConfig( async function writeRemoteConfigAtomic( remoteFs: Pick, - exec: ExecFn, + ctx: IExecutionContext, configPath: string, content: string ): Promise { const tmpPath = `${configPath}.${randomUUID()}.tmp`; try { await remoteFs.write(tmpPath, content); - await exec('mv', [tmpPath, configPath]); + await ctx.exec('mv', [tmpPath, configPath]); } catch (error: unknown) { try { - await exec('rm', ['-f', tmpPath]); + await ctx.exec('rm', ['-f', tmpPath]); } catch {} throw error; } diff --git a/src/main/core/agent-hooks/hook-config.test.ts b/src/main/core/agent-hooks/hook-config.test.ts new file mode 100644 index 0000000000..f3ca40cca4 --- /dev/null +++ b/src/main/core/agent-hooks/hook-config.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import { MemoryFs } from '@main/core/fs/test-helpers/memory-fs'; +import { HookConfigWriter } from './hook-config'; + +const mockResolveCommandPath = vi.hoisted(() => vi.fn()); + +vi.mock('@main/core/dependencies/probe', () => ({ + resolveCommandPath: mockResolveCommandPath, +})); + +function makeExecutionContext(): IExecutionContext { + return { + supportsLocalSpawn: false, + exec: vi.fn(async () => ({ stdout: '', stderr: '' })), + execStreaming: vi.fn(async () => {}), + dispose: vi.fn(), + }; +} + +function makeWriter(fs: MemoryFs): HookConfigWriter { + return new HookConfigWriter(fs, makeExecutionContext()); +} + +describe('HookConfigWriter', () => { + beforeEach(() => { + mockResolveCommandPath.mockReset(); + mockResolveCommandPath.mockResolvedValue('/usr/local/bin/pi'); + }); + + it('writes the Pi lifecycle extension and ignores it in git', async () => { + const fs = new MemoryFs(); + const writer = makeWriter(fs); + + await writer.writeForProvider('pi'); + + expect(fs.files.get('.pi/extensions/emdash-hook.ts')).toContain("pi.on('agent_end'"); + expect(fs.files.get('.pi/extensions/emdash-hook.ts')).toContain( + "process.once('uncaughtException'" + ); + expect(fs.files.get('.pi/extensions/emdash-hook.ts')).toContain("'X-Emdash-Event-Type'"); + expect(fs.files.get('.gitignore')).toBe('.pi/extensions/emdash-hook.ts\n'); + }); + + it('does not duplicate the Pi gitignore entry', async () => { + const fs = new MemoryFs(); + fs.files.set('.gitignore', '.pi/extensions/emdash-hook.ts\n'); + const writer = makeWriter(fs); + + await writer.writeForProvider('pi'); + + expect(fs.files.get('.gitignore')).toBe('.pi/extensions/emdash-hook.ts\n'); + }); + + it('skips the Pi extension when pi is unavailable', async () => { + mockResolveCommandPath.mockResolvedValue(undefined); + const fs = new MemoryFs(); + const writer = makeWriter(fs); + + await writer.writeForProvider('pi'); + + expect(fs.files.has('.pi/extensions/emdash-hook.ts')).toBe(false); + expect(fs.files.has('.gitignore')).toBe(false); + }); + + it('writes the OpenCode notifications plugin and ignores it in git', async () => { + mockResolveCommandPath.mockResolvedValue('/usr/local/bin/opencode'); + const fs = new MemoryFs(); + const writer = makeWriter(fs); + + await writer.writeForProvider('opencode'); + + expect(fs.files.get('.opencode/plugins/emdash-notifications.js')).toContain( + 'EmdashNotifications' + ); + expect(fs.files.get('.opencode/plugins/emdash-notifications.js')).toContain( + "event.type === 'session.idle'" + ); + expect(fs.files.get('.gitignore')).toBe('.opencode/plugins/emdash-notifications.js\n'); + }); + + it('does not duplicate the OpenCode gitignore entry', async () => { + mockResolveCommandPath.mockResolvedValue('/usr/local/bin/opencode'); + const fs = new MemoryFs(); + fs.files.set('.gitignore', '.opencode/plugins/emdash-notifications.js\n'); + const writer = makeWriter(fs); + + await writer.writeForProvider('opencode'); + + expect(fs.files.get('.gitignore')).toBe('.opencode/plugins/emdash-notifications.js\n'); + }); + + it('skips the OpenCode plugin when opencode is unavailable', async () => { + mockResolveCommandPath.mockResolvedValue(undefined); + const fs = new MemoryFs(); + const writer = makeWriter(fs); + + await writer.writeForProvider('opencode'); + + expect(fs.files.has('.opencode/plugins/emdash-notifications.js')).toBe(false); + expect(fs.files.has('.gitignore')).toBe(false); + }); +}); diff --git a/src/main/core/agent-hooks/hook-config.ts b/src/main/core/agent-hooks/hook-config.ts index c2e2c4e072..a835e82dd3 100644 --- a/src/main/core/agent-hooks/hook-config.ts +++ b/src/main/core/agent-hooks/hook-config.ts @@ -1,14 +1,22 @@ import * as toml from 'smol-toml'; import type { AgentProviderId } from '@shared/agent-provider-registry'; import { resolveCommandPath } from '@main/core/dependencies/probe'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; -import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; +import { + makeClaudeHookCommand, + makeCodexNotifyCommand, + makeOpenCodePluginContent, +} from './agent-notify-command'; +import piEmdashExtension from './pi-emdash-extension.ts?raw'; const EMDASH_MARKER = 'EMDASH_HOOK_PORT'; const CLAUDE_SETTINGS_PATH = '.claude/settings.local.json'; const CODEX_CONFIG_PATH = '.codex/config.toml'; +const PI_EMDASH_EXTENSION_PATH = '.pi/extensions/emdash-hook.ts'; +const OPENCODE_PLUGIN_PATH = '.opencode/plugins/emdash-notifications.js'; const GITIGNORE_PATH = '.gitignore'; type HookConfigWriteOptions = { writeGitIgnoreEntries?: boolean }; @@ -17,46 +25,21 @@ const HOOK_EVENT_MAP = [ { eventType: 'stop', hookKey: 'Stop' }, ] satisfies { eventType: string; hookKey: string }[]; -function makeClaudeHookCommand(eventType: string): string { - return ( - 'curl -sf -X POST ' + - '-H "Content-Type: application/json" ' + - '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + - '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + - `-H "X-Emdash-Event-Type: ${eventType}" ` + - '-d @- ' + - '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true' - ); -} - -function makeCodexNotifyCommand(): string[] { - return [ - 'bash', - '-c', - 'curl -sf -X POST ' + - "-H 'Content-Type: application/json' " + - '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + - '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + - '-H "X-Emdash-Event-Type: notification" ' + - '-d "$1" ' + - '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true', - '_', - ]; -} - export class HookConfigWriter { constructor( private readonly fs: FileSystemProvider, - private readonly exec: ExecFn + private readonly exec: IExecutionContext ) {} async writeClaudeHooks(): Promise { if (!(await resolveCommandPath('claude', this.exec))) return false; - const config: Record = await this.fs - .read(CLAUDE_SETTINGS_PATH) - .then((r) => JSON.parse(r.content) ?? {}) - .catch(() => ({})); + const config: Record = (await this.fs.exists(CLAUDE_SETTINGS_PATH)) + ? await this.fs + .read(CLAUDE_SETTINGS_PATH) + .then((r) => JSON.parse(r.content) ?? {}) + .catch(() => ({})) + : {}; const hooks = (config.hooks ?? {}) as Record; @@ -72,16 +55,45 @@ export class HookConfigWriter { async writeCodexNotify(): Promise { if (!(await resolveCommandPath('codex', this.exec))) return false; - const config: Record = await this.fs - .read(CODEX_CONFIG_PATH) - .then((result) => toml.parse(result.content) ?? {}) - .catch(() => ({})); + const config: Record = (await this.fs.exists(CODEX_CONFIG_PATH)) + ? await this.fs + .read(CODEX_CONFIG_PATH) + .then((result) => toml.parse(result.content) ?? {}) + .catch(() => ({})) + : {}; config.notify = makeCodexNotifyCommand(); await this.fs.write(CODEX_CONFIG_PATH, toml.stringify(config)); return true; } + async writePiExtension(): Promise { + if (!(await resolveCommandPath('pi', this.exec))) return false; + + const existing = await this.fs + .read(PI_EMDASH_EXTENSION_PATH) + .then((r) => r.content) + .catch(() => undefined); + if (existing === piEmdashExtension) return true; + + await this.fs.write(PI_EMDASH_EXTENSION_PATH, piEmdashExtension); + return true; + } + + async writeOpenCodePlugin(): Promise { + if (!(await resolveCommandPath('opencode', this.exec))) return false; + + const pluginContent = makeOpenCodePluginContent(); + const existing = await this.fs + .read(OPENCODE_PLUGIN_PATH) + .then((r) => r.content) + .catch(() => undefined); + if (existing === pluginContent) return true; + + await this.fs.write(OPENCODE_PLUGIN_PATH, pluginContent); + return true; + } + async writeForProvider( providerId: AgentProviderId, options: HookConfigWriteOptions = {} @@ -101,12 +113,29 @@ export class HookConfigWriter { if (wroteConfig && writeGitIgnoreEntries) { await this.ensureGitIgnoreEntries([CODEX_CONFIG_PATH]); } + return; + } + + if (providerId === 'pi') { + const wroteConfig = await this.writePiExtension(); + if (wroteConfig && writeGitIgnoreEntries) { + await this.ensureGitIgnoreEntries([PI_EMDASH_EXTENSION_PATH]); + } + return; + } + + if (providerId === 'opencode') { + const wroteConfig = await this.writeOpenCodePlugin(); + if (wroteConfig && writeGitIgnoreEntries) { + await this.ensureGitIgnoreEntries([OPENCODE_PLUGIN_PATH]); + } + return; } } async writeAll(options: HookConfigWriteOptions = {}): Promise { await Promise.all( - (['claude', 'codex'] as const).map((providerId) => + (['claude', 'codex', 'pi', 'opencode'] as const).map((providerId) => this.writeForProvider(providerId, options).catch((err: Error) => { log.warn(`Failed to write ${providerId} hook config`, { error: String(err) }); }) diff --git a/src/main/core/agent-hooks/notification.ts b/src/main/core/agent-hooks/notification.ts index 62c55b3234..dbc7c8dc61 100644 --- a/src/main/core/agent-hooks/notification.ts +++ b/src/main/core/agent-hooks/notification.ts @@ -52,7 +52,13 @@ export async function maybeShowNotification(event: AgentEvent, appFocused: boole if (win.isMinimized()) win.restore(); win.show(); win.focus(); - if (event.taskId) events.emit(notificationFocusTaskChannel, { taskId: event.taskId }); + if (event.taskId) { + events.emit(notificationFocusTaskChannel, { + projectId: event.projectId, + taskId: event.taskId, + conversationId: event.conversationId, + }); + } }); notification.show(); diff --git a/src/main/core/agent-hooks/opencode-notifications-plugin.js b/src/main/core/agent-hooks/opencode-notifications-plugin.js new file mode 100644 index 0000000000..c1315f6f6f --- /dev/null +++ b/src/main/core/agent-hooks/opencode-notifications-plugin.js @@ -0,0 +1,53 @@ +/* global fetch, process */ + +export const EmdashNotifications = async () => ({ + event: async ({ event }) => { + const port = process.env.EMDASH_HOOK_PORT; + const token = process.env.EMDASH_HOOK_TOKEN; + const ptyId = process.env.EMDASH_PTY_ID; + if (!port || !token || !ptyId) return; + + const payload = toEmdashPayload(event); + if (!payload) return; + + try { + await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emdash-Token': token, + 'X-Emdash-Pty-Id': ptyId, + 'X-Emdash-Event-Type': payload.type, + }, + body: JSON.stringify(payload.body), + }); + } catch { + // Hook delivery is best-effort and must never interrupt OpenCode. + } + }, +}); + +function toEmdashPayload(event) { + if (event.type === 'session.idle') { + return { + type: 'notification', + body: { + notification_type: 'idle_prompt', + title: 'OpenCode', + message: 'OpenCode is ready for input.', + }, + }; + } + + if (event.type === 'session.error') { + return { + type: 'error', + body: { + title: 'OpenCode error', + message: typeof event.properties?.error === 'string' ? event.properties.error : undefined, + }, + }; + } + + return undefined; +} diff --git a/src/main/core/agent-hooks/pi-emdash-extension.ts b/src/main/core/agent-hooks/pi-emdash-extension.ts new file mode 100644 index 0000000000..5d9a475398 --- /dev/null +++ b/src/main/core/agent-hooks/pi-emdash-extension.ts @@ -0,0 +1,56 @@ +type ExtensionAPI = { + on(event: 'agent_end', handler: () => unknown): void; + on(event: 'session_shutdown', handler: (event: { reason: string }) => unknown): void; +}; + +async function notifyEmdash( + eventType: 'stop' | 'error' | 'notification', + body: Record = {} +) { + const port = process.env.EMDASH_HOOK_PORT; + const token = process.env.EMDASH_HOOK_TOKEN; + const ptyId = process.env.EMDASH_PTY_ID; + + if (!port || !token || !ptyId) return; + + try { + await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emdash-Token': token, + 'X-Emdash-Pty-Id': ptyId, + 'X-Emdash-Event-Type': eventType, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(2000), + }); + } catch { + // Emdash may not be running when pi is launched directly; ignore hook failures. + } +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return 'Pi exited with an error'; +} + +export default function (pi: ExtensionAPI) { + pi.on('agent_end', async () => { + await notifyEmdash('stop', { message: 'Task completed' }); + }); + + pi.on('session_shutdown', async (event) => { + if (event.reason !== 'quit') return; + await notifyEmdash('stop', { message: 'Session ended' }); + }); + + process.once('uncaughtException', (error) => { + void notifyEmdash('error', { message: errorMessage(error) }); + }); + + process.once('unhandledRejection', (reason) => { + void notifyEmdash('error', { message: errorMessage(reason) }); + }); +} diff --git a/src/main/core/app/controller.ts b/src/main/core/app/controller.ts index 79a4c2dbd7..31757ed650 100644 --- a/src/main/core/app/controller.ts +++ b/src/main/core/app/controller.ts @@ -1,13 +1,13 @@ import { createRPCController } from '@shared/ipc/rpc'; import type { OpenInAppId } from '@shared/openInApps'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { appService } from './service'; export const appController = createRPCController({ openExternal: async (url: string) => { try { await appService.openExternal(url); - capture('open_in_external', { app: 'browser' }); + telemetryService.capture('open_in_external', { app: 'browser' }); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -29,7 +29,7 @@ export const appController = createRPCController({ }) => { try { await appService.openIn(args); - capture('open_in_external', { app: args.app }); + telemetryService.capture('open_in_external', { app: args.app }); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; diff --git a/src/main/core/app/service.ts b/src/main/core/app/service.ts index c1e97b683f..f32efe1a21 100644 --- a/src/main/core/app/service.ts +++ b/src/main/core/app/service.ts @@ -14,12 +14,13 @@ import { getMainWindow } from '@main/app/window'; import { db } from '@main/db/client'; import { sshConnections } from '@main/db/schema'; import { events } from '@main/lib/events'; +import type { IDisposable, IInitializable } from '@main/lib/lifecycle'; import { log } from '@main/lib/logger'; import { buildExternalToolEnv } from '@main/utils/childProcessEnv'; import { - buildGhosttyRemoteExecArgs, buildRemoteEditorUrl, buildRemoteSshCommand, + buildRemoteTerminalExecArgs, } from '@main/utils/remoteOpenIn'; import { checkCommand, @@ -33,23 +34,36 @@ import { const FONT_CACHE_TTL_MS = 5 * 60 * 1_000; -class AppService { +type RemoteTerminalLaunchAttempt = { + file: string; + args: string[]; +}; + +class AppService implements IInitializable, IDisposable { private cachedAppVersion: string | null = null; private cachedAppVersionPromise: Promise | null = null; private cachedInstalledFonts: { fonts: string[]; fetchedAt: number } | null = null; + private _unsubscribes: Array<() => void> = []; initialize(): void { void this.getCachedAppVersion(); - events.on(appUndoChannel, () => { - getMainWindow()?.webContents.undo(); - }); - events.on(appRedoChannel, () => { - getMainWindow()?.webContents.redo(); - }); - events.on(appPasteChannel, () => { - getMainWindow()?.webContents.paste(); - }); + this._unsubscribes = [ + events.on(appUndoChannel, () => { + getMainWindow()?.webContents.undo(); + }), + events.on(appRedoChannel, () => { + getMainWindow()?.webContents.redo(); + }), + events.on(appPasteChannel, () => { + getMainWindow()?.webContents.paste(); + }), + ]; + } + + dispose(): void { + for (const unsub of this._unsubscribes) unsub(); + this._unsubscribes = []; } getCachedAppVersion(): Promise { @@ -209,7 +223,7 @@ class AppService { const { host, username, port } = connection; - if (appId === 'vscode' || appId === 'cursor') { + if (appId === 'vscode' || appId === 'vscodium' || appId === 'cursor') { await shell.openExternal(buildRemoteEditorUrl(appId, host, username, target)); return; } @@ -238,7 +252,7 @@ class AppService { } if (appId === 'ghostty') { - const ghosttyExecArgs = buildGhosttyRemoteExecArgs({ + const remoteExecArgs = buildRemoteTerminalExecArgs({ host, username, port, @@ -249,24 +263,38 @@ class AppService { ? [ { file: 'open', - args: ['-n', '-b', 'com.mitchellh.ghostty', '--args', '-e', ...ghosttyExecArgs], + args: ['-n', '-b', 'com.mitchellh.ghostty', '--args', '-e', ...remoteExecArgs], }, - { file: 'open', args: ['-na', 'Ghostty', '--args', '-e', ...ghosttyExecArgs] }, - { file: 'ghostty', args: ['-e', ...ghosttyExecArgs] }, + { file: 'open', args: ['-na', 'Ghostty', '--args', '-e', ...remoteExecArgs] }, + { file: 'ghostty', args: ['-e', ...remoteExecArgs] }, ] - : [{ file: 'ghostty', args: ['-e', ...ghosttyExecArgs] }]; + : [{ file: 'ghostty', args: ['-e', ...remoteExecArgs] }]; - let lastError: unknown = null; - for (const attempt of attempts) { - try { - await execFileCommand(attempt.file, attempt.args); - return; - } catch (error) { - lastError = error; - } - } - if (lastError instanceof Error) throw lastError; - throw new Error('Unable to launch Ghostty'); + await this.launchRemoteTerminal('Ghostty', attempts); + return; + } + + if (appId === 'kitty') { + const remoteExecArgs = buildRemoteTerminalExecArgs({ + host, + username, + port, + targetPath: target, + }); + const attempts = + platform === 'darwin' + ? [ + { + file: 'open', + args: ['-n', '-b', 'net.kovidgoyal.kitty', '--args', ...remoteExecArgs], + }, + { file: 'open', args: ['-na', 'kitty', '--args', ...remoteExecArgs] }, + { file: 'kitty', args: remoteExecArgs }, + ] + : [{ file: 'kitty', args: remoteExecArgs }]; + + await this.launchRemoteTerminal('Kitty', attempts); + return; } if (appConfig?.supportsRemote) { @@ -274,6 +302,23 @@ class AppService { } } + private async launchRemoteTerminal( + label: string, + attempts: RemoteTerminalLaunchAttempt[] + ): Promise { + let lastError: unknown = null; + for (const attempt of attempts) { + try { + await execFileCommand(attempt.file, attempt.args); + return; + } catch (error) { + lastError = error; + } + } + if (lastError instanceof Error) throw lastError; + throw new Error(`Unable to launch ${label}`); + } + private async openInLocal(args: { label: string; target: string; diff --git a/src/main/core/conversations/controller.ts b/src/main/core/conversations/controller.ts index 7ff550842a..71c44e6248 100644 --- a/src/main/core/conversations/controller.ts +++ b/src/main/core/conversations/controller.ts @@ -4,6 +4,7 @@ import { deleteConversation } from './deleteConversation'; import { getConversations } from './getConversations'; import { getConversationsForTask } from './getConversationsForTask'; import { renameConversation } from './renameConversation'; +import { touchConversation } from './touchConversation'; export const conversationController = createRPCController({ getConversations, @@ -11,4 +12,5 @@ export const conversationController = createRPCController({ deleteConversation, renameConversation, getConversationsForTask, + touchConversation, }); diff --git a/src/main/core/conversations/conversation-events.ts b/src/main/core/conversations/conversation-events.ts new file mode 100644 index 0000000000..9e577672b8 --- /dev/null +++ b/src/main/core/conversations/conversation-events.ts @@ -0,0 +1,33 @@ +import type { Conversation } from '@shared/conversations'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { log } from '@main/lib/logger'; + +export type ConversationCrudHooks = { + 'conversation:created': (conversation: Conversation) => void | Promise; + 'conversation:renamed': ( + conversationId: string, + projectId: string, + taskId: string, + newTitle: string + ) => void | Promise; + 'conversation:deleted': (conversationId: string) => void | Promise; +}; + +class ConversationEvents implements Hookable { + private readonly _core = new HookCore((name, e) => + log.error(`ConversationEvents: ${String(name)} hook error`, e) + ); + + on(name: K, handler: ConversationCrudHooks[K]) { + return this._core.on(name, handler); + } + + _emit( + name: K, + ...args: Parameters + ): void { + this._core.callHookBackground(name, ...args); + } +} + +export const conversationEvents = new ConversationEvents(); diff --git a/src/main/core/conversations/createConversation.ts b/src/main/core/conversations/createConversation.ts index c4f6095da6..d9c877e537 100644 --- a/src/main/core/conversations/createConversation.ts +++ b/src/main/core/conversations/createConversation.ts @@ -1,10 +1,11 @@ import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; -import { Conversation, CreateConversationParams } from '@shared/conversations'; +import { type Conversation, type CreateConversationParams } from '@shared/conversations'; import { db } from '@main/db/client'; import { conversations } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { resolveTask } from '../projects/utils'; +import { conversationEvents } from './conversation-events'; import { mapConversationRowToConversation } from './utils'; export async function createConversation(params: CreateConversationParams): Promise { @@ -29,8 +30,10 @@ export async function createConversation(params: CreateConversationParams): Prom title: params.title, provider: params.provider, config, + isInitialConversation: params.isInitialConversation ?? false, createdAt: sql`CURRENT_TIMESTAMP`, updatedAt: sql`CURRENT_TIMESTAMP`, + lastInteractedAt: new Date().toISOString(), }) .returning(); @@ -41,13 +44,15 @@ export async function createConversation(params: CreateConversationParams): Prom const conversation = mapConversationRowToConversation(row); + conversationEvents._emit('conversation:created', conversation); + await task.conversations.startSession( conversation, params.initialSize, false, params.initialPrompt ); - capture('conversation_created', { + telemetryService.capture('conversation_created', { provider: params.provider, is_first_in_task: existingConversation === undefined, project_id: params.projectId, diff --git a/src/main/core/conversations/deleteConversation.ts b/src/main/core/conversations/deleteConversation.ts index bfe9219aa7..4fdfc23922 100644 --- a/src/main/core/conversations/deleteConversation.ts +++ b/src/main/core/conversations/deleteConversation.ts @@ -1,8 +1,9 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@main/db/client'; import { conversations } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { resolveTask } from '../projects/utils'; +import { conversationEvents } from './conversation-events'; export async function deleteConversation( projectId: string, @@ -19,9 +20,11 @@ export async function deleteConversation( ) ); + conversationEvents._emit('conversation:deleted', conversationId); + const task = resolveTask(projectId, taskId); await task?.conversations.stopSession(conversationId); - capture('conversation_deleted', { + telemetryService.capture('conversation_deleted', { project_id: projectId, task_id: taskId, conversation_id: conversationId, diff --git a/src/main/core/conversations/impl/agent-command.test.ts b/src/main/core/conversations/impl/agent-command.test.ts new file mode 100644 index 0000000000..46e42f6360 --- /dev/null +++ b/src/main/core/conversations/impl/agent-command.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentProviderId } from '@shared/agent-provider-registry'; +import type { ProviderCustomConfig } from '@shared/app-settings'; +import { providerConfigDefaults } from '@main/core/settings/schema'; +import { buildAgentCommand } from './agent-command'; + +function makeConfig(overrides: Partial = {}): ProviderCustomConfig { + return { + cli: 'claude', + resumeFlag: '--resume', + autoApproveFlag: '--dangerously-skip-permissions', + initialPromptFlag: '', + sessionIdFlag: '--session-id', + ...overrides, + }; +} + +describe('buildAgentCommand', () => { + it('uses the current Codex bypass flag when auto-approve is enabled', () => { + const command = buildAgentCommand({ + providerId: 'codex', + providerConfig: providerConfigDefaults.codex, + autoApprove: true, + initialPrompt: 'Fix the issue', + sessionId: 'session-1', + }); + + expect(command).toEqual({ + command: 'codex', + args: ['--dangerously-bypass-approvals-and-sandbox', 'Fix the issue'], + }); + }); + + it('supports custom CLI command prefixes and appends managed provider args', () => { + const result = buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ + cli: 'caffeinate -i direnv exec . claude', + }), + autoApprove: true, + initialPrompt: 'Fix the bug', + sessionId: 'conv-1', + }); + + expect(result).toEqual({ + command: 'caffeinate', + args: [ + '-i', + 'direnv', + 'exec', + '.', + 'claude', + '--session-id', + 'conv-1', + '--dangerously-skip-permissions', + 'Fix the bug', + ], + }); + }); + + it('preserves quoted custom CLI and flag arguments', () => { + const result = buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ + cli: '"/opt/Claude Code/bin/claude"', + resumeFlag: '--resume "existing session"', + }), + sessionId: 'conv-1', + isResuming: true, + }); + + expect(result.command).toBe('/opt/Claude Code/bin/claude'); + expect(result.args).toEqual(['--resume', 'existing session', 'conv-1']); + }); + + it('parses multi-token session id flags', () => { + const result = buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ sessionIdFlag: '--session id' }), + sessionId: 'conv-1', + }); + + expect(result.args).toEqual(['--session', 'id', 'conv-1']); + }); + + it('puts default args before resume flags for CLIs with subcommands', () => { + const result = buildAgentCommand({ + providerId: 'goose', + providerConfig: providerConfigDefaults.goose, + sessionId: 'conv-1', + isResuming: true, + }); + + expect(result.args).toEqual(['run', '-s', '--resume']); + }); + + it('does not pass Droid session id on fresh sessions', () => { + const result = buildAgentCommand({ + providerId: 'droid', + providerConfig: providerConfigDefaults.droid, + initialPrompt: 'Fix the bug', + sessionId: 'conv-1', + }); + + expect(result.args).toEqual(['Fix the bug']); + }); + + it('passes Droid session id when resuming', () => { + const result = buildAgentCommand({ + providerId: 'droid', + providerConfig: providerConfigDefaults.droid, + sessionId: 'conv-1', + isResuming: true, + }); + + expect(result.args).toEqual(['--session-id', 'conv-1']); + }); + + it.each<{ + providerId: AgentProviderId; + freshArgs: string[]; + resumeArgs: string[]; + }>([ + { providerId: 'cursor', freshArgs: ['Fix the bug'], resumeArgs: ['--resume'] }, + { providerId: 'opencode', freshArgs: [], resumeArgs: ['--continue'] }, + { providerId: 'copilot', freshArgs: ['Fix the bug'], resumeArgs: ['--resume'] }, + { + providerId: 'auggie', + freshArgs: ['--allow-indexing', 'Fix the bug'], + resumeArgs: ['--allow-indexing', '--continue'], + }, + { + providerId: 'goose', + freshArgs: ['run', '-s', '-t', 'Fix the bug'], + resumeArgs: ['run', '-s', '--resume'], + }, + { providerId: 'kimi', freshArgs: ['-c', 'Fix the bug'], resumeArgs: ['--continue'] }, + { providerId: 'mistral', freshArgs: ['Fix the bug'], resumeArgs: [] }, + ])('builds fresh and resume args for $providerId', ({ providerId, freshArgs, resumeArgs }) => { + const fresh = buildAgentCommand({ + providerId, + providerConfig: providerConfigDefaults[providerId], + initialPrompt: 'Fix the bug', + sessionId: 'conv-1', + }); + + const resume = buildAgentCommand({ + providerId, + providerConfig: providerConfigDefaults[providerId], + sessionId: 'conv-1', + isResuming: true, + }); + + expect(fresh.args).toEqual(freshArgs); + expect(resume.args).toEqual(resumeArgs); + }); + + it('appends extra args', () => { + const result = buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ + extraArgs: '--model "Claude Sonnet"', + }), + sessionId: 'conv-1', + }); + + expect(result.args).toContain('--model'); + expect(result.args).toContain('Claude Sonnet'); + }); + + it('rejects shell control syntax that makes managed args ambiguous', () => { + expect(() => + buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ cli: 'claude | tee log' }), + sessionId: 'conv-1', + }) + ).toThrow(/executable command prefixes/); + }); + + it('rejects shell setup in the CLI command field', () => { + expect(() => + buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ cli: 'source ~/.zshrc && claude' }), + sessionId: 'conv-1', + }) + ).toThrow(/executable command prefixes/); + }); + + it('rejects inline environment assignment in the CLI command field', () => { + expect(() => + buildAgentCommand({ + providerId: 'claude', + providerConfig: makeConfig({ cli: 'FOO=bar claude' }), + sessionId: 'conv-1', + }) + ).toThrow(/executable command prefixes/); + }); +}); diff --git a/src/main/core/conversations/impl/agent-command.ts b/src/main/core/conversations/impl/agent-command.ts index d14ec73cdc..dd753d76d4 100644 --- a/src/main/core/conversations/impl/agent-command.ts +++ b/src/main/core/conversations/impl/agent-command.ts @@ -1,48 +1,139 @@ -import { AgentProviderId, getProvider } from '@shared/agent-provider-registry'; -import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; +import { getProvider, type AgentProviderId } from '@shared/agent-provider-registry'; +import type { ProviderCustomConfig } from '@shared/app-settings'; -export async function buildAgentCommand({ +export type AgentCommand = { + command: string; + args: string[]; +}; + +const SHELL_SYNTAX_ERROR = 'Custom CLI commands support executable command prefixes only. '; + +const SHELL_BUILTINS = new Set(['.', 'source', 'eval', 'exec', 'cd', 'alias', 'export']); + +type ParsedWords = { ok: true; words: string[] } | { ok: false; reason: string }; + +export function parseShellWords( + input: string, + options: { rejectShellSyntax?: boolean } = {} +): ParsedWords { + const words: string[] = []; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + let escaped = false; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + + if (escaped) { + current += char; + escaped = false; + continue; + } + + if (char === '\\' && !inSingleQuote) { + escaped = true; + continue; + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (options.rejectShellSyntax && !inSingleQuote && !inDoubleQuote) { + if (char === '$' || char === '`' || /[|&;<>]/.test(char)) { + return { ok: false, reason: SHELL_SYNTAX_ERROR }; + } + } + + if (/\s/.test(char) && !inSingleQuote && !inDoubleQuote) { + if (current.length > 0) { + words.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (escaped) current += '\\'; + if (inSingleQuote || inDoubleQuote) return { ok: false, reason: 'Unclosed quote.' }; + if (current.length > 0) words.push(current); + + return { ok: true, words }; +} + +function parseArgField(value: string | undefined): string[] { + if (!value) return []; + const parsed = parseShellWords(value); + if (!parsed.ok) throw new Error(parsed.reason); + return parsed.words; +} + +function parseCliPrefix(value: string | undefined, providerId: AgentProviderId): string[] { + const cli = value?.trim(); + if (!cli) throw new Error(`Missing CLI command for provider: ${providerId}`); + + const parsed = parseShellWords(cli, { rejectShellSyntax: true }); + if (!parsed.ok) throw new Error(parsed.reason); + const [command] = parsed.words; + if (!command) throw new Error(`Missing CLI command for provider: ${providerId}`); + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(command)) throw new Error(SHELL_SYNTAX_ERROR); + if (SHELL_BUILTINS.has(command)) throw new Error(SHELL_SYNTAX_ERROR); + + return parsed.words; +} + +export function buildAgentCommand({ providerId, + providerConfig, autoApprove, initialPrompt, sessionId, isResuming, }: { providerId: AgentProviderId; + providerConfig: ProviderCustomConfig | undefined; autoApprove?: boolean; initialPrompt?: string; sessionId: string; isResuming?: boolean; -}) { - const providerConfig = await providerOverrideSettings.getItem(providerId); +}): AgentCommand { const providerDef = getProvider(providerId); + const [command, ...args] = parseCliPrefix(providerConfig?.cli, providerId); - const cli = providerConfig?.cli; - const args: string[] = []; + args.push(...(providerConfig?.defaultArgs ?? [])); + + const shouldPassSessionId = + providerConfig?.sessionIdFlag && (!providerConfig.sessionIdOnResumeOnly || isResuming); if (isResuming && providerConfig?.resumeFlag) { - args.push(...providerConfig.resumeFlag.split(' ')); - if (providerConfig?.sessionIdFlag) { + args.push(...parseArgField(providerConfig.resumeFlag)); + if (providerConfig.sessionIdFlag) { args.push(sessionId); } - } else if (providerConfig?.sessionIdFlag) { - args.push(providerConfig.sessionIdFlag, sessionId); + } else if (shouldPassSessionId) { + args.push(...parseArgField(providerConfig.sessionIdFlag), sessionId); + } else if (!isResuming && providerDef?.newConversationFlag) { + args.push(providerDef.newConversationFlag); } if (autoApprove && providerConfig?.autoApproveFlag) { - args.push(providerConfig.autoApproveFlag); + args.push(...parseArgField(providerConfig.autoApproveFlag)); } if (!isResuming && initialPrompt && !providerDef?.useKeystrokeInjection) { - const flag = providerConfig?.initialPromptFlag; - if (flag) { - args.push(flag, initialPrompt); - } else { - args.push(initialPrompt); - } + args.push(...parseArgField(providerConfig?.initialPromptFlag), initialPrompt); } - args.push(...(providerConfig?.defaultArgs ?? [])); + args.push(...parseArgField(providerConfig?.extraArgs)); - return { command: cli!, args }; + return { command, args }; } diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index 715b2c6652..802e159081 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -1,7 +1,6 @@ import { homedir } from 'node:os'; import { getProvider } from '@shared/agent-provider-registry'; -import type { AgentSessionConfig } from '@shared/agent-session'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; import { makePtyId } from '@shared/ptyId'; import { makePtySessionId } from '@shared/ptySessionId'; @@ -10,19 +9,21 @@ import { wireAgentClassifier } from '@main/core/agent-hooks/classifier-wiring'; import { claudeTrustService } from '@main/core/agent-hooks/claude-trust-service'; import { HookConfigWriter } from '@main/core/agent-hooks/hook-config'; import type { ConversationProvider } from '@main/core/conversations/types'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { spawnLocalPty } from '@main/core/pty/local-pty'; -import { Pty } from '@main/core/pty/pty'; +import type { Pty } from '@main/core/pty/pty'; import { buildAgentEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; -import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; +import { logLocalPtySpawnWarnings, resolveLocalPtySpawn } from '@main/core/pty/pty-spawn-platform'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; import { appSettingsService } from '@main/core/settings/settings-service'; -import type { ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { buildAgentCommand } from './agent-command'; +import { resolveProviderEnv } from './provider-env'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -37,7 +38,7 @@ export class LocalConversationProvider implements ConversationProvider { private readonly taskId: string; private readonly tmux: boolean; private readonly shellSetup?: string; - private readonly exec: ExecFn; + private readonly ctx: IExecutionContext; private readonly taskEnvVars: Record; private readonly hookConfigWriter: HookConfigWriter; private readonly preparedHookProviders = new Map(); @@ -48,7 +49,7 @@ export class LocalConversationProvider implements ConversationProvider { taskId, tmux = false, shellSetup, - exec, + ctx, taskEnvVars = {}, }: { projectId: string; @@ -56,7 +57,7 @@ export class LocalConversationProvider implements ConversationProvider { taskId: string; tmux?: boolean; shellSetup?: string; - exec: ExecFn; + ctx: IExecutionContext; taskEnvVars?: Record; }) { this.projectId = projectId; @@ -64,9 +65,9 @@ export class LocalConversationProvider implements ConversationProvider { this.taskId = taskId; this.tmux = tmux; this.shellSetup = shellSetup; - this.exec = exec; + this.ctx = ctx; this.taskEnvVars = taskEnvVars; - this.hookConfigWriter = new HookConfigWriter(new LocalFileSystem(taskPath), exec); + this.hookConfigWriter = new HookConfigWriter(new LocalFileSystem(taskPath), ctx); } async startSession( @@ -90,42 +91,48 @@ export class LocalConversationProvider implements ConversationProvider { }); await this.prepareHookConfig(conversation.providerId); - const { command, args } = await buildAgentCommand({ + const providerConfig = await providerOverrideSettings.getItem(conversation.providerId); + const { command, args } = buildAgentCommand({ providerId: conversation.providerId, + providerConfig, autoApprove: conversation.autoApprove, sessionId: conversation.id, isResuming, initialPrompt, }); + const providerEnv = resolveProviderEnv(providerConfig); const tmuxSessionName = this.tmux ? makeTmuxSessionName(sessionId) : undefined; - const cfg: AgentSessionConfig = { - taskId: this.taskId, - conversationId: conversation.id, - providerId: conversation.providerId, - command, - args, - cwd: this.taskPath, - shellSetup: this.shellSetup, - tmuxSessionName, - autoApprove: conversation.autoApprove ?? false, - resume: isResuming, - }; + const resolved = resolveLocalPtySpawn({ + platform: process.platform, + env: process.env, + intent: { + kind: 'run-command', + cwd: this.taskPath, + command: { kind: 'argv', command, args }, + shellSetup: this.shellSetup, + tmuxSessionName, + }, + }); - const spawnParams = resolveSpawnParams('agent', cfg); + logLocalPtySpawnWarnings('LocalConversationProvider', resolved.warnings, { + conversationId: conversation.id, + sessionId, + }); const ptyId = makePtyId(conversation.providerId, conversation.id); const port = agentHookService.getPort(); const token = agentHookService.getToken(); const pty = spawnLocalPty({ id: sessionId, - command: spawnParams.command, - args: spawnParams.args, - cwd: this.taskPath, + command: resolved.command, + args: resolved.args, + cwd: resolved.cwd, env: { ...buildAgentEnv({ hook: port > 0 ? { port, ptyId, token } : undefined, + providerVars: providerEnv, }), ...this.taskEnvVars, }, @@ -151,7 +158,7 @@ export class LocalConversationProvider implements ConversationProvider { ptySessionRegistry.unregister(sessionId); const shouldRespawn = this.sessions.has(sessionId); this.sessions.delete(sessionId); - capture('agent_run_finished', { + telemetryService.capture('agent_run_finished', { provider: conversation.providerId, exit_code: typeof exitCode === 'number' ? exitCode : -1, project_id: conversation.projectId, @@ -193,7 +200,7 @@ export class LocalConversationProvider implements ConversationProvider { ptySessionRegistry.register(sessionId, pty); this.sessions.set(sessionId, pty); - capture('agent_run_started', { + telemetryService.capture('agent_run_started', { provider: conversation.providerId, project_id: conversation.projectId, task_id: conversation.taskId, @@ -238,7 +245,7 @@ export class LocalConversationProvider implements ConversationProvider { ptySessionRegistry.unregister(sessionId); } if (this.tmux) { - await killTmuxSession(this.exec, makeTmuxSessionName(sessionId)); + await killTmuxSession(this.ctx, makeTmuxSessionName(sessionId)); } } @@ -246,9 +253,7 @@ export class LocalConversationProvider implements ConversationProvider { const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { - await Promise.all( - sessionIds.map((id) => killTmuxSession(this.exec, makeTmuxSessionName(id))) - ); + await Promise.all(sessionIds.map((id) => killTmuxSession(this.ctx, makeTmuxSessionName(id)))); } this.knownSessionIds.clear(); } diff --git a/src/main/core/conversations/impl/provider-env.test.ts b/src/main/core/conversations/impl/provider-env.test.ts new file mode 100644 index 0000000000..6a4f4a4574 --- /dev/null +++ b/src/main/core/conversations/impl/provider-env.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { resolveProviderEnv } from './provider-env'; + +describe('resolveProviderEnv', () => { + it('returns valid provider environment variables', () => { + expect( + resolveProviderEnv({ + env: { + ANTHROPIC_BASE_URL: 'https://example.test', + _TOKEN: 'secret', + 'INVALID-NAME': 'ignored', + '1TOKEN': 'ignored', + }, + }) + ).toEqual({ + ANTHROPIC_BASE_URL: 'https://example.test', + _TOKEN: 'secret', + }); + }); + + it('returns undefined when no valid provider environment variables exist', () => { + expect(resolveProviderEnv(undefined)).toBeUndefined(); + expect(resolveProviderEnv({ env: { 'INVALID-NAME': 'ignored' } })).toBeUndefined(); + }); +}); diff --git a/src/main/core/conversations/impl/provider-env.ts b/src/main/core/conversations/impl/provider-env.ts new file mode 100644 index 0000000000..a14db6cb65 --- /dev/null +++ b/src/main/core/conversations/impl/provider-env.ts @@ -0,0 +1,16 @@ +import type { ProviderCustomConfig } from '@shared/app-settings'; + +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function resolveProviderEnv( + providerConfig: ProviderCustomConfig | undefined +): Record | undefined { + if (!providerConfig?.env) return undefined; + + const env: Record = {}; + for (const [key, value] of Object.entries(providerConfig.env)) { + if (ENV_NAME_PATTERN.test(key)) env[key] = value; + } + + return Object.keys(env).length > 0 ? env : undefined; +} diff --git a/src/main/core/conversations/impl/ssh-conversation.ts b/src/main/core/conversations/impl/ssh-conversation.ts index 44b144550a..c9ccd97366 100644 --- a/src/main/core/conversations/impl/ssh-conversation.ts +++ b/src/main/core/conversations/impl/ssh-conversation.ts @@ -1,22 +1,24 @@ import type { AgentSessionConfig } from '@shared/agent-session'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; import { makePtySessionId } from '@shared/ptySessionId'; import { wireAgentClassifier } from '@main/core/agent-hooks/classifier-wiring'; import { claudeTrustService } from '@main/core/agent-hooks/claude-trust-service'; import type { ConversationProvider } from '@main/core/conversations/types'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import { Pty } from '@main/core/pty/pty'; +import type { Pty } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; -import type { ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { buildAgentCommand } from './agent-command'; +import { resolveProviderEnv } from './provider-env'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -32,7 +34,7 @@ export class SshConversationProvider implements ConversationProvider { private readonly taskEnvVars: Record; private readonly tmux: boolean = false; private readonly shellSetup?: string; - private readonly exec: ExecFn; + private readonly ctx: IExecutionContext; private readonly proxy: SshClientProxy; constructor({ @@ -42,7 +44,7 @@ export class SshConversationProvider implements ConversationProvider { taskEnvVars = {}, tmux = false, shellSetup, - exec, + ctx, proxy, }: { projectId: string; @@ -51,7 +53,7 @@ export class SshConversationProvider implements ConversationProvider { taskEnvVars?: Record; tmux?: boolean; shellSetup?: string; - exec: ExecFn; + ctx: IExecutionContext; proxy: SshClientProxy; }) { this.projectId = projectId; @@ -60,7 +62,7 @@ export class SshConversationProvider implements ConversationProvider { this.taskEnvVars = taskEnvVars; this.tmux = tmux; this.shellSetup = shellSetup; - this.exec = exec; + this.ctx = ctx; this.proxy = proxy; } @@ -82,17 +84,20 @@ export class SshConversationProvider implements ConversationProvider { await claudeTrustService.maybeAutoTrustSsh({ providerId: conversation.providerId, cwd: this.taskPath, - exec: this.exec, + ctx: this.ctx, remoteFs: new SshFileSystem(this.proxy, '/'), }); - const { command, args } = await buildAgentCommand({ + const providerConfig = await providerOverrideSettings.getItem(conversation.providerId); + const { command, args } = buildAgentCommand({ providerId: conversation.providerId, + providerConfig, autoApprove: conversation.autoApprove, sessionId: conversation.id, isResuming, initialPrompt, }); + const providerEnv = resolveProviderEnv(providerConfig); const tmuxSessionName = this.tmux ? makeTmuxSessionName(sessionId) : undefined; @@ -109,7 +114,13 @@ export class SshConversationProvider implements ConversationProvider { resume: isResuming, }; - const sshCommand = resolveSshCommand('agent', cfg, this.taskEnvVars); + const profile = await this.proxy.getRemoteShellProfile(); + const sshCommand = resolveSshCommand( + 'agent', + cfg, + { ...providerEnv, ...this.taskEnvVars }, + profile + ); const result = await openSsh2Pty(this.proxy.client, { id: sessionId, @@ -141,7 +152,7 @@ export class SshConversationProvider implements ConversationProvider { ptySessionRegistry.unregister(sessionId); const shouldRespawn = this.sessions.has(sessionId); this.sessions.delete(sessionId); - capture('agent_run_finished', { + telemetryService.capture('agent_run_finished', { provider: conversation.providerId, exit_code: typeof exitCode === 'number' ? exitCode : -1, project_id: conversation.projectId, @@ -183,7 +194,7 @@ export class SshConversationProvider implements ConversationProvider { ptySessionRegistry.register(sessionId, pty); this.sessions.set(sessionId, pty); - capture('agent_run_started', { + telemetryService.capture('agent_run_started', { provider: conversation.providerId, project_id: conversation.projectId, task_id: conversation.taskId, @@ -205,7 +216,7 @@ export class SshConversationProvider implements ConversationProvider { ptySessionRegistry.unregister(sessionId); } if (this.tmux) { - await killTmuxSession(this.exec, makeTmuxSessionName(sessionId)); + await killTmuxSession(this.ctx, makeTmuxSessionName(sessionId)); } } @@ -213,9 +224,7 @@ export class SshConversationProvider implements ConversationProvider { const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { - await Promise.all( - sessionIds.map((id) => killTmuxSession(this.exec, makeTmuxSessionName(id))) - ); + await Promise.all(sessionIds.map((id) => killTmuxSession(this.ctx, makeTmuxSessionName(id)))); } this.knownSessionIds.clear(); } diff --git a/src/main/core/conversations/renameConversation.ts b/src/main/core/conversations/renameConversation.ts index 8db99a2bdb..e3d75b94f7 100644 --- a/src/main/core/conversations/renameConversation.ts +++ b/src/main/core/conversations/renameConversation.ts @@ -1,7 +1,24 @@ import { eq } from 'drizzle-orm'; import { db } from '@main/db/client'; import { conversations } from '@main/db/schema'; +import { conversationEvents } from './conversation-events'; export async function renameConversation(conversationId: string, name: string) { - return db.update(conversations).set({ title: name }).where(eq(conversations.id, conversationId)); + const [existing] = await db + .select({ projectId: conversations.projectId, taskId: conversations.taskId }) + .from(conversations) + .where(eq(conversations.id, conversationId)) + .limit(1); + + await db.update(conversations).set({ title: name }).where(eq(conversations.id, conversationId)); + + if (existing) { + conversationEvents._emit( + 'conversation:renamed', + conversationId, + existing.projectId, + existing.taskId, + name + ); + } } diff --git a/src/main/core/conversations/touchConversation.ts b/src/main/core/conversations/touchConversation.ts new file mode 100644 index 0000000000..d5e0f91f1a --- /dev/null +++ b/src/main/core/conversations/touchConversation.ts @@ -0,0 +1,13 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@main/db/client'; +import { conversations } from '@main/db/schema'; + +export async function touchConversation( + conversationId: string, + lastInteractedAt: string +): Promise { + await db + .update(conversations) + .set({ lastInteractedAt }) + .where(eq(conversations.id, conversationId)); +} diff --git a/src/main/core/conversations/types.ts b/src/main/core/conversations/types.ts index a273800dfc..16b1d4d4ed 100644 --- a/src/main/core/conversations/types.ts +++ b/src/main/core/conversations/types.ts @@ -1,4 +1,4 @@ -import { Conversation } from '@shared/conversations'; +import { type Conversation } from '@shared/conversations'; export interface ConversationProvider { startSession( diff --git a/src/main/core/conversations/utils.ts b/src/main/core/conversations/utils.ts index ef291a848d..758827f5cc 100644 --- a/src/main/core/conversations/utils.ts +++ b/src/main/core/conversations/utils.ts @@ -1,6 +1,6 @@ -import { AgentProviderId } from '@shared/agent-provider-registry'; -import { Conversation } from '@shared/conversations'; -import { ConversationRow } from '@main/db/schema'; +import { type AgentProviderId } from '@shared/agent-provider-registry'; +import { type Conversation } from '@shared/conversations'; +import { type ConversationRow } from '@main/db/schema'; export function mapConversationRowToConversation( row: ConversationRow, @@ -14,5 +14,7 @@ export function mapConversationRowToConversation( providerId: row.provider as AgentProviderId, autoApprove: row.config ? JSON.parse(row.config).autoApprove : undefined, resume: resume, + lastInteractedAt: row.lastInteractedAt ?? null, + isInitialConversation: row.isInitialConversation, }; } diff --git a/src/main/core/dependencies/dependency-manager.test.ts b/src/main/core/dependencies/dependency-manager.test.ts index 1fd15a5e26..deb5afc4a7 100644 --- a/src/main/core/dependencies/dependency-manager.test.ts +++ b/src/main/core/dependencies/dependency-manager.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { err, ok } from '@shared/result'; -import type { ExecFn } from '../utils/exec'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { DependencyManager } from './dependency-manager'; vi.mock('@main/lib/events', () => ({ @@ -15,11 +15,23 @@ vi.mock('../ssh/ssh-connection-manager', () => ({ }, })); -const missingExec: ExecFn = async () => { +function makeCtx( + handler: (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> +): IExecutionContext { + return { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn().mockImplementation(handler), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext; +} + +const missingCtx = makeCtx(async () => { throw new Error('missing'); -}; +}); -const availableExec: ExecFn = async (command, args = []) => { +const availableCtx = makeCtx(async (command, args = []) => { if (command === 'which' && args[0] === 'codex') { return { stdout: '/bin/codex\n', stderr: '' }; } @@ -27,14 +39,14 @@ const availableExec: ExecFn = async (command, args = []) => { return { stdout: 'codex-cli 1.2.3\n', stderr: '' }; } throw new Error('missing'); -}; +}); const { events } = await import('@main/lib/events'); describe('DependencyManager install', () => { it('runs dependency install commands through the configured runner before probing', async () => { const runInstallCommand = vi.fn(async () => ok()); - const manager = new DependencyManager(missingExec, { + const manager = new DependencyManager(missingCtx, { emitEvents: false, runInstallCommand, }); @@ -49,7 +61,7 @@ describe('DependencyManager install', () => { }); it('returns an error result for unknown dependency ids', async () => { - const manager = new DependencyManager(missingExec, { emitEvents: false }); + const manager = new DependencyManager(missingCtx, { emitEvents: false }); const result = await manager.install('missing-agent' as never); @@ -60,7 +72,7 @@ describe('DependencyManager install', () => { }); it('returns an error result when no install command is configured', async () => { - const manager = new DependencyManager(missingExec, { emitEvents: false }); + const manager = new DependencyManager(missingCtx, { emitEvents: false }); const result = await manager.install('git'); @@ -79,7 +91,7 @@ describe('DependencyManager install', () => { exitCode: 243, }) ); - const manager = new DependencyManager(availableExec, { + const manager = new DependencyManager(availableCtx, { emitEvents: false, runInstallCommand, }); @@ -91,7 +103,7 @@ describe('DependencyManager install', () => { }); it('returns the available dependency state on successful install and probe', async () => { - const manager = new DependencyManager(availableExec, { + const manager = new DependencyManager(availableCtx, { emitEvents: false, runInstallCommand: async () => ok(), }); @@ -103,7 +115,7 @@ describe('DependencyManager install', () => { }); it('emits dependency updates with the SSH connection id', async () => { - const manager = new DependencyManager(availableExec, { + const manager = new DependencyManager(availableCtx, { connectionId: 'ssh-1', }); diff --git a/src/main/core/dependencies/dependency-manager.ts b/src/main/core/dependencies/dependency-manager.ts index b7c82a4aee..1381dc2122 100644 --- a/src/main/core/dependencies/dependency-manager.ts +++ b/src/main/core/dependencies/dependency-manager.ts @@ -7,9 +7,12 @@ import type { } from '@shared/dependencies'; import { dependencyStatusUpdatedChannel } from '@shared/events/appEvents'; import { err, ok } from '@shared/result'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; -import { getLocalExec, getSshExec, type ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; +import type { IInitializable } from '@main/lib/lifecycle'; import { log } from '@main/lib/logger'; import { createSshInstallCommandRunner, @@ -73,15 +76,15 @@ function dependencyStateFromProbeResult( }; } -export class DependencyManager { +export class DependencyManager implements IInitializable { private state = new Map(); - private readonly exec: ExecFn; + private readonly ctx: IExecutionContext; private readonly emitEvents: boolean; private readonly runInstallCommand: InstallCommandRunner; private readonly connectionId: string | undefined; constructor( - exec: ExecFn, + ctx: IExecutionContext, { emitEvents = true, runInstallCommand = runLocalInstallCommand, @@ -92,7 +95,7 @@ export class DependencyManager { connectionId?: string; } = {} ) { - this.exec = exec; + this.ctx = ctx; this.emitEvents = emitEvents; this.runInstallCommand = runInstallCommand; this.connectionId = connectionId; @@ -147,7 +150,7 @@ export class DependencyManager { descriptor.commands[0] ?? id, resolvedPath, versionArgs, - this.exec + this.ctx ); const fullState = dependencyStateFromProbeResult(descriptor, resolvedPath, probeResult); this.updateState(fullState); @@ -206,7 +209,7 @@ export class DependencyManager { private async resolveFirstPath(descriptor: DependencyDescriptor): Promise { for (const command of descriptor.commands) { - const path = await resolveCommandPath(command, this.exec); + const path = await resolveCommandPath(command, this.ctx); if (path) return path; } return null; @@ -224,7 +227,7 @@ export class DependencyManager { } } -export const localDependencyManager = new DependencyManager(getLocalExec()); +export const localDependencyManager = new DependencyManager(new LocalExecutionContext()); const sshManagers = new Map(); @@ -233,7 +236,7 @@ export async function getDependencyManager(connectionId?: string): Promise ({ + openSsh2Pty: vi.fn(), + spawnLocalPty: vi.fn(), + ensureUserBinDirsInPath: vi.fn(), +})); + +vi.mock('@main/core/pty/ssh2-pty', () => ({ + openSsh2Pty: mocks.openSsh2Pty, +})); + +vi.mock('@main/core/pty/local-pty', () => ({ + spawnLocalPty: mocks.spawnLocalPty, +})); + +vi.mock('@main/utils/userEnv', () => ({ + ensureUserBinDirsInPath: mocks.ensureUserBinDirsInPath, +})); + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalEnv = { ...process.env }; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +function createSuccessfulPty(): Pty { + return { + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((handler) => handler({ exitCode: 0 })), + }; +} + +beforeEach(() => { + mocks.spawnLocalPty.mockReturnValue(createSuccessfulPty()); + mocks.openSsh2Pty.mockResolvedValue({ success: true, data: createSuccessfulPty() }); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + vi.clearAllMocks(); +}); describe('classifyInstallCommandFailure', () => { it('summarizes permission errors from npm global installs', () => { @@ -32,3 +89,49 @@ describe('classifyInstallCommandFailure', () => { }); }); }); + +describe('runLocalInstallCommand', () => { + it('runs Windows installs through the local PTY platform resolver', async () => { + setPlatform('win32'); + delete process.env.SHELL; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + const result = await runLocalInstallCommand('npm install -g @openai/codex'); + + expect(result.success).toBe(true); + expect(mocks.spawnLocalPty).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'npm install -g @openai/codex'], + cwd: expect.any(String), + } satisfies Partial) + ); + }); +}); + +describe('createSshInstallCommandRunner', () => { + it('runs remote installs through the captured remote shell profile', async () => { + const proxy = { + client: {}, + getRemoteShellProfile: vi.fn(async () => ({ + shell: '/bin/zsh', + env: { + PATH: '/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin', + }, + })), + }; + const runner = createSshInstallCommandRunner(proxy as never); + + const result = await runner('npm install -g @anthropic-ai/claude-code'); + + expect(result.success).toBe(true); + expect(proxy.getRemoteShellProfile).toHaveBeenCalledWith(); + expect(mocks.openSsh2Pty).toHaveBeenCalledWith( + proxy.client, + expect.objectContaining({ + command: + "'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; npm install -g @anthropic-ai/claude-code'", + }) + ); + }); +}); diff --git a/src/main/core/dependencies/install-runner.ts b/src/main/core/dependencies/install-runner.ts index 2d86ac65f8..3f687fbdc8 100644 --- a/src/main/core/dependencies/install-runner.ts +++ b/src/main/core/dependencies/install-runner.ts @@ -3,11 +3,12 @@ import type { InstallCommandError } from '@shared/dependencies'; import { err, ok, type Result } from '@shared/result'; import { spawnLocalPty } from '@main/core/pty/local-pty'; import type { Pty } from '@main/core/pty/pty'; +import { logLocalPtySpawnWarnings, resolveLocalPtySpawn } from '@main/core/pty/pty-spawn-platform'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; +import { buildRemoteShellCommand } from '@main/core/ssh/remote-shell-profile'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; import { log } from '@main/lib/logger'; import { ensureUserBinDirsInPath } from '@main/utils/userEnv'; -import { quoteShellArg } from '../../utils/shellEscape'; export type InstallCommandRunner = ( command: string @@ -61,14 +62,25 @@ function waitForInstallPty(pty: Pty): Promise> export function runLocalInstallCommand( command: string ): Promise> { - const shell = process.env.SHELL ?? '/bin/sh'; + const installId = `install:${crypto.randomUUID()}`; + const resolved = resolveLocalPtySpawn({ + platform: process.platform, + env: process.env, + intent: { + kind: 'run-command', + cwd: os.homedir(), + command: { kind: 'shell-line', commandLine: command }, + }, + }); + logLocalPtySpawnWarnings('DependencyManager', resolved.warnings, { installId }); + let pty: Pty; try { pty = spawnLocalPty({ - id: `install:${crypto.randomUUID()}`, - command: shell, - args: ['-c', command], - cwd: os.homedir(), + id: installId, + command: resolved.command, + args: resolved.args, + cwd: resolved.cwd, env: process.env as Record, cols: 80, rows: 24, @@ -88,9 +100,10 @@ export function runLocalInstallCommand( export function createSshInstallCommandRunner(proxy: SshClientProxy): InstallCommandRunner { return async (command: string) => { + const profile = await proxy.getRemoteShellProfile(); const result = await openSsh2Pty(proxy.client, { id: `install:${crypto.randomUUID()}`, - command: `bash -l -c ${quoteShellArg(command)}`, + command: buildRemoteShellCommand(profile, command), cols: 80, rows: 24, }); diff --git a/src/main/core/dependencies/probe.ts b/src/main/core/dependencies/probe.ts index 6fa449df50..6baeeb760e 100644 --- a/src/main/core/dependencies/probe.ts +++ b/src/main/core/dependencies/probe.ts @@ -1,4 +1,4 @@ -import type { ExecFn } from '@main/core/utils/exec'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import type { ProbeResult } from './types'; const WHICH_TIMEOUT_MS = 5_000; @@ -12,9 +12,12 @@ const RESOLVE_CMD = process.platform === 'win32' ? 'where' : 'which'; * Uses `where` on Windows and `which` on macOS/Linux. * Returns `null` if the command is not found or the resolution fails. */ -export async function resolveCommandPath(command: string, exec: ExecFn): Promise { +export async function resolveCommandPath( + command: string, + ctx: IExecutionContext +): Promise { try { - const { stdout } = await exec(RESOLVE_CMD, [command], { timeout: WHICH_TIMEOUT_MS }); + const { stdout } = await ctx.exec(RESOLVE_CMD, [command], { timeout: WHICH_TIMEOUT_MS }); const firstLine = stdout.trim().split('\n')[0]?.trim(); return firstLine ?? null; } catch { @@ -30,12 +33,12 @@ export async function runVersionProbe( command: string, resolvedPath: string | null, args: string[], - exec: ExecFn, + ctx: IExecutionContext, timeoutMs: number = VERSION_PROBE_TIMEOUT_MS ): Promise { const bin = resolvedPath ?? command; try { - const { stdout, stderr } = await exec(bin, args, { timeout: timeoutMs }); + const { stdout, stderr } = await ctx.exec(bin, args, { timeout: timeoutMs }); return { command, path: resolvedPath, stdout, stderr, exitCode: 0, timedOut: false }; } catch (err: unknown) { const e = err as { stdout?: string; stderr?: string; code?: number; killed?: boolean }; diff --git a/src/main/core/editor/editor-buffer-service.ts b/src/main/core/editor/editor-buffer-service.ts index 7b42e20aef..44ce151e85 100644 --- a/src/main/core/editor/editor-buffer-service.ts +++ b/src/main/core/editor/editor-buffer-service.ts @@ -1,6 +1,9 @@ import { and, eq, lt } from 'drizzle-orm'; import { db } from '@/main/db/client'; import { editorBuffers } from '@/main/db/schema'; +import { log } from '@main/lib/logger'; + +const BUFFER_STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export class EditorBufferService { async saveBuffer( @@ -41,9 +44,13 @@ export class EditorBufferService { return rows; } - async pruneStale(olderThanMs: number): Promise { - const cutoff = Date.now() - olderThanMs; - await db.delete(editorBuffers).where(lt(editorBuffers.updatedAt, cutoff)); + async pruneStale(): Promise { + try { + const cutoff = Date.now() - BUFFER_STALE_MS; + await db.delete(editorBuffers).where(lt(editorBuffers.updatedAt, cutoff)); + } catch (e) { + log.error('Failed to prune stale editor buffers:', e); + } } } diff --git a/src/main/core/execution-context/github-auth-execution-context.ts b/src/main/core/execution-context/github-auth-execution-context.ts new file mode 100644 index 0000000000..aefe4e5ce7 --- /dev/null +++ b/src/main/core/execution-context/github-auth-execution-context.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; +import { addGitHubAuthConfig } from '@main/core/utils/exec'; +import type { ExecOptions, ExecResult, IExecutionContext } from './types'; + +export class GitHubAuthExecutionContext implements IExecutionContext { + readonly root: string | undefined; + readonly supportsLocalSpawn: boolean; + + constructor( + private readonly inner: IExecutionContext, + private readonly getToken: () => Promise + ) { + this.root = inner.root; + this.supportsLocalSpawn = inner.supportsLocalSpawn; + } + + async exec(command: string, args: string[] = [], opts?: ExecOptions): Promise { + if (path.basename(command) === 'git') { + args = await addGitHubAuthConfig(args, this.getToken); + } + return this.inner.exec(command, args, opts); + } + + execStreaming( + command: string, + args: string[], + onChunk: (chunk: string) => boolean, + opts?: { signal?: AbortSignal } + ): Promise { + return this.inner.execStreaming(command, args, onChunk, opts); + } + + dispose(): void { + this.inner.dispose(); + } +} diff --git a/src/main/core/execution-context/local-execution-context.test.ts b/src/main/core/execution-context/local-execution-context.test.ts new file mode 100644 index 0000000000..736f58cc74 --- /dev/null +++ b/src/main/core/execution-context/local-execution-context.test.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GIT_EXECUTABLE } from '@main/core/utils/exec'; + +const spawnMock = vi.hoisted(() => vi.fn()); +const execFileMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + spawn: spawnMock, +})); + +const { LocalExecutionContext } = await import('./local-execution-context'); + +class FakeChildProcess extends EventEmitter { + stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); + + kill = vi.fn(); +} + +describe('LocalExecutionContext', () => { + beforeEach(() => { + execFileMock.mockReset(); + spawnMock.mockReset(); + }); + + it('resolves logical git command for buffered local execution', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, { stdout: '', stderr: '' }); + }); + const ctx = new LocalExecutionContext({ root: '/repo' }); + + await ctx.exec('git', ['status']); + + expect(execFileMock).toHaveBeenCalledWith( + GIT_EXECUTABLE, + ['status'], + expect.objectContaining({ cwd: '/repo' }), + expect.any(Function) + ); + }); + + it('resolves logical git command for streaming local execution', async () => { + const child = new FakeChildProcess(); + spawnMock.mockReturnValue(child); + const ctx = new LocalExecutionContext({ root: '/repo' }); + + const promise = ctx.execStreaming('git', ['status'], () => true); + child.emit('close', 0); + await promise; + + expect(spawnMock).toHaveBeenCalledWith(GIT_EXECUTABLE, ['status'], { cwd: '/repo' }); + }); +}); diff --git a/src/main/core/execution-context/local-execution-context.ts b/src/main/core/execution-context/local-execution-context.ts new file mode 100644 index 0000000000..a1270f479c --- /dev/null +++ b/src/main/core/execution-context/local-execution-context.ts @@ -0,0 +1,93 @@ +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { GIT_EXECUTABLE } from '@main/core/utils/exec'; +import type { ExecOptions, ExecResult, IExecutionContext } from './types'; + +const execFileAsync = promisify(execFile); + +export class LocalExecutionContext implements IExecutionContext { + readonly root: string; + readonly supportsLocalSpawn = true; + + private readonly _lifetime = new AbortController(); + + constructor(opts: { root?: string } = {}) { + this.root = opts.root ?? ''; + } + + private _signal(callerSignal?: AbortSignal): AbortSignal { + const signals: AbortSignal[] = [this._lifetime.signal]; + if (callerSignal) signals.push(callerSignal); + return AbortSignal.any(signals); + } + + private resolveCommand(command: string): string { + return command === 'git' ? GIT_EXECUTABLE : command; + } + + exec(command: string, args: string[] = [], opts: ExecOptions = {}): Promise { + const { timeout, maxBuffer } = opts; + return execFileAsync(this.resolveCommand(command), args, { + cwd: this.root || undefined, + timeout, + maxBuffer, + signal: this._signal(opts.signal), + }) as Promise; + } + + execStreaming( + command: string, + args: string[], + onChunk: (chunk: string) => boolean, + opts: { signal?: AbortSignal } = {} + ): Promise { + return new Promise((resolve, reject) => { + const signal = this._signal(opts.signal); + + if (signal.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + + const child = spawn(this.resolveCommand(command), args, { cwd: this.root || undefined }); + + let settled = false; + + const onAbort = () => { + if (settled) return; + settled = true; + child.kill('SIGTERM'); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + if (settled) return; + if (!onChunk(chunk)) { + child.kill('SIGTERM'); + } + }); + + child.on('error', (err) => { + signal.removeEventListener('abort', onAbort); + if (!settled) { + settled = true; + reject(err); + } + }); + + child.on('close', () => { + signal.removeEventListener('abort', onAbort); + if (!settled) { + settled = true; + resolve(); + } + }); + }); + } + + dispose(): void { + this._lifetime.abort(); + } +} diff --git a/src/main/core/execution-context/ssh-execution-context.test.ts b/src/main/core/execution-context/ssh-execution-context.test.ts new file mode 100644 index 0000000000..b96edbd8fc --- /dev/null +++ b/src/main/core/execution-context/ssh-execution-context.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import type { RemoteShellProfile } from '@main/core/ssh/remote-shell-profile'; +import { buildSshCommand } from './ssh-execution-context'; + +describe('buildSshCommand', () => { + it('uses the shared remote shell command builder for fallback SSH exec commands', () => { + const command = buildSshCommand('/workspace/project', 'which', ['claude']); + + expect(command).toBe( + "'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && which '\\''claude'\\'''" + ); + }); + + it('uses the remote shell profile and cwd when building SSH exec commands', () => { + const profile: RemoteShellProfile = { + shell: '/bin/zsh', + env: { + PATH: '/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin', + }, + }; + + const command = buildSshCommand('/workspace/project', 'which', ['claude'], profile); + + expect(command).toBe( + "'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; cd '\\''/workspace/project'\\'' && which '\\''claude'\\'''" + ); + }); +}); diff --git a/src/main/core/execution-context/ssh-execution-context.ts b/src/main/core/execution-context/ssh-execution-context.ts new file mode 100644 index 0000000000..b74a8d0112 --- /dev/null +++ b/src/main/core/execution-context/ssh-execution-context.ts @@ -0,0 +1,167 @@ +import { + buildRemoteShellCommand, + FALLBACK_REMOTE_SHELL_PROFILE, + type RemoteShellProfile, +} from '@main/core/ssh/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { quoteShellArg } from '@main/utils/shellEscape'; +import type { ExecOptions, ExecResult, IExecutionContext } from './types'; + +/** + * Builds the full shell command string to send over SSH. + * When `root` is provided the command runs inside `cd root &&`. + * Args are shell-escaped for safe remote execution. + */ +export function buildSshCommand( + root: string | undefined, + command: string, + args: string[], + profile?: RemoteShellProfile +): string { + const escaped = args.map(quoteShellArg).join(' '); + const inner = args.length ? `${command} ${escaped}` : command; + const body = root ? `cd ${quoteShellArg(root)} && ${inner}` : inner; + return buildRemoteShellCommand(profile ?? FALLBACK_REMOTE_SHELL_PROFILE, body); +} + +export class SshExecutionContext implements IExecutionContext { + readonly root?: string; + readonly supportsLocalSpawn = false; + + private readonly _lifetime = new AbortController(); + + constructor( + private readonly proxy: SshClientProxy, + opts: { root?: string } = {} + ) { + this.root = opts.root; + } + + async exec(command: string, args: string[] = [], opts: ExecOptions = {}): Promise { + const { signal } = opts; + const profile = await this.proxy.getRemoteShellProfile(); + const full = buildSshCommand(this.root, command, args, profile); + const combined = this._signal(signal); + + return new Promise((resolve, reject) => { + if (combined.aborted) { + reject(combined.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + + this.proxy.client.exec(full, (execErr, stream) => { + if (execErr) return reject(execErr); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const onAbort = () => { + if (settled) return; + settled = true; + stream.destroy(); + reject(combined.reason ?? new DOMException('Aborted', 'AbortError')); + }; + combined.addEventListener('abort', onAbort, { once: true }); + + stream.on('data', (d: Buffer) => { + stdout += d.toString('utf-8'); + }); + stream.stderr.on('data', (d: Buffer) => { + stderr += d.toString('utf-8'); + }); + + stream.on('close', (code: number | null) => { + combined.removeEventListener('abort', onAbort); + if (settled) return; + settled = true; + if ((code ?? 0) === 0) { + resolve({ stdout, stderr }); + } else { + reject( + Object.assign(new Error(stderr || `Process exited with code ${code}`), { + stdout, + stderr, + }) + ); + } + }); + + stream.on('error', (err: Error) => { + combined.removeEventListener('abort', onAbort); + if (!settled) { + settled = true; + reject(err); + } + }); + }); + }); + } + + async execStreaming( + command: string, + args: string[], + onChunk: (chunk: string) => boolean, + opts: { signal?: AbortSignal } = {} + ): Promise { + const { signal } = opts; + const profile = await this.proxy.getRemoteShellProfile(); + const full = buildSshCommand(this.root, command, args, profile); + const combined = this._signal(signal); + + return new Promise((resolve, reject) => { + if (combined.aborted) { + reject(combined.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + + this.proxy.client.exec(full, (execErr, stream) => { + if (execErr) return reject(execErr); + + let settled = false; + + const onAbort = () => { + if (settled) return; + settled = true; + stream.destroy(); + reject(combined.reason ?? new DOMException('Aborted', 'AbortError')); + }; + combined.addEventListener('abort', onAbort, { once: true }); + + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + if (settled) return; + if (!onChunk(chunk)) { + stream.destroy(); + } + }); + + stream.on('close', () => { + combined.removeEventListener('abort', onAbort); + if (!settled) { + settled = true; + resolve(); + } + }); + + stream.on('error', (err: Error) => { + combined.removeEventListener('abort', onAbort); + if (!settled) { + settled = true; + reject(err); + } + }); + }); + }); + } + + dispose(): void { + this._lifetime.abort(); + } + + private _signal(callerSignal?: AbortSignal): AbortSignal { + const signals: AbortSignal[] = [this._lifetime.signal]; + if (callerSignal) signals.push(callerSignal); + return AbortSignal.any(signals); + } +} diff --git a/src/main/core/execution-context/types.ts b/src/main/core/execution-context/types.ts new file mode 100644 index 0000000000..321df6fbad --- /dev/null +++ b/src/main/core/execution-context/types.ts @@ -0,0 +1,47 @@ +export interface ExecResult { + stdout: string; + stderr: string; +} + +export interface ExecOptions { + timeout?: number; + maxBuffer?: number; + signal?: AbortSignal; +} + +/** + * An execution context represents a host + optional working directory where commands run. + * Implementations abstract the transport (local spawn vs SSH exec) so consumers + * have no knowledge of whether they are running locally or remotely. + */ +export interface IExecutionContext { + /** The working directory all commands run in. Undefined/empty = no cwd constraint. */ + readonly root?: string; + + /** + * True only for LocalExecutionContext. Used by GitService to decide + * whether to use CatFileBatch (local spawn) or git-show fallback (SSH). + */ + readonly supportsLocalSpawn: boolean; + + /** Run a command and buffer all output. Rejects on non-zero exit code. */ + exec(command: string, args?: string[], opts?: ExecOptions): Promise; + + /** + * Run a command and stream stdout chunks to `onChunk`. + * Return false from `onChunk` to abort the process early (resolves normally). + * Passing `signal` rejects with an AbortError when the signal fires. + */ + execStreaming( + command: string, + args: string[], + onChunk: (chunk: string) => boolean, + opts?: { signal?: AbortSignal } + ): Promise; + + /** + * Abort all in-flight exec/execStreaming calls and release resources. + * Idempotent — safe to call multiple times. + */ + dispose(): void; +} diff --git a/src/main/core/fs/impl/local-fs.ts b/src/main/core/fs/impl/local-fs.ts index dea86ccd89..4983707187 100644 --- a/src/main/core/fs/impl/local-fs.ts +++ b/src/main/core/fs/impl/local-fs.ts @@ -5,20 +5,21 @@ import parcelWatcher from '@parcel/watcher'; import { glob } from 'glob'; import ignore from 'ignore'; import type { FileWatchEvent } from '@shared/fs'; +import { log } from '@main/lib/logger'; import { DEFAULT_EMDASH_CONFIG, - FileEntry, - FileListResult, FileSystemError, FileSystemErrorCodes, - FileSystemProvider, - FileWatcher, - ListOptions, - ReadResult, - SearchMatch, - SearchOptions, - SearchResult, - WriteResult, + type FileEntry, + type FileListResult, + type FileSystemProvider, + type FileWatcher, + type ListOptions, + type ReadResult, + type SearchMatch, + type SearchOptions, + type SearchResult, + type WriteResult, } from '../types'; // Binary file extensions to skip during search @@ -334,6 +335,7 @@ export class LocalFileSystem implements FileSystemProvider { try { stat = await fs.stat(fullPath); } catch (err) { + log.error('Failed to stat file', { path, error: err }); throw new FileSystemError(`File not found: ${path}`, FileSystemErrorCodes.NOT_FOUND, path); } @@ -378,6 +380,7 @@ export class LocalFileSystem implements FileSystemProvider { try { await fs.mkdir(dir, { recursive: true }); } catch (err) { + log.error('Failed to create directory', { dir, error: err }); throw new FileSystemError( `Failed to create directory: ${dir}`, FileSystemErrorCodes.PERMISSION_DENIED, @@ -388,6 +391,7 @@ export class LocalFileSystem implements FileSystemProvider { try { await fs.writeFile(fullPath, content, 'utf-8'); } catch (err) { + log.error('Failed to write file', { path, error: err }); throw new FileSystemError( `Failed to write file: ${path}`, FileSystemErrorCodes.PERMISSION_DENIED, diff --git a/src/main/core/fs/impl/ssh-fs.ts b/src/main/core/fs/impl/ssh-fs.ts index f4d81aa57d..bd6328fd1e 100644 --- a/src/main/core/fs/impl/ssh-fs.ts +++ b/src/main/core/fs/impl/ssh-fs.ts @@ -5,21 +5,24 @@ import type { SFTPWrapper } from 'ssh2'; import type { FileWatchEvent } from '@shared/fs'; -import { quoteShellArg } from '../../../utils/shellEscape'; -import type { SshClientProxy } from '../../ssh/ssh-client-proxy'; +import { buildRemoteShellCommand } from '@main/core/ssh/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; import { DEFAULT_EMDASH_CONFIG, - FileEntry, - FileListResult, FileSystemError, FileSystemErrorCodes, - FileSystemProvider, - FileWatcher, - ListOptions, - ReadResult, - SearchOptions, - SearchResult, - WriteResult, + type FileEntry, + type FileListResult, + type FileSystemProvider, + type FileWatcher, + type ListOptions, + type ReadResult, + type SearchMatch, + type SearchOptions, + type SearchResult, + type WriteResult, } from '../types'; const SFTP_STATUS = { @@ -81,8 +84,11 @@ export class SshFileSystem implements FileSystemProvider { }); } - private exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const full = `bash -l -c ${quoteShellArg(command)}`; + private async exec( + command: string + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const profile = await this.proxy.getRemoteShellProfile(); + const full = buildRemoteShellCommand(profile, command); return new Promise((resolve, reject) => { this.proxy.client.exec(full, (err, stream) => { if (err) return reject(err); @@ -482,6 +488,14 @@ export class SshFileSystem implements FileSystemProvider { } } + async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { + const sftp = await this.getSftp(); + const remoteFull = this.resolveRemotePath(destRelPath); + await new Promise((resolve, reject) => { + sftp.fastPut(localAbsPath, remoteFull, (e) => (e ? reject(e) : resolve())); + }); + } + async copyFile(src: string, dest: string): Promise { const fullSrc = this.resolveRemotePath(src); const fullDest = this.resolveRemotePath(dest); @@ -560,7 +574,7 @@ export class SshFileSystem implements FileSystemProvider { return { matches: [], total: 0, filesSearched: 0 }; } - const matches: import('../types').SearchMatch[] = []; + const matches: SearchMatch[] = []; const lines = result.stdout.split('\n').filter((line) => line.trim()); const seenFiles = new Set(); @@ -612,6 +626,7 @@ export class SshFileSystem implements FileSystemProvider { filesSearched: seenFiles.size, }; } catch (error) { + log.error('Failed to search', { query, options, error }); // If command execution fails, return empty results return { matches: [], total: 0, filesSearched: 0 }; } @@ -809,7 +824,7 @@ export class SshFileSystem implements FileSystemProvider { // Handle absolute paths (should not escape base) if (normalized.startsWith('/')) { - const resolved = normalized; + const resolved = this.normalizePosixPath(normalized); // Security: ensure resolved path is within remotePath base if (!this.isWithinBase(resolved)) { throw new FileSystemError( @@ -821,8 +836,9 @@ export class SshFileSystem implements FileSystemProvider { return resolved; } - // Join with base path - const fullPath = `${this.remotePath}/${normalized}`.replace(/\/+/g, '/'); + // Join with base path and normalize away any '.' segments (e.g. when relPath is '.') + const joined = `${this.remotePath}/${normalized}`.replace(/\/+/g, '/'); + const fullPath = this.normalizePosixPath(joined); // Security: ensure path is within basePath if (!this.isWithinBase(fullPath)) { @@ -836,6 +852,18 @@ export class SshFileSystem implements FileSystemProvider { return fullPath; } + /** Remove single-dot segments from a POSIX path (e.g. /a/./b → /a/b). */ + private normalizePosixPath(p: string): string { + const parts = p.split('/'); + const out: string[] = []; + for (const seg of parts) { + if (seg === '.') continue; + out.push(seg); + } + // Re-join and collapse any double slashes introduced by the filter + return out.join('/').replace(/\/+/g, '/') || '/'; + } + /** * Check if a path is within the base directory */ diff --git a/src/main/core/fs/test-helpers/memory-fs.ts b/src/main/core/fs/test-helpers/memory-fs.ts new file mode 100644 index 0000000000..37da3b4380 --- /dev/null +++ b/src/main/core/fs/test-helpers/memory-fs.ts @@ -0,0 +1,88 @@ +import type { + FileEntry, + FileListResult, + FileSystemProvider, + ReadResult, + SearchResult, + WriteResult, +} from '../types'; + +export class MemoryFs implements FileSystemProvider { + readonly files = new Map(); + + async list(): Promise { + return { + entries: Array.from(this.files.keys()).map((path) => ({ path, type: 'file' as const })), + total: this.files.size, + }; + } + + async exists(path: string): Promise { + return this.files.has(path); + } + + async read(path: string): Promise { + const content = this.files.get(path); + if (content === undefined) { + throw new Error(`not found: ${path}`); + } + return { + content, + truncated: false, + totalSize: Buffer.byteLength(content), + }; + } + + async write(path: string, content: string): Promise { + this.files.set(path, content); + return { + success: true, + bytesWritten: Buffer.byteLength(content), + }; + } + + async stat(path: string): Promise { + const content = this.files.get(path); + if (content === undefined) return null; + return { + path, + type: 'file', + size: Buffer.byteLength(content), + }; + } + + async search(): Promise { + return { + matches: [], + total: 0, + }; + } + + async remove(path: string): Promise<{ success: boolean; error?: string }> { + this.files.delete(path); + return { success: true }; + } + + async realPath(path: string): Promise { + return path; + } + + async glob(): Promise { + return Array.from(this.files.keys()); + } + + async copyFile(src: string, dest: string): Promise { + const content = this.files.get(src); + if (content === undefined) throw new Error(`not found: ${src}`); + this.files.set(dest, content); + } + + async mkdir(): Promise {} + + watch(): { update(paths: string[]): void; close(): void } { + return { + update: () => {}, + close: () => {}, + }; + } +} diff --git a/src/main/core/fs/types.ts b/src/main/core/fs/types.ts index 936216d6e9..3036f294b2 100644 --- a/src/main/core/fs/types.ts +++ b/src/main/core/fs/types.ts @@ -267,6 +267,14 @@ export interface FileSystemProvider { mkdir(diPath: string, options?: { recursive?: boolean }): Promise; + /** + * Copy an absolute local file into this filesystem at the given relative path. + * For SSH: transfers via SFTP fastPut. For local: delegates to fs.copyFile. + * @param localAbsPath - Absolute path of the source file on the local machine + * @param destRelPath - Destination path relative to this filesystem's root + */ + copyLocalFile?(localAbsPath: string, destRelPath: string): Promise; + /** * Watch the worktree for filesystem changes. Returns a FileWatcher handle; * call update() to hint which paths matter (SSH uses this for polling), diff --git a/src/main/core/git/controller.ts b/src/main/core/git/controller.ts index 58655fd616..3a11c26fbe 100644 --- a/src/main/core/git/controller.ts +++ b/src/main/core/git/controller.ts @@ -4,7 +4,7 @@ import { err, ok } from '@shared/result'; import { TooManyFilesChangedError } from '@main/core/git/impl/status-parser'; import { resolveWorkspace } from '@main/core/projects/utils'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export const gitController = createRPCController({ getFullStatus: async (projectId: string, workspaceId: string) => { @@ -125,6 +125,36 @@ export const gitController = createRPCController({ } }, + getImageAtRef: async (projectId: string, workspaceId: string, filePath: string, ref: string) => { + try { + const env = resolveWorkspace(projectId, workspaceId); + if (!env) return err({ type: 'not_found' as const }); + const result = await env.git.getImageAtRef(filePath, ref); + return ok({ result }); + } catch (e) { + log.error('gitCtrl.getImageAtRef failed', { + projectId, + workspaceId, + filePath, + ref, + error: e, + }); + return err({ type: 'git_error' as const, message: String(e) }); + } + }, + + getImageAtIndex: async (projectId: string, workspaceId: string, filePath: string) => { + try { + const env = resolveWorkspace(projectId, workspaceId); + if (!env) return err({ type: 'not_found' as const }); + const result = await env.git.getImageAtIndex(filePath); + return ok({ result }); + } catch (e) { + log.error('gitCtrl.getImageAtIndex failed', { projectId, workspaceId, filePath, error: e }); + return err({ type: 'git_error' as const, message: String(e) }); + } + }, + getFileDiff: async (projectId: string, workspaceId: string, filePath: string, base?: GitRef) => { try { const env = resolveWorkspace(projectId, workspaceId); @@ -142,7 +172,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); await env.git.stageFiles([filePath]); - capture('vcs_files_staged', { + telemetryService.capture('vcs_files_staged', { count: 1, scope: 'single', project_id: projectId, @@ -160,7 +190,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); await env.git.stageFiles(filePaths); - capture('vcs_files_staged', { + telemetryService.capture('vcs_files_staged', { count: filePaths.length, scope: filePaths.length === 1 ? 'single' : 'multiple', project_id: projectId, @@ -179,7 +209,7 @@ export const gitController = createRPCController({ if (!env) return err({ type: 'not_found' as const }); const unstaged = await env.git.getUnstagedChanges(); await env.git.stageAllFiles(); - capture('vcs_files_staged', { + telemetryService.capture('vcs_files_staged', { count: unstaged.changes.length, scope: 'all', project_id: projectId, @@ -197,7 +227,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); await env.git.unstageFiles([filePath]); - capture('vcs_files_unstaged', { + telemetryService.capture('vcs_files_unstaged', { count: 1, scope: 'single', project_id: projectId, @@ -215,7 +245,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); await env.git.unstageFiles(filePaths); - capture('vcs_files_unstaged', { + telemetryService.capture('vcs_files_unstaged', { count: filePaths.length, scope: filePaths.length === 1 ? 'single' : 'multiple', project_id: projectId, @@ -234,7 +264,7 @@ export const gitController = createRPCController({ if (!env) return err({ type: 'not_found' as const }); const staged = await env.git.getStagedChanges(); await env.git.unstageAllFiles(); - capture('vcs_files_unstaged', { + telemetryService.capture('vcs_files_unstaged', { count: staged.changes.length, scope: 'all', project_id: projectId, @@ -252,7 +282,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); await env.git.revertFiles([filePath]); - capture('vcs_files_discarded', { + telemetryService.capture('vcs_files_discarded', { count: 1, scope: 'single', project_id: projectId, @@ -270,7 +300,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); await env.git.revertFiles(filePaths); - capture('vcs_files_discarded', { + telemetryService.capture('vcs_files_discarded', { count: filePaths.length, scope: filePaths.length === 1 ? 'single' : 'multiple', project_id: projectId, @@ -290,7 +320,7 @@ export const gitController = createRPCController({ const status = await env.git.getStatus(); const changedCount = new Set(status.changes.map((change) => change.path)).size; await env.git.revertAllFiles(); - capture('vcs_files_discarded', { + telemetryService.capture('vcs_files_discarded', { count: changedCount, scope: 'all', project_id: projectId, @@ -315,7 +345,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); const result = await env.git.push(remote); - capture('vcs_push', { + telemetryService.capture('vcs_push', { success: result.success, project_id: projectId, task_id: workspaceId, @@ -334,7 +364,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); const result = await env.git.publishBranch(branchName, remote); - capture('vcs_branch_published', { + telemetryService.capture('vcs_branch_published', { success: result.success, project_id: projectId, task_id: workspaceId, @@ -348,7 +378,7 @@ export const gitController = createRPCController({ const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const }); const result = await env.git.pull(); - capture('vcs_pull', { + telemetryService.capture('vcs_pull', { success: result.success, project_id: projectId, task_id: workspaceId, diff --git a/src/main/core/git/git-fetch-service.ts b/src/main/core/git/git-fetch-service.ts index 7391b20ee8..fa5ff36a11 100644 --- a/src/main/core/git/git-fetch-service.ts +++ b/src/main/core/git/git-fetch-service.ts @@ -2,21 +2,25 @@ import type { FetchError } from '@shared/git'; import { err, type Result } from '@shared/result'; import { log } from '@main/lib/logger'; import type { GitService } from './impl/git-service'; +import { isGitHubSshRemoteUrl, isSshRemoteUrl } from './remote-helper'; const DEFAULT_INTERVAL_MS = 2 * 60 * 1000; export class GitFetchService { private _timer: ReturnType | undefined; private _inflight: Promise> | undefined; + private readonly intervalMs = DEFAULT_INTERVAL_MS; constructor( private readonly git: GitService, - private readonly intervalMs = DEFAULT_INTERVAL_MS + private readonly hasGitHubToken: () => Promise ) {} /** Start the background fetch loop: immediate fetch, then every `intervalMs`. */ start(): void { - void this._doFetch(); + void this._canBackgroundFetchWithoutPrompt().then((canFetch) => { + if (canFetch) void this._doFetch(); + }); this._scheduleNext(); } @@ -55,6 +59,25 @@ export class GitFetchService { } private _scheduleNext(): void { - this._timer = setInterval(() => void this._doFetch(), this.intervalMs); + this._timer = setInterval(() => { + void this._canBackgroundFetchWithoutPrompt().then((canFetch) => { + if (canFetch) void this._doFetch(); + }); + }, this.intervalMs); + } + + private async _canBackgroundFetchWithoutPrompt(): Promise { + let remotes: { url: string }[] = []; + try { + remotes = await this.git.getRemotes(); + } catch { + return false; + } + + const sshRemotes = remotes.filter((remote) => isSshRemoteUrl(remote.url)); + if (sshRemotes.length === 0) return true; + if (!sshRemotes.every((remote) => isGitHubSshRemoteUrl(remote.url))) return false; + + return await this.hasGitHubToken(); } } diff --git a/src/main/core/git/git-watcher-registry.ts b/src/main/core/git/git-watcher-registry.ts new file mode 100644 index 0000000000..26cc7a6857 --- /dev/null +++ b/src/main/core/git/git-watcher-registry.ts @@ -0,0 +1,155 @@ +import path from 'node:path'; +import parcelWatcher from '@parcel/watcher'; +import { + gitRefChangedChannel, + gitWorkspaceChangedChannel, + type GitRefChange, +} from '@shared/events/gitEvents'; +import { branchRef, remoteRef, toRefString, type GitObjectRef } from '@shared/git'; +import { events } from '@main/lib/events'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import type { IDisposable, IInitializable } from '@main/lib/lifecycle'; +import { log } from '@main/lib/logger'; +import { projectManager } from '../projects/project-manager'; +import { taskManager } from '../tasks/task-manager'; + +export type GitWatcherHooks = { + 'ref:changed': (change: GitRefChange) => void | Promise; +}; + +class GitWatcherRegistry implements Hookable, IInitializable, IDisposable { + private readonly _hooks = new HookCore((name, e) => + log.error(`GitWatcherRegistry: ${String(name)} hook error`, e) + ); + private readonly _subscriptions = new Map(); + /** + * Per-project worktree registry. + * projectId → (workspaceId → relativeGitDir) + */ + private readonly _worktrees = new Map>(); + + on(name: K, handler: GitWatcherHooks[K]) { + return this._hooks.on(name, handler); + } + + initialize(): void { + // IPC bridge: forward all ref changes to the renderer. + this._hooks.on('ref:changed', (change) => events.emit(gitRefChangedChannel, change)); + + projectManager.on('projectOpened', (projectId, provider) => { + if (provider.type !== 'local') return; + void this._startWatching(projectId, provider.repoPath); + }); + + projectManager.on('projectClosed', (projectId) => { + void this._stopWatching(projectId); + }); + + taskManager.hooks.on('task:provisioned', ({ projectId, workspaceId, worktreeGitDir }) => { + if (!worktreeGitDir) return; + this._worktrees.get(projectId)?.set(workspaceId, worktreeGitDir); + }); + + taskManager.hooks.on('task:torn-down', ({ projectId, workspaceId }) => { + this._worktrees.get(projectId)?.delete(workspaceId); + }); + } + + async dispose(): Promise { + const ids = [...this._subscriptions.keys()]; + try { + await Promise.allSettled(ids.map((id) => this._stopWatching(id))); + } catch (e) { + log.error('Failed to stop watching git repositories:', e); + } + } + + private async _startWatching(projectId: string, repoPath: string): Promise { + const gitDir = path.join(repoPath, '.git'); + this._worktrees.set(projectId, new Map()); + try { + const sub = await parcelWatcher.subscribe(gitDir, (_err, rawEvents) => { + if (_err) return; + let emitLocal = false; + let emitRemote = false; + let emitConfig = false; + const changedLocalByKey = new Map(); + const changedRemoteByKey = new Map(); + + const worktrees = this._worktrees.get(projectId) ?? new Map(); + + for (const e of rawEvents) { + const rel = path.relative(gitDir, e.path).replace(/\\/g, '/'); + + // Project-level ref changes + if (rel.startsWith('refs/heads/')) { + const branch = rel.slice('refs/heads/'.length); + const r = branchRef({ type: 'local', branch }); + changedLocalByKey.set(toRefString(r), r); + emitLocal = true; + } else if (rel === 'HEAD') { + emitLocal = true; + } + if (rel.startsWith('refs/remotes/')) { + const full = rel.slice('refs/remotes/'.length); + const idx = full.indexOf('/'); + if (idx > 0) { + const r = remoteRef(full.slice(0, idx), full.slice(idx + 1)); + changedRemoteByKey.set(toRefString(r), r); + } + emitRemote = true; + } + if (rel === 'packed-refs') { + emitLocal = true; + emitRemote = true; + } + if (rel === 'config') emitConfig = true; + + // Workspace-level index/HEAD changes (renderer-only, direct IPC emit) + for (const [workspaceId, relGitDir] of worktrees) { + const prefix = relGitDir ? `${relGitDir}/` : ''; + if (rel === `${prefix}index`) { + events.emit(gitWorkspaceChangedChannel, { projectId, workspaceId, kind: 'index' }); + } + if (rel === `${prefix}HEAD`) { + events.emit(gitWorkspaceChangedChannel, { projectId, workspaceId, kind: 'head' }); + } + } + } + + if (emitLocal) { + const changedRefs = + changedLocalByKey.size > 0 ? [...changedLocalByKey.values()] : undefined; + this._hooks.callHookBackground('ref:changed', { + projectId, + kind: 'local-refs', + changedRefs, + }); + } + if (emitRemote) { + const changedRefs = + changedRemoteByKey.size > 0 ? [...changedRemoteByKey.values()] : undefined; + this._hooks.callHookBackground('ref:changed', { + projectId, + kind: 'remote-refs', + changedRefs, + }); + } + if (emitConfig) { + this._hooks.callHookBackground('ref:changed', { projectId, kind: 'config' }); + } + }); + this._subscriptions.set(projectId, sub); + } catch { + // Subscription failed (e.g. project path removed or .git directory missing). + } + } + + private async _stopWatching(projectId: string): Promise { + await this._subscriptions.get(projectId)?.unsubscribe(); + this._subscriptions.delete(projectId); + this._worktrees.delete(projectId); + } +} + +export const gitWatcherRegistry = new GitWatcherRegistry(); diff --git a/src/main/core/git/git-watcher-service.ts b/src/main/core/git/git-watcher-service.ts deleted file mode 100644 index d8279d1a3b..0000000000 --- a/src/main/core/git/git-watcher-service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import path from 'node:path'; -import parcelWatcher from '@parcel/watcher'; -import { gitRefChangedChannel, gitWorkspaceChangedChannel } from '@shared/events/gitEvents'; -import { branchRef, remoteRef, toRefString, type GitObjectRef } from '@shared/git'; -import { events } from '@main/lib/events'; - -export class GitWatcherService { - private sub: parcelWatcher.AsyncSubscription | null = null; - - /** - * Registered worktrees. Maps workspaceId → git-dir path relative to the - * repo's .git directory (from `GitService.getWorktreeGitDir`). - * Main workspace → '' - * Linked worktree → e.g. 'worktrees/' - */ - private readonly _worktrees = new Map(); - - constructor( - private readonly projectId: string, - private readonly repoPath: string - ) {} - - /** - * Register a workspace so that index/HEAD changes inside its git dir are - * emitted as gitWorkspaceChangedChannel events. - * - * @param workspaceId The renderer-side workspace key. - * @param relativeGitDir Path of the worktree's git dir relative to .git/. - * Pass '' for the main worktree; 'worktrees/' for linked worktrees. - */ - registerWorktree(workspaceId: string, relativeGitDir: string): void { - this._worktrees.set(workspaceId, relativeGitDir); - } - - unregisterWorktree(workspaceId: string): void { - this._worktrees.delete(workspaceId); - } - - async start(): Promise { - const gitDir = path.join(this.repoPath, '.git'); - try { - this.sub = await parcelWatcher.subscribe(gitDir, (_err, rawEvents) => { - if (_err) return; - let emitLocal = false; - let emitRemote = false; - let emitConfig = false; - const changedLocalByKey = new Map(); - const changedRemoteByKey = new Map(); - for (const e of rawEvents) { - const rel = path.relative(gitDir, e.path).replace(/\\/g, '/'); - // Project-level ref changes - if (rel.startsWith('refs/heads/')) { - const branch = rel.slice('refs/heads/'.length); - const r = branchRef({ type: 'local', branch }); - changedLocalByKey.set(toRefString(r), r); - emitLocal = true; - } else if (rel === 'HEAD') { - emitLocal = true; - } - if (rel.startsWith('refs/remotes/')) { - const full = rel.slice('refs/remotes/'.length); - const idx = full.indexOf('/'); - if (idx > 0) { - const r = remoteRef(full.slice(0, idx), full.slice(idx + 1)); - changedRemoteByKey.set(toRefString(r), r); - } - emitRemote = true; - } - if (rel === 'packed-refs') { - emitLocal = true; - emitRemote = true; - } - if (rel === 'config') emitConfig = true; - - // Workspace-level index/HEAD changes - for (const [workspaceId, relGitDir] of this._worktrees) { - const prefix = relGitDir ? `${relGitDir}/` : ''; - if (rel === `${prefix}index`) { - events.emit(gitWorkspaceChangedChannel, { - projectId: this.projectId, - workspaceId, - kind: 'index', - }); - } - // HEAD but not refs/heads/* (that's a branch pointer update, not a checkout) - if (rel === `${prefix}HEAD`) { - events.emit(gitWorkspaceChangedChannel, { - projectId: this.projectId, - workspaceId, - kind: 'head', - }); - } - } - } - if (emitLocal) { - const changedRefs = - changedLocalByKey.size > 0 ? [...changedLocalByKey.values()] : undefined; - events.emit(gitRefChangedChannel, { - projectId: this.projectId, - kind: 'local-refs', - changedRefs, - }); - } - if (emitRemote) { - const changedRefs = - changedRemoteByKey.size > 0 ? [...changedRemoteByKey.values()] : undefined; - events.emit(gitRefChangedChannel, { - projectId: this.projectId, - kind: 'remote-refs', - changedRefs, - }); - } - if (emitConfig) { - events.emit(gitRefChangedChannel, { projectId: this.projectId, kind: 'config' }); - } - }); - } catch { - // Subscription failed (e.g. project path removed or .git directory missing). - } - } - - async stop(): Promise { - await this.sub?.unsubscribe(); - this.sub = null; - } -} diff --git a/src/main/core/git/impl/cat-file-batch.ts b/src/main/core/git/impl/cat-file-batch.ts index a61aea25de..51febaccae 100644 --- a/src/main/core/git/impl/cat-file-batch.ts +++ b/src/main/core/git/impl/cat-file-batch.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { GIT_EXECUTABLE } from '@main/core/utils/exec'; +import type { IDisposable } from '@main/lib/lifecycle'; const REQUEST_TIMEOUT_MS = 5000; @@ -13,7 +14,7 @@ type Pending = { * Persistent `git cat-file --batch` subprocess with a strictly serialized queue. * Local workspace only — SSH workspaces use per-call `git show` in GitService. */ -export class CatFileBatch { +export class CatFileBatch implements IDisposable { private disposed = false; private proc: ChildProcessWithoutNullStreams | null = null; private buf = Buffer.alloc(0); diff --git a/src/main/core/git/impl/detectGitInfo.ts b/src/main/core/git/impl/detectGitInfo.ts deleted file mode 100644 index ed93c192ec..0000000000 --- a/src/main/core/git/impl/detectGitInfo.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { exec } from 'node:child_process'; -import fs from 'node:fs'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -const execAsync = promisify(exec); - -const DEFAULT_REMOTE = 'origin'; -const DEFAULT_BRANCH = 'main'; - -export interface GitInfo { - isGitRepo: boolean; - remote?: string; - branch?: string; - baseRef: string; - rootPath: string; -} - -export function checkIsValidDirectory(path: string): boolean { - return fs.existsSync(path) && fs.statSync(path).isDirectory(); -} - -async function resolveRealPath(target: string): Promise { - try { - return await fs.promises.realpath(target); - } catch { - return target; - } -} - -function normalizeRemoteName(remote?: string | null): string { - if (!remote) return DEFAULT_REMOTE; - const trimmed = remote.trim(); - if (!trimmed) return ''; - if (/^[A-Za-z0-9._-]+$/.test(trimmed) && !trimmed.includes('://')) return trimmed; - return DEFAULT_REMOTE; -} - -function computeBaseRef(remote?: string | null, branch?: string | null): string { - const remoteName = normalizeRemoteName(remote); - if (branch?.trim()) { - const trimmed = branch.trim(); - if (trimmed.includes('/')) return trimmed; - return remoteName ? `${remoteName}/${trimmed}` : trimmed; - } - return remoteName ? `${remoteName}/${DEFAULT_BRANCH}` : DEFAULT_BRANCH; -} - -async function detectDefaultBranch( - projectPath: string, - remote?: string | null -): Promise { - const remoteName = normalizeRemoteName(remote); - if (!remoteName) { - try { - const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); - return stdout.trim() || null; - } catch { - return null; - } - } - try { - const { stdout } = await execAsync(`git remote show ${remoteName}`, { cwd: projectPath }); - const match = stdout.match(/HEAD branch:\s*(\S+)/); - return match ? match[1] : null; - } catch { - return null; - } -} - -export async function detectGitInfo(projectPath: string): Promise { - const resolvedPath = await resolveRealPath(projectPath); - const isGitRepo = fs.existsSync(join(resolvedPath, '.git')); - - if (!isGitRepo) { - return { isGitRepo: false, baseRef: DEFAULT_BRANCH, rootPath: resolvedPath }; - } - - let remote: string | undefined; - try { - const { stdout } = await execAsync('git remote get-url origin', { cwd: resolvedPath }); - remote = stdout.trim() || undefined; - } catch {} - - let branch: string | undefined; - try { - const { stdout } = await execAsync('git branch --show-current', { cwd: resolvedPath }); - branch = stdout.trim() || undefined; - } catch {} - - if (!branch) { - const defaultBranch = await detectDefaultBranch(resolvedPath, remote); - branch = defaultBranch ?? undefined; - } - - let rootPath = resolvedPath; - try { - const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd: resolvedPath }); - const trimmed = stdout.trim(); - if (trimmed) rootPath = await resolveRealPath(trimmed); - } catch {} - - return { - isGitRepo: true, - remote, - branch, - baseRef: computeBaseRef(remote, branch), - rootPath, - }; -} - -export async function isGitRepository(projectPath: string): Promise { - const resolvedPath = await resolveRealPath(projectPath); - return fs.existsSync(join(resolvedPath, '.git')); -} - -export function checkIsGithubRemote(remote?: string): boolean { - return remote ? /github\.com[:/]/i.test(remote) : false; -} diff --git a/src/main/core/git/impl/git-repo-utils.test.ts b/src/main/core/git/impl/git-repo-utils.test.ts new file mode 100644 index 0000000000..3ef73f38e8 --- /dev/null +++ b/src/main/core/git/impl/git-repo-utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import { cloneRepository } from './git-repo-utils'; + +function makeContext(): IExecutionContext & { + exec: ReturnType; +} { + return { + root: '/repo-parent', + supportsLocalSpawn: false, + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext & { exec: ReturnType }; +} + +describe('cloneRepository', () => { + it('passes logical git command to the execution context', async () => { + const ctx = makeContext(); + + await cloneRepository('https://github.com/example/repo.git', '/work/repo', ctx); + + expect(ctx.exec).toHaveBeenCalledWith('git', [ + 'clone', + 'https://github.com/example/repo.git', + '/work/repo', + ]); + }); +}); diff --git a/src/main/core/git/impl/git-repo-utils.ts b/src/main/core/git/impl/git-repo-utils.ts index 19407bc8ab..7028688f11 100644 --- a/src/main/core/git/impl/git-repo-utils.ts +++ b/src/main/core/git/impl/git-repo-utils.ts @@ -3,12 +3,12 @@ * belong on the path-scoped GitService (e.g. cloning, initial project setup, * fetching PR refs). * - * All functions accept an ExecFn + FileSystemProvider so they remain testable + * All functions accept an IExecutionContext so they remain testable * without touching the real filesystem or spawning real processes. */ +import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; -import type { ExecFn } from '@main/core/utils/exec'; // --------------------------------------------------------------------------- // cloneRepository @@ -17,14 +17,15 @@ import type { ExecFn } from '@main/core/utils/exec'; /** * Clone a git repository to a local path. * The caller is responsible for ensuring the parent directory exists. + * The context's root is used as the working directory for the clone command. */ export async function cloneRepository( repoUrl: string, localPath: string, - exec: ExecFn + ctx: IExecutionContext ): Promise<{ success: boolean; error?: string }> { try { - await exec('git', ['clone', repoUrl, localPath]); + await ctx.exec('git', ['clone', repoUrl, localPath]); return { success: true }; } catch (error) { return { @@ -47,6 +48,7 @@ export interface InitializeNewProjectParams { /** * Initialize a freshly-cloned (empty) project with a README and initial commit. + * The context must be rooted at `localPath` so git commands run there directly. * * Steps: * 1. Write a README.md @@ -56,10 +58,10 @@ export interface InitializeNewProjectParams { */ export async function initializeNewProject( params: InitializeNewProjectParams, - exec: ExecFn, + ctx: IExecutionContext, fs: FileSystemProvider ): Promise { - const { localPath, name, description } = params; + const { name, description } = params; const exists = await fs.exists('.'); if (!exists) { @@ -69,15 +71,14 @@ export async function initializeNewProject( const readmeContent = description ? `# ${name}\n\n${description}\n` : `# ${name}\n`; await fs.write('README.md', readmeContent); - const opts = { cwd: localPath }; - await exec('git', ['add', 'README.md'], opts); - await exec('git', ['commit', '-m', 'Initial commit'], opts); + await ctx.exec('git', ['add', 'README.md']); + await ctx.exec('git', ['commit', '-m', 'Initial commit']); try { - await exec('git', ['push', '-u', 'origin', 'main'], opts); + await ctx.exec('git', ['push', '-u', 'origin', 'main']); } catch { try { - await exec('git', ['push', '-u', 'origin', 'master'], opts); + await ctx.exec('git', ['push', '-u', 'origin', 'master']); } catch { throw new Error('Failed to push to remote repository'); } diff --git a/src/main/core/git/impl/git-service.test.ts b/src/main/core/git/impl/git-service.test.ts index aa1d90f7ee..5a00faeec7 100644 --- a/src/main/core/git/impl/git-service.test.ts +++ b/src/main/core/git/impl/git-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; -import type { ExecFn } from '@main/core/utils/exec'; import { GitService } from './git-service'; import { computeBaseRef } from './git-utils'; @@ -8,11 +8,13 @@ import { computeBaseRef } from './git-utils'; // Helpers // --------------------------------------------------------------------------- +type MockExec = (cmd: string, args?: string[]) => Promise<{ stdout: string; stderr: string }>; + /** - * Builds an ExecFn that returns pre-baked responses keyed by the joined args + * Builds a mock exec that returns pre-baked responses keyed by the joined args * string. Throws for any unmapped key (surfaces missing mocks early). */ -function makeExec(map: Record): ExecFn { +function makeExec(map: Record): MockExec { return async (_cmd: string, args: string[] = []) => { const key = args.join(' '); if (key in map) { @@ -30,7 +32,7 @@ function makeExec(map: Record): ExecFn { * Like makeExec but silently returns '' for unmapped keys. Useful when a * method makes optional/fallback calls that aren't relevant to the test. */ -function makePermissiveExec(map: Record): ExecFn { +function makePermissiveExec(map: Record): MockExec { return async (_cmd: string, args: string[] = []) => ({ stdout: map[args.join(' ')] ?? '', stderr: '', @@ -42,8 +44,21 @@ const BRANCH_FORMAT = const stubFs = {} as FileSystemProvider; -function makeService(exec: ExecFn): GitService { - return new GitService('/repo', exec, stubFs); +function makeContext(exec: MockExec, root = '/repo'): IExecutionContext { + return { + root, + supportsLocalSpawn: false, + exec: (_cmd, args = [], _opts) => exec(_cmd, args), + execStreaming: async (_cmd, _args, onChunk) => { + onChunk(''); + }, + dispose: () => {}, + }; +} + +function makeService(exec: MockExec): GitService { + const ctx = makeContext(exec); + return new GitService(ctx, ctx, stubFs); } // --------------------------------------------------------------------------- @@ -239,7 +254,11 @@ describe('GitService.getDefaultBranch', () => { }); it('falls back to local branch candidate "main" when symbolic-ref fails', async () => { - const exec: ExecFn = async (_cmd, args = []) => { + const exec = makeExec({ + 'rev-parse --verify refs/heads/main': 'abc123', + }); + // Override to throw for the first two heuristics + const overriddenExec: MockExec = async (_cmd, args = []) => { const key = args.join(' '); if (key === 'symbolic-ref refs/remotes/origin/HEAD --short') { throw Object.assign(new Error('no HEAD'), { code: 128 }); @@ -247,20 +266,16 @@ describe('GitService.getDefaultBranch', () => { if (key === 'remote show origin') { throw Object.assign(new Error('no remote'), { code: 128 }); } - // heuristic 3: "main" exists locally - if (key === 'rev-parse --verify refs/heads/main') { - return { stdout: 'abc123', stderr: '' }; - } - throw Object.assign(new Error('unexpected'), { code: 128 }); + return exec(_cmd, args); }; - expect(await makeService(exec).getDefaultBranch()).toBe('main'); + expect(await makeService(overriddenExec).getDefaultBranch()).toBe('main'); }); it('falls back to "main" convention when no heuristic resolves', async () => { - const exec: ExecFn = async () => { + const failingExec: MockExec = async () => { throw Object.assign(new Error('nothing works'), { code: 128 }); }; - expect(await makeService(exec).getDefaultBranch()).toBe('main'); + expect(await makeService(failingExec).getDefaultBranch()).toBe('main'); }); }); diff --git a/src/main/core/git/impl/git-service.ts b/src/main/core/git/impl/git-service.ts index bea5e5fe0c..e9c5d84dcc 100644 --- a/src/main/core/git/impl/git-service.ts +++ b/src/main/core/git/impl/git-service.ts @@ -20,6 +20,7 @@ import { type GitHeadState, type GitInfo, type GitObjectRef, + type ImageReadResult, type LocalBranch, type MergeBaseRange, type PullError, @@ -29,17 +30,20 @@ import { type SoftResetError, } from '@shared/git'; import { DEFAULT_REMOTE_NAME } from '@shared/git-utils'; -import { ownerFromUrl } from '@shared/pull-requests'; +import { parseGitHubRepository } from '@shared/github-repository'; import { err, ok, type Result } from '@shared/result'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; -import { GIT_EXECUTABLE, type ExecFn } from '@main/core/utils/exec'; -import { GitProvider } from '../types'; +import { GIT_EXECUTABLE } from '@main/core/utils/exec'; +import type { IDisposable } from '@main/lib/lifecycle'; +import { type GitProvider } from '../types'; import { CatFileBatch } from './cat-file-batch'; import { computeBaseRef, mapStatus, MAX_DIFF_CONTENT_BYTES, MAX_DIFF_OUTPUT_BYTES, + MAX_REF_LIST_BYTES, parseDiffLines, stripTrailingNewline, } from './git-utils'; @@ -50,15 +54,40 @@ import { type IFileStatus, } from './status-parser'; -export class GitService implements GitProvider { +const MAX_IMAGE_BLOB_BYTES = 10 * 1024 * 1024; + +const IMAGE_MIME_BY_EXT: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + bmp: 'image/bmp', + ico: 'image/x-icon', + svg: 'image/svg+xml', +}; + +function imageMimeForPath(filePath: string): string | null { + const ext = filePath.split('.').pop()?.toLowerCase(); + return ext ? (IMAGE_MIME_BY_EXT[ext] ?? null) : null; +} + +const LFS_POINTER_PREFIX = Buffer.from('version https://git-lfs.github.com/spec/'); + +// Without an LFS smudge filter, cat-file returns pointer text instead of image bytes. +function looksLikeLfsPointer(buffer: Buffer): boolean { + if (buffer.length > 1024) return false; + return buffer.slice(0, LFS_POINTER_PREFIX.length).equals(LFS_POINTER_PREFIX); +} + +export class GitService implements GitProvider, IDisposable { private _statusInFlight: Promise | null = null; private _catFile: CatFileBatch | null = null; constructor( - private readonly path: string, - private readonly exec: ExecFn, - private readonly fs: FileSystemProvider, - private readonly _localWorkspace = true + private readonly ctx: IExecutionContext, + private readonly authCtx: IExecutionContext, + private readonly fs: FileSystemProvider ) {} dispose(): void { @@ -67,8 +96,8 @@ export class GitService implements GitProvider { } private _getCatFile(): CatFileBatch | null { - if (!this._localWorkspace) return null; - this._catFile ??= new CatFileBatch(this.path); + if (!this.ctx.supportsLocalSpawn) return null; + this._catFile ??= new CatFileBatch(this.ctx.root ?? ''); return this._catFile; } @@ -102,10 +131,10 @@ export class GitService implements GitProvider { const parser = new StatusParser(); const [, stagedRes, unstagedRes, currentBranch] = await Promise.all([ this._runStatusZ(parser), - this.exec('git', ['diff', '--numstat', '--cached'], { cwd: this.path }).catch(() => ({ + this.ctx.exec('git', ['diff', '--numstat', '--cached']).catch(() => ({ stdout: '', })), - this.exec('git', ['diff', '--numstat'], { cwd: this.path }).catch(() => ({ stdout: '' })), + this.ctx.exec('git', ['diff', '--numstat']).catch(() => ({ stdout: '' })), this.getCurrentBranch(), ]); @@ -135,47 +164,15 @@ export class GitService implements GitProvider { } private async _runStatusZ(parser: StatusParser): Promise { - if (this._localWorkspace) { - await this._runStatusZStreaming(parser); - } else { - await this._runStatusZBuffered(parser); - } - } - - private async _runStatusZStreaming(parser: StatusParser): Promise { - await new Promise((resolve, reject) => { - const child = spawn(GIT_EXECUTABLE, ['--no-optional-locks', 'status', '-z', '-uall'], { - cwd: this.path, - env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, - }); - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { + await this.ctx.execStreaming( + 'git', + ['--no-optional-locks', 'status', '-z', '-uall'], + (chunk) => { parser.update(chunk); - if (parser.tooManyFiles) { - child.kill(); - } - }); - child.on('error', reject); - child.on('close', () => { - if (parser.tooManyFiles) { - reject(new TooManyFilesChangedError()); - return; - } - resolve(); - }); - }); - } - - private async _runStatusZBuffered(parser: StatusParser): Promise { - try { - const { stdout } = await this.exec('git', ['--no-optional-locks', 'status', '-z', '-uall'], { - cwd: this.path, - }); - parser.update(stdout); - if (parser.tooManyFiles) throw new TooManyFilesChangedError(); - } catch (e) { - if (e instanceof TooManyFilesChangedError) throw e; - } + return !parser.tooManyFiles; + } + ); + if (parser.tooManyFiles) throw new TooManyFilesChangedError(); } private async _buildFullGitStatus( @@ -295,24 +292,24 @@ export class GitService implements GitProvider { async stageFiles(filePaths: string[]): Promise { if (filePaths.length === 0) return; - await this.exec('git', ['add', '--', ...filePaths], { cwd: this.path }); + await this.ctx.exec('git', ['add', '--', ...filePaths]); } async stageAllFiles(): Promise { - await this.exec('git', ['add', '-A'], { cwd: this.path }); + await this.ctx.exec('git', ['add', '-A']); } async unstageFiles(filePaths: string[]): Promise { if (filePaths.length === 0) return; try { - await this.exec('git', ['reset', 'HEAD', '--', ...filePaths], { cwd: this.path }); + await this.ctx.exec('git', ['reset', 'HEAD', '--', ...filePaths]); } catch { // Fallback for edge cases (e.g. new files with no HEAD): unstage each via rm --cached for (const filePath of filePaths) { try { - await this.exec('git', ['reset', 'HEAD', '--', filePath], { cwd: this.path }); + await this.ctx.exec('git', ['reset', 'HEAD', '--', filePath]); } catch { - await this.exec('git', ['rm', '--cached', '--', filePath], { cwd: this.path }); + await this.ctx.exec('git', ['rm', '--cached', '--', filePath]); } } } @@ -320,7 +317,7 @@ export class GitService implements GitProvider { async unstageAllFiles(): Promise { try { - await this.exec('git', ['reset', 'HEAD'], { cwd: this.path }); + await this.ctx.exec('git', ['reset', 'HEAD']); } catch { // Repo may have no commits yet; ignore. } @@ -332,11 +329,13 @@ export class GitService implements GitProvider { // Determine which files exist in HEAD in a single command let trackedPaths = new Set(); try { - const { stdout } = await this.exec( - 'git', - ['ls-tree', '--name-only', 'HEAD', '--', ...filePaths], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'ls-tree', + '--name-only', + 'HEAD', + '--', + ...filePaths, + ]); trackedPaths = new Set(stdout.trim().split('\n').filter(Boolean)); } catch { // Empty repo — no HEAD yet, all files are untracked @@ -346,7 +345,7 @@ export class GitService implements GitProvider { const untracked = filePaths.filter((f) => !trackedPaths.has(f)); if (tracked.length > 0) { - await this.exec('git', ['checkout', 'HEAD', '--', ...tracked], { cwd: this.path }); + await this.ctx.exec('git', ['checkout', 'HEAD', '--', ...tracked]); } // Untracked files don't exist in git history — remove them from disk @@ -362,11 +361,11 @@ export class GitService implements GitProvider { // Reset index and working tree for all tracked changes back to HEAD, // then remove any untracked files/directories. try { - await this.exec('git', ['reset', '--hard', 'HEAD'], { cwd: this.path }); + await this.ctx.exec('git', ['reset', '--hard', 'HEAD']); } catch { // Repo may have no commits yet; ignore. } - await this.exec('git', ['clean', '-fd'], { cwd: this.path }); + await this.ctx.exec('git', ['clean', '-fd']); } // --------------------------------------------------------------------------- @@ -387,8 +386,7 @@ export class GitService implements GitProvider { } } try { - const { stdout } = await this.exec('git', ['show', `${ref}:${filePath}`], { - cwd: this.path, + const { stdout } = await this.ctx.exec('git', ['show', `${ref}:${filePath}`], { maxBuffer: MAX_DIFF_CONTENT_BYTES, }); return stripTrailingNewline(stdout); @@ -407,8 +405,7 @@ export class GitService implements GitProvider { } } try { - const { stdout } = await this.exec('git', ['show', `:0:${filePath}`], { - cwd: this.path, + const { stdout } = await this.ctx.exec('git', ['show', `:0:${filePath}`], { maxBuffer: MAX_DIFF_CONTENT_BYTES, }); return stripTrailingNewline(stdout); @@ -417,6 +414,70 @@ export class GitService implements GitProvider { } } + async getImageAtRef(filePath: string, ref: string): Promise { + return this._readImageBlob(`${ref}:${filePath}`, filePath); + } + + async getImageAtIndex(filePath: string): Promise { + return this._readImageBlob(`:0:${filePath}`, filePath); + } + + // SSH workspaces have no binary-safe exec channel. + private async _readImageBlob(spec: string, filePath: string): Promise { + if (!this.ctx.supportsLocalSpawn) return { kind: 'unavailable', reason: 'ssh' }; + const mimeType = imageMimeForPath(filePath); + if (!mimeType) return { kind: 'unavailable', reason: 'unsupported' }; + + return new Promise((resolve) => { + const child = spawn(GIT_EXECUTABLE, ['cat-file', '--filters', spec], { + cwd: this.ctx.root || undefined, + }); + const chunks: Buffer[] = []; + let total = 0; + let aborted = false; + + child.stdout.on('data', (chunk: Buffer) => { + if (aborted) return; + total += chunk.length; + if (total > MAX_IMAGE_BLOB_BYTES) { + aborted = true; + child.kill(); + resolve({ kind: 'unavailable', reason: 'too-large' }); + return; + } + chunks.push(chunk); + }); + child.stderr.resume(); + child.on('error', () => resolve({ kind: 'unavailable', reason: 'git-error' })); + child.on('close', (code) => { + if (aborted) return; + if (code !== 0) { + resolve( + code === 128 ? { kind: 'missing' } : { kind: 'unavailable', reason: 'git-error' } + ); + return; + } + const buffer = Buffer.concat(chunks); + if (buffer.length === 0) { + resolve({ kind: 'unavailable', reason: 'git-error' }); + return; + } + if (looksLikeLfsPointer(buffer)) { + resolve({ kind: 'unavailable', reason: 'lfs-pointer' }); + return; + } + resolve({ + kind: 'image', + image: { + dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, + mimeType, + size: buffer.length, + }, + }); + }); + }); + } + async getFileDiff( filePath: string, base: DiffMode | GitObjectRef = HEAD_MODE @@ -443,8 +504,7 @@ export class GitService implements GitProvider { let diffStdout: string | undefined; try { - const { stdout } = await this.exec('git', diffArgs, { - cwd: this.path, + const { stdout } = await this.ctx.exec('git', diffArgs, { maxBuffer: MAX_DIFF_OUTPUT_BYTES, }); diffStdout = stdout; @@ -454,8 +514,7 @@ export class GitService implements GitProvider { const getOriginalContent = async (): Promise => { try { - const { stdout } = await this.exec('git', ['show', `${originalRef}:${filePath}`], { - cwd: this.path, + const { stdout } = await this.ctx.exec('git', ['show', `${originalRef}:${filePath}`], { maxBuffer: MAX_DIFF_CONTENT_BYTES, }); return stripTrailingNewline(stdout); @@ -467,8 +526,7 @@ export class GitService implements GitProvider { const getModifiedContent = async (): Promise => { if (isObjectRef) { try { - const { stdout } = await this.exec('git', ['show', `HEAD:${filePath}`], { - cwd: this.path, + const { stdout } = await this.ctx.exec('git', ['show', `HEAD:${filePath}`], { maxBuffer: MAX_DIFF_CONTENT_BYTES, }); return stripTrailingNewline(stdout); @@ -536,8 +594,7 @@ export class GitService implements GitProvider { async getCommitFileDiff(commitHash: string, filePath: string): Promise { const getContentAt = async (ref: string): Promise => { try { - const { stdout } = await this.exec('git', ['show', `${ref}:${filePath}`], { - cwd: this.path, + const { stdout } = await this.ctx.exec('git', ['show', `${ref}:${filePath}`], { maxBuffer: MAX_DIFF_CONTENT_BYTES, }); return stripTrailingNewline(stdout); @@ -548,7 +605,7 @@ export class GitService implements GitProvider { let hasParent = true; try { - await this.exec('git', ['rev-parse', '--verify', `${commitHash}~1`], { cwd: this.path }); + await this.ctx.exec('git', ['rev-parse', '--verify', `${commitHash}~1`]); } catch { hasParent = false; } @@ -565,10 +622,10 @@ export class GitService implements GitProvider { let diffStdout: string | undefined; try { - const { stdout } = await this.exec( + const { stdout } = await this.ctx.exec( 'git', ['diff', '--no-color', '--unified=2000', `${commitHash}~1`, commitHash, '--', filePath], - { cwd: this.path, maxBuffer: MAX_DIFF_OUTPUT_BYTES } + { maxBuffer: MAX_DIFF_OUTPUT_BYTES } ); diffStdout = stdout; } catch {} @@ -636,48 +693,50 @@ export class GitService implements GitProvider { if (base !== undefined) { // PR-relative count: compare explicitly against the PR base ref. try { - const { stdout } = await this.exec( - 'git', - ['rev-list', '--count', `${toRefString(base)}..${headStr}`], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'rev-list', + '--count', + `${toRefString(base)}..${headStr}`, + ]); aheadCount = Number.parseInt(stdout.trim(), 10) || 0; } catch { aheadCount = 0; } } else { try { - const { stdout } = await this.exec('git', ['rev-list', '--count', '@{upstream}..HEAD'], { - cwd: this.path, - }); + const { stdout } = await this.ctx.exec('git', [ + 'rev-list', + '--count', + '@{upstream}..HEAD', + ]); aheadCount = Number.parseInt(stdout.trim(), 10) || 0; } catch { try { - const { stdout: branchOut } = await this.exec( - 'git', - ['rev-parse', '--abbrev-ref', 'HEAD'], - { cwd: this.path } - ); + const { stdout: branchOut } = await this.ctx.exec('git', [ + 'rev-parse', + '--abbrev-ref', + 'HEAD', + ]); const currentBranch = branchOut.trim(); - const { stdout } = await this.exec( - 'git', - ['rev-list', '--count', `${remote}/${currentBranch}..HEAD`], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'rev-list', + '--count', + `${remote}/${currentBranch}..HEAD`, + ]); aheadCount = Number.parseInt(stdout.trim(), 10) || 0; } catch { try { - const { stdout: defaultBranchOut } = await this.exec( - 'git', - ['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], - { cwd: this.path } - ); + const { stdout: defaultBranchOut } = await this.ctx.exec('git', [ + 'symbolic-ref', + '--short', + `refs/remotes/${remote}/HEAD`, + ]); const defaultBranch = defaultBranchOut.trim(); - const { stdout } = await this.exec( - 'git', - ['rev-list', '--count', `${defaultBranch}..HEAD`], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'rev-list', + '--count', + `${defaultBranch}..HEAD`, + ]); aheadCount = Number.parseInt(stdout.trim(), 10) || 0; } catch { aheadCount = 0; @@ -693,18 +752,14 @@ export class GitService implements GitProvider { // When base is provided (PR view), use a range so only commits between // base and head are returned — not a raw linear walk from head. const rangeArg = base ? `${toRefString(base)}..${headStr}` : headStr; - const { stdout } = await this.exec( - 'git', - [ - 'log', - `--max-count=${maxCount}`, - `--skip=${skip}`, - `--pretty=format:${format}`, - rangeArg, - '--', - ], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'log', + `--max-count=${maxCount}`, + `--skip=${skip}`, + `--pretty=format:${format}`, + rangeArg, + '--', + ]); if (!stdout.trim()) return { commits: [], aheadCount }; @@ -772,8 +827,8 @@ export class GitService implements GitProvider { : ['diff', '--name-status', ref]; const [numstatResult, nameStatusResult] = await Promise.all([ - this.exec('git', diffArgs, { cwd: this.path }).catch(() => ({ stdout: '' })), - this.exec('git', nameArgs, { cwd: this.path }).catch(() => ({ stdout: '' })), + this.ctx.exec('git', diffArgs).catch(() => ({ stdout: '' })), + this.ctx.exec('git', nameArgs).catch(() => ({ stdout: '' })), ]); const numstatMap = parseNumstat(numstatResult.stdout); @@ -798,35 +853,27 @@ export class GitService implements GitProvider { } async getCommitFiles(commitHash: string): Promise { - const { stdout } = await this.exec( - 'git', - [ - 'diff-tree', - '--root', - '--no-commit-id', - '-r', - '-m', - '--first-parent', - '--numstat', - commitHash, - ], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'diff-tree', + '--root', + '--no-commit-id', + '-r', + '-m', + '--first-parent', + '--numstat', + commitHash, + ]); - const { stdout: nameStatus } = await this.exec( - 'git', - [ - 'diff-tree', - '--root', - '--no-commit-id', - '-r', - '-m', - '--first-parent', - '--name-status', - commitHash, - ], - { cwd: this.path } - ); + const { stdout: nameStatus } = await this.ctx.exec('git', [ + 'diff-tree', + '--root', + '--no-commit-id', + '-r', + '-m', + '--first-parent', + '--name-status', + commitHash, + ]); const statLines = stdout.trim().split('\n').filter(Boolean); const statusLines = nameStatus.trim().split('\n').filter(Boolean); @@ -857,7 +904,7 @@ export class GitService implements GitProvider { async commit(message: string): Promise> { if (!message || !message.trim()) return err({ type: 'empty_message' }); try { - await this.exec('git', ['commit', '-m', message], { cwd: this.path }); + await this.ctx.exec('git', ['commit', '-m', message]); } catch (error: unknown) { const stderr = (error as { stderr?: string })?.stderr || ''; const stdout = (error as { stdout?: string })?.stdout || ''; @@ -868,7 +915,7 @@ export class GitService implements GitProvider { return err({ type: 'hook_failed', message: output }); } try { - const { stdout } = await this.exec('git', ['rev-parse', 'HEAD'], { cwd: this.path }); + const { stdout } = await this.ctx.exec('git', ['rev-parse', 'HEAD']); return ok({ hash: stdout.trim() }); } catch (error: unknown) { return err({ type: 'error', message: String(error) }); @@ -877,7 +924,7 @@ export class GitService implements GitProvider { async fetch(remote?: string): Promise> { try { - const remotes = await this.exec('git', ['remote'], { cwd: this.path }).catch(() => ({ + const remotes = await this.ctx.exec('git', ['remote']).catch(() => ({ stdout: '', })); const remoteNames = remotes.stdout @@ -891,8 +938,8 @@ export class GitService implements GitProvider { return err({ type: 'remote_not_found', message: `Remote "${selectedRemote}" not found` }); } - await this.exec('git', selectedRemote ? ['fetch', selectedRemote] : ['fetch'], { - cwd: this.path, + await this.authCtx.exec('git', selectedRemote ? ['fetch', selectedRemote] : ['fetch'], { + maxBuffer: MAX_REF_LIST_BYTES, }); return ok(); } catch (error: unknown) { @@ -930,7 +977,7 @@ export class GitService implements GitProvider { async push(preferredRemote?: string): Promise> { const doPush = async (args: string[]): Promise => { - const { stdout, stderr } = await this.exec('git', args, { cwd: this.path }); + const { stdout, stderr } = await this.authCtx.exec('git', args); return (stdout || stderr || '').trim(); }; @@ -951,17 +998,15 @@ export class GitService implements GitProvider { stderr.includes('upstream branch of your current branch does not match') ) { try { - const { stdout: branchOut } = await this.exec('git', ['branch', '--show-current'], { - cwd: this.path, - }); + const { stdout: branchOut } = await this.ctx.exec('git', ['branch', '--show-current']); const currentBranch = branchOut.trim(); let pushRemote = preferredRemote?.trim() || DEFAULT_REMOTE_NAME; try { - const { stdout: remoteOut } = await this.exec( - 'git', - ['config', '--get', `branch.${currentBranch}.remote`], - { cwd: this.path } - ); + const { stdout: remoteOut } = await this.ctx.exec('git', [ + 'config', + '--get', + `branch.${currentBranch}.remote`, + ]); if (remoteOut.trim()) pushRemote = remoteOut.trim(); } catch {} const output = await doPush(['push', '--set-upstream', pushRemote, currentBranch]); @@ -1017,7 +1062,7 @@ export class GitService implements GitProvider { remote = 'origin' ): Promise> { const doPush = async (args: string[]): Promise => { - const { stdout, stderr } = await this.exec('git', args, { cwd: this.path }); + const { stdout, stderr } = await this.authCtx.exec('git', args); return (stdout || stderr || '').trim(); }; @@ -1038,11 +1083,11 @@ export class GitService implements GitProvider { stderr.includes('non-fast-forward') ) { try { - await this.exec( - 'git', - ['branch', '--set-upstream-to', `${remote}/${branchName}`, branchName], - { cwd: this.path } - ); + await this.ctx.exec('git', [ + 'branch', + `--set-upstream-to=${remote}/${branchName}`, + branchName, + ]); } catch {} return err({ type: 'rejected', message }); } @@ -1085,7 +1130,7 @@ export class GitService implements GitProvider { async pull(): Promise> { try { - const { stdout } = await this.exec('git', ['pull'], { cwd: this.path }); + const { stdout } = await this.authCtx.exec('git', ['pull']); return ok({ output: stdout.trim() }); } catch (error: unknown) { const stdout = (error as { stdout?: string })?.stdout || ''; @@ -1095,11 +1140,11 @@ export class GitService implements GitProvider { if (stdout.includes('CONFLICT') || stderr.includes('CONFLICT')) { let conflictedFiles: string[] = []; try { - const { stdout: conflictOut } = await this.exec( - 'git', - ['diff', '--name-only', '--diff-filter=U'], - { cwd: this.path } - ); + const { stdout: conflictOut } = await this.ctx.exec('git', [ + 'diff', + '--name-only', + '--diff-filter=U', + ]); conflictedFiles = conflictOut .split('\n') .map((f) => f.trim()) @@ -1151,7 +1196,7 @@ export class GitService implements GitProvider { async softReset(): Promise> { try { - await this.exec('git', ['rev-parse', '--verify', 'HEAD~1'], { cwd: this.path }); + await this.ctx.exec('git', ['rev-parse', '--verify', 'HEAD~1']); } catch { return err({ type: 'initial_commit' }); } @@ -1162,14 +1207,10 @@ export class GitService implements GitProvider { } try { - const { stdout: subject } = await this.exec('git', ['log', '-1', '--pretty=format:%s'], { - cwd: this.path, - }); - const { stdout: body } = await this.exec('git', ['log', '-1', '--pretty=format:%b'], { - cwd: this.path, - }); + const { stdout: subject } = await this.ctx.exec('git', ['log', '-1', '--pretty=format:%s']); + const { stdout: body } = await this.ctx.exec('git', ['log', '-1', '--pretty=format:%b']); - await this.exec('git', ['reset', '--soft', 'HEAD~1'], { cwd: this.path }); + await this.ctx.exec('git', ['reset', '--soft', 'HEAD~1']); return ok({ subject: subject.trim(), body: body.trim() }); } catch (error: unknown) { @@ -1179,9 +1220,7 @@ export class GitService implements GitProvider { async getCurrentBranch(): Promise { try { - const { stdout } = await this.exec('git', ['rev-parse', '--symbolic-full-name', 'HEAD'], { - cwd: this.path, - }); + const { stdout } = await this.ctx.exec('git', ['rev-parse', '--symbolic-full-name', 'HEAD']); const ref = stdout.trim(); if (ref === 'HEAD' || !ref) return null; if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length); @@ -1194,25 +1233,24 @@ export class GitService implements GitProvider { async getWorktreeGitDir(mainDotGitAbs: string): Promise { try { - const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], { - cwd: this.path, - }); + const { stdout } = await this.ctx.exec('git', ['rev-parse', '--git-dir']); const raw = stdout.trim(); - const gitDirAbs = path.isAbsolute(raw) ? raw : path.resolve(this.path, raw); + const root = this.ctx.root ?? ''; + const gitDirAbs = path.isAbsolute(raw) ? raw : path.resolve(root, raw); const rel = path.relative(mainDotGitAbs, gitDirAbs).replace(/\\/g, '/'); return rel === '.' || rel === '' ? '' : rel; } catch { - return `worktrees/${path.basename(this.path)}`; + return `worktrees/${path.basename(this.ctx.root ?? '')}`; } } async getBranches(): Promise { const remotes = await this.getRemotes(); const remoteByName = new Map(remotes.map((remote) => [remote.name, remote])); - const { stdout } = await this.exec( + const { stdout } = await this.ctx.exec( 'git', ['branch', '-a', '--format=%(refname:short)|%(upstream:short)|%(upstream:track)|%(refname)'], - { cwd: this.path } + { maxBuffer: MAX_REF_LIST_BYTES } ); const branches: Branch[] = []; @@ -1261,11 +1299,11 @@ export class GitService implements GitProvider { // Heuristic 1: ask the remote what its HEAD points to (fast, no network call needed // because git caches this in refs/remotes//HEAD after a fetch/clone). try { - const { stdout } = await this.exec( - 'git', - ['symbolic-ref', `refs/remotes/${remote}/HEAD`, '--short'], - { cwd: this.path } - ); + const { stdout } = await this.ctx.exec('git', [ + 'symbolic-ref', + `refs/remotes/${remote}/HEAD`, + '--short', + ]); const ref = stdout.trim(); if (ref) { const slashIdx = ref.indexOf('/'); @@ -1275,7 +1313,7 @@ export class GitService implements GitProvider { // Heuristic 2: ask the remote directly (requires a network call). try { - const { stdout } = await this.exec('git', ['remote', 'show', remote], { cwd: this.path }); + const { stdout } = await this.authCtx.exec('git', ['remote', 'show', remote]); const match = /HEAD branch:\s*(\S+)/.exec(stdout); if (match?.[1]) return match[1]; } catch {} @@ -1291,9 +1329,7 @@ export class GitService implements GitProvider { private async _branchExistsLocally(branch: string): Promise { try { - await this.exec('git', ['rev-parse', '--verify', `refs/heads/${branch}`], { - cwd: this.path, - }); + await this.ctx.exec('git', ['rev-parse', '--verify', `refs/heads/${branch}`]); return true; } catch { return false; @@ -1302,7 +1338,7 @@ export class GitService implements GitProvider { async getRemotes(): Promise<{ name: string; url: string }[]> { try { - const { stdout } = await this.exec('git', ['remote', '-v'], { cwd: this.path }); + const { stdout } = await this.ctx.exec('git', ['remote', '-v']); const seen = new Set(); const remotes: { name: string; url: string }[] = []; for (const line of stdout.split('\n')) { @@ -1321,14 +1357,12 @@ export class GitService implements GitProvider { async getHeadState(): Promise { let headName: string | undefined; try { - const { stdout } = await this.exec('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { - cwd: this.path, - }); + const { stdout } = await this.ctx.exec('git', ['symbolic-ref', '--quiet', '--short', 'HEAD']); headName = stdout.trim() || undefined; } catch {} try { - await this.exec('git', ['rev-parse', '--verify', 'HEAD'], { cwd: this.path }); + await this.ctx.exec('git', ['rev-parse', '--verify', 'HEAD']); return { headName, isUnborn: false }; } catch { return { headName, isUnborn: true }; @@ -1336,7 +1370,7 @@ export class GitService implements GitProvider { } async addRemote(name: string, url: string): Promise { - await this.exec('git', ['remote', 'add', name, url], { cwd: this.path }); + await this.ctx.exec('git', ['remote', 'add', name, url]); } async createBranch( @@ -1346,11 +1380,15 @@ export class GitService implements GitProvider { remote = 'origin' ): Promise> { if (syncWithRemote) { - await this.exec('git', ['fetch', remote], { cwd: this.path }).catch(() => {}); + await this.authCtx + .exec('git', ['fetch', remote], { + maxBuffer: MAX_REF_LIST_BYTES, + }) + .catch(() => {}); } const base = syncWithRemote ? `${remote}/${from}` : `refs/heads/${from}`; try { - await this.exec('git', ['branch', '--no-track', name, base], { cwd: this.path }); + await this.ctx.exec('git', ['branch', '--no-track', name, base]); return ok(); } catch (error: unknown) { const stderr = (error as { stderr?: string })?.stderr || String(error); @@ -1385,52 +1423,45 @@ export class GitService implements GitProvider { ): Promise> { try { if (isFork) { - const forkRemote = ownerFromUrl(headRepositoryUrl) ?? 'fork'; + const forkRemote = parseGitHubRepository(headRepositoryUrl)?.owner ?? 'fork'; // Idempotently ensure remote exists with the correct URL - const remotes = await this.exec('git', ['remote'], { cwd: this.path }).catch(() => ({ - stdout: '', - })); + const remotes = await this.ctx.exec('git', ['remote']).catch(() => ({ stdout: '' })); const names = remotes.stdout .split('\n') .map((s) => s.trim()) .filter(Boolean); if (!names.includes(forkRemote)) { - await this.exec('git', ['remote', 'add', forkRemote, headRepositoryUrl], { - cwd: this.path, - }); + await this.ctx.exec('git', ['remote', 'add', forkRemote, headRepositoryUrl]); } else { - await this.exec('git', ['remote', 'set-url', forkRemote, headRepositoryUrl], { - cwd: this.path, - }).catch(() => {}); + await this.ctx + .exec('git', ['remote', 'set-url', forkRemote, headRepositoryUrl]) + .catch(() => {}); } - await this.exec( - 'git', - ['fetch', forkRemote, `${headRefName}:refs/heads/${localBranch}`, '--force'], - { cwd: this.path } - ); + await this.authCtx.exec('git', [ + 'fetch', + forkRemote, + `${headRefName}:refs/heads/${localBranch}`, + '--force', + ]); // Set tracking so `git push` targets the contributor's fork branch - await this.exec( - 'git', - ['branch', `--set-upstream-to=${forkRemote}/${headRefName}`, localBranch], - { cwd: this.path } - ).catch(() => {}); + await this.ctx + .exec('git', ['branch', `--set-upstream-to=${forkRemote}/${headRefName}`, localBranch]) + .catch(() => {}); } else { // Same-repo: GitHub always exposes refs/pull/{N}/head on origin - await this.exec( - 'git', - [ - 'fetch', - configuredRemote, - `refs/pull/${prNumber}/head:refs/heads/${localBranch}`, - '--force', - ], - { cwd: this.path } - ); - await this.exec( - 'git', - ['branch', `--set-upstream-to=${configuredRemote}/${headRefName}`, localBranch], - { cwd: this.path } - ).catch(() => {}); + await this.authCtx.exec('git', [ + 'fetch', + configuredRemote, + `refs/pull/${prNumber}/head:refs/heads/${localBranch}`, + '--force', + ]); + await this.ctx + .exec('git', [ + 'branch', + `--set-upstream-to=${configuredRemote}/${headRefName}`, + localBranch, + ]) + .catch(() => {}); } return ok(); } catch (error: unknown) { @@ -1452,14 +1483,16 @@ export class GitService implements GitProvider { ): Promise> { let remoteName: string | undefined; try { - const { stdout } = await this.exec('git', ['config', '--get', `branch.${oldBranch}.remote`], { - cwd: this.path, - }); + const { stdout } = await this.ctx.exec('git', [ + 'config', + '--get', + `branch.${oldBranch}.remote`, + ]); remoteName = stdout.trim() || undefined; } catch {} try { - await this.exec('git', ['branch', '-m', oldBranch, newBranch], { cwd: this.path }); + await this.ctx.exec('git', ['branch', '-m', oldBranch, newBranch]); } catch (error: unknown) { const stderr = (error as { stderr?: string })?.stderr || String(error); if (stderr.includes('already exists')) { @@ -1470,10 +1503,10 @@ export class GitService implements GitProvider { if (remoteName) { try { - await this.exec('git', ['push', remoteName, '--delete', oldBranch], { cwd: this.path }); + await this.authCtx.exec('git', ['push', remoteName, '--delete', oldBranch]); } catch {} try { - await this.exec('git', ['push', '-u', remoteName, newBranch], { cwd: this.path }); + await this.authCtx.exec('git', ['push', '-u', remoteName, newBranch]); } catch (error: unknown) { const stderr = (error as { stderr?: string })?.stderr || String(error); return err({ type: 'remote_push_failed', message: stderr }); @@ -1486,7 +1519,7 @@ export class GitService implements GitProvider { async deleteBranch(branch: string, force = true): Promise> { const flag = force ? '-D' : '-d'; try { - await this.exec('git', ['branch', flag, branch], { cwd: this.path }); + await this.ctx.exec('git', ['branch', flag, branch]); return ok(); } catch (error: unknown) { const stderr = (error as { stderr?: string })?.stderr || String(error); @@ -1509,63 +1542,47 @@ export class GitService implements GitProvider { async detectInfo(): Promise { try { - await this.exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd: this.path }); + await this.ctx.exec('git', ['rev-parse', '--is-inside-work-tree']); } catch { - return { isGitRepo: false, baseRef: 'main', rootPath: this.path }; + return { isGitRepo: false, baseRef: 'main', rootPath: this.ctx.root ?? '' }; } let remoteName: string | undefined; - let remote: string | undefined; try { - const { stdout } = await this.exec('git', ['remote'], { cwd: this.path }); + const { stdout } = await this.ctx.exec('git', ['remote']); const remotes = stdout.trim().split('\n').filter(Boolean); remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } catch {} - if (remoteName) { - try { - const { stdout } = await this.exec('git', ['remote', 'get-url', remoteName], { - cwd: this.path, - }); - remote = stdout.trim() || undefined; - } catch {} - } - let branch: string | undefined; try { - const { stdout } = await this.exec('git', ['branch', '--show-current'], { cwd: this.path }); + const { stdout } = await this.ctx.exec('git', ['branch', '--show-current']); branch = stdout.trim() || undefined; } catch {} if (!branch && remoteName) { try { - const { stdout } = await this.exec('git', ['remote', 'show', remoteName], { - cwd: this.path, - }); + const { stdout } = await this.authCtx.exec('git', ['remote', 'show', remoteName]); const match = /HEAD branch:\s*(\S+)/.exec(stdout); branch = match?.[1] ?? undefined; } catch {} } - let rootPath = this.path; + let rootPath: string = this.ctx.root ?? ''; try { - const { stdout } = await this.exec('git', ['rev-parse', '--show-toplevel'], { - cwd: this.path, - }); + const { stdout } = await this.ctx.exec('git', ['rev-parse', '--show-toplevel']); const trimmed = stdout.trim(); if (trimmed) rootPath = trimmed; } catch {} return { isGitRepo: true, - remote, - branch, baseRef: computeBaseRef(undefined, remoteName, branch), rootPath, }; } async initRepository(): Promise { - await this.exec('git', ['init'], { cwd: this.path }); + await this.ctx.exec('git', ['init']); } } diff --git a/src/main/core/git/impl/git-utils.ts b/src/main/core/git/impl/git-utils.ts index 1804b7c32e..fa6a52ab73 100644 --- a/src/main/core/git/impl/git-utils.ts +++ b/src/main/core/git/impl/git-utils.ts @@ -6,6 +6,13 @@ export const MAX_DIFF_CONTENT_BYTES = 512 * 1024; /** Maximum bytes for `git diff` output (larger than content limit due to headers/context). */ export const MAX_DIFF_OUTPUT_BYTES = 10 * 1024 * 1024; +/** + * Maximum bytes for ref-listing / fetch output. Repos with many thousands of refs + * (e.g. monorepos) easily exceed Node's 1 MB default `maxBuffer`, which would otherwise + * cause `git branch -a` and `git fetch` to fail silently with no branches surfaced. + */ +export const MAX_REF_LIST_BYTES = 64 * 1024 * 1024; + /** Headers emitted by `git diff` that should be skipped when parsing hunks. */ const DIFF_HEADER_PREFIXES = [ 'diff ', diff --git a/src/main/core/git/remote-helper.ts b/src/main/core/git/remote-helper.ts new file mode 100644 index 0000000000..1a09e5bfb4 --- /dev/null +++ b/src/main/core/git/remote-helper.ts @@ -0,0 +1,9 @@ +export function isSshRemoteUrl(remoteUrl: string): boolean { + return /^git@[^:]+:/i.test(remoteUrl) || /^ssh:\/\//i.test(remoteUrl); +} + +export function isGitHubSshRemoteUrl(remoteUrl: string): boolean { + return ( + /^git@github\.com:/i.test(remoteUrl) || /^ssh:\/\/git@github\.com(?::\d+)?\//i.test(remoteUrl) + ); +} diff --git a/src/main/core/git/repository-service.ts b/src/main/core/git/repository-service.ts index ea90f27886..84dd41115d 100644 --- a/src/main/core/git/repository-service.ts +++ b/src/main/core/git/repository-service.ts @@ -12,7 +12,8 @@ import type { RemoteBranchesPayload, RenameBranchError, } from '@shared/git'; -import { computeDefaultBranch, selectPreferredRemote } from '@shared/git-utils'; +import { selectPreferredRemote } from '@shared/git-utils'; +import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/schema'; import type { RepositoryGitProvider } from './repository-git-provider'; @@ -31,14 +32,6 @@ export class GitRepositoryService { return selectPreferredRemote(configured, remotes).name; } - async getDefaultBranchName(): Promise { - const configured = await this.settings.getDefaultBranch(); - const remote = await this.getConfiguredRemote(); - const branches = await this.git.getBranches(); - const gitDefault = await this.git.getDefaultBranch(remote); - return computeDefaultBranch(configured, branches, remote, gitDefault); - } - async getRepositoryInfo(): Promise<{ isUnborn: boolean; currentBranch: string | null }> { const headState = await this.git.getHeadState(); const currentBranch = headState.isUnborn @@ -160,4 +153,15 @@ export class GitRepositoryService { const gitDefaultBranch = await this.git.getDefaultBranch(remote); return { remoteBranches, remotes, gitDefaultBranch }; } + + async getRemoteState(): Promise { + try { + const remotes = await this.getRemotes(); + const remoteName = await this.getConfiguredRemote(); + const remoteUrl = remotes.find((r) => r.name === remoteName)?.url; + return { hasRemote: remotes.length > 0, selectedRemoteUrl: remoteUrl ?? null }; + } catch { + return { hasRemote: false, selectedRemoteUrl: null }; + } + } } diff --git a/src/main/core/git/workspace-git-provider.ts b/src/main/core/git/workspace-git-provider.ts index 69f0578186..ee5564fa23 100644 --- a/src/main/core/git/workspace-git-provider.ts +++ b/src/main/core/git/workspace-git-provider.ts @@ -8,6 +8,7 @@ import type { FullGitStatus, GitChange, GitObjectRef, + ImageReadResult, MergeBaseRange, PullError, PushError, @@ -39,6 +40,9 @@ export interface WorkspaceGitProvider { getFileAtHead(filePath: string): Promise; getFileAtRef(filePath: string, ref: string): Promise; getFileAtIndex(filePath: string): Promise; + /** Reads a binary image blob with smudge filters (e.g. LFS) applied. */ + getImageAtRef(filePath: string, ref: string): Promise; + getImageAtIndex(filePath: string): Promise; getCommitFileDiff(commitHash: string, filePath: string): Promise; stageFiles(filePaths: string[]): Promise; diff --git a/src/main/core/github/controller.ts b/src/main/core/github/controller.ts index 97b8d59a68..db4e40a22a 100644 --- a/src/main/core/github/controller.ts +++ b/src/main/core/github/controller.ts @@ -7,6 +7,9 @@ import type { } from '@shared/github'; import { createRPCController } from '@shared/ipc/rpc'; import { ACCOUNT_CONFIG } from '@main/core/account/config'; +import { GitHubAuthExecutionContext } from '@main/core/execution-context/github-auth-execution-context'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; @@ -14,9 +17,8 @@ import { cloneRepository, initializeNewProject } from '@main/core/git/impl/git-r import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import { repoService } from '@main/core/github/services/repo-service'; import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; -import { getGitLocalExec, getGitSshExec, type ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; -import { capture, identify as telemetryIdentify } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export const githubController = createRPCController({ getStatus: async (): Promise => { @@ -32,11 +34,7 @@ export const githubController = createRPCController({ try { const result = await githubConnectionService.startDeviceFlowAuth(); if (result.success) { - capture('integration_connected', { provider: 'github' }); - const user = await githubConnectionService.getCurrentUser(); - if (user?.login) { - telemetryIdentify(user.login); - } + telemetryService.capture('integration_connected', { provider: 'github' }); } return result; } catch (error) { @@ -50,15 +48,7 @@ export const githubController = createRPCController({ const { baseUrl } = ACCOUNT_CONFIG.authServer; const result = await githubConnectionService.startOAuthFlow(baseUrl); if (result.success) { - capture('integration_connected', { provider: 'github' }); - if (result.user?.login) { - telemetryIdentify(result.user.login); - } else { - const user = await githubConnectionService.getCurrentUser(); - if (user?.login) { - telemetryIdentify(user.login); - } - } + telemetryService.capture('integration_connected', { provider: 'github' }); } return result; } catch (error) { @@ -89,7 +79,7 @@ export const githubController = createRPCController({ logout: async () => { try { await githubConnectionService.logout(); - capture('integration_disconnected', { provider: 'github' }); + telemetryService.capture('integration_disconnected', { provider: 'github' }); return { success: true }; } catch (error) { log.error('GitHub logout failed:', error); @@ -236,20 +226,26 @@ export const githubController = createRPCController({ cloneRepository: async (repoUrl: string, targetPath: string, connectionId?: string) => { try { - let exec: ExecFn; + let ctx; let parentFs: FileSystemProvider; if (connectionId) { const proxy = await sshConnectionManager.connect(connectionId); - exec = getGitSshExec(proxy, () => githubConnectionService.getToken()); + ctx = new GitHubAuthExecutionContext( + new SshExecutionContext(proxy, { root: path.posix.dirname(targetPath) }), + () => githubConnectionService.getToken() + ); parentFs = new SshFileSystem(proxy, path.posix.dirname(targetPath)); } else { - exec = getGitLocalExec(() => githubConnectionService.getToken()); + ctx = new GitHubAuthExecutionContext( + new LocalExecutionContext({ root: path.dirname(targetPath) }), + () => githubConnectionService.getToken() + ); parentFs = new LocalFileSystem(path.dirname(targetPath)); } await parentFs.mkdir('.', { recursive: true }); - return await cloneRepository(repoUrl, targetPath, exec); + return await cloneRepository(repoUrl, targetPath, ctx); } catch (error) { log.error('Failed to clone repository:', error); return { @@ -266,15 +262,21 @@ export const githubController = createRPCController({ connectionId?: string; }) => { try { - let exec: ExecFn; + let ctx; let projectFs: FileSystemProvider; if (params.connectionId) { const proxy = await sshConnectionManager.connect(params.connectionId); - exec = getGitSshExec(proxy, () => githubConnectionService.getToken()); + ctx = new GitHubAuthExecutionContext( + new SshExecutionContext(proxy, { root: params.targetPath }), + () => githubConnectionService.getToken() + ); projectFs = new SshFileSystem(proxy, params.targetPath); } else { - exec = getGitLocalExec(() => githubConnectionService.getToken()); + ctx = new GitHubAuthExecutionContext( + new LocalExecutionContext({ root: params.targetPath }), + () => githubConnectionService.getToken() + ); projectFs = new LocalFileSystem(params.targetPath); } @@ -285,7 +287,7 @@ export const githubController = createRPCController({ name: params.name, description: params.description, }, - exec, + ctx, projectFs ); @@ -325,18 +327,25 @@ export const githubController = createRPCController({ (settings as { projects?: { defaultDirectory?: string } }).projects?.defaultDirectory ?? path.join(homedir(), 'emdash-projects'); const localPath = path.join(projectDir, name); - const exec = getGitLocalExec(() => githubConnectionService.getToken()); + const cloneCtx = new GitHubAuthExecutionContext( + new LocalExecutionContext({ root: path.dirname(localPath) }), + () => githubConnectionService.getToken() + ); const parentFs = new LocalFileSystem(path.dirname(localPath)); await parentFs.mkdir('.', { recursive: true }); - const cloneResult = await cloneRepository(cloneUrl, localPath, exec); + const cloneResult = await cloneRepository(cloneUrl, localPath, cloneCtx); if (!cloneResult.success) { throw new Error(cloneResult.error ?? 'Clone failed'); } + const initCtx = new GitHubAuthExecutionContext( + new LocalExecutionContext({ root: localPath }), + () => githubConnectionService.getToken() + ); const projectFs = new LocalFileSystem(localPath); await initializeNewProject( { repoUrl: cloneUrl, localPath, name, description }, - exec, + initCtx, projectFs ); diff --git a/src/main/core/github/github-issue-provider.test.ts b/src/main/core/github/github-issue-provider.test.ts new file mode 100644 index 0000000000..9b7ba62dcc --- /dev/null +++ b/src/main/core/github/github-issue-provider.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { githubIssueProvider } from './github-issue-provider'; +import { issueService } from './services/issue-service'; + +vi.mock('./services/issue-service', () => ({ + issueService: { + listIssues: vi.fn(), + searchIssues: vi.fn(), + }, +})); + +vi.mock('./services/github-connection-service', () => ({ + githubConnectionService: { + getStatus: vi.fn(), + }, +})); + +const mockIssueService = vi.mocked(issueService); + +describe('githubIssueProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses repositoryUrl to resolve the GitHub repository before listing issues', async () => { + mockIssueService.listIssues.mockResolvedValue([]); + + await githubIssueProvider.listIssues({ + repositoryUrl: 'https://github.com/owner/repo', + limit: 7, + }); + + expect(mockIssueService.listIssues).toHaveBeenCalledWith( + { + owner: 'owner', + repo: 'repo', + nameWithOwner: 'owner/repo', + repositoryUrl: 'https://github.com/owner/repo', + }, + 7 + ); + }); + + it('falls back to the resolved remote when repositoryUrl is not provided', async () => { + mockIssueService.searchIssues.mockResolvedValue([]); + + await githubIssueProvider.searchIssues({ + remote: 'git@github.com:owner/repo.git', + searchTerm: 'bug', + limit: 3, + }); + + expect(mockIssueService.searchIssues).toHaveBeenCalledWith( + { + owner: 'owner', + repo: 'repo', + nameWithOwner: 'owner/repo', + repositoryUrl: 'https://github.com/owner/repo', + }, + 'bug', + 3 + ); + }); +}); diff --git a/src/main/core/github/github-issue-provider.ts b/src/main/core/github/github-issue-provider.ts index 0ed62da3b4..979eb3128f 100644 --- a/src/main/core/github/github-issue-provider.ts +++ b/src/main/core/github/github-issue-provider.ts @@ -1,9 +1,7 @@ +import { parseGitHubRepository, type GitHubRepositoryRef } from '@shared/github-repository'; import { ISSUE_PROVIDER_CAPABILITIES, type IssueListResult } from '@shared/issue-providers'; import type { Issue } from '@shared/tasks'; -import { - normalizeSearchTerm, - requireNameWithOwner, -} from '@main/core/issues/helpers/provider-inputs'; +import { normalizeSearchTerm } from '@main/core/issues/helpers/provider-inputs'; import type { IssueProvider } from '@main/core/issues/issue-provider'; import { githubConnectionService } from './services/github-connection-service'; import { issueService } from './services/issue-service'; @@ -30,9 +28,12 @@ function toIssue(raw: { }; } -async function listIssues(nameWithOwner: string, limit: number): Promise { +async function listIssues( + repository: GitHubRepositoryRef, + limit: number +): Promise { try { - const issues = await issueService.listIssues(nameWithOwner, limit); + const issues = await issueService.listIssues(repository, limit); return { success: true, issues: issues.map(toIssue), @@ -46,7 +47,7 @@ async function listIssues(nameWithOwner: string, limit: number): Promise { @@ -55,7 +56,7 @@ async function searchIssues( } try { - const issues = await issueService.searchIssues(nameWithOwner, searchTerm, limit); + const issues = await issueService.searchIssues(repository, searchTerm, limit); return { success: true, issues: issues.map(toIssue), @@ -82,20 +83,22 @@ export const githubIssueProvider: IssueProvider = { }, listIssues: async (opts) => { - const nameWithOwner = requireNameWithOwner(opts.nameWithOwner); - if (!nameWithOwner) { - return { success: false, error: 'Repository name is required.' }; + const repository = + parseGitHubRepository(opts.repositoryUrl) ?? parseGitHubRepository(opts.remote); + if (!repository) { + return { success: false, error: 'Repository URL is required.' }; } - return listIssues(nameWithOwner, opts.limit ?? 50); + return listIssues(repository, opts.limit ?? 50); }, searchIssues: async (opts) => { - const nameWithOwner = requireNameWithOwner(opts.nameWithOwner); - if (!nameWithOwner) { - return { success: false, error: 'Repository name is required.' }; + const repository = + parseGitHubRepository(opts.repositoryUrl) ?? parseGitHubRepository(opts.remote); + if (!repository) { + return { success: false, error: 'Repository URL is required.' }; } - return searchIssues(nameWithOwner, opts.searchTerm, opts.limit ?? 20); + return searchIssues(repository, opts.searchTerm, opts.limit ?? 20); }, }; diff --git a/src/main/core/github/services/gh-cli-token.test.ts b/src/main/core/github/services/gh-cli-token.test.ts index 0eb890b934..d4a36a4345 100644 --- a/src/main/core/github/services/gh-cli-token.test.ts +++ b/src/main/core/github/services/gh-cli-token.test.ts @@ -1,45 +1,51 @@ -import { describe, expect, it } from 'vitest'; -import type { ExecFn } from '@main/core/utils/exec'; +import { describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { extractGhCliToken, isGhCliAuthenticated } from './gh-cli-token'; -function makeExec(responses: Record): ExecFn { - return async (command: string, args?: string[]) => { - const key = [command, ...(args || [])].join(' '); - const response = responses[key]; - if (!response) throw new Error(`Command not found: ${key}`); - return response; - }; +function makeCtx( + responses: Record, + throwAll?: Error +): IExecutionContext { + return { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn().mockImplementation(async (command: string, args: string[] = []) => { + if (throwAll) throw throwAll; + const key = [command, ...args].join(' '); + const response = responses[key]; + if (!response) throw new Error(`Command not found: ${key}`); + return response; + }), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext; } describe('isGhCliAuthenticated', () => { it('returns true when gh auth status succeeds', async () => { - const exec = makeExec({ 'gh auth status': { stdout: '', stderr: '' } }); - expect(await isGhCliAuthenticated(exec)).toBe(true); + const ctx = makeCtx({ 'gh auth status': { stdout: '', stderr: '' } }); + expect(await isGhCliAuthenticated(ctx)).toBe(true); }); it('returns false when gh auth status fails', async () => { - const exec: ExecFn = async () => { - throw new Error('not authenticated'); - }; - expect(await isGhCliAuthenticated(exec)).toBe(false); + const ctx = makeCtx({}, new Error('not authenticated')); + expect(await isGhCliAuthenticated(ctx)).toBe(false); }); }); describe('extractGhCliToken', () => { it('returns trimmed token from gh auth token', async () => { - const exec = makeExec({ 'gh auth token': { stdout: 'gho_abc123\n', stderr: '' } }); - expect(await extractGhCliToken(exec)).toBe('gho_abc123'); + const ctx = makeCtx({ 'gh auth token': { stdout: 'gho_abc123\n', stderr: '' } }); + expect(await extractGhCliToken(ctx)).toBe('gho_abc123'); }); it('returns null when gh auth token fails', async () => { - const exec: ExecFn = async () => { - throw new Error('no token'); - }; - expect(await extractGhCliToken(exec)).toBeNull(); + const ctx = makeCtx({}, new Error('no token')); + expect(await extractGhCliToken(ctx)).toBeNull(); }); it('returns null for empty stdout', async () => { - const exec = makeExec({ 'gh auth token': { stdout: '', stderr: '' } }); - expect(await extractGhCliToken(exec)).toBeNull(); + const ctx = makeCtx({ 'gh auth token': { stdout: '', stderr: '' } }); + expect(await extractGhCliToken(ctx)).toBeNull(); }); }); diff --git a/src/main/core/github/services/gh-cli-token.ts b/src/main/core/github/services/gh-cli-token.ts index f73033093f..2d808ac7ba 100644 --- a/src/main/core/github/services/gh-cli-token.ts +++ b/src/main/core/github/services/gh-cli-token.ts @@ -1,17 +1,17 @@ -import type { ExecFn } from '@main/core/utils/exec'; +import type { IExecutionContext } from '@main/core/execution-context/types'; -export async function isGhCliAuthenticated(exec: ExecFn): Promise { +export async function isGhCliAuthenticated(ctx: IExecutionContext): Promise { try { - await exec('gh', ['auth', 'status']); + await ctx.exec('gh', ['auth', 'status']); return true; } catch { return false; } } -export async function extractGhCliToken(exec: ExecFn): Promise { +export async function extractGhCliToken(ctx: IExecutionContext): Promise { try { - const { stdout } = await exec('gh', ['auth', 'token']); + const { stdout } = await ctx.exec('gh', ['auth', 'token']); const token = stdout.trim(); return token || null; } catch { diff --git a/src/main/core/github/services/github-connection-service.test.ts b/src/main/core/github/services/github-connection-service.test.ts new file mode 100644 index 0000000000..5ff3d77e4f --- /dev/null +++ b/src/main/core/github/services/github-connection-service.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GitHubConnectionServiceImpl } from './github-connection-service'; + +// --------------------------------------------------------------------------- +// Mock dependencies +// --------------------------------------------------------------------------- + +const mockGetSecret = vi.fn(); +const mockSetSecret = vi.fn(); +const mockDeleteSecret = vi.fn(); +vi.mock('@main/core/secrets/encrypted-app-secrets-store', () => ({ + encryptedAppSecretsStore: { + getSecret: (...args: unknown[]) => mockGetSecret(...args), + setSecret: (...args: unknown[]) => mockSetSecret(...args), + deleteSecret: (...args: unknown[]) => mockDeleteSecret(...args), + }, +})); + +const mockKvGet = vi.fn(); +const mockKvSet = vi.fn(); +const mockKvDel = vi.fn(); +vi.mock('@main/db/kv', () => ({ + KV: class { + get(...args: unknown[]) { + return mockKvGet(...args); + } + set(...args: unknown[]) { + return mockKvSet(...args); + } + del(...args: unknown[]) { + return mockKvDel(...args); + } + }, +})); + +const mockExtractGhCliToken = vi.fn(); +vi.mock('./gh-cli-token', () => ({ + extractGhCliToken: (...args: unknown[]) => mockExtractGhCliToken(...args), +})); + +vi.mock('@main/core/execution-context/local-execution-context', () => ({ + LocalExecutionContext: class { + root = undefined; + supportsLocalSpawn = false; + exec = vi.fn(); + execStreaming = vi.fn(); + dispose = vi.fn(); + }, +})); + +vi.mock('@main/lib/logger', () => ({ + log: { warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GitHubConnectionServiceImpl token caching', () => { + let service: GitHubConnectionServiceImpl; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + service = new GitHubConnectionServiceImpl(); + mockGetSecret.mockResolvedValue(null); + mockKvGet.mockResolvedValue(null); + mockKvSet.mockResolvedValue(undefined); + mockKvDel.mockResolvedValue(undefined); + mockSetSecret.mockResolvedValue(undefined); + mockDeleteSecret.mockResolvedValue(undefined); + mockExtractGhCliToken.mockResolvedValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns the token and hits the keychain only once while cache is warm', async () => { + mockGetSecret.mockResolvedValue('gho_token123'); + mockKvGet.mockResolvedValue('secure_storage'); + + const t1 = await service.getToken(); + const t2 = await service.getToken(); + + expect(t1).toBe('gho_token123'); + expect(t2).toBe('gho_token123'); + expect(mockGetSecret).toHaveBeenCalledTimes(1); + }); + + it('concurrent getToken() calls share one in-flight resolution', async () => { + let resolve!: (v: string) => void; + const pending = new Promise((res) => { + resolve = res; + }); + mockGetSecret.mockReturnValue(pending); + mockKvGet.mockResolvedValue('secure_storage'); + + const p1 = service.getToken(); + const p2 = service.getToken(); + resolve('gho_shared'); + + const [t1, t2] = await Promise.all([p1, p2]); + + expect(t1).toBe('gho_shared'); + expect(t2).toBe('gho_shared'); + expect(mockGetSecret).toHaveBeenCalledTimes(1); + }); + + it('storeToken() invalidates the cache so the next getToken() re-reads the keychain', async () => { + mockGetSecret.mockResolvedValueOnce('gho_old').mockResolvedValue('gho_new'); + mockKvGet.mockResolvedValue('secure_storage'); + + await service.getToken(); + await service.storeToken('gho_new'); + const token = await service.getToken(); + + expect(token).toBe('gho_new'); + expect(mockGetSecret).toHaveBeenCalledTimes(2); + }); + + it('logout() invalidates the cache so the next getToken() re-reads the keychain', async () => { + mockGetSecret.mockResolvedValueOnce('gho_token').mockResolvedValue(null); + mockKvGet.mockResolvedValue('secure_storage'); + + await service.getToken(); + await service.logout(); + const token = await service.getToken(); + + expect(token).toBeNull(); + expect(mockGetSecret).toHaveBeenCalledTimes(2); + }); + + it('re-resolves after the 5-minute TTL expires', async () => { + mockGetSecret.mockResolvedValue('gho_token'); + mockKvGet.mockResolvedValue('secure_storage'); + + await service.getToken(); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await service.getToken(); + + expect(mockGetSecret).toHaveBeenCalledTimes(2); + }); + + it('does not re-resolve before the TTL expires', async () => { + mockGetSecret.mockResolvedValue('gho_token'); + mockKvGet.mockResolvedValue('secure_storage'); + + await service.getToken(); + vi.advanceTimersByTime(5 * 60 * 1000 - 1); + await service.getToken(); + + expect(mockGetSecret).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/core/github/services/github-connection-service.ts b/src/main/core/github/services/github-connection-service.ts index 7f93138f50..2c3db5860e 100644 --- a/src/main/core/github/services/github-connection-service.ts +++ b/src/main/core/github/services/github-connection-service.ts @@ -7,9 +7,10 @@ import { githubAuthSuccessChannel, } from '@shared/events/githubEvents'; import type { GitHubConnectResponse, GitHubUser } from '@shared/github'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { encryptedAppSecretsStore } from '@main/core/secrets/encrypted-app-secrets-store'; import { executeOAuthFlow } from '@main/core/shared/oauth-flow'; -import { getLocalExec } from '@main/core/utils/exec'; +import { TTLCache } from '@main/core/utils/ttl-cache'; import { KV } from '@main/db/kv'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; @@ -71,6 +72,11 @@ const GITHUB_CONFIG = { export class GitHubConnectionServiceImpl implements GitHubConnectionService { private deviceFlowAbortController: AbortController | null = null; + private readonly _tokenRecordCache = new TTLCache<{ + token: string | null; + source: TokenSource; + }>(5 * 60 * 1000); + private parseTokenSource(raw: unknown): Exclude | null { return raw === 'cli' || raw === 'secure_storage' ? raw : null; } @@ -105,18 +111,27 @@ export class GitHubConnectionServiceImpl implements GitHubConnectionService { } private async clearStoredToken(): Promise { + this._invalidateTokenCache(); await Promise.all([ encryptedAppSecretsStore.deleteSecret(GITHUB_TOKEN_SECRET_KEY), this.clearStoredTokenSource(), ]); } - private async resolveTokenRecord(): Promise<{ token: string | null; source: TokenSource }> { + private _invalidateTokenCache(): void { + this._tokenRecordCache.invalidate(); + } + + private resolveTokenRecord(): Promise<{ token: string | null; source: TokenSource }> { + return this._tokenRecordCache.get(() => this._doResolveTokenRecord()); + } + + private async _doResolveTokenRecord(): Promise<{ token: string | null; source: TokenSource }> { const { token: storedToken, source } = await this.getStoredTokenRecord(); - const exec = getLocalExec(); + const ctx = new LocalExecutionContext(); if (storedToken && source === 'cli') { - const cliToken = await extractGhCliToken(exec); + const cliToken = await extractGhCliToken(ctx); if (!cliToken) { try { await this.clearStoredToken(); @@ -140,7 +155,7 @@ export class GitHubConnectionServiceImpl implements GitHubConnectionService { return { token: storedToken, source: source ?? 'secure_storage' }; } - const cliToken = await extractGhCliToken(exec); + const cliToken = await extractGhCliToken(ctx); if (!cliToken) return { token: null, source: null }; try { @@ -294,6 +309,7 @@ export class GitHubConnectionServiceImpl implements GitHubConnectionService { token: string, source: Exclude = 'secure_storage' ): Promise { + this._invalidateTokenCache(); await encryptedAppSecretsStore.setSecret(GITHUB_TOKEN_SECRET_KEY, token); await this.setStoredTokenSource(source); } diff --git a/src/main/core/github/services/issue-service.test.ts b/src/main/core/github/services/issue-service.test.ts index c461ab0f3f..96a25afaf6 100644 --- a/src/main/core/github/services/issue-service.test.ts +++ b/src/main/core/github/services/issue-service.test.ts @@ -57,6 +57,13 @@ const expectedIssue = { labels: [{ name: 'bug', color: 'fc2929' }], }; +const repository = { + owner: 'owner', + repo: 'repo', + nameWithOwner: 'owner/repo', + repositoryUrl: 'https://github.com/owner/repo', +}; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -67,7 +74,7 @@ describe('GitHubIssueServiceImpl', () => { const listForRepo = vi.fn().mockResolvedValue({ data: [restIssue] }); mockGetOctokit.mockResolvedValue(makeOctokit({ listForRepo })); - const result = await issueService.listIssues('owner/repo', 30); + const result = await issueService.listIssues(repository, 30); expect(listForRepo).toHaveBeenCalledWith({ owner: 'owner', @@ -85,7 +92,7 @@ describe('GitHubIssueServiceImpl', () => { const listForRepo = vi.fn().mockResolvedValue({ data: [restIssue, pr] }); mockGetOctokit.mockResolvedValue(makeOctokit({ listForRepo })); - const result = await issueService.listIssues('owner/repo'); + const result = await issueService.listIssues(repository); expect(result).toHaveLength(1); expect(result[0].number).toBe(1); @@ -95,18 +102,18 @@ describe('GitHubIssueServiceImpl', () => { const listForRepo = vi.fn().mockRejectedValue(new Error('Network error')); mockGetOctokit.mockResolvedValue(makeOctokit({ listForRepo })); - expect(await issueService.listIssues('owner/repo')).toEqual([]); + expect(await issueService.listIssues(repository)).toEqual([]); }); it('clamps limit to 1-100', async () => { const listForRepo = vi.fn().mockResolvedValue({ data: [] }); mockGetOctokit.mockResolvedValue(makeOctokit({ listForRepo })); - await issueService.listIssues('owner/repo', 0); + await issueService.listIssues(repository, 0); expect(listForRepo).toHaveBeenCalledWith(expect.objectContaining({ per_page: 1 })); listForRepo.mockClear(); - await issueService.listIssues('owner/repo', 999); + await issueService.listIssues(repository, 999); expect(listForRepo).toHaveBeenCalledWith(expect.objectContaining({ per_page: 100 })); }); }); @@ -116,7 +123,7 @@ describe('GitHubIssueServiceImpl', () => { const issuesAndPullRequests = vi.fn().mockResolvedValue({ data: { items: [restIssue] } }); mockGetOctokit.mockResolvedValue(makeOctokit({ issuesAndPullRequests })); - const result = await issueService.searchIssues('owner/repo', 'bug fix', 15); + const result = await issueService.searchIssues(repository, 'bug fix', 15); expect(issuesAndPullRequests).toHaveBeenCalledWith({ q: 'bug fix repo:owner/repo is:issue is:open', @@ -131,8 +138,8 @@ describe('GitHubIssueServiceImpl', () => { const issuesAndPullRequests = vi.fn(); mockGetOctokit.mockResolvedValue(makeOctokit({ issuesAndPullRequests })); - expect(await issueService.searchIssues('owner/repo', ' ')).toEqual([]); - expect(await issueService.searchIssues('owner/repo', '')).toEqual([]); + expect(await issueService.searchIssues(repository, ' ')).toEqual([]); + expect(await issueService.searchIssues(repository, '')).toEqual([]); expect(issuesAndPullRequests).not.toHaveBeenCalled(); }); @@ -140,7 +147,7 @@ describe('GitHubIssueServiceImpl', () => { const issuesAndPullRequests = vi.fn().mockRejectedValue(new Error('API error')); mockGetOctokit.mockResolvedValue(makeOctokit({ issuesAndPullRequests })); - expect(await issueService.searchIssues('owner/repo', 'query')).toEqual([]); + expect(await issueService.searchIssues(repository, 'query')).toEqual([]); }); }); @@ -149,7 +156,7 @@ describe('GitHubIssueServiceImpl', () => { const issuesGet = vi.fn().mockResolvedValue({ data: { ...restIssue, body: 'Issue body' } }); mockGetOctokit.mockResolvedValue(makeOctokit({ issuesGet })); - const result = await issueService.getIssue('owner/repo', 42); + const result = await issueService.getIssue(repository, 42); expect(issuesGet).toHaveBeenCalledWith({ owner: 'owner', @@ -163,7 +170,7 @@ describe('GitHubIssueServiceImpl', () => { const issuesGet = vi.fn().mockRejectedValue(new Error('Not found')); mockGetOctokit.mockResolvedValue(makeOctokit({ issuesGet })); - expect(await issueService.getIssue('owner/repo', 99)).toBeNull(); + expect(await issueService.getIssue(repository, 99)).toBeNull(); }); }); }); diff --git a/src/main/core/github/services/issue-service.ts b/src/main/core/github/services/issue-service.ts index 250770cfbe..4fe0826e5c 100644 --- a/src/main/core/github/services/issue-service.ts +++ b/src/main/core/github/services/issue-service.ts @@ -1,6 +1,6 @@ import type { Octokit } from '@octokit/rest'; +import type { GitHubRepositoryRef } from '@shared/github-repository'; import { getOctokit } from './octokit-provider'; -import { splitRepo } from './utils'; // --------------------------------------------------------------------------- // Types @@ -24,9 +24,13 @@ export interface GitHubIssueDetail extends GitHubIssue { } export interface GitHubIssueService { - listIssues(nameWithOwner: string, limit?: number): Promise; - searchIssues(nameWithOwner: string, searchTerm: string, limit?: number): Promise; - getIssue(nameWithOwner: string, issueNumber: number): Promise; + listIssues(repository: GitHubRepositoryRef, limit?: number): Promise; + searchIssues( + repository: GitHubRepositoryRef, + searchTerm: string, + limit?: number + ): Promise; + getIssue(repository: GitHubRepositoryRef, issueNumber: number): Promise; } // --------------------------------------------------------------------------- @@ -55,8 +59,8 @@ interface RestIssue { export class GitHubIssueServiceImpl implements GitHubIssueService { constructor(private readonly getOctokit: () => Promise) {} - async listIssues(nameWithOwner: string, limit: number = 50): Promise { - const { owner, repo } = splitRepo(nameWithOwner); + async listIssues(repository: GitHubRepositoryRef, limit: number = 50): Promise { + const { owner, repo } = repository; try { const octokit = await this.getOctokit(); const { data } = await octokit.rest.issues.listForRepo({ @@ -76,13 +80,13 @@ export class GitHubIssueServiceImpl implements GitHubIssueService { } async searchIssues( - nameWithOwner: string, + repository: GitHubRepositoryRef, searchTerm: string, limit: number = 20 ): Promise { const term = searchTerm.trim(); if (!term) return []; - const { owner, repo } = splitRepo(nameWithOwner); + const { owner, repo } = repository; try { const octokit = await this.getOctokit(); const { data } = await octokit.rest.search.issuesAndPullRequests({ @@ -97,8 +101,11 @@ export class GitHubIssueServiceImpl implements GitHubIssueService { } } - async getIssue(nameWithOwner: string, issueNumber: number): Promise { - const { owner, repo } = splitRepo(nameWithOwner); + async getIssue( + repository: GitHubRepositoryRef, + issueNumber: number + ): Promise { + const { owner, repo } = repository; try { const octokit = await this.getOctokit(); const { data } = await octokit.rest.issues.get({ diff --git a/src/main/core/github/services/utils.ts b/src/main/core/github/services/utils.ts deleted file mode 100644 index e3f880f829..0000000000 --- a/src/main/core/github/services/utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Normalise a git remote URL to a canonical HTTPS form without a `.git` suffix. - * Used as the stable identifier stored in `repository_url` / `head_repository_url`. - * Returns the original URL unchanged if it is not a recognisable GitHub remote. - */ -export function normalizeGitHubUrl(remoteUrl: string): string { - const nwo = parseNameWithOwner(remoteUrl); - if (!nwo) return remoteUrl; - return `https://github.com/${nwo}`; -} - -/** Returns true when the URL points to a GitHub host (github.com). */ -export function isGitHubUrl(remoteUrl: string): boolean { - return parseNameWithOwner(remoteUrl) !== null; -} - -/** - * Extract owner/repo from a normalised GitHub URL. - * Works for `https://github.com/owner/repo` (no .git). - */ -export function splitNormalizedUrl(repositoryUrl: string): { owner: string; repo: string } { - const match = /github\.com\/([^/]+)\/([^/?#]+)/.exec(repositoryUrl); - if (!match) throw new Error(`Not a GitHub URL: "${repositoryUrl}"`); - return { owner: match[1], repo: match[2] }; -} - -export function splitRepo(nameWithOwner: string): { owner: string; repo: string } { - const idx = nameWithOwner.indexOf('/'); - if (idx === -1) { - throw new Error(`Invalid nameWithOwner: "${nameWithOwner}" (expected "owner/repo")`); - } - return { owner: nameWithOwner.slice(0, idx), repo: nameWithOwner.slice(idx + 1) }; -} - -/** - * Extract a GitHub `owner/repo` string from a git remote URL. - * Handles both HTTPS (`https://github.com/owner/repo.git`) and - * SSH (`git@github.com:owner/repo.git`) formats. - * Returns `null` if the URL is not a recognisable GitHub remote. - */ -export function parseNameWithOwner(remoteUrl: string): string | null { - // https://github.com/owner/repo[.git][/?#...] - const https = /github\.com\/([^/]+\/[^/?#]+?)(?:\.git)?(?:[/?#]|$)/.exec(remoteUrl); - if (https) return https[1]; - // git@github.com:owner/repo[.git] - const ssh = /github\.com:([^/]+\/[^/?#]+?)(?:\.git)?$/.exec(remoteUrl); - if (ssh) return ssh[1]; - return null; -} diff --git a/src/main/core/issues/controller.ts b/src/main/core/issues/controller.ts index 5ccb86f1c0..c5ce583722 100644 --- a/src/main/core/issues/controller.ts +++ b/src/main/core/issues/controller.ts @@ -10,7 +10,7 @@ import { getAllIssueProviders, getIssueProvider } from './registry'; const DEFAULT_CAPABILITIES = { requiresProjectPath: false, - requiresNameWithOwner: false, + requiresRepositoryUrl: false, } as const; const CONNECTION_CHECK_TIMEOUT_MS = 8_000; diff --git a/src/main/core/issues/helpers/provider-inputs.test.ts b/src/main/core/issues/helpers/provider-inputs.test.ts index db6db1d4b3..aa2851f66d 100644 --- a/src/main/core/issues/helpers/provider-inputs.test.ts +++ b/src/main/core/issues/helpers/provider-inputs.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - clampIssueLimit, - normalizeSearchTerm, - requireNameWithOwner, - requireProjectPath, -} from './provider-inputs'; +import { clampIssueLimit, normalizeSearchTerm, requireProjectPath } from './provider-inputs'; describe('clampIssueLimit', () => { it('uses fallback and clamps bounds', () => { @@ -21,13 +16,6 @@ describe('requireProjectPath', () => { }); }); -describe('requireNameWithOwner', () => { - it('returns trimmed repository name', () => { - expect(requireNameWithOwner(' owner/repo ')).toBe('owner/repo'); - expect(requireNameWithOwner('')).toBeNull(); - }); -}); - describe('normalizeSearchTerm', () => { it('trims and normalizes values', () => { expect(normalizeSearchTerm(' abc ')).toBe('abc'); diff --git a/src/main/core/issues/helpers/provider-inputs.ts b/src/main/core/issues/helpers/provider-inputs.ts index 50baa7e42a..de5acef910 100644 --- a/src/main/core/issues/helpers/provider-inputs.ts +++ b/src/main/core/issues/helpers/provider-inputs.ts @@ -8,11 +8,6 @@ export function requireProjectPath(projectPath?: string): string | null { return trimmed ? trimmed : null; } -export function requireNameWithOwner(nameWithOwner?: string): string | null { - const trimmed = nameWithOwner?.trim(); - return trimmed ? trimmed : null; -} - export function normalizeSearchTerm(searchTerm: string): string { return String(searchTerm || '').trim(); } diff --git a/src/main/core/issues/issue-provider.ts b/src/main/core/issues/issue-provider.ts index 474242ec56..de73d1d630 100644 --- a/src/main/core/issues/issue-provider.ts +++ b/src/main/core/issues/issue-provider.ts @@ -10,7 +10,7 @@ export type IssueQueryOpts = { projectId?: string; projectPath?: string; remote?: string; - nameWithOwner?: string; + repositoryUrl?: string; }; export type IssueSearchOpts = IssueQueryOpts & { diff --git a/src/main/core/jira/jira-connection-service.ts b/src/main/core/jira/jira-connection-service.ts index a5c9c7ba10..81c32eae14 100644 --- a/src/main/core/jira/jira-connection-service.ts +++ b/src/main/core/jira/jira-connection-service.ts @@ -3,7 +3,7 @@ import { URL } from 'node:url'; import { ISSUE_PROVIDER_CAPABILITIES, type ConnectionStatus } from '@shared/issue-providers'; import { encryptedAppSecretsStore } from '@main/core/secrets/encrypted-app-secrets-store'; import { KV } from '@main/db/kv'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; type JiraCreds = { siteUrl: string; email: string }; @@ -36,7 +36,7 @@ export class JiraConnectionService { const me = await this.getMyself(siteUrl, email, token); await encryptedAppSecretsStore.setSecret(this.JIRA_TOKEN_SECRET_KEY, token); await this.writeCreds({ siteUrl, email }); - capture('integration_connected', { provider: 'jira' }); + telemetryService.capture('integration_connected', { provider: 'jira' }); return { success: true, displayName: me?.displayName }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -51,7 +51,7 @@ export class JiraConnectionService { try { await jiraKV.del('creds'); } catch {} - capture('integration_disconnected', { provider: 'jira' }); + telemetryService.capture('integration_disconnected', { provider: 'jira' }); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; diff --git a/src/main/core/linear/linear-connection-service.ts b/src/main/core/linear/linear-connection-service.ts index 93a779d717..b78a8eeda6 100644 --- a/src/main/core/linear/linear-connection-service.ts +++ b/src/main/core/linear/linear-connection-service.ts @@ -2,7 +2,7 @@ import { LinearClient } from '@linear/sdk'; import { ISSUE_PROVIDER_CAPABILITIES, type ConnectionStatus } from '@shared/issue-providers'; import { encryptedAppSecretsStore } from '@main/core/secrets/encrypted-app-secrets-store'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export class LinearConnectionService { private readonly LINEAR_TOKEN_SECRET_KEY = 'emdash-linear-token'; @@ -25,7 +25,7 @@ export class LinearConnectionService { const org = await viewer.organization; await this.storeToken(clean); - capture('integration_connected', { provider: 'linear' }); + telemetryService.capture('integration_connected', { provider: 'linear' }); return { success: true, @@ -46,7 +46,7 @@ export class LinearConnectionService { this.cachedToken = null; this.client = null; this.clientToken = null; - capture('integration_disconnected', { provider: 'linear' }); + telemetryService.capture('integration_disconnected', { provider: 'linear' }); return { success: true }; } catch (error) { log.error('Failed to clear Linear token:', error); diff --git a/src/main/core/projects/create-project-provider.ts b/src/main/core/projects/create-project-provider.ts new file mode 100644 index 0000000000..db1c8675d1 --- /dev/null +++ b/src/main/core/projects/create-project-provider.ts @@ -0,0 +1,171 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { bareRefName } from '@shared/git-utils'; +import { safePathSegment } from '@shared/path-name'; +import type { LocalProject, SshProject } from '@shared/projects'; +import { GitHubAuthExecutionContext } from '@main/core/execution-context/github-auth-execution-context'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; +import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { FileSystemProvider } from '@main/core/fs/types'; +import { GitFetchService } from '@main/core/git/git-fetch-service'; +import { GitService } from '@main/core/git/impl/git-service'; +import { GitRepositoryService } from '@main/core/git/repository-service'; +import { githubConnectionService } from '@main/core/github/services/github-connection-service'; +import { + sshConnectionManager, + type SshConnectionEvent, +} from '@main/core/ssh/ssh-connection-manager'; +import { log } from '@main/lib/logger'; +import { ProjectProvider, type ProjectProviderTransport } from './project-provider'; +import { + LocalProjectSettingsProvider, + SshProjectSettingsProvider, +} from './settings/project-settings'; +import type { ProjectSettingsProvider } from './settings/schema'; +import { LocalWorktreeHost } from './worktrees/hosts/local-worktree-host'; +import { SshWorktreeHost } from './worktrees/hosts/ssh-worktree-host'; +import type { WorktreeHost } from './worktrees/hosts/worktree-host'; +import { WorktreeService } from './worktrees/worktree-service'; + +const hasGitHubToken = async (): Promise => + (await githubConnectionService.getToken()) !== null; + +export async function createProvider(project: LocalProject | SshProject): Promise { + if (project.type === 'ssh') { + return createSshProvider(project); + } + return createLocalProvider(project); +} + +async function createLocalProvider(project: LocalProject): Promise { + const localFs = new LocalFileSystem(project.path); + const baseCtx = new LocalExecutionContext({ root: project.path }); + const authCtx = new GitHubAuthExecutionContext(baseCtx, () => githubConnectionService.getToken()); + const ctx = baseCtx; + + const settings = new LocalProjectSettingsProvider(project.path, bareRefName(project.baseRef)); + const worktreeDirectory = await settings.getWorktreeDirectory(); + await fs.promises.mkdir(worktreeDirectory, { recursive: true }); + const worktreePoolPath = path.join(worktreeDirectory, safePathSegment(project.name, project.id)); + const worktreeHost = await LocalWorktreeHost.create({ + allowedRoots: [project.path, worktreeDirectory], + }); + + return buildProvider( + project.id, + project.path, + { kind: 'local', defaultWorkspaceType: { kind: 'local' }, ctx, authCtx }, + localFs, + settings, + worktreeHost, + worktreePoolPath, + () => {} + ); +} + +async function createSshProvider(project: SshProject): Promise { + try { + const proxy = await sshConnectionManager.connect(project.connectionId); + const rootFs = new SshFileSystem(proxy, '/'); + const projectFs = new SshFileSystem(proxy, project.path); + + const baseCtx = new SshExecutionContext(proxy, { root: project.path }); + const authCtx = new GitHubAuthExecutionContext(baseCtx, () => + githubConnectionService.getToken() + ); + const ctx = baseCtx; + + const settings = new SshProjectSettingsProvider( + projectFs, + bareRefName(project.baseRef), + rootFs, + project.path, + baseCtx + ); + const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); + const worktreeHost = new SshWorktreeHost(rootFs); + await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); + + const dispose = () => sshConnectionManager.off('connection-event', handler); + + const provider = buildProvider( + project.id, + project.path, + { + kind: 'ssh', + defaultWorkspaceType: { kind: 'ssh', proxy, connectionId: project.connectionId }, + ctx, + authCtx, + }, + projectFs, + settings, + worktreeHost, + worktreePoolPath, + dispose + ); + + // Wire reconnect handler after provider is built so gitFetchService is available. + const handler = (evt: SshConnectionEvent) => { + if (evt.type === 'reconnected' && evt.connectionId === project.connectionId) { + void provider.gitFetchService.fetch(); + } + }; + sshConnectionManager.on('connection-event', handler); + + return provider; + } catch (error) { + log.warn('createSshProvider: SSH connection failed', { + projectId: project.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +function buildProvider( + projectId: string, + repoPath: string, + transportMeta: Pick< + ProjectProviderTransport, + 'kind' | 'defaultWorkspaceType' | 'ctx' | 'authCtx' + >, + projectFs: FileSystemProvider, + settings: ProjectSettingsProvider, + worktreeHost: WorktreeHost, + worktreePoolPath: string, + dispose: () => void +): ProjectProvider { + const { ctx, authCtx } = transportMeta; + + const transport: ProjectProviderTransport = { + ...transportMeta, + fs: projectFs, + settings, + worktreeHost, + worktreePoolPath, + }; + + const repoGit = new GitService(ctx, authCtx, projectFs); + const repository = new GitRepositoryService(repoGit, settings); + const worktreeService = new WorktreeService({ + worktreePoolPath, + repoPath, + projectSettings: settings, + ctx, + host: worktreeHost, + }); + const gitFetchService = new GitFetchService(repoGit, hasGitHubToken); + gitFetchService.start(); + + return new ProjectProvider( + projectId, + repoPath, + transport, + repository, + worktreeService, + gitFetchService, + dispose + ); +} diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts deleted file mode 100644 index cbf4da7651..0000000000 --- a/src/main/core/projects/impl/local-project-provider.ts +++ /dev/null @@ -1,520 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { Conversation } from '@shared/conversations'; -import { gitRefChangedChannel } from '@shared/events/gitEvents'; -import type { FetchError } from '@shared/git'; -import { bareRefName } from '@shared/git-utils'; -import { LocalProject } from '@shared/projects'; -import { makePtySessionId } from '@shared/ptySessionId'; -import { err, ok, type Result } from '@shared/result'; -import { getTaskEnvVars } from '@shared/task/envVars'; -import { Task, type TaskBootstrapStatus } from '@shared/tasks'; -import { type Terminal } from '@shared/terminals'; -import { workspaceKey } from '@shared/workspace-key'; -import { LocalConversationProvider } from '@main/core/conversations/impl/local-conversation'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; -import { GitFetchService } from '@main/core/git/git-fetch-service'; -import { GitWatcherService } from '@main/core/git/git-watcher-service'; -import { GitService } from '@main/core/git/impl/git-service'; -import { GitRepositoryService } from '@main/core/git/repository-service'; -import { githubConnectionService } from '@main/core/github/services/github-connection-service'; -import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; -import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; -import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; -import type { Workspace } from '@main/core/workspaces/workspace'; -import { WorkspaceLifecycleService } from '@main/core/workspaces/workspace-lifecycle-service'; -import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { events } from '@main/lib/events'; -import { log } from '@main/lib/logger'; -import { - type ProjectProvider, - type ProjectRemoteState, - type ProvisionTaskError, - type TaskProvider, - type TeardownTaskError, -} from '../project-provider'; -import { - formatProvisionTaskError, - isProvisionTaskError, - mapWorktreeErrorToProvisionError, -} from '../provision-task-error'; -import { LocalProjectSettingsProvider } from '../settings/project-settings'; -import type { ProjectSettingsProvider } from '../settings/schema'; -import { getEffectiveTaskSettings } from '../settings/task-settings'; -import { TimeoutSignal, withTimeout } from '../utils'; -import { WorktreeService } from '../worktrees/worktree-service'; - -const TASK_TIMEOUT_MS = 60_000; -const TEARDOWN_SCRIPT_WAIT_MS = 10_000; - -function toProvisionError(e: unknown): ProvisionTaskError { - if (isProvisionTaskError(e)) return e; - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - -function toTeardownError(e: unknown): TeardownTaskError { - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - -export async function createLocalProvider( - project: LocalProject, - rootFs: FileSystemProvider -): Promise { - const settings = new LocalProjectSettingsProvider( - project.path, - bareRefName(project.baseRef), - rootFs - ); - const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); - - await fs.promises.mkdir(worktreePoolPath, { recursive: true }); - - return new LocalProjectProvider(project, rootFs, { settings, worktreePoolPath }); -} - -export class LocalProjectProvider implements ProjectProvider { - readonly type = 'local'; - readonly settings: ProjectSettingsProvider; - readonly repository: GitRepositoryService; - readonly fs: FileSystemProvider; - - private tasks = new Map(); - private provisioningTasks = new Map>>(); - private tearingDownTasks = new Map>>(); - private bootstrapErrors = new Map(); - private worktreeService: WorktreeService; - private workspaceRegistry = new WorkspaceRegistry(); - private readonly localExec = getLocalExec(); - private readonly _gitWatcher: GitWatcherService; - private readonly _gitFetchService: GitFetchService; - private _configChangeUnsubscribe: (() => void) | undefined; - - constructor( - private readonly project: LocalProject, - readonly rootFs: FileSystemProvider, - options: { - settings: ProjectSettingsProvider; - worktreePoolPath: string; - } - ) { - this.settings = options.settings; - this.fs = new LocalFileSystem(project.path); - const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); - const repoGit = new GitService(project.path, gitExec, this.fs); - this.repository = new GitRepositoryService(repoGit, this.settings); - this.worktreeService = new WorktreeService({ - worktreePoolPath: options.worktreePoolPath, - repoPath: project.path, - projectSettings: this.settings, - exec: gitExec, - rootFs: rootFs, - }); - this._gitWatcher = new GitWatcherService(project.id, project.path); - void this._gitWatcher.start(); - - this._gitFetchService = new GitFetchService(repoGit); - this._gitFetchService.start(); - - // Re-sync remotes whenever .git/config changes (remote added/removed/changed) - this._configChangeUnsubscribe = events.on(gitRefChangedChannel, (p) => { - if (p.projectId === project.id && p.kind === 'config') { - void prSyncScheduler.onRemoteChanged(project.id); - } - }); - } - - async provisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise> { - const existing = this.tasks.get(task.id); - if (existing) return ok(existing); - if (this.provisioningTasks.has(task.id)) return this.provisioningTasks.get(task.id)!; - - const promise = withTimeout( - this.doProvisionTask(task, conversations, terminals), - TASK_TIMEOUT_MS - ) - .then((taskEnv) => { - this.tasks.set(task.id, taskEnv); - this.provisioningTasks.delete(task.id); - return ok(taskEnv); - }) - .catch((e) => { - const provisionError = toProvisionError(e); - this.bootstrapErrors.set(task.id, provisionError); - this.provisioningTasks.delete(task.id); - log.error('LocalProjectProvider: failed to provision task', { - taskId: task.id, - error: String(e), - }); - return err(provisionError); - }); - - this.provisioningTasks.set(task.id, promise); - return promise; - } - - private async doProvisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise { - log.debug('LocalProjectProvider: doProvisionTask START', { - taskId: task.id, - }); - - // Refresh remote-tracking refs in the background so they are as fresh as - // possible during the lifetime of this task. Non-blocking — provision - // continues without waiting for the network round-trip. - void this._gitFetchService.fetch(); - - // Sync PRs for this task's branch in the background. - void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); - - const workspaceId = workspaceKey(task.taskBranch); - const workspace = await this.workspaceRegistry.acquire(workspaceId, async () => { - const workDir = await this.resolveTaskWorkDir(task); - const exec = getGitLocalExec(() => githubConnectionService.getToken()); - const workspaceFs = new LocalFileSystem(workDir); - - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const bootstrapTaskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workDir, - projectPath: this.project.path, - defaultBranch, - portSeed: workDir, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspaceFs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - const scripts = taskLevelSettings.scripts; - - const workspaceTerminals = new LocalTerminalProvider({ - projectId: this.project.id, - scopeId: workspaceId, - taskPath: workDir, - tmux: tmuxEnabled, - shellSetup, - exec, - taskEnvVars: bootstrapTaskEnvVars, - }); - const lifecycleService = new WorkspaceLifecycleService({ - projectId: this.project.id, - workspaceId, - terminals: workspaceTerminals, - }); - - const createdWorkspace: Workspace = { - id: workspaceId, - path: workDir, - fs: workspaceFs, - git: new GitService(workDir, exec, workspaceFs), - settings: this.settings, - lifecycleService, - }; - - if (scripts?.setup) { - void lifecycleService.prepareAndRunLifecycleScript({ - type: 'setup', - script: scripts.setup, - }); - } - - if (scripts?.run) { - void lifecycleService.prepareLifecycleScript({ - type: 'run', - script: scripts.run, - }); - } - - if (scripts?.teardown) { - void lifecycleService.prepareLifecycleScript({ - type: 'teardown', - script: scripts.teardown, - }); - } - - return createdWorkspace; - }); - - // Register the workspace with the git watcher so that index/HEAD changes - // in its worktree git dir are emitted as granular workspace events. - const mainDotGitAbs = path.resolve(this.project.path, '.git'); - const relativeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); - this._gitWatcher.registerWorktree(workspaceId, relativeGitDir); - - let provisionSucceeded = false; - try { - const exec = getGitLocalExec(() => githubConnectionService.getToken()); - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const taskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workspace.path, - projectPath: this.project.path, - defaultBranch, - portSeed: workspace.path, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - - const conversationProvider = new LocalConversationProvider({ - projectId: this.project.id, - taskPath: workspace.path, - taskId: task.id, - tmux: tmuxEnabled, - shellSetup, - exec, - taskEnvVars, - }); - - const terminalProvider = new LocalTerminalProvider({ - projectId: this.project.id, - scopeId: task.id, - taskPath: workspace.path, - tmux: tmuxEnabled, - shellSetup, - exec, - taskEnvVars, - }); - - const taskEnv: TaskProvider = { - taskId: task.id, - taskBranch: task.taskBranch, - sourceBranch: task.sourceBranch, - taskEnvVars, - conversations: conversationProvider, - terminals: terminalProvider, - }; - - Promise.all( - terminals.map((term) => - terminalProvider.spawnTerminal(term).catch((e) => { - log.error('LocalEnvironmentProvider: failed to hydrate terminal', { - terminalId: term.id, - error: String(e), - }); - }) - ) - ); - - Promise.all( - conversations.map((conv) => - conversationProvider.startSession(conv, undefined, true).catch((e) => { - log.error('LocalEnvironmentProvider: failed to hydrate conversation', { - conversationId: conv.id, - error: String(e), - }); - }) - ) - ); - - log.debug('LocalProjectProvider: doProvisionTask DONE', { - taskId: task.id, - }); - provisionSucceeded = true; - return taskEnv; - } finally { - if (!provisionSucceeded) { - await this.workspaceRegistry.release(workspace.id).catch(() => {}); - } - } - } - - getTask(taskId: string): TaskProvider | undefined { - return this.tasks.get(taskId); - } - - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { - if (this.tasks.has(taskId)) return { status: 'ready' }; - if (this.provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; - const bootstrapError = this.bootstrapErrors.get(taskId); - if (bootstrapError) - return { status: 'error', message: formatProvisionTaskError(bootstrapError) }; - return { status: 'not-started' }; - } - - async teardownTask(taskId: string): Promise> { - if (this.tearingDownTasks.has(taskId)) return this.tearingDownTasks.get(taskId)!; - const task = this.tasks.get(taskId); - if (!task) { - await this.cleanupDetachedTmuxSessions(taskId); - return ok(); - } - - const promise = withTimeout(this.doTeardownTask(task), TASK_TIMEOUT_MS) - .then(() => ok()) - .catch(async (e) => { - log.error('LocalProjectProvider: failed to teardown task', { - taskId, - error: String(e), - }); - await this.cleanupDetachedTmuxSessions(taskId).catch((cleanupError) => { - log.warn('LocalProjectProvider: fallback tmux cleanup failed', { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - }) - .finally(() => { - this.tasks.delete(taskId); - this.tearingDownTasks.delete(taskId); - }); - - this.tearingDownTasks.set(taskId, promise); - return promise; - } - - getWorkspace( - workspaceId: string - ): import('@main/core/workspaces/workspace').Workspace | undefined { - return this.workspaceRegistry.get(workspaceId); - } - - private async doTeardownTask(task: TaskProvider): Promise { - const wsId = workspaceKey(task.taskBranch); - const workspace = this.workspaceRegistry.get(wsId); - - if (workspace) { - const settings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const scripts = settings.scripts; - - if (scripts?.teardown && this.workspaceRegistry.refCount(wsId) === 1) { - try { - const runTeardown = workspace.lifecycleService.runLifecycleScript( - { type: 'teardown', script: scripts.teardown }, - { waitForExit: true, exit: true } - ); - await withTimeout(runTeardown, TEARDOWN_SCRIPT_WAIT_MS); - } catch (error) { - if (error instanceof TimeoutSignal) { - log.debug('LocalProjectProvider: teardown script wait timed out', { - taskId: task.taskId, - timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, - }); - } else { - log.warn('LocalProjectProvider: teardown script failed (continuing cleanup)', { - taskId: task.taskId, - error: String(error), - }); - } - } - } - } - - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - if (this.workspaceRegistry.refCount(wsId) <= 1) { - this._gitWatcher.unregisterWorktree(wsId); - } - await this.workspaceRegistry.release(wsId); - } - - private async cleanupDetachedTmuxSessions(taskId: string): Promise { - const { conversationIds, terminalIds } = await getTaskSessionLeafIds(this.project.id, taskId); - const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => - makePtySessionId(this.project.id, taskId, leafId) - ); - await Promise.all( - sessionIds.map((sessionId) => killTmuxSession(this.localExec, makeTmuxSessionName(sessionId))) - ); - } - - async getWorktreeForBranch(branchName: string): Promise { - return this.worktreeService.getWorktree(branchName); - } - - async removeTaskWorktree(taskBranch: string): Promise { - const worktreePath = await this.worktreeService.getWorktree(taskBranch); - if (worktreePath) { - await this.worktreeService.removeWorktree(worktreePath); - } - } - - async fetch(): Promise> { - return this._gitFetchService.fetch(); - } - - async cleanup(): Promise { - this._configChangeUnsubscribe?.(); - this._gitFetchService.stop(); - await this._gitWatcher.stop(); - - const settings = await this.settings.get(); - - if (settings.tmux) { - await Promise.all( - Array.from(this.tasks.values()).map((task) => - Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) - ) - ); - this.tasks.clear(); - await this.workspaceRegistry.releaseAll(); - } else { - await Promise.all(Array.from(this.tasks.keys()).map((id) => this.teardownTask(id))); - await this.workspaceRegistry.releaseAll(); - } - } - - private async resolveTaskWorkDir(task: Task): Promise { - if (!task.taskBranch) { - return this.project.path; - } - - const existing = await this.worktreeService.getWorktree(task.taskBranch); - if (existing) { - return existing; - } - - if (!task.sourceBranch || task.taskBranch === task.sourceBranch.branch) { - const result = await this.worktreeService.checkoutExistingBranch(task.taskBranch); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } - - const result = await this.worktreeService.checkoutBranchWorktree( - task.sourceBranch, - task.taskBranch - ); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } - - async getRemoteState(): Promise { - try { - const remotes = await this.repository.getRemotes(); - const remoteName = await this.repository.getConfiguredRemote(); - const remoteUrl = remotes.find((r) => r.name === remoteName)?.url; - return { hasRemote: remotes.length > 0, selectedRemoteUrl: remoteUrl ?? null }; - } catch { - return { hasRemote: false, selectedRemoteUrl: null }; - } - } -} diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts deleted file mode 100644 index 0ec6bcf386..0000000000 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ /dev/null @@ -1,595 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import path from 'node:path'; -import type { SFTPWrapper } from 'ssh2'; -import { Conversation } from '@shared/conversations'; -import type { FetchError } from '@shared/git'; -import { bareRefName } from '@shared/git-utils'; -import type { SshProject } from '@shared/projects'; -import { makePtySessionId } from '@shared/ptySessionId'; -import { err, ok, type Result } from '@shared/result'; -import { getTaskEnvVars } from '@shared/task/envVars'; -import { Task, type TaskBootstrapStatus } from '@shared/tasks'; -import { Terminal } from '@shared/terminals'; -import { workspaceKey } from '@shared/workspace-key'; -import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; -import { GitFetchService } from '@main/core/git/git-fetch-service'; -import { GitService } from '@main/core/git/impl/git-service'; -import { GitRepositoryService } from '@main/core/git/repository-service'; -import { githubConnectionService } from '@main/core/github/services/github-connection-service'; -import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; -import { SshConnectionEvent, sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; -import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; -import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; -import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; -import type { Workspace } from '@main/core/workspaces/workspace'; -import { WorkspaceLifecycleService } from '@main/core/workspaces/workspace-lifecycle-service'; -import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { log } from '@main/lib/logger'; -import { - type ProjectProvider, - type ProjectRemoteState, - type ProvisionTaskError, - type TaskProvider, - type TeardownTaskError, -} from '../project-provider'; -import { - formatProvisionTaskError, - isProvisionTaskError, - mapWorktreeErrorToProvisionError, -} from '../provision-task-error'; -import { SshProjectSettingsProvider } from '../settings/project-settings'; -import type { ProjectSettingsProvider } from '../settings/schema'; -import { getEffectiveTaskSettings } from '../settings/task-settings'; -import { TimeoutSignal, withTimeout } from '../utils'; -import { WorktreeService } from '../worktrees/worktree-service'; - -const TASK_TIMEOUT_MS = 60_000; -const TEARDOWN_SCRIPT_WAIT_MS = 10_000; - -function toProvisionError(e: unknown): ProvisionTaskError { - if (isProvisionTaskError(e)) return e; - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - -function toTeardownError(e: unknown): TeardownTaskError { - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - -export async function createSshProvider( - project: SshProject, - rootFs: FileSystemProvider, - proxy: SshClientProxy -): Promise { - try { - const projectFs = new SshFileSystem(proxy, project.path); - const exec = getSshExec(proxy); - - const settings = new SshProjectSettingsProvider( - projectFs, - bareRefName(project.baseRef), - rootFs, - project.path, - exec - ); - const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); - await rootFs.mkdir(worktreePoolPath, { recursive: true }); - - return new SshProjectProvider(project, rootFs, proxy, { - fs: projectFs, - settings, - worktreePoolPath, - }); - } catch (error) { - log.warn('createSshProvider: SSH connection failed', { - projectId: project.id, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} - -export class SshProjectProvider implements ProjectProvider { - readonly type = 'ssh'; - readonly settings: ProjectSettingsProvider; - readonly repository: GitRepositoryService; - readonly fs: SshFileSystem; - - private tasks = new Map(); - private conversationProviders = new Map(); - private terminalProviders = new Map(); - private provisioningTasks = new Map>>(); - private tearingDownTasks = new Map>>(); - private bootstrapErrors = new Map(); - private worktreeService: WorktreeService; - private workspaceRegistry = new WorkspaceRegistry(); - private cachedSftp: SFTPWrapper | undefined; - private readonly _gitFetchService: GitFetchService; - - constructor( - private readonly project: SshProject, - rootFs: FileSystemProvider, - private readonly proxy: SshClientProxy, - options: { - fs: SshFileSystem; - settings: ProjectSettingsProvider; - worktreePoolPath: string; - } - ) { - this.fs = options.fs; - this.settings = options.settings; - const gitExec = getGitSshExec(this.proxy, () => githubConnectionService.getToken()); - const repoGit = new GitService(project.path, gitExec, this.fs, false); - this.repository = new GitRepositoryService(repoGit, this.settings); - this.worktreeService = new WorktreeService({ - worktreePoolPath: options.worktreePoolPath, - repoPath: project.path, - projectSettings: this.settings, - exec: gitExec, - rootFs: rootFs, - }); - this._gitFetchService = new GitFetchService(repoGit); - this._gitFetchService.start(); - sshConnectionManager.on('connection-event', this.handleConnectionEvent); - } - - private handleConnectionEvent = (evt: SshConnectionEvent): void => { - if (evt.type === 'reconnected' && evt.connectionId === this.project.connectionId) { - // Re-sync remote-tracking refs as soon as the connection is restored. - void this._gitFetchService.fetch(); - this.rehydrateTerminals().catch((e: unknown) => { - log.error('SshProjectProvider: rehydrateTerminals failed after reconnect', { - projectId: this.project.id, - connectionId: this.project.connectionId, - error: String(e), - }); - }); - } - }; - - private getSftp(): Promise { - if (this.cachedSftp) return Promise.resolve(this.cachedSftp); - return new Promise((resolve, reject) => { - this.proxy.client.sftp((err, sftp) => { - if (err) return reject(err); - this.cachedSftp = sftp; - sftp.on('close', () => { - this.cachedSftp = undefined; - }); - resolve(sftp); - }); - }); - } - - async provisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise> { - const existing = this.tasks.get(task.id); - if (existing) return ok(existing); - if (this.provisioningTasks.has(task.id)) return this.provisioningTasks.get(task.id)!; - - const promise = withTimeout( - this.doProvisionTask(task, conversations, terminals), - TASK_TIMEOUT_MS - ) - .then((taskEnv) => { - this.tasks.set(task.id, taskEnv); - this.provisioningTasks.delete(task.id); - return ok(taskEnv); - }) - .catch((e) => { - const provisionError = toProvisionError(e); - this.bootstrapErrors.set(task.id, provisionError); - this.provisioningTasks.delete(task.id); - log.error('SshProjectProvider: failed to provision task', { - taskId: task.id, - error: String(e), - }); - return err(provisionError); - }); - - this.provisioningTasks.set(task.id, promise); - return promise; - } - - private async doProvisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise { - log.debug('SshProjectProvider: doProvisionTask START', { - taskId: task.id, - }); - - // Refresh remote-tracking refs in the background so they are as fresh as - // possible during the lifetime of this task. Non-blocking — provision - // continues without waiting for the network round-trip. - void this._gitFetchService.fetch(); - - // Sync PRs for this task's branch in the background. - void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); - - const workspaceId = workspaceKey(task.taskBranch); - const workspace = await this.workspaceRegistry.acquire(workspaceId, async () => { - const workDir = await this.resolveTaskWorkDir(task); - const workspaceFs = new SshFileSystem(this.proxy, workDir); - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const bootstrapTaskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workDir, - projectPath: this.project.path, - defaultBranch, - portSeed: workDir, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspaceFs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - const scripts = taskLevelSettings.scripts; - const proxy = this.proxy; - const exec = getSshExec(proxy); - const workspaceTerminals = new SshTerminalProvider({ - projectId: this.project.id, - scopeId: workspaceId, - taskPath: workDir, - tmux: tmuxEnabled, - shellSetup, - exec, - proxy, - taskEnvVars: bootstrapTaskEnvVars, - }); - const lifecycleService = new WorkspaceLifecycleService({ - projectId: this.project.id, - workspaceId, - terminals: workspaceTerminals, - }); - const workspaceGitExec = getGitSshExec(proxy, () => githubConnectionService.getToken()); - const createdWorkspace: Workspace = { - id: workspaceId, - path: workDir, - fs: workspaceFs, - git: new GitService(workDir, workspaceGitExec, workspaceFs, false), - settings: this.settings, - lifecycleService, - }; - - if (scripts?.setup) { - void lifecycleService.prepareAndRunLifecycleScript({ - type: 'setup', - script: scripts.setup, - }); - } - if (scripts?.run) { - void lifecycleService.prepareLifecycleScript({ - type: 'run', - script: scripts.run, - }); - } - if (scripts?.teardown) { - void lifecycleService.prepareLifecycleScript({ - type: 'teardown', - script: scripts.teardown, - }); - } - - return createdWorkspace; - }); - - let provisionSucceeded = false; - try { - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const taskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workspace.path, - projectPath: this.project.path, - defaultBranch, - portSeed: workspace.path, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - const proxy = this.proxy; - const exec = getSshExec(proxy); - - const conversationProvider = new SshConversationProvider({ - projectId: this.project.id, - taskPath: workspace.path, - taskId: task.id, - tmux: tmuxEnabled, - shellSetup, - exec, - proxy, - taskEnvVars, - }); - - const terminalProvider = new SshTerminalProvider({ - projectId: this.project.id, - scopeId: task.id, - taskPath: workspace.path, - tmux: tmuxEnabled, - shellSetup, - exec, - proxy, - taskEnvVars, - }); - - const taskEnv: TaskProvider = { - taskId: task.id, - taskBranch: task.taskBranch, - sourceBranch: task.sourceBranch, - taskEnvVars, - conversations: conversationProvider, - terminals: terminalProvider, - }; - - Promise.all( - terminals.map((term) => - terminalProvider.spawnTerminal(term).catch((e) => { - log.error('SshEnvironmentProvider: failed to hydrate terminal', { - terminalId: term.id, - error: String(e), - }); - }) - ) - ); - - Promise.all( - conversations.map((conv) => - conversationProvider.startSession(conv, undefined, true).catch((e) => { - log.error('SshEnvironmentProvider: failed to hydrate conversation', { - conversationId: conv.id, - error: String(e), - }); - }) - ) - ); - - this.terminalProviders.set(task.id, terminalProvider); - this.conversationProviders.set(task.id, conversationProvider); - log.debug('SshProjectProvider: doProvisionTask DONE', { - taskId: task.id, - }); - provisionSucceeded = true; - return taskEnv; - } finally { - if (!provisionSucceeded) { - await this.workspaceRegistry.release(workspace.id).catch(() => {}); - } - } - } - - getTask(taskId: string): TaskProvider | undefined { - return this.tasks.get(taskId); - } - - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { - if (this.tasks.has(taskId)) return { status: 'ready' }; - if (this.provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; - const bootstrapError = this.bootstrapErrors.get(taskId); - if (bootstrapError) - return { status: 'error', message: formatProvisionTaskError(bootstrapError) }; - return { status: 'not-started' }; - } - - async teardownTask(taskId: string): Promise> { - if (this.tearingDownTasks.has(taskId)) return this.tearingDownTasks.get(taskId)!; - const task = this.tasks.get(taskId); - if (!task) { - await this.cleanupDetachedTmuxSessions(taskId); - return ok(); - } - - const promise = withTimeout(this.doTeardownTask(task), TASK_TIMEOUT_MS) - .then(() => ok()) - .catch(async (e) => { - log.error('SshProjectProvider: failed to teardown task', { - taskId, - error: String(e), - }); - await this.cleanupDetachedTmuxSessions(taskId).catch((cleanupError) => { - log.warn('SshProjectProvider: fallback tmux cleanup failed', { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - }) - .finally(() => { - this.tasks.delete(taskId); - this.tearingDownTasks.delete(taskId); - this.conversationProviders.delete(taskId); - this.terminalProviders.delete(taskId); - }); - - this.tearingDownTasks.set(taskId, promise); - return promise; - } - - getWorkspace( - workspaceId: string - ): import('@main/core/workspaces/workspace').Workspace | undefined { - return this.workspaceRegistry.get(workspaceId); - } - - private async doTeardownTask(task: TaskProvider): Promise { - const wsId = workspaceKey(task.taskBranch); - const workspace = this.workspaceRegistry.get(wsId); - - if (workspace) { - const settings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const scripts = settings.scripts; - - if (scripts?.teardown && this.workspaceRegistry.refCount(wsId) === 1) { - try { - const runTeardown = workspace.lifecycleService.runLifecycleScript( - { type: 'teardown', script: scripts.teardown }, - { waitForExit: true, exit: true } - ); - await withTimeout(runTeardown, TEARDOWN_SCRIPT_WAIT_MS); - } catch (error) { - if (error instanceof TimeoutSignal) { - log.debug('SshProjectProvider: teardown script wait timed out', { - taskId: task.taskId, - timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, - }); - } else { - log.warn('SshProjectProvider: teardown script failed (continuing cleanup)', { - taskId: task.taskId, - error: String(error), - }); - } - } - } - } - - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - await this.workspaceRegistry.release(wsId); - } - - private async cleanupDetachedTmuxSessions(taskId: string): Promise { - const { conversationIds, terminalIds } = await getTaskSessionLeafIds(this.project.id, taskId); - const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => - makePtySessionId(this.project.id, taskId, leafId) - ); - const exec = getSshExec(this.proxy); - await Promise.all( - sessionIds.map((sessionId) => killTmuxSession(exec, makeTmuxSessionName(sessionId))) - ); - } - - async getWorktreeForBranch(branchName: string): Promise { - return this.worktreeService.getWorktree(branchName); - } - - async removeTaskWorktree(taskBranch: string): Promise { - const worktreePath = await this.worktreeService.getWorktree(taskBranch); - if (worktreePath) { - await this.worktreeService.removeWorktree(worktreePath); - } - } - - async fetch(): Promise> { - return this._gitFetchService.fetch(); - } - - async cleanup(): Promise { - this._gitFetchService.stop(); - sshConnectionManager.off('connection-event', this.handleConnectionEvent); - - const settings = await this.settings.get(); - - if (settings.tmux) { - await Promise.all( - Array.from(this.tasks.values()).map((task) => - Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) - ) - ); - this.tasks.clear(); - this.conversationProviders.clear(); - this.terminalProviders.clear(); - await this.workspaceRegistry.releaseAll(); - } else { - await Promise.all(Array.from(this.tasks.keys()).map((id) => this.teardownTask(id))); - await this.workspaceRegistry.releaseAll(); - } - } - - /** - * Re-spawn all terminal sessions for every active task after an SSH reconnect. - * Agent sessions are intentionally excluded — they must be restarted manually. - */ - private async rehydrateTerminals(): Promise { - await Promise.all( - Array.from(this.terminalProviders.values()).map((provider) => - provider.rehydrate().catch((e: unknown) => { - log.error('SshEnvironmentProvider: rehydrateTerminals failed for a provider', { - error: String(e), - }); - }) - ) - ); - } - - /** - * Upload local files into the task's working directory via SFTP and return - * their remote paths. - */ - async uploadFiles(taskId: string, localPaths: string[]): Promise { - const env = this.tasks.get(taskId); - if (!env) throw new Error(`No provisioned environment for task: ${taskId}`); - - const sftp = await this.getSftp(); - const wsId = workspaceKey(env.taskBranch); - const destDir = this.workspaceRegistry.get(wsId)?.path ?? env.taskId; - - return Promise.all( - localPaths.map(async (localPath) => { - const remoteName = `${randomUUID()}-${path.basename(localPath)}`; - const remotePath = `${destDir}/${remoteName}`; - await new Promise((resolve, reject) => { - sftp.fastPut(localPath, remotePath, (e) => (e ? reject(e) : resolve())); - }); - return remotePath; - }) - ); - } - - async getRemoteState(): Promise { - try { - const remotes = await this.repository.getRemotes(); - const remoteName = await this.repository.getConfiguredRemote(); - const remoteUrl = remotes.find((r) => r.name === remoteName)?.url; - return { hasRemote: remotes.length > 0, selectedRemoteUrl: remoteUrl ?? null }; - } catch { - return { hasRemote: false, selectedRemoteUrl: null }; - } - } - - private async resolveTaskWorkDir(task: Task): Promise { - if (!task.taskBranch) { - return this.project.path; - } - - const existing = await this.worktreeService.getWorktree(task.taskBranch); - if (existing) { - return existing; - } - - if (!task.sourceBranch || task.taskBranch === task.sourceBranch.branch) { - const result = await this.worktreeService.checkoutExistingBranch(task.taskBranch); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } - - const result = await this.worktreeService.checkoutBranchWorktree( - task.sourceBranch, - task.taskBranch - ); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } -} diff --git a/src/main/core/projects/operations/createProject.test.ts b/src/main/core/projects/operations/createProject.test.ts index 6b97972e5f..719e56a455 100644 --- a/src/main/core/projects/operations/createProject.test.ts +++ b/src/main/core/projects/operations/createProject.test.ts @@ -6,6 +6,8 @@ import { createLocalProject, createSshProject, getSshProjectPathStatus } from '. const mocks = vi.hoisted(() => ({ detectInfoMock: vi.fn(), + getBranchesMock: vi.fn(), + getDefaultBranchMock: vi.fn(), initRepositoryMock: vi.fn(), openProjectMock: vi.fn(), getProjectMock: vi.fn(), @@ -20,6 +22,8 @@ vi.mock('@main/core/git/impl/git-service', () => ({ GitService: vi.fn(function MockGitService() { return { detectInfo: mocks.detectInfoMock, + getBranches: mocks.getBranchesMock, + getDefaultBranch: mocks.getDefaultBranchMock, initRepository: mocks.initRepositoryMock, }; }), @@ -59,6 +63,8 @@ beforeEach(() => { mocks.valuesMock.mockReturnValue({ returning: mocks.returningMock }); mocks.openProjectMock.mockResolvedValue(undefined); mocks.getProjectMock.mockReturnValue(undefined); + mocks.getBranchesMock.mockResolvedValue([]); + mocks.getDefaultBranchMock.mockResolvedValue('main'); mocks.initRepositoryMock.mockResolvedValue(undefined); mocks.sshConnectMock.mockResolvedValue({ id: 'ssh-proxy' }); mocks.sshStatMock.mockResolvedValue({ path: '', type: 'dir' }); @@ -172,6 +178,84 @@ describe('createLocalProject', () => { expect(mocks.initRepositoryMock).not.toHaveBeenCalled(); expect(mocks.detectInfoMock).toHaveBeenCalledTimes(1); }); + + it('stores the git remote default branch as baseRef instead of the current feature branch', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + tempDirs.push(projectPath); + const row = { + id: 'project-id', + name: 'Project', + path: projectPath, + baseRef: 'origin/main', + createdAt: '2026-04-16T00:00:00.000Z', + updatedAt: '2026-04-16T00:00:00.000Z', + }; + + mocks.detectInfoMock.mockResolvedValue({ + isGitRepo: true, + baseRef: 'origin/feature/current', + rootPath: projectPath, + }); + mocks.getDefaultBranchMock.mockResolvedValue('main'); + mocks.getBranchesMock.mockResolvedValue([ + { + type: 'remote', + branch: 'main', + remote: { name: 'origin', url: 'git@github.com:example/repo.git' }, + }, + ]); + mocks.returningMock.mockResolvedValue([row]); + + const created = await createLocalProject({ + id: 'project-id', + name: 'Project', + path: projectPath, + }); + + expect(mocks.valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ baseRef: 'origin/main' }) + ); + expect(created.baseRef).toBe('origin/main'); + }); + + it('keeps the detected baseRef when the git default branch is not present on the remote', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + tempDirs.push(projectPath); + const row = { + id: 'project-id', + name: 'Project', + path: projectPath, + baseRef: 'origin/feature/current', + createdAt: '2026-04-16T00:00:00.000Z', + updatedAt: '2026-04-16T00:00:00.000Z', + }; + + mocks.detectInfoMock.mockResolvedValue({ + isGitRepo: true, + baseRef: 'origin/feature/current', + rootPath: projectPath, + }); + mocks.getDefaultBranchMock.mockResolvedValue('main'); + mocks.getBranchesMock.mockResolvedValue([ + { + type: 'remote', + branch: 'develop', + remote: { name: 'origin', url: 'git@github.com:example/repo.git' }, + }, + ]); + mocks.returningMock.mockResolvedValue([row]); + + const created = await createLocalProject({ + id: 'project-id', + name: 'Project', + path: projectPath, + }); + + expect(mocks.valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ baseRef: 'origin/feature/current' }) + ); + expect(created.baseRef).toBe('origin/feature/current'); + }); }); describe('createSshProject', () => { @@ -262,6 +346,40 @@ describe('createSshProject', () => { expect(mocks.detectInfoMock).not.toHaveBeenCalled(); expect(mocks.initRepositoryMock).not.toHaveBeenCalled(); }); + + it('stores the git remote default branch as the SSH project baseRef', async () => { + const rowWithDefault = { + ...row, + baseRef: 'origin/main', + }; + + mocks.detectInfoMock.mockResolvedValue({ + isGitRepo: true, + baseRef: 'origin/feature/current', + rootPath: row.path, + }); + mocks.getDefaultBranchMock.mockResolvedValue('main'); + mocks.getBranchesMock.mockResolvedValue([ + { + type: 'remote', + branch: 'main', + remote: { name: 'origin', url: 'git@github.com:example/repo.git' }, + }, + ]); + mocks.returningMock.mockResolvedValue([rowWithDefault]); + + const created = await createSshProject({ + id: 'project-id', + name: 'Project', + path: projectPath, + connectionId: 'connection-id', + }); + + expect(mocks.valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ baseRef: 'origin/main' }) + ); + expect(created.baseRef).toBe('origin/main'); + }); }); describe('getSshProjectPathStatus', () => { diff --git a/src/main/core/projects/operations/createProject.ts b/src/main/core/projects/operations/createProject.ts index de2ae5cbe3..8bdd0e6710 100644 --- a/src/main/core/projects/operations/createProject.ts +++ b/src/main/core/projects/operations/createProject.ts @@ -1,16 +1,41 @@ import { randomUUID } from 'node:crypto'; import { sql } from 'drizzle-orm'; +import { remoteNameFromQualifiedRef, resolveBaseRefFromRemoteDefault } from '@shared/git-utils'; import { type LocalProject, type ProjectPathStatus, type SshProject } from '@shared/projects'; +import { GitHubAuthExecutionContext } from '@main/core/execution-context/github-auth-execution-context'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import { checkIsValidDirectory } from '@main/core/git/impl/detectGitInfo'; import { GitService } from '@main/core/git/impl/git-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; +import { projectEvents } from '@main/core/projects/project-events'; import { projectManager } from '@main/core/projects/project-manager'; import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; -import { getGitSshExec, getLocalExec } from '@main/core/utils/exec'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; +import { log } from '@main/lib/logger'; +import { checkIsValidDirectory } from '../path-utils'; + +async function resolveProjectBaseRef(git: GitService, detectedBaseRef: string): Promise { + const remoteName = remoteNameFromQualifiedRef(detectedBaseRef); + if (!remoteName) return detectedBaseRef; + + try { + const [gitDefaultBranch, branches] = await Promise.all([ + git.getDefaultBranch(remoteName), + git.getBranches(), + ]); + return resolveBaseRefFromRemoteDefault({ detectedBaseRef, gitDefaultBranch, branches }); + } catch (error) { + log.debug('Failed to resolve project base ref, using detected base ref', { + detectedBaseRef, + error, + }); + } + + return detectedBaseRef; +} async function ensureGitRepository( git: GitService, @@ -46,8 +71,11 @@ export async function createLocalProject(params: CreateLocalProjectParams): Prom } const fs = new LocalFileSystem(params.path); - const git = new GitService(params.path, getLocalExec(), fs); + const baseCtx = new LocalExecutionContext({ root: params.path }); + const authCtx = new GitHubAuthExecutionContext(baseCtx, () => githubConnectionService.getToken()); + const git = new GitService(baseCtx, authCtx, fs); const gitInfo = await ensureGitRepository(git, params.initGitRepository); + const baseRef = await resolveProjectBaseRef(git, gitInfo.baseRef); const [row] = await db .insert(projects) @@ -56,7 +84,7 @@ export async function createLocalProject(params: CreateLocalProjectParams): Prom name: params.name, path: gitInfo.rootPath, workspaceProvider: 'local', - baseRef: gitInfo.baseRef, + baseRef, updatedAt: sql`CURRENT_TIMESTAMP`, }) .returning(); @@ -66,12 +94,13 @@ export async function createLocalProject(params: CreateLocalProjectParams): Prom id: row.id, name: row.name, path: row.path, - baseRef: row.baseRef ?? gitInfo.baseRef, + baseRef: row.baseRef ?? baseRef, createdAt: row.createdAt, updatedAt: row.updatedAt, }; await projectManager.openProject(project); + projectEvents._emit('project:created', project); return project; } @@ -83,7 +112,9 @@ export async function getLocalProjectPathStatus(path: string): Promise githubConnectionService.getToken()); + const git = new GitService(baseCtx, authCtx, fs); const gitInfo = await git.detectInfo(); return { isDirectory: true, isGitRepo: gitInfo.isGitRepo }; } @@ -104,14 +135,14 @@ export async function createSshProject(params: CreateSshProjectParams): Promise< if (!pathEntry || pathEntry.type !== 'dir') { throw new Error('Invalid directory'); } - const git = new GitService( - params.path, - getGitSshExec(sshProxy, () => githubConnectionService.getToken()), - sshFs, - false + const baseSshCtx = new SshExecutionContext(sshProxy, { root: params.path }); + const authSshCtx = new GitHubAuthExecutionContext(baseSshCtx, () => + githubConnectionService.getToken() ); + const git = new GitService(baseSshCtx, authSshCtx, sshFs); const gitInfo = await ensureGitRepository(git, params.initGitRepository); + const baseRef = await resolveProjectBaseRef(git, gitInfo.baseRef); const [row] = await db .insert(projects) @@ -121,7 +152,7 @@ export async function createSshProject(params: CreateSshProjectParams): Promise< path: gitInfo.rootPath, workspaceProvider: 'ssh', sshConnectionId: params.connectionId, - baseRef: gitInfo.baseRef, + baseRef, updatedAt: sql`CURRENT_TIMESTAMP`, }) .returning(); @@ -132,12 +163,13 @@ export async function createSshProject(params: CreateSshProjectParams): Promise< name: row.name, path: row.path, connectionId: params.connectionId, - baseRef: row.baseRef ?? gitInfo.baseRef, + baseRef: row.baseRef ?? baseRef, createdAt: row.createdAt, updatedAt: row.updatedAt, }; await projectManager.openProject(project); + projectEvents._emit('project:created', project); return project; } @@ -154,12 +186,11 @@ export async function getSshProjectPathStatus( return { isDirectory: false, isGitRepo: false }; } - const git = new GitService( - path, - getGitSshExec(sshProxy, () => githubConnectionService.getToken()), - sshFs, - false + const baseSshCtx = new SshExecutionContext(sshProxy, { root: path }); + const authSshCtx = new GitHubAuthExecutionContext(baseSshCtx, () => + githubConnectionService.getToken() ); + const git = new GitService(baseSshCtx, authSshCtx, sshFs); const gitInfo = await git.detectInfo(); return { isDirectory: true, isGitRepo: gitInfo.isGitRepo }; } catch { diff --git a/src/main/core/projects/operations/deleteProject.ts b/src/main/core/projects/operations/deleteProject.ts index 7060d75bad..61e69bd4f5 100644 --- a/src/main/core/projects/operations/deleteProject.ts +++ b/src/main/core/projects/operations/deleteProject.ts @@ -1,18 +1,20 @@ import { eq } from 'drizzle-orm'; +import { projectEvents } from '@main/core/projects/project-events'; import { projectManager } from '@main/core/projects/project-manager'; import { prSyncEngine } from '@main/core/pull-requests/pr-sync-engine'; -import { getTasks } from '@main/core/tasks/getTasks'; +import { getTasks } from '@main/core/tasks/operations/getTasks'; +import { taskManager } from '@main/core/tasks/task-manager'; import { viewStateService } from '@main/core/view-state/view-state-service'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export async function deleteProject(id: string): Promise { const provider = projectManager.getProject(id); if (provider) { const projectTasks = await getTasks(id); await Promise.allSettled([ - ...projectTasks.map((t) => provider.teardownTask(t.id)), + ...projectTasks.map((t) => taskManager.teardownTask(t.id)), ...projectTasks.map((t) => viewStateService.del(`task:${t.id}`)), ]); } @@ -21,6 +23,7 @@ export async function deleteProject(id: string): Promise { await db.delete(projects).where(eq(projects.id, id)); void viewStateService.del(`project:${id}`); + projectEvents._emit('project:deleted', id); await projectManager.closeProject(id); - capture('project_deleted', { project_id: id }); + telemetryService.capture('project_deleted', { project_id: id }); } diff --git a/src/main/core/projects/operations/getProjectSettings.ts b/src/main/core/projects/operations/getProjectSettings.ts index df8c04fdb8..963266da45 100644 --- a/src/main/core/projects/operations/getProjectSettings.ts +++ b/src/main/core/projects/operations/getProjectSettings.ts @@ -1,5 +1,5 @@ import { projectManager } from '../project-manager'; -import { ProjectSettings } from '../settings/schema'; +import { type ProjectSettings } from '../settings/schema'; export async function getProjectSettings(projectId: string): Promise { const project = projectManager.getProject(projectId); diff --git a/src/main/core/projects/operations/openProject.ts b/src/main/core/projects/operations/openProject.ts index a17e17464e..a47f7ebde8 100644 --- a/src/main/core/projects/operations/openProject.ts +++ b/src/main/core/projects/operations/openProject.ts @@ -1,7 +1,21 @@ import type { OpenProjectError } from '@shared/projects'; -import type { Result } from '@shared/result'; +import { err, ok, type Result } from '@shared/result'; import { projectManager } from '@main/core/projects/project-manager'; +import { checkIsValidDirectory } from '../path-utils'; +import { getProjectById } from './getProjects'; export async function openProject(projectId: string): Promise> { - return projectManager.openProjectById(projectId); + const project = await getProjectById(projectId); + if (!project) return err({ type: 'error', message: `Project not found: ${projectId}` }); + if (project.type === 'local' && !checkIsValidDirectory(project.path)) { + return err({ type: 'path-not-found', path: project.path }); + } + const result = await projectManager.openProject(project); + if (!result.success) { + if (project.type === 'ssh') { + return err({ type: 'ssh-disconnected', connectionId: project.connectionId }); + } + return err({ type: 'error', message: result.error.message }); + } + return ok(); } diff --git a/src/main/core/projects/operations/updateProjectSettings.ts b/src/main/core/projects/operations/updateProjectSettings.ts index 3d6e350313..aee069284d 100644 --- a/src/main/core/projects/operations/updateProjectSettings.ts +++ b/src/main/core/projects/operations/updateProjectSettings.ts @@ -1,7 +1,7 @@ import type { UpdateProjectSettingsError } from '@shared/projects'; import { err, type Result } from '@shared/result'; import { projectManager } from '../project-manager'; -import { ProjectSettings } from '../settings/schema'; +import type { ProjectSettings } from '../settings/schema'; export async function updateProjectSettings( projectId: string, diff --git a/src/main/core/projects/path-utils.ts b/src/main/core/projects/path-utils.ts new file mode 100644 index 0000000000..2cfdf27f58 --- /dev/null +++ b/src/main/core/projects/path-utils.ts @@ -0,0 +1,5 @@ +import fs from 'node:fs'; + +export function checkIsValidDirectory(path: string): boolean { + return fs.existsSync(path) && fs.statSync(path).isDirectory(); +} diff --git a/src/main/core/projects/project-events.ts b/src/main/core/projects/project-events.ts new file mode 100644 index 0000000000..2c9c848860 --- /dev/null +++ b/src/main/core/projects/project-events.ts @@ -0,0 +1,24 @@ +import type { Project } from '@shared/projects'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { log } from '@main/lib/logger'; + +export type ProjectCrudHooks = { + 'project:created': (project: Project) => void | Promise; + 'project:deleted': (projectId: string) => void | Promise; +}; + +class ProjectEvents implements Hookable { + private readonly _core = new HookCore((name, e) => + log.error(`ProjectEvents: ${String(name)} hook error`, e) + ); + + on(name: K, handler: ProjectCrudHooks[K]) { + return this._core.on(name, handler); + } + + _emit(name: K, ...args: Parameters): void { + this._core.callHookBackground(name, ...args); + } +} + +export const projectEvents = new ProjectEvents(); diff --git a/src/main/core/projects/project-manager.ts b/src/main/core/projects/project-manager.ts index 71abe61889..0dec804f12 100644 --- a/src/main/core/projects/project-manager.ts +++ b/src/main/core/projects/project-manager.ts @@ -1,179 +1,97 @@ -import type { - LocalProject, - OpenProjectError, - ProjectBootstrapStatus, - SshProject, -} from '@shared/projects'; +import type { LocalProject, ProjectBootstrapStatus, SshProject } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import type { IDisposable } from '@main/lib/lifecycle'; +import { LifecycleMap } from '@main/lib/lifecycle-map'; import { log } from '@main/lib/logger'; -import { LocalFileSystem } from '../fs/impl/local-fs'; -import { SshFileSystem } from '../fs/impl/ssh-fs'; -import { checkIsValidDirectory } from '../git/impl/detectGitInfo'; -import { getProjectById, getProjects } from '../projects/operations/getProjects'; -import { sshConnectionManager } from '../ssh/ssh-connection-manager'; -import { createLocalProvider } from './impl/local-project-provider'; -import { createSshProvider } from './impl/ssh-project-provider'; +import { createProvider } from './create-project-provider'; import type { ProjectProvider } from './project-provider'; import { TimeoutSignal, withTimeout } from './utils'; -const PROVIDER_TIMEOUT_MS = 60_000; +const SSH_PROVIDER_TIMEOUT_MS = 60_000; +const LOCAL_PROVIDER_TIMEOUT_MS = 20_000; +const TEARDOWN_PROVIDER_TIMEOUT_MS = 60_000; -type ProjectLifecycleHook = (projectId: string) => void | Promise; - -type ProviderError = { - type: 'error'; - message: string; -}; - -type TimeoutError = { - type: 'timeout'; - message: string; - timeout: number; +type ProjectManagerHooks = { + projectOpened: (projectId: string, provider: ProjectProvider) => void | Promise; + projectClosed: (projectId: string) => void | Promise; }; -type InitializeProviderError = TimeoutError | ProviderError; -type TeardownProviderError = TimeoutError | ProviderError; +type ProviderLifecycleError = + | { type: 'timeout'; message: string; timeout: number } + | { type: 'error'; message: string }; -function toInitError(e: unknown): InitializeProviderError { +function toInitError(e: unknown): ProviderLifecycleError { if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; return { type: 'error', message: e instanceof Error ? e.message : String(e) }; } -function toTeardownError(e: unknown): TeardownProviderError { +function toTeardownError(e: unknown): ProviderLifecycleError { if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; return { type: 'error', message: e instanceof Error ? e.message : String(e) }; } -class ProjectManager { - private initializingProviders = new Map< - string, - Promise> - >(); - private providers = new Map(); - private tearingDownProviders = new Map>>(); - private initializationErrors = new Map(); - private _onProjectOpenedHooks: ProjectLifecycleHook[] = []; - private _onProjectClosedHooks: ProjectLifecycleHook[] = []; - - async initialize(): Promise { - const allProjects = await getProjects(); - - await Promise.allSettled( - allProjects.map(async (project) => { - await this.openProject(project); - }) - ); +class ProjectManager implements Hookable, IDisposable { + private readonly _hooks = new HookCore((name, e) => + log.error(`ProjectManager: ${String(name)} hook error`, e) + ); + private readonly _lifecycle = new LifecycleMap({ + postProvision: (id, provider) => this._hooks.callHookBackground('projectOpened', id, provider), + postTeardown: (id) => this._hooks.callHookBackground('projectClosed', id), + }); + + on(name: K, handler: ProjectManagerHooks[K]) { + return this._hooks.on(name, handler); } async openProject( project: LocalProject | SshProject - ): Promise> { - if (this.providers.has(project.id)) return ok(this.providers.get(project.id)!); - if (this.initializingProviders.has(project.id)) - return this.initializingProviders.get(project.id)!; - - const promise = withTimeout(createProvider(project), PROVIDER_TIMEOUT_MS) - .then((provider) => { - this.providers.set(project.id, provider); - this.initializingProviders.delete(project.id); - this._fireHooks(this._onProjectOpenedHooks, project.id, 'onProjectOpened'); + ): Promise> { + return this._lifecycle.provision(project.id, async () => { + try { + const provider = await withTimeout( + createProvider(project), + project.type === 'ssh' ? SSH_PROVIDER_TIMEOUT_MS : LOCAL_PROVIDER_TIMEOUT_MS + ); return ok(provider); - }) - .catch((e) => { + } catch (e) { const initError = toInitError(e); - this.initializationErrors.set(project.id, initError); - this.initializingProviders.delete(project.id); log.error('ProjectManager: error during project initialization', { projectId: project.id, ...initError, }); - return err(initError); - }); - - this.initializingProviders.set(project.id, promise); - return promise; - } - - async closeProject(projectId: string): Promise> { - if (this.tearingDownProviders.has(projectId)) return this.tearingDownProviders.get(projectId)!; - const provider = this.providers.get(projectId); - if (!provider) return ok(); - - const promise = withTimeout(provider.cleanup(), PROVIDER_TIMEOUT_MS) - .then(() => ok()) - .catch((e) => { - const error = toTeardownError(e); - log.error('ProjectManager: error during project teardown', { projectId, ...error }); - return err(error); - }) - .finally(() => { - this.providers.delete(projectId); - this.tearingDownProviders.delete(projectId); - this._fireHooks(this._onProjectClosedHooks, projectId, 'onProjectClosed'); - }); - - this.tearingDownProviders.set(projectId, promise); - return promise; - } - - registerOnProjectOpened(hook: ProjectLifecycleHook): void { - this._onProjectOpenedHooks.push(hook); - } - - registerOnProjectClosed(hook: ProjectLifecycleHook): void { - this._onProjectClosedHooks.push(hook); + return err(initError); + } + }); } - private _fireHooks(hooks: ProjectLifecycleHook[], projectId: string, name: string): void { - for (const hook of hooks) { - Promise.resolve(hook(projectId)).catch((e) => - log.error(`ProjectManager: ${name} hook error`, { projectId, error: String(e) }) - ); - } + async closeProject(projectId: string): Promise> { + return ( + this._lifecycle.teardown(projectId, async (provider) => { + try { + await withTimeout(provider.dispose(), TEARDOWN_PROVIDER_TIMEOUT_MS); + return ok(); + } catch (e) { + const error = toTeardownError(e); + log.error('ProjectManager: error during project teardown', { projectId, ...error }); + return err(error); + } + }) ?? ok() + ); } getProject(projectId: string): ProjectProvider | undefined { - return this.providers.get(projectId); + return this._lifecycle.get(projectId); } getProjectBootstrapStatus(projectId: string): ProjectBootstrapStatus { - if (this.providers.has(projectId)) return { status: 'ready' }; - if (this.initializingProviders.has(projectId)) return { status: 'bootstrapping' }; - const initError = this.initializationErrors.get(projectId); - if (initError) return { status: 'error', message: initError.message }; - return { status: 'not-started' }; + return this._lifecycle.bootstrapStatus(projectId, (e) => e.message); } - async openProjectById(projectId: string): Promise> { - const project = await getProjectById(projectId); - if (!project) return err({ type: 'error', message: `Project not found: ${projectId}` }); - if (project.type === 'local' && !checkIsValidDirectory(project.path)) { - return err({ type: 'path-not-found', path: project.path }); - } - const result = await this.openProject(project); - if (!result.success) { - if (project.type === 'ssh') { - return err({ type: 'ssh-disconnected', connectionId: project.connectionId }); - } - return err({ type: 'error', message: result.error.message }); - } - return ok(); - } - - async shutdown(): Promise { - const ids = Array.from(this.providers.keys()); + async dispose(): Promise { + const ids = Array.from(this._lifecycle.keys()); await Promise.allSettled(ids.map((id) => this.closeProject(id))); } } -async function createProvider(project: LocalProject | SshProject): Promise { - if (project.type === 'ssh') { - const proxy = await sshConnectionManager.connect(project.connectionId); - const rootFs = new SshFileSystem(proxy, '/'); - return createSshProvider(project, rootFs, proxy); - } - const rootFs = new LocalFileSystem('/'); - return createLocalProvider(project, rootFs); -} - export const projectManager = new ProjectManager(); diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index c42929285f..22e3be906b 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -1,34 +1,34 @@ -import { Conversation } from '@shared/conversations'; import type { Branch, FetchError } from '@shared/git'; +import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; -import { Task, TaskBootstrapStatus } from '@shared/tasks'; -import { Terminal } from '@shared/terminals'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; -import { ConversationProvider } from '../conversations/types'; -import type { GitRepositoryService } from '../git/repository-service'; -import { TerminalProvider } from '../terminals/terminal-provider'; -import type { Workspace } from '../workspaces/workspace'; -import { ProjectSettingsProvider } from './settings/schema'; - -export type BaseTaskProvisionArgs = { - taskId: string; - conversations: Conversation[]; - terminals: Terminal[]; -}; - -export type ProvisionTaskError = - | { type: 'timeout'; message: string; timeout: number } - | { type: 'branch-not-found'; branch: string } - | { type: 'worktree-setup-failed'; branch: string; message?: string } - | { type: 'error'; message: string }; +import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { GitRepositoryService } from '@main/core/git/repository-service'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import type { IDisposable } from '@main/lib/lifecycle'; +import type { ConversationProvider } from '../conversations/types'; +import { taskManager } from '../tasks/task-manager'; +import type { TerminalProvider } from '../terminals/terminal-provider'; +import type { WorkspaceType } from '../workspaces/workspace-factory'; +import type { ProjectSettingsProvider } from './settings/schema'; +import type { WorktreeHost } from './worktrees/hosts/worktree-host'; +import type { WorktreeService } from './worktrees/worktree-service'; -export type TeardownTaskError = - | { type: 'timeout'; message: string; timeout: number } - | { type: 'error'; message: string }; +export type WorkspaceProviderData = { + provisionCommand: string; + terminateCommand: string; + remoteWorkspaceId?: string; +}; -export type ProjectRemoteState = { - hasRemote: boolean; - selectedRemoteUrl: string | null; +export type ProvisionResult = { + taskProvider: TaskProvider; + persistData: { + workspaceId: string; + workspaceProviderData?: WorkspaceProviderData; + sshConnectionId?: string; + worktreeGitDir?: string; + }; }; export interface TaskProvider { @@ -40,23 +40,85 @@ export interface TaskProvider { readonly terminals: TerminalProvider; } -export interface ProjectProvider { +/** + * Transport-specific dependencies: the only things that differ between local and SSH. + * Pure data — no lifecycle methods. + */ +export type ProjectProviderTransport = { + readonly kind: string; + readonly defaultWorkspaceType: WorkspaceType; + readonly ctx: IExecutionContext; + readonly authCtx: IExecutionContext; + readonly fs: FileSystemProvider; + readonly settings: ProjectSettingsProvider; + readonly worktreeHost: WorktreeHost; + readonly worktreePoolPath: string; +}; + +export class ProjectProvider implements IDisposable { readonly type: string; + readonly projectId: string; + readonly repoPath: string; readonly settings: ProjectSettingsProvider; readonly repository: GitRepositoryService; readonly fs: FileSystemProvider; - getRemoteState(): Promise; - getWorkspace(workspaceId: string): Workspace | undefined; - provisionTask( - args: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise>; - getTask(taskId: string): TaskProvider | undefined; - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus; - teardownTask(taskId: string): Promise>; - getWorktreeForBranch(branchName: string): Promise; - removeTaskWorktree(taskBranch: string): Promise; - fetch(): Promise>; - cleanup(): Promise; + readonly worktreeService: WorktreeService; + readonly gitFetchService: GitFetchService; + /** Workspace type for standard worktree tasks. BYOI tasks use their own remote workspace type. */ + readonly defaultWorkspaceType: WorkspaceType; + + private readonly _ctx: IExecutionContext; + + constructor( + projectId: string, + repoPath: string, + transport: ProjectProviderTransport, + repository: GitRepositoryService, + worktreeService: WorktreeService, + gitFetchService: GitFetchService, + private readonly _dispose: () => void + ) { + this.type = transport.kind; + this.projectId = projectId; + this.repoPath = repoPath; + this._ctx = transport.ctx; + this.settings = transport.settings; + this.fs = transport.fs; + this.repository = repository; + this.worktreeService = worktreeService; + this.gitFetchService = gitFetchService; + this.defaultWorkspaceType = transport.defaultWorkspaceType; + } + + get ctx(): IExecutionContext { + return this._ctx; + } + + getRemoteState(): Promise { + return this.repository.getRemoteState(); + } + + getWorktreeForBranch(branchName: string): Promise { + return this.worktreeService.getWorktree(branchName); + } + + async removeTaskWorktree(taskBranch: string): Promise { + const worktreePath = await this.worktreeService.getWorktree(taskBranch); + if (worktreePath) { + await this.worktreeService.removeWorktree(worktreePath); + } + } + + fetch(): Promise> { + return this.gitFetchService.fetch(); + } + + async dispose(): Promise { + this._dispose(); + this.gitFetchService.stop(); + const projectSettings = await this.settings.get(); + const mode = projectSettings.tmux ? 'detach' : 'terminate'; + await taskManager.teardownAllForProject(this.projectId, mode); + await workspaceRegistry.releaseAllForProject(this.projectId, mode); + } } diff --git a/src/main/core/projects/provision-task-error.ts b/src/main/core/projects/provision-task-error.ts deleted file mode 100644 index ca22f7aaea..0000000000 --- a/src/main/core/projects/provision-task-error.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ProvisionTaskError } from './project-provider'; -import type { ServeWorktreeError } from './worktrees/worktree-service'; - -export function mapWorktreeErrorToProvisionError( - branch: string, - error: ServeWorktreeError -): ProvisionTaskError { - switch (error.type) { - case 'branch-not-found': - return { type: 'branch-not-found', branch: error.branch }; - case 'worktree-setup-failed': - return { - type: 'worktree-setup-failed', - branch, - message: error.cause instanceof Error ? error.cause.message : String(error.cause), - }; - } -} - -export function isProvisionTaskError(e: unknown): e is ProvisionTaskError { - if (!e || typeof e !== 'object' || !('type' in e)) return false; - const type = (e as { type?: string }).type; - return ( - type === 'timeout' || - type === 'error' || - type === 'branch-not-found' || - type === 'worktree-setup-failed' - ); -} - -export function formatProvisionTaskError(error: ProvisionTaskError): string { - switch (error.type) { - case 'timeout': - case 'error': - return error.message; - case 'branch-not-found': - return `Branch "${error.branch}" was not found locally or on remote`; - case 'worktree-setup-failed': - return error.message - ? `Failed to set up worktree for branch "${error.branch}": ${error.message}` - : `Failed to set up worktree for branch "${error.branch}"`; - } -} diff --git a/src/main/core/projects/settings/project-settings.test.ts b/src/main/core/projects/settings/project-settings.test.ts index 60f4f05467..cfe07de0bd 100644 --- a/src/main/core/projects/settings/project-settings.test.ts +++ b/src/main/core/projects/settings/project-settings.test.ts @@ -2,8 +2,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { ExecFn } from '@main/core/utils/exec'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { LocalProjectSettingsProvider, SshProjectSettingsProvider } from './project-settings'; vi.mock('@main/core/settings/settings-service', () => ({ @@ -32,36 +32,27 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('normalizes and canonicalizes local worktreeDirectory on update', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/worktrees'), - }; - const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + const provider = new LocalProjectSettingsProvider(projectPath, 'main'); const result = await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); expect(result.success).toBe(true); - expect(rootFs.mkdir).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees'), { - recursive: true, - }); - expect(rootFs.realPath).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees')); + const expectedPath = path.resolve(projectPath, 'worktrees'); + expect(fs.existsSync(expectedPath)).toBe(true); const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); - expect(persisted.worktreeDirectory).toBe('/canonical/worktrees'); + expect(persisted.worktreeDirectory).toBe(fs.realpathSync(expectedPath)); }); it('surfaces local worktreeDirectory validation errors', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const rootFs = { - mkdir: vi.fn().mockRejectedValue(new Error('EACCES')), - realPath: vi.fn(), - }; + fs.writeFileSync(path.join(projectPath, 'not-a-directory'), 'file'); - const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + const provider = new LocalProjectSettingsProvider(projectPath, 'main'); const result = await provider.update({ preservePatterns: [], - worktreeDirectory: '/restricted', + worktreeDirectory: path.join(projectPath, 'not-a-directory', 'worktrees'), }); expect(result).toEqual({ success: false, @@ -72,16 +63,11 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('clears blank local worktreeDirectory values', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/unused'), - }; - const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + const provider = new LocalProjectSettingsProvider(projectPath, 'main'); const result = await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); expect(result.success).toBe(true); - expect(rootFs.mkdir).not.toHaveBeenCalled(); const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); expect(persisted.worktreeDirectory).toBeUndefined(); }); @@ -160,21 +146,21 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { mkdir: vi.fn().mockResolvedValue(undefined), realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), }; - const exec = vi.fn().mockResolvedValue({ stdout: '/home/ubuntu', stderr: '' }) as ExecFn; - - const provider = new SshProjectSettingsProvider( - projectFs, - 'main', - rootFs, - '/remote/repo', - exec - ); + const ctx = { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn().mockResolvedValue({ stdout: '/home/ubuntu', stderr: '' }), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext; + + const provider = new SshProjectSettingsProvider(projectFs, 'main', rootFs, '/remote/repo', ctx); const first = await provider.update({ preservePatterns: [], worktreeDirectory: '~/worktrees' }); const second = await provider.update({ preservePatterns: [], worktreeDirectory: '~' }); expect(first.success).toBe(true); expect(second.success).toBe(true); - expect(exec).toHaveBeenCalledTimes(1); + expect(ctx.exec).toHaveBeenCalledTimes(1); expect(rootFs.mkdir).toHaveBeenCalledWith('/home/ubuntu/worktrees', { recursive: true }); expect(rootFs.realPath).toHaveBeenCalledWith('/home/ubuntu/worktrees'); expect(writeMock).toHaveBeenCalledTimes(2); diff --git a/src/main/core/projects/settings/project-settings.ts b/src/main/core/projects/settings/project-settings.ts index f89aaf2711..a1c65fb5cf 100644 --- a/src/main/core/projects/settings/project-settings.ts +++ b/src/main/core/projects/settings/project-settings.ts @@ -3,16 +3,19 @@ import os from 'node:os'; import path from 'node:path'; import type { UpdateProjectSettingsError } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { getDefaultSshWorktreeDirectory } from '@main/core/settings/worktree-defaults'; import { resolveRemoteHome } from '@main/core/ssh/utils'; -import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; -import { ProjectSettings, ProjectSettingsProvider, projectSettingsSchema } from './schema'; import { - defaultLocalWorktreeFs, + projectSettingsSchema, + type ProjectSettings, + type ProjectSettingsProvider, +} from './schema'; +import { normalizeWorktreeDirectory, resolveAndValidateWorktreeDirectory, } from './worktree-directory'; @@ -31,8 +34,7 @@ function parseSettingsOrDefault(raw: string, source: string): ProjectSettings { export class LocalProjectSettingsProvider implements ProjectSettingsProvider { constructor( private readonly projectPath: string, - private readonly defaultBranchFallback: string = 'main', - private readonly rootFs?: Pick + private readonly defaultBranchFallback: string = 'main' ) {} async get(): Promise { @@ -54,7 +56,12 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { { projectPath: this.projectPath, pathApi: path, - fs: this.rootFs ?? defaultLocalWorktreeFs, + fs: { + mkdir: async (p, options) => { + await fs.promises.mkdir(p, options); + }, + realPath: async (p) => fs.promises.realpath(p), + }, homeDirectory: os.homedir(), } ); @@ -126,17 +133,17 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { private readonly defaultBranchFallback: string = 'main', private readonly rootFs?: Pick, private readonly projectPath: string = '/', - private readonly exec?: ExecFn + private readonly ctx?: IExecutionContext ) {} private homeDirectory?: Promise; private async getHomeDirectory(): Promise> { - if (!this.exec) { + if (!this.ctx) { return err({ type: 'invalid-worktree-directory' }); } try { - this.homeDirectory ??= resolveRemoteHome(this.exec); + this.homeDirectory ??= resolveRemoteHome(this.ctx); return ok(await this.homeDirectory); } catch { return err({ type: 'invalid-worktree-directory' }); @@ -221,6 +228,20 @@ export class SshProjectSettingsProvider implements ProjectSettingsProvider { }, }); if (normalized.success) { + if (this.rootFs) { + try { + await this.rootFs.mkdir(normalized.data, { recursive: true }); + } catch { + log.warn( + 'SshProjectSettingsProvider: inaccessible worktreeDirectory, falling back to default', + { + worktreeDirectory: settings.worktreeDirectory, + defaultWorktreeDirectory, + } + ); + return defaultWorktreeDirectory; + } + } return normalized.data; } { diff --git a/src/main/core/projects/settings/schema.ts b/src/main/core/projects/settings/schema.ts index 4cb837e347..0b26a0734b 100644 --- a/src/main/core/projects/settings/schema.ts +++ b/src/main/core/projects/settings/schema.ts @@ -34,6 +34,13 @@ export const projectSettingsSchema = z.object({ worktreeDirectory: z.string().trim().optional(), defaultBranch: defaultBranchSettingSchema.optional(), remote: z.string().optional(), + workspaceProvider: z + .object({ + type: z.literal('script'), + provisionCommand: z.string().min(1), + terminateCommand: z.string().min(1), + }) + .optional(), }); export type ProjectSettings = z.infer; diff --git a/src/main/core/projects/settings/worktree-directory.ts b/src/main/core/projects/settings/worktree-directory.ts index 37c425b1c5..aba0017f88 100644 --- a/src/main/core/projects/settings/worktree-directory.ts +++ b/src/main/core/projects/settings/worktree-directory.ts @@ -1,11 +1,8 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import type path from 'node:path'; import type { UpdateProjectSettingsError } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; import type { FileSystemProvider } from '@main/core/fs/types'; -export type WorktreeDirectoryFs = Pick; - type PathApi = Pick; export async function normalizeWorktreeDirectory( @@ -44,7 +41,7 @@ export async function normalizeWorktreeDirectory( export async function canonicalizeWorktreeDirectory( directory: string, - fs: WorktreeDirectoryFs + fs: Pick ): Promise> { try { await fs.mkdir(directory, { recursive: true }); @@ -54,19 +51,12 @@ export async function canonicalizeWorktreeDirectory( } } -export const defaultLocalWorktreeFs: WorktreeDirectoryFs = { - mkdir: async (p, options) => { - await fs.promises.mkdir(p, options); - }, - realPath: async (p) => fs.promises.realpath(p), -}; - export async function resolveAndValidateWorktreeDirectory( input: string | undefined, options: { projectPath: string; pathApi: Pick; - fs: WorktreeDirectoryFs; + fs: Pick; homeDirectory?: string; resolveHomeDirectory?: () => Promise; } diff --git a/src/main/core/projects/utils.ts b/src/main/core/projects/utils.ts index 4c1af08d17..4efc098ce2 100644 --- a/src/main/core/projects/utils.ts +++ b/src/main/core/projects/utils.ts @@ -1,11 +1,12 @@ -import { projectManager } from './project-manager'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { taskManager } from '../tasks/task-manager'; -export function resolveTask(projectId: string, taskId: string) { - return projectManager.getProject(projectId)?.getTask(taskId) ?? null; +export function resolveTask(_projectId: string, taskId: string) { + return taskManager.getTask(taskId) ?? null; } -export function resolveWorkspace(projectId: string, workspaceId: string) { - return projectManager.getProject(projectId)?.getWorkspace(workspaceId) ?? null; +export function resolveWorkspace(_projectId: string, workspaceId: string) { + return workspaceRegistry.get(workspaceId) ?? null; } export class TimeoutSignal extends Error { @@ -21,3 +22,37 @@ export function withTimeout(promise: Promise, ms: number): Promise { }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } + +export type TimeoutError = { + type: 'timeout'; + scope: T; + timeout: number; + message?: string; +}; + +export function timeoutError( + scope: T, + timeout: number, + message?: string +): TimeoutError { + return { + type: 'timeout', + scope, + timeout, + message, + }; +} + +export type AbortError = { + type: 'abort'; + scope: T; + message?: string; +}; + +export function abortError(scope: T, message?: string): AbortError { + return { + type: 'abort', + scope, + message, + }; +} diff --git a/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts b/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts new file mode 100644 index 0000000000..e443d7ef34 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FileSystemErrorCodes } from '@main/core/fs/types'; +import { isPathInsideRoot, LocalWorktreeHost } from './local-worktree-host'; + +describe('LocalWorktreeHost', () => { + let repoDir: string; + let worktreeDir: string; + let outsideDir: string; + + beforeEach(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-repo-')); + worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-worktrees-')); + outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-outside-')); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(worktreeDir, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + }); + + async function makeHost(): Promise { + return LocalWorktreeHost.create({ + allowedRoots: [repoDir, worktreeDir], + }); + } + + it('copies files between separate allowed roots using absolute paths', async () => { + const host = await makeHost(); + const src = path.join(repoDir, '.env'); + const dest = path.join(worktreeDir, 'task-1', '.env'); + fs.writeFileSync(src, 'SECRET=abc'); + + await host.mkdirAbsolute(path.dirname(dest), { recursive: true }); + await host.copyFileAbsolute(src, dest); + + expect(fs.readFileSync(dest, 'utf8')).toBe('SECRET=abc'); + }); + + it('rejects relative paths', async () => { + const host = await makeHost(); + + await expect(host.mkdirAbsolute('relative/path', { recursive: true })).rejects.toMatchObject({ + code: FileSystemErrorCodes.INVALID_PATH, + }); + }); + + it('rejects paths outside the allowed roots', async () => { + const host = await makeHost(); + const src = path.join(outsideDir, 'secret.txt'); + const dest = path.join(worktreeDir, 'secret.txt'); + fs.writeFileSync(src, 'outside'); + + await expect(host.copyFileAbsolute(src, dest)).rejects.toMatchObject({ + code: FileSystemErrorCodes.PATH_ESCAPE, + }); + }); + + it('rejects symlink escapes outside the allowed roots', async () => { + if (process.platform === 'win32') { + return; + } + + const host = await makeHost(); + const secret = path.join(outsideDir, 'passwords.txt'); + const escape = path.join(worktreeDir, 'escape'); + fs.writeFileSync(secret, 'outside'); + fs.symlinkSync(outsideDir, escape); + + await expect(host.realPathAbsolute(path.join(escape, 'passwords.txt'))).rejects.toMatchObject({ + code: FileSystemErrorCodes.PATH_ESCAPE, + }); + }); + + it('returns false/null for out-of-scope existence checks', async () => { + const host = await makeHost(); + const outside = path.join(outsideDir, 'file.txt'); + fs.writeFileSync(outside, 'outside'); + + await expect(host.existsAbsolute(outside)).resolves.toBe(false); + await expect(host.statAbsolute(outside)).resolves.toBeNull(); + }); + + it('matches Windows paths by drive-aware containment rules', () => { + expect( + isPathInsideRoot(String.raw`C:\repo\.env`, String.raw`C:\repo`, { + pathApi: path.win32, + }) + ).toBe(true); + expect( + isPathInsideRoot(String.raw`C:\repo2\.env`, String.raw`C:\repo`, { + pathApi: path.win32, + }) + ).toBe(false); + expect( + isPathInsideRoot(String.raw`D:\repo\.env`, String.raw`C:\repo`, { + pathApi: path.win32, + }) + ).toBe(false); + expect( + isPathInsideRoot(String.raw`c:\repo\.env`, String.raw`C:\Repo`, { + pathApi: path.win32, + }) + ).toBe(true); + }); +}); diff --git a/src/main/core/projects/worktrees/hosts/local-worktree-host.ts b/src/main/core/projects/worktrees/hosts/local-worktree-host.ts new file mode 100644 index 0000000000..9c5a88bce7 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/local-worktree-host.ts @@ -0,0 +1,177 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { glob } from 'glob'; +import { FileSystemError, FileSystemErrorCodes, type FileEntry } from '@main/core/fs/types'; +import type { WorktreeHost } from './worktree-host'; + +type PathApi = Pick; + +export function isPathInsideRoot( + child: string, + parent: string, + options: { pathApi?: PathApi } = {} +): boolean { + const pathApi = options.pathApi ?? path; + const rel = pathApi.relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !pathApi.isAbsolute(rel)); +} + +function isNotFound(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +export class LocalWorktreeHost implements WorktreeHost { + private constructor(private readonly roots: string[]) {} + + static async create(args: { allowedRoots: string[] }): Promise { + if (args.allowedRoots.length === 0) { + throw new FileSystemError( + 'At least one allowed root is required', + FileSystemErrorCodes.INVALID_PATH + ); + } + + const roots = await Promise.all( + args.allowedRoots.map(async (root) => { + const resolved = path.resolve(root); + if (!path.isAbsolute(resolved)) { + throw new FileSystemError( + `Expected absolute allowed root: ${root}`, + FileSystemErrorCodes.INVALID_PATH, + root + ); + } + return fs.realpath(resolved); + }) + ); + + return new LocalWorktreeHost(roots); + } + + private assertAbsolute(input: string): string { + const resolved = path.resolve(input); + if (!path.isAbsolute(input)) { + throw new FileSystemError( + `Expected absolute path: ${input}`, + FileSystemErrorCodes.INVALID_PATH, + input + ); + } + return resolved; + } + + private assertInsideAllowedRoots(resolved: string, originalPath: string): void { + if (!this.roots.some((root) => isPathInsideRoot(resolved, root))) { + throw new FileSystemError( + `Path outside allowed roots: ${originalPath}`, + FileSystemErrorCodes.PATH_ESCAPE, + originalPath + ); + } + } + + private async validateExisting(input: string): Promise { + const resolved = this.assertAbsolute(input); + const real = await fs.realpath(resolved); + this.assertInsideAllowedRoots(real, input); + return real; + } + + private async nearestExistingPath(resolved: string): Promise<{ + realAncestor: string; + unresolvedSegments: string[]; + }> { + const unresolvedSegments: string[] = []; + let current = resolved; + + while (true) { + try { + return { + realAncestor: await fs.realpath(current), + unresolvedSegments: unresolvedSegments.reverse(), + }; + } catch (error) { + if (!isNotFound(error)) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + unresolvedSegments.push(path.basename(current)); + current = parent; + } + } + } + + private async validateTarget(input: string): Promise { + const resolved = this.assertAbsolute(input); + try { + return await this.validateExisting(resolved); + } catch (error) { + if (!isNotFound(error)) throw error; + } + + const { realAncestor, unresolvedSegments } = await this.nearestExistingPath(resolved); + this.assertInsideAllowedRoots(realAncestor, input); + const target = path.join(realAncestor, ...unresolvedSegments); + this.assertInsideAllowedRoots(target, input); + return target; + } + + async existsAbsolute(filePath: string): Promise { + try { + await this.validateExisting(filePath); + return true; + } catch { + return false; + } + } + + async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { + const target = await this.validateTarget(dirPath); + await fs.mkdir(target, { recursive: options?.recursive ?? false }); + } + + async removeAbsolute( + filePath: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }> { + try { + const target = await this.validateExisting(filePath); + await fs.rm(target, { recursive: options?.recursive ?? false, force: false }); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + async realPathAbsolute(filePath: string): Promise { + return this.validateExisting(filePath); + } + + async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { + const cwd = await this.validateExisting(options.cwd); + return glob(pattern, { cwd, dot: options.dot ?? false, absolute: false }); + } + + async copyFileAbsolute(src: string, dest: string): Promise { + const safeSrc = await this.validateExisting(src); + const safeDest = await this.validateTarget(dest); + await fs.copyFile(safeSrc, safeDest); + } + + async statAbsolute(filePath: string): Promise { + try { + const fullPath = await this.validateExisting(filePath); + const stat = await fs.stat(fullPath); + return { + path: fullPath, + type: stat.isDirectory() ? 'dir' : 'file', + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + mode: stat.mode, + }; + } catch { + return null; + } + } +} diff --git a/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts new file mode 100644 index 0000000000..082c43d890 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FileSystemErrorCodes, type FileSystemProvider } from '@main/core/fs/types'; +import { SshWorktreeHost } from './ssh-worktree-host'; + +function makeFs(): Pick< + FileSystemProvider, + 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'copyFile' | 'stat' +> { + return { + exists: vi.fn().mockResolvedValue(true), + mkdir: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue({ success: true }), + realPath: vi.fn().mockResolvedValue('/real/path'), + glob: vi.fn().mockResolvedValue(['.env']), + copyFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue(null), + }; +} + +describe('SshWorktreeHost', () => { + it('delegates absolute POSIX paths to the wrapped filesystem', async () => { + const fs = makeFs(); + const host = new SshWorktreeHost(fs); + + await host.mkdirAbsolute('/remote/worktrees/project', { recursive: true }); + await host.copyFileAbsolute('/remote/repo/.env', '/remote/worktrees/project/task/.env'); + await host.globAbsolute('.env', { cwd: '/remote/repo', dot: true }); + + expect(fs.mkdir).toHaveBeenCalledWith('/remote/worktrees/project', { recursive: true }); + expect(fs.copyFile).toHaveBeenCalledWith( + '/remote/repo/.env', + '/remote/worktrees/project/task/.env' + ); + expect(fs.glob).toHaveBeenCalledWith('.env', { cwd: '/remote/repo', dot: true }); + }); + + it('rejects relative paths before delegating', async () => { + const fs = makeFs(); + const host = new SshWorktreeHost(fs); + + await expect(host.existsAbsolute('relative/path')).rejects.toMatchObject({ + code: FileSystemErrorCodes.INVALID_PATH, + }); + expect(fs.exists).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts new file mode 100644 index 0000000000..68704910de --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; +import { + FileSystemError, + FileSystemErrorCodes, + type FileEntry, + type FileSystemProvider, +} from '@main/core/fs/types'; +import type { WorktreeHost } from './worktree-host'; + +type SshWorktreeFs = Pick< + FileSystemProvider, + 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'copyFile' | 'stat' +>; + +export class SshWorktreeHost implements WorktreeHost { + constructor(private readonly fs: SshWorktreeFs) {} + + private validateAbsolute(input: string): string { + if (!path.posix.isAbsolute(input)) { + throw new FileSystemError( + `Expected absolute POSIX path: ${input}`, + FileSystemErrorCodes.INVALID_PATH, + input + ); + } + return input; + } + + async existsAbsolute(filePath: string): Promise { + return this.fs.exists(this.validateAbsolute(filePath)); + } + + async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { + return this.fs.mkdir(this.validateAbsolute(dirPath), options); + } + + async removeAbsolute( + filePath: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }> { + return this.fs.remove(this.validateAbsolute(filePath), options); + } + + async realPathAbsolute(filePath: string): Promise { + return this.fs.realPath(this.validateAbsolute(filePath)); + } + + async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { + return this.fs.glob(pattern, { + ...options, + cwd: this.validateAbsolute(options.cwd), + }); + } + + async copyFileAbsolute(src: string, dest: string): Promise { + return this.fs.copyFile(this.validateAbsolute(src), this.validateAbsolute(dest)); + } + + async statAbsolute(filePath: string): Promise { + return this.fs.stat(this.validateAbsolute(filePath)); + } +} diff --git a/src/main/core/projects/worktrees/hosts/worktree-host.ts b/src/main/core/projects/worktrees/hosts/worktree-host.ts new file mode 100644 index 0000000000..fc5e8d97c7 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/worktree-host.ts @@ -0,0 +1,14 @@ +import type { FileEntry } from '@main/core/fs/types'; + +export interface WorktreeHost { + existsAbsolute(path: string): Promise; + mkdirAbsolute(path: string, options?: { recursive?: boolean }): Promise; + removeAbsolute( + path: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }>; + realPathAbsolute(path: string): Promise; + globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise; + copyFileAbsolute(src: string, dest: string): Promise; + statAbsolute(path: string): Promise; +} diff --git a/src/main/core/projects/worktrees/utils.ts b/src/main/core/projects/worktrees/utils.ts index 0bf67a1492..68548e106c 100644 --- a/src/main/core/projects/worktrees/utils.ts +++ b/src/main/core/projects/worktrees/utils.ts @@ -1,6 +1,9 @@ import fs from 'fs'; import path from 'path'; -import { FileSystemProvider } from '@main/core/fs/types'; +import type { Task } from '@shared/tasks'; +import type { FileSystemProvider } from '@main/core/fs/types'; +import { mapWorktreeErrorToProvisionError } from '../../tasks/provision-task-error'; +import type { WorktreeService } from './worktree-service'; export const ensureLocalWorktreeDirectory = ({ directory, @@ -33,3 +36,24 @@ export const ensureSshWorktreeDirectory = async ({ } return directory; }; + +export async function resolveTaskWorkDir( + task: Pick, + projectPath: string, + worktreeService: WorktreeService +): Promise { + if (!task.taskBranch) return projectPath; + + const existing = await worktreeService.getWorktree(task.taskBranch); + if (existing) return existing; + + if (!task.sourceBranch || task.taskBranch === task.sourceBranch.branch) { + const result = await worktreeService.checkoutExistingBranch(task.taskBranch); + if (!result.success) throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); + return result.data; + } + + const result = await worktreeService.checkoutBranchWorktree(task.sourceBranch, task.taskBranch); + if (!result.success) throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); + return result.data; +} diff --git a/src/main/core/projects/worktrees/worktree-service.test.ts b/src/main/core/projects/worktrees/worktree-service.test.ts index dc2c9e8a39..e9154c278b 100644 --- a/src/main/core/projects/worktrees/worktree-service.test.ts +++ b/src/main/core/projects/worktrees/worktree-service.test.ts @@ -4,17 +4,26 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Remote } from '@shared/git'; import { ok } from '@shared/result'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { getLocalExec, type ExecFn } from '@main/core/utils/exec'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import type { ProjectSettingsProvider } from '../settings/schema'; +import { LocalWorktreeHost } from './hosts/local-worktree-host'; +import type { WorktreeHost } from './hosts/worktree-host'; import { WorktreeService } from './worktree-service'; -async function initRepo(dir: string, exec: ExecFn): Promise { - await exec('git', ['init'], { cwd: dir }); - await exec('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir }); - await exec('git', ['config', 'user.email', 'test@test.com'], { cwd: dir }); - await exec('git', ['config', 'user.name', 'Test'], { cwd: dir }); - await exec('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir }); +async function git( + args: string[], + opts: { cwd: string } +): Promise<{ stdout: string; stderr: string }> { + const ctx = new LocalExecutionContext({ root: opts.cwd }); + return ctx.exec('git', args); +} + +async function initRepo(dir: string): Promise { + await git(['init'], { cwd: dir }); + await git(['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir }); + await git(['config', 'user.email', 'test@test.com'], { cwd: dir }); + await git(['config', 'user.name', 'Test'], { cwd: dir }); + await git(['commit', '--allow-empty', '-m', 'init'], { cwd: dir }); } function makeSettings(preservePatterns: string[] = []): ProjectSettingsProvider { @@ -33,13 +42,15 @@ const originRemote = (url = 'ssh://example.com/repo.git'): Remote => ({ name: 'o describe('WorktreeService', () => { let repoDir: string; let poolDir: string; - let exec: ExecFn; + let host: WorktreeHost; beforeEach(async () => { repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-repo-')); poolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-pool-')); - exec = getLocalExec(); - await initRepo(repoDir, exec); + await initRepo(repoDir); + host = await LocalWorktreeHost.create({ + allowedRoots: [repoDir, poolDir], + }); }); afterEach(() => { @@ -51,23 +62,22 @@ describe('WorktreeService', () => { overrides: Partial<{ worktreePoolPath: string; repoPath: string; - exec: ExecFn; projectSettings: ProjectSettingsProvider; }> = {} ): WorktreeService { + const repoPath = overrides.repoPath ?? repoDir; return new WorktreeService({ - worktreePoolPath: poolDir, - repoPath: repoDir, - exec, - rootFs: new LocalFileSystem('/'), - projectSettings: makeSettings(), - ...overrides, + worktreePoolPath: overrides.worktreePoolPath ?? poolDir, + repoPath, + ctx: new LocalExecutionContext({ root: repoPath }), + host, + projectSettings: overrides.projectSettings ?? makeSettings(), }); } describe('checkoutBranchWorktree', () => { it('creates a worktree from an existing local source branch', async () => { - await exec('git', ['branch', 'task/local-checkout'], { cwd: repoDir }); + await git(['branch', 'task/local-checkout'], { cwd: repoDir }); const svc = makeService(); const result = await svc.checkoutBranchWorktree( @@ -84,11 +94,11 @@ describe('WorktreeService', () => { it('creates a worktree from a remote source branch when branch is not local', async () => { const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-remote-')); try { - await exec('git', ['init', '--bare'], { cwd: remoteDir }); - await exec('git', ['remote', 'add', 'origin', remoteDir], { cwd: repoDir }); - await exec('git', ['branch', 'feature/remote-base'], { cwd: repoDir }); - await exec('git', ['push', '-u', 'origin', 'feature/remote-base'], { cwd: repoDir }); - await exec('git', ['branch', '-D', 'feature/remote-base'], { cwd: repoDir }); + await git(['init', '--bare'], { cwd: remoteDir }); + await git(['remote', 'add', 'origin', remoteDir], { cwd: repoDir }); + await git(['branch', 'feature/remote-base'], { cwd: repoDir }); + await git(['push', '-u', 'origin', 'feature/remote-base'], { cwd: repoDir }); + await git(['branch', '-D', 'feature/remote-base'], { cwd: repoDir }); const svc = makeService(); const result = await svc.checkoutBranchWorktree( @@ -100,7 +110,7 @@ describe('WorktreeService', () => { if (!result.success) throw new Error('expected success'); expect(fs.existsSync(result.data)).toBe(true); - const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: result.data, }); expect(stdout.trim()).toBe('task/from-remote'); @@ -110,10 +120,10 @@ describe('WorktreeService', () => { }); it('returns existing checked out path when branch is already checked out elsewhere', async () => { - await exec('git', ['branch', 'feature/already-open'], { cwd: repoDir }); + await git(['branch', 'feature/already-open'], { cwd: repoDir }); const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-external-')); const externalPath = path.join(externalDir, 'feature-already-open'); - await exec('git', ['worktree', 'add', externalPath, 'feature/already-open'], { + await git(['worktree', 'add', externalPath, 'feature/already-open'], { cwd: repoDir, }); @@ -145,7 +155,7 @@ describe('WorktreeService', () => { it('copies preserved files into the created worktree', async () => { fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=abc'); - await exec('git', ['branch', 'task/env-test'], { cwd: repoDir }); + await git(['branch', 'task/env-test'], { cwd: repoDir }); const svc = makeService({ projectSettings: makeSettings(['.env']) }); const result = await svc.checkoutBranchWorktree( @@ -161,10 +171,10 @@ describe('WorktreeService', () => { describe('checkoutExistingBranch', () => { it('returns existing checked out path when branch is already checked out elsewhere', async () => { - await exec('git', ['branch', 'feature/already-open-existing'], { cwd: repoDir }); + await git(['branch', 'feature/already-open-existing'], { cwd: repoDir }); const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-external-')); const externalPath = path.join(externalDir, 'feature-already-open-existing'); - await exec('git', ['worktree', 'add', externalPath, 'feature/already-open-existing'], { + await git(['worktree', 'add', externalPath, 'feature/already-open-existing'], { cwd: repoDir, }); @@ -181,11 +191,11 @@ describe('WorktreeService', () => { it('creates local branch from remote when needed', async () => { const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-remote-')); try { - await exec('git', ['init', '--bare'], { cwd: remoteDir }); - await exec('git', ['remote', 'add', 'origin', remoteDir], { cwd: repoDir }); - await exec('git', ['branch', 'feature/from-remote'], { cwd: repoDir }); - await exec('git', ['push', '-u', 'origin', 'feature/from-remote'], { cwd: repoDir }); - await exec('git', ['branch', '-D', 'feature/from-remote'], { cwd: repoDir }); + await git(['init', '--bare'], { cwd: remoteDir }); + await git(['remote', 'add', 'origin', remoteDir], { cwd: repoDir }); + await git(['branch', 'feature/from-remote'], { cwd: repoDir }); + await git(['push', '-u', 'origin', 'feature/from-remote'], { cwd: repoDir }); + await git(['branch', '-D', 'feature/from-remote'], { cwd: repoDir }); const svc = makeService(); const result = await svc.checkoutExistingBranch('feature/from-remote'); diff --git a/src/main/core/projects/worktrees/worktree-service.ts b/src/main/core/projects/worktrees/worktree-service.ts index 82f42479e8..7a4c033dce 100644 --- a/src/main/core/projects/worktrees/worktree-service.ts +++ b/src/main/core/projects/worktrees/worktree-service.ts @@ -1,11 +1,12 @@ +import { promises as fsPromises } from 'node:fs'; import path from 'node:path'; import type { Branch } from '@shared/git'; import { DEFAULT_REMOTE_NAME } from '@shared/git-utils'; -import { err, ok, Result } from '@shared/result'; -import { FileSystemProvider } from '@main/core/fs/types'; -import { ExecFn } from '@main/core/utils/exec'; +import { err, ok, type Result } from '@shared/result'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { log } from '@main/lib/logger'; -import { ProjectSettingsProvider } from '../settings/schema'; +import type { ProjectSettingsProvider } from '../settings/schema'; +import type { WorktreeHost } from './hosts/worktree-host'; export type ServeWorktreeError = | { type: 'worktree-setup-failed'; cause: unknown } @@ -15,24 +16,24 @@ export class WorktreeService { private gitOpQueue: Promise = Promise.resolve(); private readonly worktreePoolPath: string; private readonly repoPath: string; - private readonly exec: ExecFn; - private readonly rootFs: FileSystemProvider; + private readonly ctx: IExecutionContext; + private readonly host: WorktreeHost; private readonly projectSettings: ProjectSettingsProvider; constructor(args: { worktreePoolPath: string; repoPath: string; - exec: ExecFn; - rootFs: FileSystemProvider; + ctx: IExecutionContext; + host: WorktreeHost; projectSettings: ProjectSettingsProvider; }) { this.worktreePoolPath = args.worktreePoolPath; this.repoPath = args.repoPath; this.projectSettings = args.projectSettings; - this.exec = args.exec; - this.rootFs = args.rootFs; + this.ctx = args.ctx; + this.host = args.host; - this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); + this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } private enqueueGitOp(fn: () => Promise): Promise { @@ -42,16 +43,23 @@ export class WorktreeService { } private async isValidWorktree(worktreePath: string): Promise { - try { - await this.exec('git', ['rev-parse', '--git-dir'], { cwd: worktreePath }); - return true; - } catch { - return false; + // A linked worktree contains a .git FILE pointing to the main repo's worktrees + // directory. For local execution we bypass host path-restriction checks and use + // fs directly so external worktrees (outside allowedRoots) are still detected. + // For SSH we rely on the host (SshWorktreeHost has no root restrictions). + if (this.ctx.supportsLocalSpawn) { + try { + await fsPromises.access(path.join(worktreePath, '.git')); + return true; + } catch { + return false; + } } + return this.host.existsAbsolute(path.join(worktreePath, '.git')); } private async ensureWorktreePoolDirExists(): Promise { - await this.rootFs.mkdir(this.worktreePoolPath, { recursive: true }); + await this.host.mkdirAbsolute(this.worktreePoolPath, { recursive: true }); } private async getRemoteCandidates(): Promise { @@ -64,9 +72,7 @@ export class WorktreeService { private async findCheckedOutPathForBranch(branchName: string): Promise { try { - const { stdout } = await this.exec('git', ['worktree', 'list', '--porcelain'], { - cwd: this.repoPath, - }); + const { stdout } = await this.ctx.exec('git', ['worktree', 'list', '--porcelain']); const branchLine = `branch refs/heads/${branchName}`; for (const block of stdout.split('\n\n')) { if (!block.split('\n').some((line) => line === branchLine)) { @@ -78,7 +84,7 @@ export class WorktreeService { if (await this.isValidWorktree(candidatePath)) { return candidatePath; } - await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); + await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } } catch {} return undefined; @@ -92,7 +98,7 @@ export class WorktreeService { if (sourceBranch.type === 'local') { const localRef = `refs/heads/${sourceBranch.branch}`; try { - await this.exec('git', ['rev-parse', '--verify', localRef], { cwd: this.repoPath }); + await this.ctx.exec('git', ['rev-parse', '--verify', localRef]); return localRef; } catch { return undefined; @@ -100,10 +106,10 @@ export class WorktreeService { } const remoteName = sourceBranch.remote.name; - await this.exec('git', ['fetch', remoteName], { cwd: this.repoPath }).catch(() => {}); + await this.ctx.exec('git', ['fetch', remoteName]).catch(() => {}); const remoteRef = `refs/remotes/${remoteName}/${sourceBranch.branch}`; try { - await this.exec('git', ['rev-parse', '--verify', remoteRef], { cwd: this.repoPath }); + await this.ctx.exec('git', ['rev-parse', '--verify', remoteRef]); return remoteRef; } catch { return undefined; @@ -112,16 +118,14 @@ export class WorktreeService { async getWorktree(branchName: string): Promise { const worktreePath = path.join(this.worktreePoolPath, branchName); - if (await this.rootFs.exists(worktreePath)) { + if (await this.host.existsAbsolute(worktreePath)) { if (await this.isValidWorktree(worktreePath)) return worktreePath; - await this.rootFs.remove(worktreePath, { recursive: true }).catch(() => {}); + await this.host.removeAbsolute(worktreePath, { recursive: true }).catch(() => {}); } try { - const realPoolPath = await this.rootFs.realPath(this.worktreePoolPath); - const { stdout } = await this.exec('git', ['worktree', 'list', '--porcelain'], { - cwd: this.repoPath, - }); + const realPoolPath = await this.host.realPathAbsolute(this.worktreePoolPath); + const { stdout } = await this.ctx.exec('git', ['worktree', 'list', '--porcelain']); const branchLine = `branch refs/heads/${branchName}`; for (const block of stdout.split('\n\n')) { if (block.split('\n').some((line) => line === branchLine)) { @@ -151,18 +155,16 @@ export class WorktreeService { } const targetPath = path.join(this.worktreePoolPath, branchName); - if (await this.rootFs.exists(targetPath)) { + if (await this.host.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) return ok(targetPath); - await this.rootFs.remove(targetPath, { recursive: true }).catch(() => {}); - await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); + await this.host.removeAbsolute(targetPath, { recursive: true }).catch(() => {}); + await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } try { let localExists = false; try { - await this.exec('git', ['rev-parse', '--verify', `refs/heads/${branchName}`], { - cwd: this.repoPath, - }); + await this.ctx.exec('git', ['rev-parse', '--verify', `refs/heads/${branchName}`]); localExists = true; } catch {} @@ -171,16 +173,12 @@ export class WorktreeService { if (!sourceRef) { return err({ type: 'branch-not-found', branch: sourceBranch?.branch ?? branchName }); } - await this.exec('git', ['branch', '--no-track', branchName, sourceRef], { - cwd: this.repoPath, - }); + await this.ctx.exec('git', ['branch', '--no-track', branchName, sourceRef]); } - await this.rootFs.mkdir(path.dirname(targetPath), { recursive: true }); - await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); - await this.exec('git', ['worktree', 'add', targetPath, branchName], { - cwd: this.repoPath, - }); + await this.host.mkdirAbsolute(path.dirname(targetPath), { recursive: true }); + await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); + await this.ctx.exec('git', ['worktree', 'add', targetPath, branchName]); } catch (cause) { return err({ type: 'worktree-setup-failed', cause }); } @@ -211,22 +209,20 @@ export class WorktreeService { const targetPath = path.join(this.worktreePoolPath, branchName); const remoteCandidates = await this.getRemoteCandidates(); - if (await this.rootFs.exists(targetPath)) { + if (await this.host.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) return ok(targetPath); - await this.rootFs.remove(targetPath, { recursive: true }); - await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); + await this.host.removeAbsolute(targetPath, { recursive: true }); + await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } try { - await this.rootFs.mkdir(path.dirname(targetPath), { recursive: true }); + await this.host.mkdirAbsolute(path.dirname(targetPath), { recursive: true }); for (const remoteName of remoteCandidates) { - await this.exec('git', ['fetch', remoteName], { cwd: this.repoPath }).catch(() => {}); + await this.ctx.exec('git', ['fetch', remoteName]).catch(() => {}); } let localExists = false; try { - await this.exec('git', ['rev-parse', '--verify', `refs/heads/${branchName}`], { - cwd: this.repoPath, - }); + await this.ctx.exec('git', ['rev-parse', '--verify', `refs/heads/${branchName}`]); localExists = true; } catch {} @@ -234,13 +230,11 @@ export class WorktreeService { let trackingRemote: string | undefined; for (const remoteName of remoteCandidates) { try { - await this.exec( - 'git', - ['rev-parse', '--verify', `refs/remotes/${remoteName}/${branchName}`], - { - cwd: this.repoPath, - } - ); + await this.ctx.exec('git', [ + 'rev-parse', + '--verify', + `refs/remotes/${remoteName}/${branchName}`, + ]); trackingRemote = remoteName; break; } catch {} @@ -248,19 +242,16 @@ export class WorktreeService { if (!trackingRemote) { return err({ type: 'branch-not-found', branch: branchName }); } - await this.exec( - 'git', - ['branch', '--track', branchName, `${trackingRemote}/${branchName}`], - { - cwd: this.repoPath, - } - ); + await this.ctx.exec('git', [ + 'branch', + '--track', + branchName, + `${trackingRemote}/${branchName}`, + ]); } - await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); - await this.exec('git', ['worktree', 'add', targetPath, branchName], { - cwd: this.repoPath, - }); + await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); + await this.ctx.exec('git', ['worktree', 'add', targetPath, branchName]); } catch (cause) { return err({ type: 'worktree-setup-failed', cause }); } @@ -276,29 +267,29 @@ export class WorktreeService { } async moveWorktree(oldPath: string, newPath: string): Promise { - await this.exec('git', ['worktree', 'move', oldPath, newPath], { cwd: this.repoPath }); + await this.ctx.exec('git', ['worktree', 'move', oldPath, newPath]); } async removeWorktree(worktreePath: string): Promise { - await this.rootFs.remove(worktreePath, { recursive: true }).catch(() => {}); - await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); + await this.host.removeAbsolute(worktreePath, { recursive: true }).catch(() => {}); + await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } private async copyPreservedFiles(targetPath: string): Promise { const settings = await this.projectSettings.get(); const patterns = settings.preservePatterns ?? []; for (const pattern of patterns) { - const matches = await this.rootFs.glob(pattern, { + const matches = await this.host.globAbsolute(pattern, { cwd: this.repoPath, dot: true, }); for (const relPath of matches) { const src = path.join(this.repoPath, relPath); - const stat = await this.rootFs.stat(src).catch(() => null); + const stat = await this.host.statAbsolute(src).catch(() => null); if (!stat || stat.type !== 'file') continue; const dest = path.join(targetPath, relPath); - await this.rootFs.mkdir(path.dirname(dest), { recursive: true }); - await this.rootFs.copyFile(src, dest); + await this.host.mkdirAbsolute(path.dirname(dest), { recursive: true }); + await this.host.copyFileAbsolute(src, dest); } } } diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index fa69a23522..3575fa6b2d 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -1,8 +1,10 @@ +import { randomUUID } from 'node:crypto'; +import { basename } from 'node:path'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; import { log } from '@main/lib/logger'; -import type { SshProjectProvider } from '../projects/impl/ssh-project-provider'; -import { projectManager } from '../projects/project-manager'; +import { taskManager } from '../tasks/task-manager'; +import { workspaceRegistry } from '../workspaces/workspace-registry'; import { ptySessionRegistry } from './pty-session-registry'; export const ptyController = createRPCController({ @@ -65,18 +67,21 @@ export const ptyController = createRPCController({ uploadFiles: async (args: { sessionId: string; localPaths: string[] }) => { try { const [projectId, scopeId] = args.sessionId.split(':'); - if (!projectId || !scopeId) { - return err({ type: 'invalid_session' as const }); - } + if (!projectId || !scopeId) return err({ type: 'invalid_session' as const }); - const provider = projectManager.getProject(projectId); - if (!provider || provider.type !== 'ssh') { - return err({ type: 'not_ssh' as const }); - } + const taskProvider = taskManager.getTask(scopeId); + if (!taskProvider) return err({ type: 'not_ssh' as const }); + + const workspaceId = taskManager.getWorkspaceId(scopeId) ?? ''; + const workspace = workspaceRegistry.get(workspaceId); + if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); - const remotePaths = await (provider as SshProjectProvider).uploadFiles( - scopeId, - args.localPaths + const remotePaths = await Promise.all( + args.localPaths.map(async (localPath) => { + const remoteName = `${randomUUID()}-${basename(localPath)}`; + await workspace.fs.copyLocalFile!(localPath, remoteName); + return `${workspace.path}/${remoteName}`; + }) ); return ok({ remotePaths }); } catch (e: unknown) { diff --git a/src/main/core/pty/local-pty.ts b/src/main/core/pty/local-pty.ts index f1b69af416..235940fb6d 100644 --- a/src/main/core/pty/local-pty.ts +++ b/src/main/core/pty/local-pty.ts @@ -1,8 +1,8 @@ -import path from 'node:path'; import * as nodePty from 'node-pty'; import type { IPty } from 'node-pty'; import { log } from '@main/lib/logger'; import { normalizeSignal } from './exit-signals'; +import { suppressExpectedNodePtyErrors } from './node-pty-errors'; import type { Pty, PtyDimensions, PtyExitInfo } from './pty'; export interface LocalSpawnOptions extends PtyDimensions { @@ -18,25 +18,25 @@ const MIN_ROWS = 1; export function spawnLocalPty(options: LocalSpawnOptions): LocalPtySession { const { id, command, args, cwd, env, cols, rows } = options; - const spawnSpec = resolveWindowsPtySpawn(command, args); log.info('LocalPtySession:spawn', { id, - command: spawnSpec.command, - args: spawnSpec.args, + command, + args, cwd, cols, rows, }); try { - const proc = nodePty.spawn(spawnSpec.command, spawnSpec.args, { + const proc = nodePty.spawn(command, args, { name: 'xterm-256color', cols, rows, cwd, env, }); + suppressExpectedNodePtyErrors(proc); return new LocalPtySession(id, proc); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); @@ -86,34 +86,3 @@ export class LocalPtySession implements Pty { }); } } - -function resolveWindowsPtySpawn( - command: string, - args: string[] -): { command: string; args: string[] } { - if (process.platform !== 'win32') return { command, args }; - - const quoteForCmdExe = (input: string): string => { - if (input.length === 0) return '""'; - if (!/[\s"^&|<>()%!]/.test(input)) return input; - return `"${input - .replace(/%/g, '%%') - .replace(/!/g, '^!') - .replace(/(["^&|<>()])/g, '^$1')}"`; - }; - - const ext = path.extname(command).toLowerCase(); - if (ext === '.cmd' || ext === '.bat') { - const comspec = process.env.ComSpec || String.raw`C:\\Windows\\System32\\cmd.exe`; - const fullCommandString = [command, ...args].map(quoteForCmdExe).join(' '); - return { command: comspec, args: ['/d', '/s', '/c', fullCommandString] }; - } - if (ext === '.ps1') { - return { - command: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', command, ...args], - }; - } - - return { command, args }; -} diff --git a/src/main/core/pty/node-pty-errors.ts b/src/main/core/pty/node-pty-errors.ts new file mode 100644 index 0000000000..2a515b155f --- /dev/null +++ b/src/main/core/pty/node-pty-errors.ts @@ -0,0 +1,21 @@ +import type { IPty } from 'node-pty'; +import { log } from '@main/lib/logger'; + +type NodePtyWithErrorEvents = IPty & { + on?: (event: 'error', handler: (error: NodeJS.ErrnoException) => void) => void; +}; + +export function suppressExpectedNodePtyErrors( + proc: IPty, + platform: NodeJS.Platform = process.platform +): void { + if (platform !== 'win32') return; + + (proc as NodePtyWithErrorEvents).on?.('error', (error) => { + if (error.code === 'EPIPE' || error.code === 'EIO') return; + log.warn('node-pty: unexpected PTY error', { + code: error.code, + message: error.message, + }); + }); +} diff --git a/src/main/core/pty/pty-env.test.ts b/src/main/core/pty/pty-env.test.ts new file mode 100644 index 0000000000..b8fd33dd6c --- /dev/null +++ b/src/main/core/pty/pty-env.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalEnv = { ...process.env }; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +async function loadPtyEnv() { + vi.resetModules(); + return import('./pty-env'); +} + +afterEach(() => { + process.env = { ...originalEnv }; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + vi.resetModules(); +}); + +describe('pty env Windows shell handling', () => { + it('does not synthesize /bin/bash as SHELL for Windows terminals', async () => { + setPlatform('win32'); + delete process.env.SHELL; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + const { buildTerminalEnv } = await loadPtyEnv(); + const env = buildTerminalEnv(); + + expect(env.SHELL).toBeUndefined(); + expect(env.ComSpec).toBe('C:\\Windows\\System32\\cmd.exe'); + }); + + it('does not synthesize /bin/bash when includeShellVar is true on Windows', async () => { + setPlatform('win32'); + delete process.env.SHELL; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + const { buildAgentEnv } = await loadPtyEnv(); + const env = buildAgentEnv({ includeShellVar: true, agentApiVars: false }); + + expect(env.SHELL).toBeUndefined(); + expect(env.ComSpec).toBe('C:\\Windows\\System32\\cmd.exe'); + }); + + it('keeps POSIX shell fallback for non-Windows terminal envs', async () => { + setPlatform('linux'); + delete process.env.SHELL; + + const { buildTerminalEnv } = await loadPtyEnv(); + const env = buildTerminalEnv(); + + expect(env.SHELL).toBe('/bin/bash'); + }); + + it('adds provider vars while keeping hook variables authoritative', async () => { + const { buildAgentEnv } = await loadPtyEnv(); + const env = buildAgentEnv({ + agentApiVars: false, + hook: { port: 1234, ptyId: 'claude:conv-1', token: 'real-token' }, + providerVars: { + ANTHROPIC_BASE_URL: 'https://example.test', + EMDASH_HOOK_PORT: '9999', + EMDASH_PTY_ID: 'wrong', + EMDASH_HOOK_TOKEN: 'wrong-token', + }, + }); + + expect(env.ANTHROPIC_BASE_URL).toBe('https://example.test'); + expect(env.EMDASH_HOOK_PORT).toBe('1234'); + expect(env.EMDASH_PTY_ID).toBe('claude:conv-1'); + expect(env.EMDASH_HOOK_TOKEN).toBe('real-token'); + }); +}); diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index bb544d565e..052b9a3881 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import { detectSshAuthSock } from '@main/utils/shellEnv'; +import { getWindowsEnvValue } from '@main/utils/windows-env'; export const AGENT_ENV_VARS = [ 'AMP_API_KEY', @@ -35,6 +36,8 @@ export const AGENT_ENV_VARS = [ 'NO_PROXY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', + 'OPENROUTER_API_KEY', + 'OPENROUTER_BASE_URL', ] as const; const DISPLAY_ENV_VARS = [ @@ -60,25 +63,31 @@ function getWindowsEssentialEnv(resolvedPath: string): Record { const home = os.homedir(); return { PATH: resolvedPath, - PATHEXT: process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC', - SystemRoot: process.env.SystemRoot || 'C:\\Windows', - ComSpec: process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', - TEMP: process.env.TEMP || process.env.TMP || '', - TMP: process.env.TMP || process.env.TEMP || '', - USERPROFILE: process.env.USERPROFILE || home, - APPDATA: process.env.APPDATA || '', - LOCALAPPDATA: process.env.LOCALAPPDATA || '', - HOMEDRIVE: process.env.HOMEDRIVE || '', - HOMEPATH: process.env.HOMEPATH || '', - USERNAME: process.env.USERNAME || os.userInfo().username, - ProgramFiles: process.env.ProgramFiles || 'C:\\Program Files', - 'ProgramFiles(x86)': process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', - ProgramData: process.env.ProgramData || 'C:\\ProgramData', - CommonProgramFiles: process.env.CommonProgramFiles || 'C:\\Program Files\\Common Files', + PATHEXT: + getWindowsEnvValue(process.env, 'PATHEXT') || + '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC', + SystemRoot: getWindowsEnvValue(process.env, 'SystemRoot') || 'C:\\Windows', + ComSpec: getWindowsEnvValue(process.env, 'ComSpec') || 'C:\\Windows\\System32\\cmd.exe', + TEMP: getWindowsEnvValue(process.env, 'TEMP') || getWindowsEnvValue(process.env, 'TMP') || '', + TMP: getWindowsEnvValue(process.env, 'TMP') || getWindowsEnvValue(process.env, 'TEMP') || '', + USERPROFILE: getWindowsEnvValue(process.env, 'USERPROFILE') || home, + APPDATA: getWindowsEnvValue(process.env, 'APPDATA') || '', + LOCALAPPDATA: getWindowsEnvValue(process.env, 'LOCALAPPDATA') || '', + HOMEDRIVE: getWindowsEnvValue(process.env, 'HOMEDRIVE') || '', + HOMEPATH: getWindowsEnvValue(process.env, 'HOMEPATH') || '', + USERNAME: getWindowsEnvValue(process.env, 'USERNAME') || os.userInfo().username, + ProgramFiles: getWindowsEnvValue(process.env, 'ProgramFiles') || 'C:\\Program Files', + 'ProgramFiles(x86)': + getWindowsEnvValue(process.env, 'ProgramFiles(x86)') || 'C:\\Program Files (x86)', + ProgramData: getWindowsEnvValue(process.env, 'ProgramData') || 'C:\\ProgramData', + CommonProgramFiles: + getWindowsEnvValue(process.env, 'CommonProgramFiles') || 'C:\\Program Files\\Common Files', 'CommonProgramFiles(x86)': - process.env['CommonProgramFiles(x86)'] || 'C:\\Program Files (x86)\\Common Files', - ProgramW6432: process.env.ProgramW6432 || 'C:\\Program Files', - CommonProgramW6432: process.env.CommonProgramW6432 || 'C:\\Program Files\\Common Files', + getWindowsEnvValue(process.env, 'CommonProgramFiles(x86)') || + 'C:\\Program Files (x86)\\Common Files', + ProgramW6432: getWindowsEnvValue(process.env, 'ProgramW6432') || 'C:\\Program Files', + CommonProgramW6432: + getWindowsEnvValue(process.env, 'CommonProgramW6432') || 'C:\\Program Files\\Common Files', }; } @@ -107,10 +116,9 @@ export interface AgentEnvOptions { }; /** - * Per-provider custom env vars configured by the user. - * Keys are validated against ^[A-Za-z_][A-Za-z0-9_]*$. + * Per-provider variables configured in custom execution settings. */ - customVars?: Record; + providerVars?: Record; } /** @@ -120,8 +128,9 @@ export interface AgentEnvOptions { * feels identical to one opened in Ghostty or Terminal.app — the user's * EDITOR, MANPATH, JAVA_HOME, custom vars, etc. are all present. * - * TERM, COLORTERM, TERM_PROGRAM, and SHELL are always set or overridden so - * the shell and programs inside it report the correct terminal identity. + * TERM, COLORTERM, and TERM_PROGRAM are always set or overridden so programs + * inside the terminal report the correct terminal identity. SHELL is only + * synthesized on POSIX platforms. * SSH_AUTH_SOCK is injected via the same cached detector used for agents, * since GUI-launched apps often don't inherit it from the user's login shell. */ @@ -137,8 +146,13 @@ export function buildTerminalEnv(): Record { env.COLORTERM = 'truecolor'; env.TERM_PROGRAM = 'emdash'; - // Ensure SHELL reflects the user's configured shell (may be absent in GUI). - env.SHELL = process.env.SHELL ?? (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); + // Ensure SHELL reflects the user's configured shell on POSIX. Native Windows + // shells are selected via ComSpec by the spawn resolver, not SHELL. + if (process.platform !== 'win32') { + env.SHELL = process.env.SHELL ?? (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); + } else if (process.env.SHELL) { + env.SHELL = process.env.SHELL; + } // SSH_AUTH_SOCK is normally set by resolveUserEnv() at startup. The // detectSshAuthSock() fallback covers cases where that failed (timeout, @@ -160,11 +174,14 @@ export function buildTerminalEnv(): Record { * find its own dependencies. */ export function buildAgentEnv(options: AgentEnvOptions = {}): Record { - const { agentApiVars = true, includeShellVar = false, hook, customVars } = options; + const { agentApiVars = true, includeShellVar = false, hook, providerVars } = options; // process.env.PATH is enriched at startup by resolveUserEnv() so it already // contains the full login-shell PATH (Homebrew, nvm, npm globals, etc.). - const resolvedPath = process.env.PATH ?? ''; + const resolvedPath = + process.platform === 'win32' + ? (getWindowsEnvValue(process.env, 'PATH') ?? '') + : (process.env.PATH ?? ''); const env: Record = { TERM: 'xterm-256color', COLORTERM: 'truecolor', @@ -181,8 +198,10 @@ export function buildAgentEnv(options: AgentEnvOptions = {}): Record 0) { env.EMDASH_HOOK_PORT = String(hook.port); env.EMDASH_PTY_ID = hook.ptyId; env.EMDASH_HOOK_TOKEN = hook.token; } - if (customVars) { - for (const [key, val] of Object.entries(customVars)) { - if (typeof val === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { - env[key] = val; - } - } - } - return env; } diff --git a/src/main/core/pty/pty-spawn-platform.test.ts b/src/main/core/pty/pty-spawn-platform.test.ts new file mode 100644 index 0000000000..8833d338bf --- /dev/null +++ b/src/main/core/pty/pty-spawn-platform.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import { resolveLocalPtySpawn } from './pty-spawn-platform'; + +const winEnv = { + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD;.PS1', +} satisfies NodeJS.ProcessEnv; + +const posixEnv = { + SHELL: '/bin/bash', +} satisfies NodeJS.ProcessEnv; + +describe('resolveLocalPtySpawn - Windows', () => { + const windowsPathEnv = { + ...winEnv, + Path: 'C:\\Users\\me\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs', + } satisfies NodeJS.ProcessEnv; + + it('uses ComSpec for interactive shells without POSIX flags', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { kind: 'interactive-shell', cwd: 'C:\\repo' }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: [], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('direct-spawns argv commands when no Windows-unsupported shell features are present', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'node.exe', args: ['--version'] }, + }, + }); + + expect(result).toEqual({ + command: 'node.exe', + args: ['--version'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('resolves extensionless commands through PATH and PATHEXT before wrapping cmd shims', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: windowsPathEnv, + fileExists: (candidate) => candidate === 'C:\\Users\\me\\AppData\\Roaming\\npm\\codex.CMD', + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'codex', args: ['hello world'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'C:\\Users\\me\\AppData\\Roaming\\npm\\codex.CMD "hello world"'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('direct-spawns extensionless commands that resolve to exe files', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: windowsPathEnv, + fileExists: (candidate) => candidate === 'C:\\Program Files\\nodejs\\node.EXE', + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'node', args: ['--version'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Program Files\\nodejs\\node.EXE', + args: ['--version'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('falls back to cmd.exe for unresolved extensionless commands', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: windowsPathEnv, + fileExists: () => false, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'codex', args: ['A&B', '100%'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'codex "A^&B" "100%%"'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('wraps cmd and bat argv commands through cmd.exe', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'pnpm.cmd', args: ['run', 'dev'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'pnpm.cmd run dev'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('quotes cmd wrapper arguments that contain Windows metacharacters', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'tool.cmd', args: ['hello world', 'A&B'] }, + }, + }); + + expect(result.args).toEqual(['/d', '/s', '/c', 'tool.cmd "hello world" "A^&B"']); + }); + + it('wraps PowerShell scripts through powershell.exe -File', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'scripts\\setup.ps1', args: ['-Verbose'] }, + }, + }); + + expect(result).toEqual({ + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'scripts\\setup.ps1', '-Verbose'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('runs shell-line commands through cmd.exe /d /s /c', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'shell-line', commandLine: 'pnpm run dev' }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'pnpm run dev'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('returns warnings for ignored shellSetup and tmux on Windows', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'interactive-shell', + cwd: 'C:\\repo', + shellSetup: 'source ~/.nvm/nvm.sh', + tmuxSessionName: 'session-1', + }, + }); + + expect(result.warnings).toEqual([ + 'shell_setup_ignored_on_windows', + 'tmux_unsupported_on_windows', + ]); + }); +}); + +describe('resolveLocalPtySpawn - POSIX', () => { + it('uses SHELL -il for interactive shells', () => { + const result = resolveLocalPtySpawn({ + platform: 'darwin', + env: posixEnv, + intent: { kind: 'interactive-shell', cwd: '/repo' }, + }); + + expect(result).toEqual({ + command: '/bin/bash', + args: ['-il'], + cwd: '/repo', + warnings: [], + }); + }); + + it('quotes argv commands before shell wrapping', () => { + const result = resolveLocalPtySpawn({ + platform: 'linux', + env: posixEnv, + intent: { + kind: 'run-command', + cwd: '/repo', + command: { kind: 'argv', command: 'node', args: ['script name.js', "it's ok"] }, + }, + }); + + expect(result).toEqual({ + command: '/bin/bash', + args: ['-c', "node 'script name.js' 'it'\\''s ok'"], + cwd: '/repo', + warnings: [], + }); + }); + + it('prepends shellSetup to shell-line commands', () => { + const result = resolveLocalPtySpawn({ + platform: 'linux', + env: posixEnv, + intent: { + kind: 'run-command', + cwd: '/repo', + shellSetup: 'source ~/.nvm/nvm.sh', + command: { kind: 'shell-line', commandLine: 'pnpm run dev' }, + }, + }); + + expect(result).toEqual({ + command: '/bin/bash', + args: ['-c', 'source ~/.nvm/nvm.sh && pnpm run dev'], + cwd: '/repo', + warnings: [], + }); + }); +}); diff --git a/src/main/core/pty/pty-spawn-platform.ts b/src/main/core/pty/pty-spawn-platform.ts new file mode 100644 index 0000000000..ad586e7b95 --- /dev/null +++ b/src/main/core/pty/pty-spawn-platform.ts @@ -0,0 +1,264 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { log } from '@main/lib/logger'; +import { getWindowsEnvValue } from '@main/utils/windows-env'; +import { buildTmuxShellLine } from './tmux-session-name'; + +export type PtyCommandSpec = + | { kind: 'argv'; command: string; args: string[] } + | { kind: 'shell-line'; commandLine: string }; + +export type PtySpawnIntent = + | { + kind: 'interactive-shell'; + cwd: string; + shellSetup?: string; + tmuxSessionName?: string; + } + | { + kind: 'run-command'; + cwd: string; + command: PtyCommandSpec; + shellSetup?: string; + tmuxSessionName?: string; + }; + +export type LocalPtySpawnWarning = 'shell_setup_ignored_on_windows' | 'tmux_unsupported_on_windows'; + +export type ResolvedLocalPtySpawn = { + command: string; + args: string[]; + cwd: string; + warnings: LocalPtySpawnWarning[]; +}; + +type FileExists = (candidate: string) => boolean; + +function getPosixShell(env: NodeJS.ProcessEnv): string { + return env.SHELL || '/bin/sh'; +} + +function getWindowsShell(env: NodeJS.ProcessEnv): string { + return env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'; +} + +function isWindows(platform: NodeJS.Platform): boolean { + return platform === 'win32'; +} + +function quotePosixArg(input: string): string { + if (input.length === 0) return "''"; + if (!/[\s'"\\$`\n\r\t;&|<>(){}[\]*?!]/.test(input)) return input; + return `'${input.replace(/'/g, "'\\''")}'`; +} + +function argvToPosixShellLine(command: string, args: string[]): string { + return [command, ...args].map(quotePosixArg).join(' '); +} + +function quoteForCmdExe(input: string): string { + if (input.length === 0) return '""'; + if (!/[\s"^&|<>()%!]/.test(input)) return input; + return `"${input + .replace(/%/g, '%%') + .replace(/!/g, '^!') + .replace(/(["^&|<>()])/g, '^$1')}"`; +} + +function getWindowsPathDirs(env: NodeJS.ProcessEnv): string[] { + const rawPath = getWindowsEnvValue(env, 'PATH') ?? ''; + return rawPath.split(path.win32.delimiter).filter(Boolean); +} + +function getWindowsPathExts(env: NodeJS.ProcessEnv): string[] { + const rawPathExt = + getWindowsEnvValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'; + return rawPathExt + .split(';') + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)); +} + +function hasWindowsPathSeparator(command: string): boolean { + return command.includes('\\') || command.includes('/'); +} + +function resolveWindowsCommandPath({ + command, + cwd, + env, + fileExists, +}: { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + fileExists: FileExists; +}): string | null { + if (path.win32.extname(command)) { + return null; + } + + const baseCandidates = + hasWindowsPathSeparator(command) || path.win32.isAbsolute(command) + ? [path.win32.isAbsolute(command) ? command : path.win32.join(cwd, command)] + : [ + path.win32.join(cwd, command), + ...getWindowsPathDirs(env).map((dir) => path.win32.join(dir, command)), + ]; + + for (const base of baseCandidates) { + for (const ext of getWindowsPathExts(env)) { + const candidate = `${base}${ext}`; + if (fileExists(candidate)) return candidate; + } + } + + return null; +} + +function windowsWarnings(intent: PtySpawnIntent): LocalPtySpawnWarning[] { + const warnings: LocalPtySpawnWarning[] = []; + if (intent.shellSetup) warnings.push('shell_setup_ignored_on_windows'); + if (intent.tmuxSessionName) warnings.push('tmux_unsupported_on_windows'); + return warnings; +} + +function resolveWindowsSpawn( + intent: PtySpawnIntent, + env: NodeJS.ProcessEnv, + fileExists: FileExists +): ResolvedLocalPtySpawn { + const warnings = windowsWarnings(intent); + const shell = getWindowsShell(env); + + if (intent.kind === 'interactive-shell') { + return { command: shell, args: [], cwd: intent.cwd, warnings }; + } + + if (intent.command.kind === 'shell-line') { + return { + command: shell, + args: ['/d', '/s', '/c', intent.command.commandLine], + cwd: intent.cwd, + warnings, + }; + } + + const { command, args } = intent.command; + const resolvedCommand = + resolveWindowsCommandPath({ + command, + cwd: intent.cwd, + env, + fileExists, + }) ?? command; + const ext = path.win32.extname(resolvedCommand).toLowerCase(); + + if (ext === '.cmd' || ext === '.bat') { + return { + command: shell, + args: ['/d', '/s', '/c', [resolvedCommand, ...args].map(quoteForCmdExe).join(' ')], + cwd: intent.cwd, + warnings, + }; + } + + if (ext === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedCommand, ...args], + cwd: intent.cwd, + warnings, + }; + } + + if (!ext) { + return { + command: shell, + args: ['/d', '/s', '/c', [command, ...args].map(quoteForCmdExe).join(' ')], + cwd: intent.cwd, + warnings, + }; + } + + return { command: resolvedCommand, args, cwd: intent.cwd, warnings }; +} + +function resolvePosixSpawn(intent: PtySpawnIntent, env: NodeJS.ProcessEnv): ResolvedLocalPtySpawn { + const shell = getPosixShell(env); + + if (intent.kind === 'interactive-shell') { + if (intent.tmuxSessionName) { + const commandLine = intent.shellSetup + ? `${intent.shellSetup} && exec ${quotePosixArg(shell)} -il` + : `exec ${quotePosixArg(shell)} -il`; + return { + command: shell, + args: ['-c', buildTmuxShellLine(intent.tmuxSessionName, commandLine)], + cwd: intent.cwd, + warnings: [], + }; + } + + if (intent.shellSetup) { + return { + command: shell, + args: ['-c', `${intent.shellSetup} && exec ${quotePosixArg(shell)} -il`], + cwd: intent.cwd, + warnings: [], + }; + } + + return { command: shell, args: ['-il'], cwd: intent.cwd, warnings: [] }; + } + + const commandLine = + intent.command.kind === 'shell-line' + ? intent.command.commandLine + : argvToPosixShellLine(intent.command.command, intent.command.args); + const fullCommandLine = intent.shellSetup + ? `${intent.shellSetup} && ${commandLine}` + : commandLine; + + if (intent.tmuxSessionName) { + return { + command: shell, + args: ['-c', buildTmuxShellLine(intent.tmuxSessionName, fullCommandLine)], + cwd: intent.cwd, + warnings: [], + }; + } + + return { + command: shell, + args: ['-c', fullCommandLine], + cwd: intent.cwd, + warnings: [], + }; +} + +export function resolveLocalPtySpawn({ + intent, + platform, + env, + fileExists = existsSync, +}: { + intent: PtySpawnIntent; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + fileExists?: FileExists; +}): ResolvedLocalPtySpawn { + return isWindows(platform) + ? resolveWindowsSpawn(intent, env, fileExists) + : resolvePosixSpawn(intent, env); +} + +export function logLocalPtySpawnWarnings( + source: string, + warnings: LocalPtySpawnWarning[], + context: Record +): void { + if (warnings.length === 0) return; + log.warn(`${source}: local PTY platform warning`, { ...context, warnings }); +} diff --git a/src/main/core/pty/spawn-utils.test.ts b/src/main/core/pty/spawn-utils.test.ts index 13c0eb22b4..75d32f136e 100644 --- a/src/main/core/pty/spawn-utils.test.ts +++ b/src/main/core/pty/spawn-utils.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest'; import type { AgentSessionConfig } from '@shared/agent-session'; import type { GeneralSessionConfig } from '@shared/general-session'; -import { buildTmuxParams, resolveSpawnParams, resolveSshCommand } from './spawn-utils'; - -const SHELL = '/bin/bash'; +import type { RemoteShellProfile } from '@main/core/ssh/remote-shell-profile'; +import { resolveSshCommand } from './spawn-utils'; function makeAgentConfig(overrides: Partial = {}): AgentSessionConfig { return { @@ -21,170 +20,90 @@ function makeAgentConfig(overrides: Partial = {}): AgentSess function makeGeneralConfig(overrides: Partial = {}): GeneralSessionConfig { return { + taskId: 'task-1', cwd: '/workspace', ...overrides, }; } -describe('resolveSpawnParams – agent type', () => { - it('no tmux, no shellSetup → shell -c with command joined', () => { - const config = makeAgentConfig(); - const result = resolveSpawnParams('agent', config); - - expect(result.cwd).toBe('/workspace'); - expect(result.command).toBe(process.env.SHELL ?? '/bin/sh'); - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('claude --resume conv-1'); - }); - - it('with shellSetup → shellSetup prepended with &&', () => { - const config = makeAgentConfig({ shellSetup: 'source ~/.nvm/nvm.sh' }); - const result = resolveSpawnParams('agent', config); - - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('source ~/.nvm/nvm.sh && claude --resume conv-1'); - }); - - it('with tmuxSessionName → tmux command contains has-session and session name', () => { - const config = makeAgentConfig({ tmuxSessionName: 'my-session' }); - const result = resolveSpawnParams('agent', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"my-session"'); - expect(cmd).toContain('tmux attach-session'); - }); - - it('with both shellSetup and tmuxSessionName → tmux command contains shellSetup', () => { - const config = makeAgentConfig({ - shellSetup: 'export NVM_DIR="$HOME/.nvm"', - tmuxSessionName: 'agent-session', - }); - const result = resolveSpawnParams('agent', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"agent-session"'); - expect(cmd).toContain('export NVM_DIR=\\"$HOME/.nvm\\"'); - expect(cmd).toContain('claude --resume conv-1'); - }); -}); - -describe('resolveSpawnParams – general type', () => { - it('no command, no shellSetup → shell -c exec shell -il', () => { - const config = makeGeneralConfig(); - const result = resolveSpawnParams('general', config); - - const shell = process.env.SHELL ?? '/bin/sh'; - expect(result.command).toBe(shell); - expect(result.args[0]).toBe('-il'); - expect(result.cwd).toBe('/workspace'); - }); - - it('with shellSetup → shell -c with shellSetup && exec shell -il', () => { - const config = makeGeneralConfig({ shellSetup: 'source /opt/homebrew/bin/brew shellenv' }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('source /opt/homebrew/bin/brew shellenv'); - expect(cmd).toContain('exec'); - expect(cmd).toContain('-il'); - }); - - it('with tmuxSessionName → tmux wrapping', () => { - const config = makeGeneralConfig({ tmuxSessionName: 'general-session' }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"general-session"'); - expect(cmd).toContain('tmux attach-session'); - }); - - it('with both shellSetup and tmuxSessionName → tmux command contains shellSetup', () => { - const config = makeGeneralConfig({ - shellSetup: 'eval "$(rbenv init -)"', - tmuxSessionName: 'ruby-session', - }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"ruby-session"'); - expect(cmd).toContain('rbenv init'); - }); +const zshProfile: RemoteShellProfile = { + shell: '/bin/zsh', + env: { + PATH: '/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin', + }, +}; - it('with command → shell -c with the command instead of interactive shell', () => { - const config = makeGeneralConfig({ command: 'npm', args: ['install'] }); - const result = resolveSpawnParams('general', config); +describe('resolveSshCommand', () => { + it('runs remote commands through a login shell so PATH matches install/probe', () => { + const result = resolveSshCommand('agent', makeAgentConfig(), undefined, zshProfile); - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('npm install'); + expect(result).toBe( + `'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; cd "/workspace" && '\\''claude'\\'' '\\''--resume'\\'' '\\''conv-1'\\'''` + ); }); - it('with command and shellSetup → shellSetup prepended to command', () => { - const config = makeGeneralConfig({ command: 'npm', args: ['install'], shellSetup: 'nvm use' }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('nvm use && npm install'); + it('adds SSH env exports before the remote command', () => { + const result = resolveSshCommand( + 'agent', + makeAgentConfig(), + { + FOO: 'bar', + }, + zshProfile + ); + + expect(result).toBe( + `'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; export FOO='\\''bar'\\''; cd "/workspace" && '\\''claude'\\'' '\\''--resume'\\'' '\\''conv-1'\\'''` + ); }); - it('with command and tmuxSessionName → tmux wrapping around the command', () => { - const config = makeGeneralConfig({ - command: 'npm', - args: ['install'], - tmuxSessionName: 'setup-session', + it('uses the shared remote shell command builder for fallback SSH commands', () => { + const result = resolveSshCommand('agent', makeAgentConfig(), { + FOO: 'bar', }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"setup-session"'); - expect(cmd).toContain('npm install'); - }); -}); - -describe('buildTmuxParams', () => { - it('produces attach-or-create command with has-session, new-session -d, and attach-session', () => { - const result = buildTmuxParams(SHELL, 'my-tmux-session', 'claude --resume conv-42', '/tmp'); - expect(result.command).toBe(SHELL); - expect(result.cwd).toBe('/tmp'); - expect(result.args[0]).toBe('-c'); - - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session -t "my-tmux-session"'); - expect(cmd).toContain('tmux new-session -d -s "my-tmux-session"'); - expect(cmd).toContain('tmux attach-session -t "my-tmux-session"'); - const attachCount = (cmd.match(/tmux attach-session/g) ?? []).length; - expect(attachCount).toBe(2); + expect(result).toBe( + `'/bin/sh' -c 'export FOO='\\''bar'\\''; cd "/workspace" && '\\''claude'\\'' '\\''--resume'\\'' '\\''conv-1'\\'''` + ); }); - it('JSON-encodes the session name and command', () => { - const result = buildTmuxParams(SHELL, 'session with spaces', 'echo hello', '/home/user'); - - const cmd = result.args[1]; - expect(cmd).toContain('"session with spaces"'); - expect(cmd).toContain('"echo hello"'); + it('quotes remote agent argv tokens independently', () => { + const result = resolveSshCommand( + 'agent', + makeAgentConfig({ + command: 'caffeinate', + args: ['-i', 'direnv', 'exec', '.', '/opt/Claude Code/bin/claude', 'Fix the bug'], + }), + undefined, + zshProfile + ); + + expect(result).toBe( + `'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; cd "/workspace" && '\\''caffeinate'\\'' '\\''-i'\\'' '\\''direnv'\\'' '\\''exec'\\'' '\\''.'\\'' '\\''/opt/Claude Code/bin/claude'\\'' '\\''Fix the bug'\\'''` + ); }); - it('uses the provided cwd', () => { - const result = buildTmuxParams(SHELL, 'sess', 'cmd', '/custom/path'); - expect(result.cwd).toBe('/custom/path'); + it('preserves remote tmux wrapping for SSH commands', () => { + const result = resolveSshCommand( + 'agent', + makeAgentConfig({ + tmuxSessionName: 'agent-session', + }), + undefined, + zshProfile + ); + + expect(result).toContain('tmux has-session -t "agent-session"'); + expect(result).toContain('tmux new-session -d -s "agent-session"'); + expect(result).toContain('tmux attach-session -t "agent-session"'); + expect(result).toContain("'\\''claude'\\'' '\\''--resume'\\'' '\\''conv-1'\\''"); }); -}); -describe('resolveSshCommand', () => { - it('runs remote commands through a login shell so PATH matches install/probe', () => { - const result = resolveSshCommand('agent', makeAgentConfig()); + it('launches remote general terminals with the captured remote shell', () => { + const result = resolveSshCommand('general', makeGeneralConfig(), undefined, zshProfile); - expect(result).toBe(`bash -l -c 'cd "/workspace" && claude --resume conv-1'`); + expect(result).toBe( + `'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; cd "/workspace" && exec /bin/zsh -il'` + ); }); }); diff --git a/src/main/core/pty/spawn-utils.ts b/src/main/core/pty/spawn-utils.ts index 9e12d39902..6d91f31b78 100644 --- a/src/main/core/pty/spawn-utils.ts +++ b/src/main/core/pty/spawn-utils.ts @@ -1,114 +1,60 @@ import type { AgentSessionConfig } from '@shared/agent-session'; import type { GeneralSessionConfig } from '@shared/general-session'; +import { + buildRemoteShellCommand, + FALLBACK_REMOTE_SHELL_PROFILE, + type RemoteShellProfile, +} from '@main/core/ssh/remote-shell-profile'; import { quoteShellArg } from '@main/utils/shellEscape'; +import { buildTmuxShellLine } from './tmux-session-name'; -export type SessionType = 'agent' | 'general' | 'lifecycle'; +export type SessionType = 'agent' | 'general'; export type SessionConfig = AgentSessionConfig | GeneralSessionConfig; -export interface SpawnParams { - command: string; - args: string[]; - cwd: string; -} - -/** - * Derive the executable, arguments, and working directory from a session config. - * Applies shellSetup and tmux wrapping where relevant. - */ -export function resolveSpawnParams(type: SessionType, config: SessionConfig): SpawnParams { - const shell = process.env.SHELL ?? '/bin/sh'; +function posixShellLineForSsh( + type: SessionType, + config: SessionConfig, + profile: RemoteShellProfile +): { cwd: string; line: string } { + const shell = profile.shell; switch (type) { case 'agent': { const cfg = config as AgentSessionConfig; - const baseCmd = [cfg.command, ...cfg.args].join(' '); - const fullCmd = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; - - if (cfg.tmuxSessionName) { - return buildTmuxParams(shell, cfg.tmuxSessionName, fullCmd, cfg.cwd); - } - + const baseCmd = [cfg.command, ...cfg.args].map(quoteShellArg).join(' '); + const line = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; return { - command: shell, - args: ['-c', fullCmd], cwd: cfg.cwd, + line: cfg.tmuxSessionName ? buildTmuxShellLine(cfg.tmuxSessionName, line) : line, }; } - case 'general': { const cfg = config as GeneralSessionConfig; const baseCmd = cfg.command ? [cfg.command, ...(cfg.args ?? [])].join(' ') : `exec ${shell} -il`; - const fullCmd = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; - - if (cfg.tmuxSessionName) { - return buildTmuxParams(shell, cfg.tmuxSessionName, fullCmd, cfg.cwd); - } - - if (cfg.command || cfg.shellSetup) { - return { command: shell, args: ['-c', fullCmd], cwd: cfg.cwd }; - } - - return { command: shell, args: ['-il'], cwd: cfg.cwd }; + const line = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; + return { + cwd: cfg.cwd, + line: cfg.tmuxSessionName ? buildTmuxShellLine(cfg.tmuxSessionName, line) : line, + }; } - - default: { + default: throw new Error(`Unsupported session type: ${type}`); - } } } -/** - * Build spawn params that wrap a command in a tmux session for persistence. - * - * Behaviour: - * - If a tmux session named `sessionName` already exists → attach to it. - * - Otherwise → create a detached session running `cmd`, then attach. - */ -export function buildTmuxParams( - shell: string, - sessionName: string, - cmd: string, - cwd: string -): SpawnParams { - const quotedName = JSON.stringify(sessionName); - const quotedCmd = JSON.stringify(cmd); - - const checkExists = `tmux has-session -t ${quotedName} 2>/dev/null`; - const newSession = `tmux new-session -d -s ${quotedName} ${quotedCmd}`; - const attach = `tmux attach-session -t ${quotedName}`; - - const tmuxCmd = `(${checkExists} && ${attach}) || (${newSession} && ${attach})`; - - return { - command: shell, - args: ['-c', tmuxCmd], - cwd, - }; -} - /** * Build a single command string for SSH remote execution. */ export function resolveSshCommand( type: SessionType, config: SessionConfig, - envVars?: Record + envVars?: Record, + profile?: RemoteShellProfile ): string { - const { command, args, cwd } = resolveSpawnParams(type, config); - const shell = process.env.SHELL ?? '/bin/sh'; - - const innerCmd = command === shell && args[0] === '-c' ? args[1] : [command, ...args].join(' '); - const envPrefix = envVars ? buildSshEnvPrefix(envVars) : ''; - const commandString = `cd ${JSON.stringify(cwd)} && ${envPrefix}${innerCmd}`; - - return `bash -l -c ${quoteShellArg(commandString)}`; -} - -export function buildSshEnvPrefix(vars: Record): string { - const entries = Object.entries(vars); - if (entries.length === 0) return ''; - const exports = entries.map(([k, v]) => `export ${k}='${v.replace(/'/g, "'\\''")}'`).join('; '); - return exports + '; '; + const effectiveProfile = profile ?? FALLBACK_REMOTE_SHELL_PROFILE; + const { cwd, line } = posixShellLineForSsh(type, config, effectiveProfile); + const commandString = `cd ${JSON.stringify(cwd)} && ${line}`; + return buildRemoteShellCommand(effectiveProfile, commandString, envVars); } diff --git a/src/main/core/pty/tmux-session-name.ts b/src/main/core/pty/tmux-session-name.ts index ac93fa4f90..b4380d3860 100644 --- a/src/main/core/pty/tmux-session-name.ts +++ b/src/main/core/pty/tmux-session-name.ts @@ -1,16 +1,25 @@ -import type { ExecFn } from '@main/core/utils/exec'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { log } from '@main/lib/logger'; const TMUX_SESSION_PREFIX = 'emdash-'; +export function buildTmuxShellLine(sessionName: string, commandLine: string): string { + const quotedName = JSON.stringify(sessionName); + const quotedCmd = JSON.stringify(commandLine); + const checkExists = `tmux has-session -t ${quotedName} 2>/dev/null`; + const newSession = `tmux new-session -d -s ${quotedName} ${quotedCmd}`; + const attach = `tmux attach-session -t ${quotedName}`; + return `(${checkExists} && ${attach}) || (${newSession} && ${attach})`; +} + export function makeTmuxSessionName(sessionId: string): string { const encoded = Buffer.from(sessionId, 'utf8').toString('base64url'); return `${TMUX_SESSION_PREFIX}${encoded}`; } -export async function killTmuxSession(exec: ExecFn, sessionName: string): Promise { +export async function killTmuxSession(ctx: IExecutionContext, sessionName: string): Promise { try { - await exec('tmux', ['kill-session', '-t', sessionName]); + await ctx.exec('tmux', ['kill-session', '-t', sessionName]); } catch (err) { log.debug('killTmuxSession: tmux session not found or already dead', { sessionName, diff --git a/src/main/core/pull-requests/controller.ts b/src/main/core/pull-requests/controller.ts index 3d3834a1d1..232b06030f 100644 --- a/src/main/core/pull-requests/controller.ts +++ b/src/main/core/pull-requests/controller.ts @@ -1,7 +1,14 @@ +import { RequestError } from '@octokit/request-error'; import { createRPCController } from '@shared/ipc/rpc'; -import type { ListPrOptions, PullRequestFile } from '@shared/pull-requests'; +import type { + ListPrOptions, + PullRequestComment, + PullRequestError, + PullRequestFile, +} from '@shared/pull-requests'; +import { err, ok } from '@shared/result'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { prQueryService } from './pr-query-service'; import { prSyncEngine } from './pr-sync-engine'; @@ -11,26 +18,26 @@ export const pullRequestController = createRPCController({ listPullRequests: async (projectId: string, options?: ListPrOptions) => { try { const prs = await prQueryService.listPullRequests(projectId, options); - return { success: true as const, prs, totalCount: prs.length }; + return ok({ prs, totalCount: prs.length }); } catch (error) { log.error('Failed to list pull requests:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to list pull requests', - }; + return err({ + type: 'list_failed', + message: error instanceof Error ? error.message : 'Unable to list pull requests', + }); } }, getFilterOptions: async (projectId: string) => { try { const options = await prQueryService.getFilterOptions(projectId); - return { success: true as const, ...options }; + return ok(options); } catch (error) { log.error('Failed to get PR filter options:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to get filter options', - }; + return err({ + type: 'filter_options_failed', + message: error instanceof Error ? error.message : 'Unable to get filter options', + }); } }, @@ -38,7 +45,7 @@ export const pullRequestController = createRPCController({ try { const capability = await prQueryService.getProjectRemoteInfo(projectId); if (capability.status !== 'ready') { - return { success: true as const, prs: [], taskBranch: null }; + return ok({ prs: [], taskBranch: null }); } const { tasks } = await import('@main/db/schema'); @@ -51,7 +58,7 @@ export const pullRequestController = createRPCController({ .limit(1); if (!taskRow?.taskBranch) { - return { success: true as const, prs: [], taskBranch: null }; + return ok({ prs: [], taskBranch: null }); } const prs = await prQueryService.getTaskPullRequests( @@ -59,13 +66,13 @@ export const pullRequestController = createRPCController({ taskRow.taskBranch, capability.repositoryUrl ); - return { success: true as const, prs, taskBranch: taskRow.taskBranch }; + return ok({ prs, taskBranch: taskRow.taskBranch }); } catch (error) { log.error('Failed to get pull requests for task:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to get task pull requests', - }; + return err({ + type: 'task_pull_requests_failed', + message: error instanceof Error ? error.message : 'Unable to get task pull requests', + }); } }, @@ -75,16 +82,16 @@ export const pullRequestController = createRPCController({ try { const capability = await prQueryService.getProjectRemoteInfo(projectId); if (capability.status !== 'ready') { - return { success: false as const, error: `Remote not ready: ${capability.status}` }; + return err({ type: 'remote_not_ready', status: capability.status }); } prSyncEngine.forceFullSync(capability.repositoryUrl); - return { success: true as const }; + return ok(); } catch (error) { log.error('Failed to force full sync:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to force sync', - }; + return err({ + type: 'sync_failed', + message: error instanceof Error ? error.message : 'Unable to force sync', + }); } }, @@ -97,52 +104,52 @@ export const pullRequestController = createRPCController({ projectId, status: capability.status, }); - return { success: false as const, error: `Remote not ready: ${capability.status}` }; + return err({ type: 'remote_not_ready', status: capability.status }); } log.info('PrController: triggering sync', { projectId, repositoryUrl: capability.repositoryUrl, }); prSyncEngine.sync(capability.repositoryUrl); - return { success: true as const }; + return ok(); } catch (error) { log.error('Failed to trigger sync:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to sync', - }; + return err({ + type: 'sync_failed', + message: error instanceof Error ? error.message : 'Unable to sync', + }); } }, refreshPullRequest: async (repositoryUrl: string, prNumber: number) => { try { const pr = await prSyncEngine.syncSingle(repositoryUrl, prNumber); - return { success: true as const, pr }; + return ok({ pr }); } catch (error) { log.error('Failed to refresh pull request:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to refresh pull request', - }; + return err({ + type: 'refresh_failed', + message: error instanceof Error ? error.message : 'Unable to refresh pull request', + }); } }, syncChecks: async (pullRequestUrl: string, headRefOid: string) => { try { const hasRunning = await prSyncEngine.syncChecks(pullRequestUrl, headRefOid); - return { success: true as const, hasRunning }; + return ok({ hasRunning }); } catch (error) { log.error('Failed to sync checks:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to sync checks', - }; + return err({ + type: 'checks_failed', + message: error instanceof Error ? error.message : 'Unable to sync checks', + }); } }, cancelSync: (repositoryUrl: string) => { prSyncEngine.cancel(repositoryUrl); - return { success: true as const }; + return ok(); }, // ── Mutations ────────────────────────────────────────────────────────────── @@ -157,23 +164,27 @@ export const pullRequestController = createRPCController({ }) => { try { const result = await prSyncEngine.createPullRequest(params); + if (!result.success) { + return err({ type: 'invalid_repository', input: result.error.input }); + } // Sync the newly created PR into the DB - void prSyncEngine.syncSingle(params.repositoryUrl, result.number); - capture('pr_created', { is_draft: params.draft }); - return { success: true as const, url: result.url, number: result.number }; + void prSyncEngine.syncSingle(params.repositoryUrl, result.data.number); + telemetryService.capture('pr_created', { is_draft: params.draft }); + return ok({ url: result.data.url, number: result.data.number }); } catch (error) { log.error('Failed to create pull request:', error); - capture('pr_creation_failed', { + telemetryService.capture('pr_creation_failed', { error_type: error instanceof Error ? error.name || 'error' : 'unknown_error', }); - const ghErrors = (error as any)?.response?.data?.errors; + const ghErrors = + error instanceof RequestError && + Array.isArray((error.response?.data as { errors?: unknown[] } | undefined)?.errors) + ? (error.response!.data as { errors: { message?: string }[] }).errors + : undefined; const message = - Array.isArray(ghErrors) && ghErrors[0]?.message - ? ghErrors[0].message - : error instanceof Error - ? error.message - : 'Unable to create pull request'; - return { success: false as const, error: message }; + ghErrors?.[0]?.message ?? + (error instanceof Error ? error.message : 'Unable to create pull request'); + return err({ type: 'create_failed', message }); } }, @@ -184,29 +195,35 @@ export const pullRequestController = createRPCController({ ) => { try { const result = await prSyncEngine.mergePullRequest(repositoryUrl, prNumber, options); + if (!result.success) { + return err({ type: 'invalid_repository', input: result.error.input }); + } // Refresh the merged PR void prSyncEngine.syncSingle(repositoryUrl, prNumber); - return { success: true as const, sha: result.sha, merged: result.merged }; + return ok({ sha: result.data.sha, merged: result.data.merged }); } catch (error) { log.error('Failed to merge pull request:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to merge pull request', - }; + return err({ + type: 'merge_failed', + message: error instanceof Error ? error.message : 'Unable to merge pull request', + }); } }, markReadyForReview: async (repositoryUrl: string, prNumber: number) => { try { - await prSyncEngine.markReadyForReview(repositoryUrl, prNumber); + const result = await prSyncEngine.markReadyForReview(repositoryUrl, prNumber); + if (!result.success) { + return err({ type: 'invalid_repository', input: result.error.input }); + } void prSyncEngine.syncSingle(repositoryUrl, prNumber); - return { success: true as const }; + return ok(); } catch (error) { log.error('Failed to mark pull request ready for review:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to mark PR ready for review', - }; + return err({ + type: 'mark_ready_failed', + message: error instanceof Error ? error.message : 'Unable to mark PR ready for review', + }); } }, @@ -214,17 +231,35 @@ export const pullRequestController = createRPCController({ getPullRequestFiles: async (repositoryUrl: string, prNumber: number) => { try { - const files: PullRequestFile[] = await prSyncEngine.getPullRequestFiles( - repositoryUrl, - prNumber - ); - return { success: true as const, files }; + const result = await prSyncEngine.getPullRequestFiles(repositoryUrl, prNumber); + if (!result.success) { + return err({ type: 'invalid_repository', input: result.error.input }); + } + const files: PullRequestFile[] = result.data; + return ok({ files }); } catch (error) { log.error('Failed to get pull request files:', error); - return { - success: false as const, - error: error instanceof Error ? error.message : 'Unable to get pull request files', - }; + return err({ + type: 'files_failed', + message: error instanceof Error ? error.message : 'Unable to get pull request files', + }); + } + }, + + getPullRequestComments: async (repositoryUrl: string, prNumber: number) => { + try { + const result = await prSyncEngine.getPullRequestComments(repositoryUrl, prNumber); + if (!result.success) { + return err({ type: 'invalid_repository', input: result.error.input }); + } + const comments: PullRequestComment[] = result.data; + return ok({ comments }); + } catch (error) { + log.error('Failed to get pull request comments:', error); + return err({ + type: 'comments_failed', + message: error instanceof Error ? error.message : 'Unable to get pull request comments', + }); } }, }); diff --git a/src/main/core/pull-requests/pr-query-service.ts b/src/main/core/pull-requests/pr-query-service.ts index 300afafdb5..9dd443cd65 100644 --- a/src/main/core/pull-requests/pr-query-service.ts +++ b/src/main/core/pull-requests/pr-query-service.ts @@ -1,6 +1,6 @@ import { and, asc, desc, eq, inArray, isNotNull, like, or } from 'drizzle-orm'; +import { parseGitHubRepository } from '@shared/github-repository'; import type { Label, ListPrOptions, PrFilterOptions, PullRequest } from '@shared/pull-requests'; -import { isGitHubUrl, normalizeGitHubUrl } from '@main/core/github/services/utils'; import { projectManager } from '@main/core/projects/project-manager'; import { db } from '@main/db/client'; import { @@ -19,10 +19,6 @@ export type ProjectRemoteCapability = | { status: 'no_remote' } | { status: 'unsupported_remote' }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - async function fetchRelated(rows: PrRow[]): Promise { if (rows.length === 0) return []; @@ -83,10 +79,6 @@ async function fetchRelated(rows: PrRow[]): Promise { ); } -// --------------------------------------------------------------------------- -// PrQueryService -// --------------------------------------------------------------------------- - export class PrQueryService { async listPullRequests(projectId: string, options: ListPrOptions = {}): Promise { let repositoryUrls: string[]; @@ -250,11 +242,12 @@ export class PrQueryService { if (!remoteState.hasRemote) return { status: 'no_remote' }; if (!remoteState.selectedRemoteUrl) return { status: 'unsupported_remote' }; - if (!isGitHubUrl(remoteState.selectedRemoteUrl)) return { status: 'unsupported_remote' }; + const repository = parseGitHubRepository(remoteState.selectedRemoteUrl); + if (!repository) return { status: 'unsupported_remote' }; return { status: 'ready', - repositoryUrl: normalizeGitHubUrl(remoteState.selectedRemoteUrl), + repositoryUrl: repository.repositoryUrl, }; } } diff --git a/src/main/core/pull-requests/pr-sync-engine.ts b/src/main/core/pull-requests/pr-sync-engine.ts index 844ab37a99..7ae196e2c8 100644 --- a/src/main/core/pull-requests/pr-sync-engine.ts +++ b/src/main/core/pull-requests/pr-sync-engine.ts @@ -2,13 +2,22 @@ import { randomUUID } from 'node:crypto'; import type { Octokit } from '@octokit/rest'; import { and, eq, inArray, lt, ne } from 'drizzle-orm'; import { prSyncProgressChannel, prUpdatedChannel } from '@shared/events/prEvents'; +import { + parseGitHubRepository, + parseGitHubRepositoryResult, + type GitHubRepositoryParseError, +} from '@shared/github-repository'; import type { MergeableState, MergeStateStatus, PrSyncProgress, PullRequest, + PullRequestComment, + PullRequestFile, PullRequestStatus, + PullRequestUser, } from '@shared/pull-requests'; +import { ok, type Result } from '@shared/result'; import { getOctokit } from '@main/core/github/services/octokit-provider'; import { GET_PR_BY_NUMBER_QUERY, @@ -16,11 +25,6 @@ import { INCREMENTAL_SYNC_PRS_QUERY, SYNC_PRS_QUERY, } from '@main/core/github/services/pr-queries'; -import { - isGitHubUrl, - normalizeGitHubUrl, - splitNormalizedUrl, -} from '@main/core/github/services/utils'; import { db } from '@main/db/client'; import { KV } from '@main/db/kv'; import { @@ -78,6 +82,23 @@ function actorUserId(actor: GqlUser): string { return actor.databaseId != null ? String(actor.databaseId) : `login:${actor.login}`; } +function restUserToPullRequestUser(user: { + id: number; + login: string; + avatar_url: string; + html_url: string; +}): PullRequestUser { + return { + userId: String(user.id), + userName: user.login, + displayName: user.login, + avatarUrl: user.avatar_url || null, + url: user.html_url, + userCreatedAt: null, + userUpdatedAt: null, + }; +} + interface GqlPrNode { number: number; title: string; @@ -245,7 +266,17 @@ export class PrSyncEngine { */ private async _runFullSync(repositoryUrl: string, signal: AbortSignal): Promise { log.info('PrSyncEngine: runFullSync start', { repositoryUrl }); - const { owner, repo } = splitNormalizedUrl(repositoryUrl); + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) { + this._emitProgress({ + remoteUrl: repositoryUrl, + kind: 'full', + status: 'error', + error: `Invalid GitHub repository URL: "${repository.error.input}"`, + }); + return; + } + const { owner, repo } = repository.data; const octokit = await this.getOctokit().catch((e: unknown) => { log.warn('PrSyncEngine: runFullSync — failed to get Octokit (not authenticated?)', { repositoryUrl, @@ -339,7 +370,17 @@ export class PrSyncEngine { private async _runIncrementalSync(repositoryUrl: string, signal: AbortSignal): Promise { log.info('PrSyncEngine: runIncrementalSync started', { repositoryUrl }); - const { owner, repo } = splitNormalizedUrl(repositoryUrl); + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) { + this._emitProgress({ + remoteUrl: repositoryUrl, + kind: 'incremental', + status: 'error', + error: `Invalid GitHub repository URL: "${repository.error.input}"`, + }); + return; + } + const { owner, repo } = repository.data; const octokit = await this.getOctokit().catch((e: unknown) => { log.warn('PrSyncEngine: runIncrementalSync — failed to get Octokit (not authenticated?)', { repositoryUrl, @@ -509,7 +550,9 @@ export class PrSyncEngine { ): Promise { if (signal.aborted) return null; - const { owner, repo } = splitNormalizedUrl(repositoryUrl); + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) return null; + const { owner, repo } = repository.data; const octokit = await this.getOctokit(); const response = await withRetry(() => @@ -598,8 +641,9 @@ export class PrSyncEngine { const prNumber = pr[0].identifier ? parseInt(pr[0].identifier.replace('#', ''), 10) : NaN; if (isNaN(prNumber)) return false; - if (!isGitHubUrl(pr[0].repositoryUrl)) return false; - const { owner, repo } = splitNormalizedUrl(normalizeGitHubUrl(pr[0].repositoryUrl)); + const repository = parseGitHubRepository(pr[0].repositoryUrl); + if (!repository) return false; + const { owner, repo } = repository; const octokit = await this.getOctokit(); type CheckNode = GqlCheckRunNode | GqlStatusContextNode; @@ -789,14 +833,11 @@ export class PrSyncEngine { const status: PullRequestStatus = node.state === 'MERGED' ? 'merged' : node.state === 'CLOSED' ? 'closed' : 'open'; - // Normalise URLs - const headRepositoryUrl = node.headRepository?.url - ? normalizeGitHubUrl(node.headRepository.url) - : repositoryUrl; + const headRepositoryUrl = + parseGitHubRepository(node.headRepository?.url)?.repositoryUrl ?? repositoryUrl; - const baseRepositoryUrl = node.baseRepository?.url - ? normalizeGitHubUrl(node.baseRepository.url) - : repositoryUrl; + const baseRepositoryUrl = + parseGitHubRepository(node.baseRepository?.url)?.repositoryUrl ?? repositoryUrl; // Upsert author let authorUserId: string | null = null; @@ -988,8 +1029,10 @@ export class PrSyncEngine { title: string; body?: string; draft: boolean; - }): Promise<{ url: string; number: number }> { - const { owner, repo } = splitNormalizedUrl(params.repositoryUrl); + }): Promise> { + const repository = parseGitHubRepositoryResult(params.repositoryUrl); + if (!repository.success) return repository; + const { owner, repo } = repository.data; const octokit = await this.getOctokit(); const response = await octokit.rest.pulls.create({ owner, @@ -1001,15 +1044,17 @@ export class PrSyncEngine { draft: params.draft, }); const { html_url: url, number } = response.data; - return { url, number }; + return ok({ url, number }); } async mergePullRequest( repositoryUrl: string, prNumber: number, options: { strategy: 'merge' | 'squash' | 'rebase'; commitHeadOid?: string } - ): Promise<{ sha: string | null; merged: boolean }> { - const { owner, repo } = splitNormalizedUrl(repositoryUrl); + ): Promise> { + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) return repository; + const { owner, repo } = repository.data; const octokit = await this.getOctokit(); const response = await octokit.rest.pulls.merge({ owner, @@ -1018,11 +1063,16 @@ export class PrSyncEngine { merge_method: options.strategy, sha: options.commitHeadOid, }); - return { sha: response.data.sha ?? null, merged: response.data.merged }; + return ok({ sha: response.data.sha ?? null, merged: response.data.merged }); } - async markReadyForReview(repositoryUrl: string, prNumber: number): Promise { - const { owner, repo } = splitNormalizedUrl(repositoryUrl); + async markReadyForReview( + repositoryUrl: string, + prNumber: number + ): Promise> { + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) return repository; + const { owner, repo } = repository.data; const octokit = await this.getOctokit(); const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber }); await octokit.graphql( @@ -1033,21 +1083,108 @@ export class PrSyncEngine { }`, { id: data.node_id } ); + return ok(); + } + + async getPullRequestComments( + repositoryUrl: string, + prNumber: number + ): Promise> { + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) return repository; + const { owner, repo } = repository.data; + const octokit = await this.getOctokit(); + const pullRequestUrl = `${repository.data.repositoryUrl}/pull/${prNumber}`; + + const [issueComments, reviewComments, reviews] = await Promise.all([ + withRetry(() => + githubRateLimiter.acquire().then(() => + octokit.paginate(octokit.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }) + ) + ), + withRetry(() => + githubRateLimiter.acquire().then(() => + octokit.paginate(octokit.rest.pulls.listReviewComments, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }) + ) + ), + withRetry(() => + githubRateLimiter.acquire().then(() => + octokit.paginate(octokit.rest.pulls.listReviews, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }) + ) + ), + ]); + + return ok([ + ...issueComments.map((comment) => ({ + id: `issue-comment:${comment.id}`, + pullRequestUrl, + kind: 'issue' as const, + body: comment.body ?? '', + url: comment.html_url, + author: comment.user ? restUserToPullRequestUser(comment.user) : null, + path: null, + line: null, + isResolved: false, + isOutdated: false, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + })), + ...reviews.flatMap((review) => { + if (!review.body?.trim() || !review.submitted_at) return []; + return { + id: `review:${review.id}`, + pullRequestUrl, + kind: 'review' as const, + body: review.body, + url: review.html_url, + author: review.user ? restUserToPullRequestUser(review.user) : null, + path: null, + line: null, + isResolved: false, + isOutdated: false, + createdAt: review.submitted_at, + updatedAt: review.submitted_at, + }; + }), + ...reviewComments.map((comment) => ({ + id: `review-comment:${comment.id}`, + pullRequestUrl, + kind: 'review' as const, + body: comment.body ?? '', + url: comment.html_url, + author: comment.user ? restUserToPullRequestUser(comment.user) : null, + path: comment.path ?? null, + line: comment.line ?? comment.original_line ?? null, + isResolved: false, + isOutdated: comment.position == null, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + })), + ]); } async getPullRequestFiles( repositoryUrl: string, prNumber: number - ): Promise< - { - filename: string; - status: string; - additions: number; - deletions: number; - patch?: string; - }[] - > { - const { owner, repo } = splitNormalizedUrl(repositoryUrl); + ): Promise> { + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) return repository; + const { owner, repo } = repository.data; const octokit = await this.getOctokit(); const files = await octokit.paginate(octokit.rest.pulls.listFiles, { owner, @@ -1055,13 +1192,15 @@ export class PrSyncEngine { pull_number: prNumber, per_page: 100, }); - return files.map((f) => ({ - filename: f.filename, - status: f.status, - additions: f.additions, - deletions: f.deletions, - patch: f.patch, - })); + return ok( + files.map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + patch: f.patch, + })) + ); } private async _getFullSyncCursor(repositoryUrl: string): Promise<{ done: boolean } | null> { diff --git a/src/main/core/pull-requests/pr-sync-scheduler.ts b/src/main/core/pull-requests/pr-sync-scheduler.ts index 748ae46b33..ce4f3ab496 100644 --- a/src/main/core/pull-requests/pr-sync-scheduler.ts +++ b/src/main/core/pull-requests/pr-sync-scheduler.ts @@ -1,8 +1,11 @@ import { eq } from 'drizzle-orm'; -import { isGitHubUrl, normalizeGitHubUrl } from '@main/core/github/services/utils'; +import { parseGitHubRepository } from '@shared/github-repository'; +import { gitWatcherRegistry } from '@main/core/git/git-watcher-registry'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/tasks/task-manager'; import { db } from '@main/db/client'; import { projectRemotes } from '@main/db/schema'; +import type { IDisposable, IInitializable } from '@main/lib/lifecycle'; import { log } from '@main/lib/logger'; import { prSyncEngine } from './pr-sync-engine'; import { syncProjectRemotes } from './project-remotes-service'; @@ -13,18 +16,30 @@ const INCREMENTAL_SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes * Wires sync coordinator to application lifecycle events. * Called from project providers at mount, unmount, provision, and config change. */ -export class PrSyncScheduler { +export class PrSyncScheduler implements IInitializable, IDisposable { /** Per-project set of interval handles for light sync polling. */ private readonly _intervals = new Map[]>(); /** Per-project set of known GitHub remote URLs (for cleanup on unmount). */ private readonly _projectRemoteUrls = new Map(); + private _unsubscribes: Array<() => void> = []; initialize(): void { - projectManager.registerOnProjectOpened((id) => this.onProjectMounted(id)); - projectManager.registerOnProjectClosed((id) => this.onProjectUnmounted(id)); + this._unsubscribes = [ + projectManager.on('projectOpened', (id) => this.onProjectMounted(id)), + projectManager.on('projectClosed', (id) => this.onProjectUnmounted(id)), + taskManager.hooks.on('task:provisioned', ({ projectId, taskBranch }) => { + void this.onTaskProvisioned(projectId, taskBranch); + }), + gitWatcherRegistry.on('ref:changed', (p) => { + if (p.kind === 'config') void this.onRemoteChanged(p.projectId); + }), + ]; } - // ── Project lifecycle ────────────────────────────────────────────────────── + dispose(): void { + for (const unsub of this._unsubscribes) unsub(); + this._unsubscribes = []; + } async onProjectMounted(projectId: string): Promise { log.info('PrSyncScheduler: onProjectMounted', { projectId }); @@ -83,10 +98,6 @@ export class PrSyncScheduler { } } - async onPushCompleted(projectId: string, taskBranch: string): Promise { - return this.onTaskProvisioned(projectId, taskBranch); - } - // ── Remote config change ─────────────────────────────────────────────────── async onRemoteChanged(projectId: string): Promise { @@ -132,7 +143,10 @@ export class PrSyncScheduler { try { const remotes = await project.repository.getRemotes(); await syncProjectRemotes(projectId, remotes); - return remotes.filter((r) => isGitHubUrl(r.url)).map((r) => normalizeGitHubUrl(r.url)); + return remotes.flatMap((r) => { + const repository = parseGitHubRepository(r.url); + return repository ? [repository.repositoryUrl] : []; + }); } catch (e) { log.warn('PrSyncScheduler: failed to sync project remotes', { projectId, error: String(e) }); return []; @@ -148,7 +162,10 @@ export class PrSyncScheduler { .from(projectRemotes) .where(eq(projectRemotes.projectId, projectId)); - return rows.filter((r) => isGitHubUrl(r.remoteUrl)).map((r) => normalizeGitHubUrl(r.remoteUrl)); + return rows.flatMap((r) => { + const repository = parseGitHubRepository(r.remoteUrl); + return repository ? [repository.repositoryUrl] : []; + }); } private async _findPrNumberForBranch( @@ -169,8 +186,8 @@ export class PrSyncScheduler { .limit(1); if (!rows[0]?.identifier) return null; - const n = parseInt(rows[0].identifier.replace('#', ''), 10); - return isNaN(n) ? null : n; + const n = Number.parseInt(rows[0].identifier.replace('#', ''), 10); + return Number.isNaN(n) ? null : n; } } diff --git a/src/main/core/pull-requests/pr-utils.ts b/src/main/core/pull-requests/pr-utils.ts index 5870070833..0d6b0bbb25 100644 --- a/src/main/core/pull-requests/pr-utils.ts +++ b/src/main/core/pull-requests/pr-utils.ts @@ -8,11 +8,11 @@ import type { PullRequestUser, } from '@shared/pull-requests'; import { - pullRequestAssignees, - pullRequestChecks, - pullRequestLabels, - pullRequests, - pullRequestUsers, + type pullRequestAssignees, + type pullRequestChecks, + type pullRequestLabels, + type pullRequests, + type pullRequestUsers, } from '@main/db/schema'; export type PrRow = typeof pullRequests.$inferSelect; diff --git a/src/main/core/pull-requests/project-remotes-service.ts b/src/main/core/pull-requests/project-remotes-service.ts index e1242d42e7..f1f43f215d 100644 --- a/src/main/core/pull-requests/project-remotes-service.ts +++ b/src/main/core/pull-requests/project-remotes-service.ts @@ -1,6 +1,6 @@ import { and, eq, notInArray } from 'drizzle-orm'; import type { Remote } from '@shared/git'; -import { isGitHubUrl, normalizeGitHubUrl } from '@main/core/github/services/utils'; +import { parseGitHubRepository } from '@shared/github-repository'; import { db } from '@main/db/client'; import { projectRemotes } from '@main/db/schema'; @@ -12,7 +12,7 @@ import { projectRemotes } from '@main/db/schema'; */ export async function syncProjectRemotes(projectId: string, remotes: Remote[]): Promise { for (const r of remotes) { - const remoteUrl = isGitHubUrl(r.url) ? normalizeGitHubUrl(r.url) : r.url; + const remoteUrl = parseGitHubRepository(r.url)?.repositoryUrl ?? r.url; await db .insert(projectRemotes) .values({ projectId, remoteName: r.name, remoteUrl }) diff --git a/src/main/core/repository/controller.ts b/src/main/core/repository/controller.ts index 3be6fb47ef..916c617df4 100644 --- a/src/main/core/repository/controller.ts +++ b/src/main/core/repository/controller.ts @@ -1,8 +1,22 @@ +import { gitRefChangedChannel } from '@shared/events/gitEvents'; import type { BranchesPayload, LocalBranchesPayload, RemoteBranchesPayload } from '@shared/git'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; -import { capture } from '@main/lib/telemetry'; +import { events } from '@main/lib/events'; +import { telemetryService } from '@main/lib/telemetry'; +import type { GitRepositoryService } from '../git/repository-service'; import { projectManager } from '../projects/project-manager'; +import { workspaceRegistry } from '../workspaces/workspace-registry'; + +function resolveRepository(projectId: string, workspaceId?: string): GitRepositoryService { + const project = projectManager.getProject(projectId); + if (!project) throw new Error('Project not found'); + if (workspaceId) { + const ws = workspaceRegistry.get(workspaceId); + if (ws) return ws.repository; + } + return project.repository; +} export const repositoryController = createRPCController({ getBranches: async (projectId: string): Promise => { @@ -13,16 +27,18 @@ export const repositoryController = createRPCController({ return project.repository.getBranchesPayload(); }, - getLocalBranches: async (projectId: string): Promise => { - const project = projectManager.getProject(projectId); - if (!project) throw new Error('Project not found'); - return project.repository.getLocalBranchesPayload(); + getLocalBranches: async ( + projectId: string, + workspaceId?: string + ): Promise => { + return resolveRepository(projectId, workspaceId).getLocalBranchesPayload(); }, - getRemoteBranches: async (projectId: string): Promise => { - const project = projectManager.getProject(projectId); - if (!project) throw new Error('Project not found'); - return project.repository.getRemoteBranchesPayload(); + getRemoteBranches: async ( + projectId: string, + workspaceId?: string + ): Promise => { + return resolveRepository(projectId, workspaceId).getRemoteBranchesPayload(); }, getRemotes: async (projectId: string) => { @@ -52,16 +68,30 @@ export const repositoryController = createRPCController({ return ok({ remotePushed: result.data.remotePushed }); }, - fetch: async (projectId: string) => { + fetch: async (projectId: string, workspaceId?: string) => { const project = projectManager.getProject(projectId); if (!project) return err({ type: 'not_found' as const }); - const result = await project.fetch(); - capture('vcs_fetch', { + + let result; + if (workspaceId) { + const ws = workspaceRegistry.get(workspaceId); + result = ws ? await ws.fetchService.fetch() : await project.fetch(); + } else { + result = await project.fetch(); + } + + telemetryService.capture('vcs_fetch', { success: result.success, project_id: projectId, ...(result.success ? {} : { error_type: result.error.type }), }); + if (!result.success) return err(result.error); + + if (workspaceId) { + events.emit(gitRefChangedChannel, { projectId, workspaceId, kind: 'remote-refs' }); + } + return ok(); }, diff --git a/src/main/core/search/controller.ts b/src/main/core/search/controller.ts new file mode 100644 index 0000000000..2134064d43 --- /dev/null +++ b/src/main/core/search/controller.ts @@ -0,0 +1,7 @@ +import { createRPCController } from '@shared/ipc/rpc'; +import type { CommandPaletteQuery } from '@shared/search'; +import { searchService } from './search-service'; + +export const searchController = createRPCController({ + commandPalette: (query: CommandPaletteQuery) => searchService.search(query), +}); diff --git a/src/main/core/search/search-service.ts b/src/main/core/search/search-service.ts new file mode 100644 index 0000000000..eccdcb04ea --- /dev/null +++ b/src/main/core/search/search-service.ts @@ -0,0 +1,287 @@ +import type { Conversation } from '@shared/conversations'; +import type { Project } from '@shared/projects'; +import type { CommandPaletteQuery, SearchItem, SearchItemKind } from '@shared/search'; +import type { Task } from '@shared/tasks'; +import { db, sqlite } from '@main/db/client'; +import { conversations, projects, tasks } from '@main/db/schema'; +import { log } from '@main/lib/logger'; +import { conversationEvents } from '../conversations/conversation-events'; +import { projectEvents } from '../projects/project-events'; +import { taskEvents } from '../tasks/task-events'; + +type FtsRow = { + item_type: string; + item_id: string; + project_id: string | null; + task_id: string | null; + title: string; + rank: number; +}; + +type RecentTaskRow = { + id: string; + name: string; + project_id: string; +}; + +type RecentConversationRow = { + id: string; + title: string; + project_id: string; + task_id: string; +}; + +class SearchService { + initialize(): void { + taskEvents.on('task:created', (task) => this.upsertTask(task)); + taskEvents.on('task:updated', (task) => this.upsertTask(task)); + taskEvents.on('task:archived', (taskId) => this.removeByType('task', taskId)); + taskEvents.on('task:deleted', (taskId) => this.removeByType('task', taskId)); + + projectEvents.on('project:created', (project) => this.upsertProject(project)); + projectEvents.on('project:deleted', (projectId) => this.removeByType('project', projectId)); + + conversationEvents.on('conversation:created', (conversation) => + this.upsertConversation(conversation) + ); + conversationEvents.on('conversation:renamed', (conversationId, projectId, taskId, newTitle) => { + this.upsertConversationById(conversationId, projectId, taskId, newTitle); + }); + conversationEvents.on('conversation:deleted', (conversationId) => + this.removeByType('conversation', conversationId) + ); + + this.backfill(); + } + + search({ query, context }: CommandPaletteQuery): SearchItem[] { + if (!query.trim()) return this.recents(context); + + const ftsQuery = query + .trim() + .split(/[\s\-_]+/) + .filter(Boolean) + .map((t) => `${t}*`) + .join(' AND '); + + let rows: FtsRow[]; + try { + if (context?.taskId) { + rows = sqlite + .prepare( + `SELECT item_type, item_id, project_id, task_id, title, bm25(search_index) AS rank + FROM search_index + WHERE search_index MATCH ? + AND (item_type != 'conversation' OR task_id = ?) + ORDER BY rank + LIMIT 30` + ) + .all(ftsQuery, context.taskId) as FtsRow[]; + } else { + rows = sqlite + .prepare( + `SELECT item_type, item_id, project_id, task_id, title, bm25(search_index) AS rank + FROM search_index + WHERE search_index MATCH ? + AND item_type != 'conversation' + ORDER BY rank + LIMIT 30` + ) + .all(ftsQuery) as FtsRow[]; + } + } catch (e) { + log.warn('SearchService: FTS query failed', { query, error: String(e) }); + return []; + } + + return rows.map((r) => ({ + kind: r.item_type as SearchItemKind, + id: r.item_id, + projectId: r.project_id, + taskId: r.task_id, + title: r.title, + subtitle: '', + score: r.rank, + })); + } + + private recents(context?: CommandPaletteQuery['context']): SearchItem[] { + const taskStmt = context?.projectId + ? sqlite.prepare( + `SELECT t.id, t.name, t.project_id + FROM tasks t + WHERE t.archived_at IS NULL AND t.project_id = ? + ORDER BY t.last_interacted_at DESC + LIMIT 10` + ) + : sqlite.prepare( + `SELECT t.id, t.name, t.project_id + FROM tasks t + WHERE t.archived_at IS NULL + ORDER BY t.last_interacted_at DESC + LIMIT 10` + ); + + const taskRows = ( + context?.projectId ? taskStmt.all(context.projectId) : taskStmt.all() + ) as RecentTaskRow[]; + + const results: SearchItem[] = taskRows.map((r) => ({ + kind: 'task' as const, + id: r.id, + projectId: r.project_id, + taskId: null, + title: r.name, + subtitle: '', + score: 0, + })); + + if (context?.taskId) { + const conversationRows = sqlite + .prepare( + `SELECT c.id, c.title, c.project_id, c.task_id + FROM conversations c + WHERE c.task_id = ? + ORDER BY c.last_interacted_at DESC + LIMIT 10` + ) + .all(context.taskId) as RecentConversationRow[]; + + for (const r of conversationRows) { + results.push({ + kind: 'conversation', + id: r.id, + projectId: r.project_id, + taskId: r.task_id, + title: r.title, + subtitle: '', + score: 0, + }); + } + } + + return results; + } + + private upsertTask(task: Task): void { + const keywords = [task.taskBranch, task.linkedIssue?.identifier, task.linkedIssue?.title] + .filter(Boolean) + .join(' '); + + try { + sqlite + .prepare( + `INSERT OR REPLACE INTO search_index(item_type, item_id, project_id, task_id, title, keywords) + VALUES ('task', ?, ?, NULL, ?, ?)` + ) + .run(task.id, task.projectId, task.name, keywords); + } catch (e) { + log.warn('SearchService: upsertTask failed', { taskId: task.id, error: String(e) }); + } + } + + private upsertProject(project: Project): void { + try { + sqlite + .prepare( + `INSERT OR REPLACE INTO search_index(item_type, item_id, project_id, task_id, title, keywords) + VALUES ('project', ?, NULL, NULL, ?, ?)` + ) + .run(project.id, project.name, project.path); + } catch (e) { + log.warn('SearchService: upsertProject failed', { + projectId: project.id, + error: String(e), + }); + } + } + + private upsertConversation(conversation: Conversation): void { + try { + sqlite + .prepare( + `INSERT OR REPLACE INTO search_index(item_type, item_id, project_id, task_id, title, keywords) + VALUES ('conversation', ?, ?, ?, ?, '')` + ) + .run(conversation.id, conversation.projectId, conversation.taskId, conversation.title); + } catch (e) { + log.warn('SearchService: upsertConversation failed', { + conversationId: conversation.id, + error: String(e), + }); + } + } + + private upsertConversationById( + conversationId: string, + projectId: string, + taskId: string, + title: string + ): void { + try { + sqlite + .prepare( + `INSERT OR REPLACE INTO search_index(item_type, item_id, project_id, task_id, title, keywords) + VALUES ('conversation', ?, ?, ?, ?, '')` + ) + .run(conversationId, projectId, taskId, title); + } catch (e) { + log.warn('SearchService: upsertConversationById failed', { + conversationId, + error: String(e), + }); + } + } + + private removeByType(itemType: string, itemId: string): void { + try { + sqlite + .prepare(`DELETE FROM search_index WHERE item_id = ? AND item_type = ?`) + .run(itemId, itemType); + } catch (e) { + log.warn('SearchService: removeByType failed', { itemType, itemId, error: String(e) }); + } + } + + private backfill(): void { + try { + const count = ( + sqlite.prepare(`SELECT count(*) as n FROM search_index`).get() as { n: number } + ).n; + + if (count > 0) return; + + const allTasks = db.select().from(tasks).all(); + const allProjects = db.select().from(projects).all(); + const allConversations = db.select().from(conversations).all(); + + const upsertStmt = sqlite.prepare( + `INSERT OR REPLACE INTO search_index(item_type, item_id, project_id, task_id, title, keywords) + VALUES (?, ?, ?, ?, ?, ?)` + ); + + sqlite.transaction(() => { + for (const t of allTasks) { + if (t.archivedAt) continue; + upsertStmt.run('task', t.id, t.projectId, null, t.name, t.taskBranch ?? ''); + } + for (const p of allProjects) { + upsertStmt.run('project', p.id, null, null, p.name, p.path); + } + for (const c of allConversations) { + upsertStmt.run('conversation', c.id, c.projectId, c.taskId, c.title, ''); + } + })(); + + log.info('SearchService: backfilled search index', { + tasks: allTasks.filter((t) => !t.archivedAt).length, + projects: allProjects.length, + conversations: allConversations.length, + }); + } catch (e) { + log.warn('SearchService: backfill failed', { error: String(e) }); + } + } +} + +export const searchService = new SearchService(); diff --git a/src/main/core/settings/schema.ts b/src/main/core/settings/schema.ts index 0880b72cc8..e07f10dc88 100644 --- a/src/main/core/settings/schema.ts +++ b/src/main/core/settings/schema.ts @@ -52,20 +52,30 @@ export const keyboardSettingsSchema = z toggleRightSidebar: z.string().nullable().optional(), toggleTheme: z.string().nullable().optional(), closeModal: z.string().nullable().optional(), - nextProject: z.string().nullable().optional(), - prevProject: z.string().nullable().optional(), newTask: z.string().nullable().optional(), newProject: z.string().nullable().optional(), openInEditor: z.string().nullable().optional(), - taskViewAgents: z.string().nullable().optional(), - taskViewDiff: z.string().nullable().optional(), - taskViewEditor: z.string().nullable().optional(), + sidebarChanges: z.string().nullable().optional(), + sidebarConversations: z.string().nullable().optional(), + sidebarFiles: z.string().nullable().optional(), tabNext: z.string().nullable().optional(), tabPrev: z.string().nullable().optional(), tabClose: z.string().nullable().optional(), + tab1: z.string().nullable().optional(), + tab2: z.string().nullable().optional(), + tab3: z.string().nullable().optional(), + tab4: z.string().nullable().optional(), + tab5: z.string().nullable().optional(), + tab6: z.string().nullable().optional(), + tab7: z.string().nullable().optional(), + tab8: z.string().nullable().optional(), + tab9: z.string().nullable().optional(), newConversation: z.string().nullable().optional(), newTerminal: z.string().nullable().optional(), confirm: z.string().nullable().optional(), + toggleTerminalDrawer: z.string().nullable().optional(), + navigateBack: z.string().nullable().optional(), + navigateForward: z.string().nullable().optional(), }) ) .default({}); @@ -77,6 +87,7 @@ export const providerCustomConfigEntrySchema = z.object({ autoApproveFlag: z.string().optional(), initialPromptFlag: z.string().optional(), sessionIdFlag: z.string().optional(), + sessionIdOnResumeOnly: z.boolean().optional(), extraArgs: z.string().optional(), env: z.record(z.string(), z.string()).optional(), }); @@ -93,6 +104,7 @@ export const providerConfigDefaults = Object.fromEntries( ...(p.initialPromptFlag !== undefined ? { initialPromptFlag: p.initialPromptFlag } : {}), ...(p.defaultArgs ? { defaultArgs: p.defaultArgs } : {}), ...(p.sessionIdFlag ? { sessionIdFlag: p.sessionIdFlag } : {}), + ...(p.sessionIdOnResumeOnly ? { sessionIdOnResumeOnly: p.sessionIdOnResumeOnly } : {}), }, ]) ); diff --git a/src/main/core/settings/settings-service.ts b/src/main/core/settings/settings-service.ts index a17365d2f6..583ec2884b 100644 --- a/src/main/core/settings/settings-service.ts +++ b/src/main/core/settings/settings-service.ts @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm'; import { AppSettingsKeys, type AppSettings, type AppSettingsKey } from '@shared/app-settings'; import { db } from '@main/db/client'; import { appSettings } from '@main/db/schema'; +import type { IInitializable } from '@main/lib/lifecycle'; import { APP_SETTINGS_SCHEMA_MAP } from './schema'; import { getDefaultForKey } from './settings-registry'; import { computeDelta, computeTrueOverrides, isDeepEqual, isPlainObject, mergeDeep } from './utils'; @@ -9,7 +10,7 @@ import { computeDelta, computeTrueOverrides, isDeepEqual, isPlainObject, mergeDe export type { AppSettings, AppSettingsKey } from '@shared/app-settings'; export { AppSettingsKeys } from '@shared/app-settings'; -export class SettingsStore { +export class SettingsStore implements IInitializable { private cache: Partial = {}; private async readRaw(key: AppSettingsKey): Promise { diff --git a/src/main/core/ssh/controller.ts b/src/main/core/ssh/controller.ts index 19c765e46c..7cd06d824e 100644 --- a/src/main/core/ssh/controller.ts +++ b/src/main/core/ssh/controller.ts @@ -8,7 +8,7 @@ import type { ConnectionState, ConnectionTestResult, FileEntry, SshConfig } from import { db } from '@main/db/client'; import { sshConnections as sshConnectionsTable, type SshConnectionInsert } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { sshConnectionManager } from './ssh-connection-manager'; import { sshCredentialService } from './ssh-credential-service'; import { resolveIdentityAgent } from './utils'; @@ -103,7 +103,12 @@ export const sshController = createRPCController({ testConnection: async ( config: SshConfig & { password?: string; passphrase?: string } ): Promise => { - return new Promise(async (resolve) => { + let identityAgent: string | undefined; + if (config.authType === 'agent') { + identityAgent = await resolveIdentityAgent(config.host); + } + + return new Promise((resolve) => { const client = new Client(); const debugLogs: string[] = []; const startTime = Date.now(); @@ -111,12 +116,12 @@ export const sshController = createRPCController({ client.on('ready', () => { const latency = Date.now() - startTime; client.end(); - capture('ssh_connection_attempted', { success: true }); + telemetryService.capture('ssh_connection_attempted', { success: true }); resolve({ success: true, latency, debugLogs }); }); client.on('error', (err: Error) => { - capture('ssh_connection_attempted', { success: false }); + telemetryService.capture('ssh_connection_attempted', { success: false }); resolve({ success: false, error: err.message, debugLogs }); }); @@ -137,13 +142,12 @@ export const sshController = createRPCController({ connectConfig.privateKey = readFileSync(keyPath); if (config.passphrase) connectConfig.passphrase = config.passphrase; } else if (config.authType === 'agent') { - const identityAgent = await resolveIdentityAgent(config.host); connectConfig.agent = identityAgent || process.env.SSH_AUTH_SOCK; } client.connect(connectConfig); } catch (e) { - capture('ssh_connection_attempted', { success: false }); + telemetryService.capture('ssh_connection_attempted', { success: false }); resolve({ success: false, error: (e as Error).message, debugLogs }); } }); diff --git a/src/main/core/ssh/remote-shell-profile.test.ts b/src/main/core/ssh/remote-shell-profile.test.ts new file mode 100644 index 0000000000..5d6dc045bb --- /dev/null +++ b/src/main/core/ssh/remote-shell-profile.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + buildRemoteShellCommand, + FALLBACK_REMOTE_SHELL_PROFILE, + normalizeRemoteShell, + type RemoteShellProfile, +} from './remote-shell-profile'; + +describe('remote shell profile command building', () => { + it('runs commands through the captured remote shell and exports captured PATH', () => { + const profile: RemoteShellProfile = { + shell: '/bin/zsh', + env: { + PATH: '/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin', + NVM_DIR: '/Users/jona/.nvm', + }, + }; + + const command = buildRemoteShellCommand(profile, 'which claude'); + + expect(command).toBe( + "'/bin/zsh' -lc 'export PATH='\\''/Users/jona/.local/bin:/opt/homebrew/bin:/usr/bin'\\''; export NVM_DIR='\\''/Users/jona/.nvm'\\''; which claude'" + ); + }); + + it('lets explicit command env override captured profile env', () => { + const profile: RemoteShellProfile = { + shell: '/bin/zsh', + env: { + PATH: '/captured/bin:/usr/bin', + FOO: 'captured', + }, + }; + + const command = buildRemoteShellCommand(profile, 'node --version', { + PATH: '/task/bin:/usr/bin', + FOO: 'task', + }); + + expect(command).toContain("export PATH='\\''/captured/bin:/usr/bin'\\''"); + expect(command).toContain("export PATH='\\''/task/bin:/usr/bin'\\''"); + expect(command.indexOf('/captured/bin')).toBeLessThan(command.indexOf('/task/bin')); + expect(command).toContain("export FOO='\\''task'\\''; node --version"); + }); + + it('uses /bin/sh without login flags for the fallback profile', () => { + const command = buildRemoteShellCommand(FALLBACK_REMOTE_SHELL_PROFILE, 'which claude'); + + expect(command).toBe("'/bin/sh' -c 'which claude'"); + }); + + it('filters volatile and invalid environment variables from command exports', () => { + const command = buildRemoteShellCommand( + { + shell: '/bin/zsh', + env: { + PATH: '/usr/bin', + PWD: '/tmp', + 'BAD-NAME': 'nope', + GOOD_NAME: 'value', + }, + }, + 'env', + { + SHLVL: '2', + ALSO_GOOD: 'yes', + } + ); + + expect(command).toBe( + "'/bin/zsh' -lc 'export PATH='\\''/usr/bin'\\''; export GOOD_NAME='\\''value'\\''; export ALSO_GOOD='\\''yes'\\''; env'" + ); + }); + + it('falls back to /bin/sh when the remote shell is empty or not absolute', () => { + expect(normalizeRemoteShell('')).toBe('/bin/sh'); + expect(normalizeRemoteShell('zsh')).toBe('/bin/sh'); + expect(normalizeRemoteShell('/bin/zsh\n')).toBe('/bin/zsh'); + }); + + it('falls back to /bin/sh for unsupported remote shells', () => { + expect(normalizeRemoteShell('/usr/local/bin/fish')).toBe('/bin/sh'); + expect(buildRemoteShellCommand({ shell: '/usr/local/bin/fish', env: {} }, 'echo ok')).toBe( + "'/bin/sh' -c 'echo ok'" + ); + }); +}); diff --git a/src/main/core/ssh/remote-shell-profile.ts b/src/main/core/ssh/remote-shell-profile.ts new file mode 100644 index 0000000000..07bb194e4e --- /dev/null +++ b/src/main/core/ssh/remote-shell-profile.ts @@ -0,0 +1,169 @@ +import type { Client, ClientChannel } from 'ssh2'; +import { isValidEnvVarName, quoteShellArg } from '@main/utils/shellEscape'; +import { parseRemoteEnvOutput, SHELL_ENV_CAPTURE_GUARD } from '@main/utils/userEnv'; + +export type RemoteShellProfile = { + shell: string; + env: Record; +}; + +export const DEFAULT_REMOTE_SHELL = '/bin/sh'; + +export const FALLBACK_REMOTE_SHELL_PROFILE: RemoteShellProfile = { + shell: DEFAULT_REMOTE_SHELL, + env: {}, +}; + +const CAPTURE_TIMEOUT_MS = 5_000; +const SHELL_TIMEOUT_MS = 3_000; + +const LOGIN_SHELLS = new Set(['bash', 'ksh', 'zsh']); +const BASIC_POSIX_SHELLS = new Set(['dash', 'sh']); +const SUPPORTED_REMOTE_SHELLS = new Set([...BASIC_POSIX_SHELLS, ...LOGIN_SHELLS]); +const VOLATILE_ENV_KEYS = new Set(['_', 'PWD', 'OLDPWD', 'SHLVL', 'COLUMNS', 'LINES']); + +type RawExecResult = { + stdout: string; + stderr: string; +}; + +export function normalizeRemoteShell(raw: string | undefined | null): string { + const shell = raw?.trim(); + if (!shell || !shell.startsWith('/') || !SUPPORTED_REMOTE_SHELLS.has(shellBasename(shell))) { + return DEFAULT_REMOTE_SHELL; + } + return shell; +} + +function buildRemoteShellEnvPrefix(env: Record): string { + const exports = Object.entries(env) + .filter(([key]) => shouldForwardEnvKey(key)) + .map(([key, value]) => `export ${key}=${quoteShellArg(value)}`); + + return exports.length > 0 ? `${exports.join('; ')}; ` : ''; +} + +function buildRemoteShellProcessEnvPrefix(env: Record): string { + const assignments = Object.entries(env) + .filter(([key]) => shouldForwardEnvKey(key)) + .map(([key, value]) => quoteShellArg(`${key}=${value}`)); + + return assignments.length > 0 ? `env ${assignments.join(' ')} ` : ''; +} + +export function buildRemoteShellCommand( + profile: RemoteShellProfile, + command: string, + env: Record = {} +): string { + const shell = normalizeRemoteShell(profile.shell); + const prefix = `${buildRemoteShellEnvPrefix(profile.env)}${buildRemoteShellEnvPrefix(env)}`; + return `${quoteShellArg(shell)} ${remoteShellCommandFlag(shell)} ${quoteShellArg( + `${prefix}${command}` + )}`; +} + +export async function captureRemoteShellProfile(client: Client): Promise { + const shell = await resolveRemoteShell(client); + const env = await captureRemoteEnv(client, shell); + return { shell, env }; +} + +async function resolveRemoteShell(client: Client): Promise { + try { + const { stdout } = await execRaw(client, 'printf %s "$SHELL"', SHELL_TIMEOUT_MS); + return normalizeRemoteShell(stdout); + } catch { + return DEFAULT_REMOTE_SHELL; + } +} + +async function captureRemoteEnv(client: Client, shell: string): Promise> { + try { + const guard = buildRemoteShellProcessEnvPrefix(SHELL_ENV_CAPTURE_GUARD); + const capture = `${guard}${quoteShellArg(shell)} ${remoteShellEnvCaptureFlag( + shell + )} ${quoteShellArg('env')}`; + const { stdout } = await execRaw(client, capture, CAPTURE_TIMEOUT_MS); + return parseRemoteEnvOutput(stdout); + } catch { + try { + const { stdout } = await execRaw(client, 'env', CAPTURE_TIMEOUT_MS); + return parseRemoteEnvOutput(stdout); + } catch { + return {}; + } + } +} + +function shouldForwardEnvKey(key: string): boolean { + return isValidEnvVarName(key) && !VOLATILE_ENV_KEYS.has(key); +} + +function remoteShellCommandFlag(shell: string): string { + return BASIC_POSIX_SHELLS.has(shellBasename(shell)) ? '-c' : '-lc'; +} + +function remoteShellEnvCaptureFlag(shell: string): string { + return BASIC_POSIX_SHELLS.has(shellBasename(shell)) ? '-ic' : '-ilc'; +} + +function shellBasename(shell: string): string { + return shell.split('/').pop() ?? ''; +} + +function execRaw(client: Client, command: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let stream: ClientChannel | undefined; + let settled = false; + let stdout = ''; + let stderr = ''; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + stream?.destroy(); + reject(new Error(`Remote command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + client.exec(command, (err, channel) => { + if (settled) return; + if (err) { + clearTimeout(timer); + settled = true; + reject(err); + return; + } + + stream = channel; + channel.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + channel.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + }); + channel.on('close', (exitCode: number | null) => { + if (settled) return; + clearTimeout(timer); + settled = true; + if ((exitCode ?? 0) === 0) { + resolve({ stdout, stderr }); + return; + } + reject( + Object.assign(new Error(stderr || `Process exited with code ${exitCode}`), { + stdout, + stderr, + exitCode, + }) + ); + }); + channel.on('error', (error: Error) => { + if (settled) return; + clearTimeout(timer); + settled = true; + reject(error); + }); + }); + }); +} diff --git a/src/main/core/ssh/ssh-client-proxy.test.ts b/src/main/core/ssh/ssh-client-proxy.test.ts new file mode 100644 index 0000000000..c5ae6eacef --- /dev/null +++ b/src/main/core/ssh/ssh-client-proxy.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as RemoteShellProfileModule from './remote-shell-profile'; +import { SshClientProxy } from './ssh-client-proxy'; + +const mocks = vi.hoisted(() => ({ + captureRemoteShellProfile: vi.fn(), +})); + +vi.mock('./remote-shell-profile', async (importOriginal) => { + const actual = (await importOriginal()) as typeof RemoteShellProfileModule; + return { + ...actual, + captureRemoteShellProfile: mocks.captureRemoteShellProfile, + }; +}); + +describe('SshClientProxy remote shell profile', () => { + beforeEach(() => { + mocks.captureRemoteShellProfile.mockReset(); + }); + + it('returns a rejected promise when the SSH connection is unavailable', async () => { + const proxy = new SshClientProxy(); + + await expect(proxy.getRemoteShellProfile()).rejects.toThrow('SSH connection is not available'); + }); + + it('captures and caches the remote shell profile behind the proxy API', async () => { + const client = {}; + const profile = { + shell: '/bin/zsh', + env: { PATH: '/opt/homebrew/bin:/usr/bin' }, + }; + mocks.captureRemoteShellProfile.mockResolvedValue(profile); + const proxy = new SshClientProxy(); + proxy.update(client as never); + + await expect(proxy.getRemoteShellProfile()).resolves.toBe(profile); + await expect(proxy.getRemoteShellProfile()).resolves.toBe(profile); + + expect(mocks.captureRemoteShellProfile).toHaveBeenCalledTimes(1); + expect(mocks.captureRemoteShellProfile).toHaveBeenCalledWith(client); + }); + + it('does not cache an in-flight shell profile after invalidation', async () => { + let resolveFirst!: (profile: { shell: string; env: Record }) => void; + const firstCapture = new Promise<{ shell: string; env: Record }>((resolve) => { + resolveFirst = resolve; + }); + const firstClient = {}; + const secondClient = {}; + mocks.captureRemoteShellProfile + .mockReturnValueOnce(firstCapture) + .mockResolvedValueOnce({ shell: '/bin/bash', env: { PATH: '/second' } }); + const proxy = new SshClientProxy(); + + proxy.update(firstClient as never); + const staleCapture = proxy.getRemoteShellProfile(); + proxy.invalidate(); + proxy.update(secondClient as never); + resolveFirst({ shell: '/bin/zsh', env: { PATH: '/first' } }); + await staleCapture; + + await expect(proxy.getRemoteShellProfile()).resolves.toEqual({ + shell: '/bin/bash', + env: { PATH: '/second' }, + }); + expect(mocks.captureRemoteShellProfile).toHaveBeenCalledTimes(2); + expect(mocks.captureRemoteShellProfile).toHaveBeenNthCalledWith(2, secondClient); + }); + + it('clears cached shell profile on invalidate', async () => { + const firstClient = {}; + const secondClient = {}; + mocks.captureRemoteShellProfile + .mockResolvedValueOnce({ shell: '/bin/zsh', env: { PATH: '/first' } }) + .mockResolvedValueOnce({ shell: '/bin/bash', env: { PATH: '/second' } }); + const proxy = new SshClientProxy(); + + proxy.update(firstClient as never); + await proxy.getRemoteShellProfile(); + proxy.invalidate(); + proxy.update(secondClient as never); + const profile = await proxy.getRemoteShellProfile(); + + expect(profile).toEqual({ shell: '/bin/bash', env: { PATH: '/second' } }); + expect(mocks.captureRemoteShellProfile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/main/core/ssh/ssh-client-proxy.ts b/src/main/core/ssh/ssh-client-proxy.ts index e58370f42c..d42ad5470b 100644 --- a/src/main/core/ssh/ssh-client-proxy.ts +++ b/src/main/core/ssh/ssh-client-proxy.ts @@ -1,4 +1,10 @@ import type { Client } from 'ssh2'; +import { captureRemoteShellProfile, type RemoteShellProfile } from './remote-shell-profile'; + +type RemoteShellProfileState = + | { kind: 'empty' } + | { kind: 'loading'; client: Client; promise: Promise } + | { kind: 'ready'; client: Client; profile: RemoteShellProfile }; /** * Stable reference to an ssh2 Client that survives reconnects. @@ -12,35 +18,45 @@ import type { Client } from 'ssh2'; */ export class SshClientProxy { private _client: Client | null = null; - private _remoteEnv: Record | null = null; + private _remoteShellProfileState: RemoteShellProfileState = { kind: 'empty' }; /** Called by SshConnectionManager when a connection becomes ready. */ update(client: Client): void { + if (this._client !== client) { + this._remoteShellProfileState = { kind: 'empty' }; + } this._client = client; } - /** - * Called by SshConnectionManager after the connection is ready with the - * remote machine's login-shell environment. Stored here so downstream - * consumers (probers, providers) can use it without re-capturing per command. - */ - updateRemoteEnv(env: Record): void { - this._remoteEnv = env; + async getRemoteShellProfile(): Promise { + const client = this.client; + const state = this._remoteShellProfileState; + + if (state.kind === 'ready' && state.client === client) { + return state.profile; + } + if (state.kind === 'loading' && state.client === client) { + return state.promise; + } + + const promise = captureRemoteShellProfile(client).then((profile) => { + if ( + this._client === client && + this._remoteShellProfileState.kind === 'loading' && + this._remoteShellProfileState.promise === promise + ) { + this._remoteShellProfileState = { kind: 'ready', client, profile }; + } + return profile; + }); + this._remoteShellProfileState = { kind: 'loading', client, promise }; + return promise; } /** Called by SshConnectionManager when the connection drops. */ invalidate(): void { this._client = null; - this._remoteEnv = null; - } - - /** - * The remote machine's login-shell environment, captured once after the - * connection becomes ready. `null` until the capture completes or if - * capture failed — callers should fall back to `bash -l -c` in that case. - */ - get remoteEnv(): Record | null { - return this._remoteEnv; + this._remoteShellProfileState = { kind: 'empty' }; } /** diff --git a/src/main/core/ssh/ssh-connection-manager.ts b/src/main/core/ssh/ssh-connection-manager.ts index 756809f527..5eb8eb81a7 100644 --- a/src/main/core/ssh/ssh-connection-manager.ts +++ b/src/main/core/ssh/ssh-connection-manager.ts @@ -7,7 +7,6 @@ import { db } from '@main/db/client'; import { sshConnections } from '@main/db/schema'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { parseRemoteEnvOutput } from '@main/utils/userEnv'; import { buildConnectConfigFromRow } from './build-connect-config'; import { SshClientProxy } from './ssh-client-proxy'; @@ -186,6 +185,23 @@ export class SshConnectionManager extends EventEmitter { await Promise.all(ids.map((id) => this.disconnect(id))); } + /** + * Establish an ephemeral connection from a caller-supplied config. + * The connection is marked intentional from the start so the close handler + * never schedules a reconnect — callers are responsible for teardown via + * `disconnect(id)`. + */ + async connectFromConfig(id: string, config: ConnectConfig): Promise { + this.intentionalDisconnects.add(id); + const connectionPromise = this.createConnection(id, config); + this.pendingConnections.set(id, connectionPromise); + try { + return await connectionPromise; + } finally { + this.pendingConnections.delete(id); + } + } + // ─── Private ───────────────────────────────────────────────────────────── private createConnection(id: string, config: ConnectConfig): Promise { @@ -268,10 +284,10 @@ export class SshConnectionManager extends EventEmitter { proxy.update(client); - // Capture the remote login-shell env once, non-blocking. Failures are + // Capture the remote login-shell profile once, non-blocking. Failures are // warned but do not prevent the connection from being used. - this.captureRemoteEnv(proxy).catch((err: unknown) => { - log.warn('SshConnectionManager: remote env capture failed', { + proxy.getRemoteShellProfile().catch((err: unknown) => { + log.warn('SshConnectionManager: remote shell profile capture failed', { connectionId: id, error: err instanceof Error ? err.message : String(err), }); @@ -298,43 +314,6 @@ export class SshConnectionManager extends EventEmitter { }); } - /** - * Runs `bash -l -c 'env'` on the remote machine and stores the result on - * the proxy. This is a one-shot operation per connection — callers that need - * the remote env before the capture completes can fall back to `bash -l -c` - * wrappers on individual commands. - */ - private captureRemoteEnv(proxy: SshClientProxy): Promise { - const TIMEOUT_MS = 5_000; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('remote env capture timed out')); - }, TIMEOUT_MS); - - proxy.client.exec("bash -l -c 'env'", (execErr, stream) => { - if (execErr) { - clearTimeout(timer); - reject(execErr); - return; - } - - let stdout = ''; - stream.on('data', (chunk: Buffer) => { - stdout += chunk.toString('utf-8'); - }); - stream.on('close', () => { - clearTimeout(timer); - proxy.updateRemoteEnv(parseRemoteEnvOutput(stdout)); - resolve(); - }); - stream.on('error', (err: Error) => { - clearTimeout(timer); - reject(err); - }); - }); - }); - } - private scheduleReconnect(id: string): void { const state = this.reconnecting.get(id) ?? { attempt: 0, timer: undefined }; const attempt = state.attempt + 1; diff --git a/src/main/core/ssh/utils.test.ts b/src/main/core/ssh/utils.test.ts index eaafd58500..3cbe5def84 100644 --- a/src/main/core/ssh/utils.test.ts +++ b/src/main/core/ssh/utils.test.ts @@ -1,16 +1,26 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ExecFn } from '@main/core/utils/exec'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { resolveRemoteHome } from './utils'; +function makeCtx(stdout: string): IExecutionContext { + return { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn().mockResolvedValue({ stdout, stderr: '' }), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext; +} + describe('resolveRemoteHome', () => { it('returns trimmed remote home', async () => { - const exec = vi.fn().mockResolvedValue({ stdout: ' /home/ubuntu \n', stderr: '' }) as ExecFn; - await expect(resolveRemoteHome(exec)).resolves.toBe('/home/ubuntu'); - expect(exec).toHaveBeenCalledWith('sh', ['-c', 'printf %s "$HOME"']); + const ctx = makeCtx(' /home/ubuntu \n'); + await expect(resolveRemoteHome(ctx)).resolves.toBe('/home/ubuntu'); + expect(ctx.exec).toHaveBeenCalledWith('sh', ['-c', 'printf %s "$HOME"']); }); it('throws when remote home is empty', async () => { - const exec = vi.fn().mockResolvedValue({ stdout: ' ', stderr: '' }) as ExecFn; - await expect(resolveRemoteHome(exec)).rejects.toThrow('Remote home directory is empty'); + const ctx = makeCtx(' '); + await expect(resolveRemoteHome(ctx)).rejects.toThrow('Remote home directory is empty'); }); }); diff --git a/src/main/core/ssh/utils.ts b/src/main/core/ssh/utils.ts index fea6897201..e7ebd9770b 100644 --- a/src/main/core/ssh/utils.ts +++ b/src/main/core/ssh/utils.ts @@ -1,5 +1,5 @@ +import type { IExecutionContext } from '@main/core/execution-context/types'; import { parseSshConfigFile } from '@main/core/ssh/sshConfigParser'; -import type { ExecFn } from '@main/core/utils/exec'; export async function resolveIdentityAgent(hostname: string): Promise { try { @@ -15,8 +15,8 @@ export async function resolveIdentityAgent(hostname: string): Promise { - const { stdout } = await exec('sh', ['-c', 'printf %s "$HOME"']); +export async function resolveRemoteHome(ctx: IExecutionContext): Promise { + const { stdout } = await ctx.exec('sh', ['-c', 'printf %s "$HOME"']); const home = stdout.trim(); if (!home) { throw new Error('Remote home directory is empty'); diff --git a/src/main/core/tasks/controller.ts b/src/main/core/tasks/controller.ts index e686861276..9c46455bba 100644 --- a/src/main/core/tasks/controller.ts +++ b/src/main/core/tasks/controller.ts @@ -1,18 +1,17 @@ import { createRPCController } from '@shared/ipc/rpc'; -import { archiveTask } from './archiveTask'; -import { createTask } from './createTask'; -import { deleteTask } from './deleteTask'; -import { generateTaskName } from './generateTaskName'; -import { getBootstrapStatus } from './getBootstrapStatus'; -import { getTasks } from './getTasks'; -import { getWorkspaceSettings } from './getWorkspaceSettings'; +import { generateTaskName } from './name-generation/generateTaskName'; +import { archiveTask } from './operations/archiveTask'; +import { createTask } from './operations/createTask'; +import { deleteTask } from './operations/deleteTask'; +import { getTasks } from './operations/getTasks'; +import { getWorkspaceSettings } from './operations/getWorkspaceSettings'; +import { renameTask } from './operations/renameTask'; +import { restoreTask } from './operations/restoreTask'; +import { setTaskPinned } from './operations/setTaskPinned'; +import { teardownTask } from './operations/teardownTask'; +import { updateLinkedIssue } from './operations/updateLinkedIssue'; +import { updateTaskStatus } from './operations/updateTaskStatus'; import { provisionTask } from './provisionTask'; -import { renameTask } from './renameTask'; -import { restoreTask } from './restoreTask'; -import { setTaskPinned } from './setTaskPinned'; -import { teardownTask } from './teardownTask'; -import { updateLinkedIssue } from './updateLinkedIssue'; -import { updateTaskStatus } from './updateTaskStatus'; export const taskController = createRPCController({ createTask, @@ -24,7 +23,6 @@ export const taskController = createRPCController({ renameTask, provisionTask, teardownTask, - getBootstrapStatus, getWorkspaceSettings, updateLinkedIssue, updateTaskStatus, diff --git a/src/main/core/tasks/getBootstrapStatus.ts b/src/main/core/tasks/getBootstrapStatus.ts deleted file mode 100644 index c72ec86c6e..0000000000 --- a/src/main/core/tasks/getBootstrapStatus.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { TaskBootstrapStatus } from '@shared/tasks'; -import { projectManager } from '@main/core/projects/project-manager'; -import { log } from '@main/lib/logger'; - -export async function getBootstrapStatus( - projectId: string, - taskId: string -): Promise { - const project = projectManager.getProject(projectId); - if (!project) throw new Error(`Project not found: ${projectId}`); - - const status = project.getTaskBootstrapStatus(taskId); - log.debug('getBootstrapStatus', { taskId, status: status.status }); - return status; -} diff --git a/src/main/core/tasks/getTaskSettings.ts b/src/main/core/tasks/getTaskSettings.ts deleted file mode 100644 index 41d1ab025b..0000000000 --- a/src/main/core/tasks/getTaskSettings.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { workspaceKey } from '@shared/workspace-key'; -import type { ProjectSettings } from '@main/core/projects/settings/schema'; -import { getEffectiveTaskSettings } from '@main/core/projects/settings/task-settings'; -import { resolveTask, resolveWorkspace } from '@main/core/projects/utils'; - -export async function getTaskSettings(projectId: string, taskId: string): Promise { - const task = resolveTask(projectId, taskId); - if (!task) { - throw new Error(`Task ${taskId} not found or not provisioned`); - } - const wsId = workspaceKey(task.taskBranch); - const workspace = resolveWorkspace(projectId, wsId); - if (!workspace) { - throw new Error(`Workspace ${wsId} not found in project ${projectId}`); - } - - return getEffectiveTaskSettings({ - projectSettings: workspace.settings, - taskFs: workspace.fs, - }); -} diff --git a/src/main/core/tasks/generateTaskName.test.ts b/src/main/core/tasks/name-generation/generateTaskName.test.ts similarity index 100% rename from src/main/core/tasks/generateTaskName.test.ts rename to src/main/core/tasks/name-generation/generateTaskName.test.ts diff --git a/src/main/core/tasks/generateTaskName.ts b/src/main/core/tasks/name-generation/generateTaskName.ts similarity index 100% rename from src/main/core/tasks/generateTaskName.ts rename to src/main/core/tasks/name-generation/generateTaskName.ts diff --git a/src/main/core/tasks/archiveTask.ts b/src/main/core/tasks/operations/archiveTask.ts similarity index 79% rename from src/main/core/tasks/archiveTask.ts rename to src/main/core/tasks/operations/archiveTask.ts index 443c5343bf..85bd3db9c6 100644 --- a/src/main/core/tasks/archiveTask.ts +++ b/src/main/core/tasks/operations/archiveTask.ts @@ -1,9 +1,11 @@ import { and, eq, isNull, sql } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskEvents } from '@main/core/tasks/task-events'; +import { taskManager } from '@main/core/tasks/task-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export async function archiveTask(projectId: string, taskId: string): Promise { const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1); @@ -20,18 +22,19 @@ export async function archiveTask(projectId: string, taskId: string): Promise { if (!teardownResult.success) { log.warn('archiveTask: teardown failed', { taskId, error: teardownResult.error.message }); } }) - .catch((e) => { + .catch((e: unknown) => { log.warn('archiveTask: teardown failed', { taskId, error: String(e) }); }); diff --git a/src/main/core/tasks/createTask.ts b/src/main/core/tasks/operations/createTask.ts similarity index 87% rename from src/main/core/tasks/createTask.ts rename to src/main/core/tasks/operations/createTask.ts index f12f9697d8..cfecdfff29 100644 --- a/src/main/core/tasks/createTask.ts +++ b/src/main/core/tasks/operations/createTask.ts @@ -1,6 +1,6 @@ import { sql } from 'drizzle-orm'; import { resolveAgentAutoApprove } from '@shared/agent-auto-approve-defaults'; -import { err, ok, Result } from '@shared/result'; +import { err, ok, type Result } from '@shared/result'; import type { CreateTaskError, CreateTaskParams, @@ -9,16 +9,18 @@ import type { TaskLifecycleStatus, } from '@shared/tasks'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskEvents } from '@main/core/tasks/task-events'; +import { taskManager } from '@main/core/tasks/task-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; -import { createConversation } from '../conversations/createConversation'; -import type { ProvisionTaskError } from '../projects/project-provider'; -import { prQueryService } from '../pull-requests/pr-query-service'; -import { appSettingsService } from '../settings/settings-service'; -import { mapTaskRowToTask } from './core'; -import { resolveTaskBranchName } from './resolveTaskBranchName'; -import { toStoredBranch } from './stored-branch'; +import { telemetryService } from '@main/lib/telemetry'; +import { createConversation } from '../../conversations/createConversation'; +import { prQueryService } from '../../pull-requests/pr-query-service'; +import { appSettingsService } from '../../settings/settings-service'; +import type { ProvisionTaskError } from '../provision-task-error'; +import { resolveTaskBranchName } from '../resolveTaskBranchName'; +import { toStoredBranch } from '../stored-branch'; +import { mapTaskRowToTask } from '../utils/utils'; function mapProvisionError(error: ProvisionTaskError): CreateTaskError { switch (error.type) { @@ -30,6 +32,8 @@ function mapProvisionError(error: ProvisionTaskError): CreateTaskError { branch: error.branch, message: error.message, }; + case 'timeout': + return { type: 'provision-timeout', timeoutMs: error.timeout, step: error.step }; default: return { type: 'provision-failed', message: error.message }; } @@ -190,6 +194,7 @@ export async function createTask( status: initialStatus, sourceBranch: toStoredBranch(dbSourceBranch), linkedIssue: params.linkedIssue ? JSON.stringify(params.linkedIssue) : null, + workspaceProvider: params.workspaceProvider ?? null, updatedAt: sql`CURRENT_TIMESTAMP`, statusChangedAt: sql`CURRENT_TIMESTAMP`, lastInteractedAt: sql`CURRENT_TIMESTAMP`, @@ -210,11 +215,13 @@ export async function createTask( const task = mapTaskRowToTask(taskRow, prs); - const provisionResult = await project.provisionTask(task, [], []); + taskEvents._emit('task:created', task); + + const provisionResult = await taskManager.provisionTask(project, task, [], []); if (!provisionResult.success) { return err(mapProvisionError(provisionResult.error)); } - capture('task_provisioned', { + telemetryService.capture('task_provisioned', { project_id: params.projectId, task_id: params.id, }); @@ -222,6 +229,7 @@ export async function createTask( if (params.initialConversation) { await createConversation({ ...params.initialConversation, + isInitialConversation: true, autoApprove: resolveAgentAutoApprove( params.initialConversation.autoApprove, agentAutoApproveDefaults, @@ -237,7 +245,7 @@ export async function createTask( return 'branch'; })(); - capture('task_created', { + telemetryService.capture('task_created', { strategy: taskCreatedStrategy, has_initial_prompt: Boolean(params.initialConversation?.initialPrompt?.trim()), has_issue: params.linkedIssue?.provider ?? 'none', @@ -246,7 +254,7 @@ export async function createTask( task_id: params.id, }); if (params.linkedIssue) { - capture('issue_linked_to_task', { + telemetryService.capture('issue_linked_to_task', { provider: params.linkedIssue.provider, project_id: params.projectId, task_id: params.id, diff --git a/src/main/core/tasks/deleteTask.ts b/src/main/core/tasks/operations/deleteTask.ts similarity index 82% rename from src/main/core/tasks/deleteTask.ts rename to src/main/core/tasks/operations/deleteTask.ts index 3126384f53..7fcc071192 100644 --- a/src/main/core/tasks/deleteTask.ts +++ b/src/main/core/tasks/operations/deleteTask.ts @@ -1,10 +1,12 @@ import { and, eq } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskEvents } from '@main/core/tasks/task-events'; +import { taskManager } from '@main/core/tasks/task-manager'; import { viewStateService } from '@main/core/view-state/view-state-service'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export async function deleteTask(projectId: string, taskId: string): Promise { const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1); @@ -14,7 +16,7 @@ export async function deleteTask(projectId: string, taskId: string): Promise { + const teardownResult = await taskManager.teardownTask(taskId, 'terminate').catch((e) => { log.warn('deleteTask: teardown failed', { taskId, error: String(e) }); return null; }); @@ -26,7 +28,8 @@ export async function deleteTask(projectId: string, taskId: string): Promise { const rows = projectId diff --git a/src/main/core/tasks/getWorkspaceSettings.ts b/src/main/core/tasks/operations/getWorkspaceSettings.ts similarity index 100% rename from src/main/core/tasks/getWorkspaceSettings.ts rename to src/main/core/tasks/operations/getWorkspaceSettings.ts diff --git a/src/main/core/tasks/renameTask.ts b/src/main/core/tasks/operations/renameTask.ts similarity index 80% rename from src/main/core/tasks/renameTask.ts rename to src/main/core/tasks/operations/renameTask.ts index fb7975b701..dea064bac8 100644 --- a/src/main/core/tasks/renameTask.ts +++ b/src/main/core/tasks/operations/renameTask.ts @@ -1,8 +1,10 @@ import { and, eq, sql } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskEvents } from '@main/core/tasks/task-events'; +import { mapTaskRowToTask } from '@main/core/tasks/utils/utils'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; -import { appSettingsService } from '../settings/settings-service'; +import { appSettingsService } from '../../settings/settings-service'; export async function renameTask( projectId: string, @@ -37,12 +39,17 @@ export async function renameTask( } } - await db + const [updatedRow] = await db .update(tasks) .set({ name: newName, taskBranch: newBranch ?? row.taskBranch, updatedAt: sql`CURRENT_TIMESTAMP`, }) - .where(eq(tasks.id, taskId)); + .where(eq(tasks.id, taskId)) + .returning(); + + if (updatedRow) { + taskEvents._emit('task:updated', mapTaskRowToTask(updatedRow)); + } } diff --git a/src/main/core/tasks/restoreTask.ts b/src/main/core/tasks/operations/restoreTask.ts similarity index 55% rename from src/main/core/tasks/restoreTask.ts rename to src/main/core/tasks/operations/restoreTask.ts index 32c3f05a16..d3b856ce34 100644 --- a/src/main/core/tasks/restoreTask.ts +++ b/src/main/core/tasks/operations/restoreTask.ts @@ -1,9 +1,11 @@ import { eq, sql } from 'drizzle-orm'; +import { taskEvents } from '@main/core/tasks/task-events'; +import { mapTaskRowToTask } from '@main/core/tasks/utils/utils'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; export async function restoreTask(id: string): Promise { - await db + const [updatedRow] = await db .update(tasks) .set({ archivedAt: null, @@ -11,5 +13,10 @@ export async function restoreTask(id: string): Promise { updatedAt: sql`CURRENT_TIMESTAMP`, statusChangedAt: sql`CURRENT_TIMESTAMP`, }) - .where(eq(tasks.id, id)); + .where(eq(tasks.id, id)) + .returning(); + + if (updatedRow) { + taskEvents._emit('task:updated', mapTaskRowToTask(updatedRow)); + } } diff --git a/src/main/core/tasks/setTaskPinned.ts b/src/main/core/tasks/operations/setTaskPinned.ts similarity index 100% rename from src/main/core/tasks/setTaskPinned.ts rename to src/main/core/tasks/operations/setTaskPinned.ts diff --git a/src/main/core/tasks/operations/teardownTask.ts b/src/main/core/tasks/operations/teardownTask.ts new file mode 100644 index 0000000000..7c84841e82 --- /dev/null +++ b/src/main/core/tasks/operations/teardownTask.ts @@ -0,0 +1,5 @@ +import { taskManager } from '../task-manager'; + +export async function teardownTask(_projectId: string, taskId: string) { + return await taskManager.teardownTask(taskId, 'terminate'); +} diff --git a/src/main/core/tasks/operations/updateLinkedIssue.ts b/src/main/core/tasks/operations/updateLinkedIssue.ts new file mode 100644 index 0000000000..d9419c8ae2 --- /dev/null +++ b/src/main/core/tasks/operations/updateLinkedIssue.ts @@ -0,0 +1,36 @@ +import { eq } from 'drizzle-orm'; +import { type Issue } from '@shared/tasks'; +import { taskEvents } from '@main/core/tasks/task-events'; +import { mapTaskRowToTask } from '@main/core/tasks/utils/utils'; +import { db } from '@main/db/client'; +import { tasks } from '@main/db/schema'; +import { telemetryService } from '@main/lib/telemetry'; + +export async function updateLinkedIssue(taskId: string, issue?: Issue) { + const [existingRow] = await db + .select({ id: tasks.id, projectId: tasks.projectId }) + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1); + if (!existingRow) return; + + const [updatedRow] = await db + .update(tasks) + .set({ + linkedIssue: issue ? JSON.stringify(issue) : null, + }) + .where(eq(tasks.id, taskId)) + .returning(); + + if (updatedRow) { + taskEvents._emit('task:updated', mapTaskRowToTask(updatedRow)); + } + + if (issue) { + telemetryService.capture('issue_linked_to_task', { + provider: issue.provider, + project_id: existingRow.projectId, + task_id: existingRow.id, + }); + } +} diff --git a/src/main/core/tasks/updateTaskStatus.ts b/src/main/core/tasks/operations/updateTaskStatus.ts similarity index 81% rename from src/main/core/tasks/updateTaskStatus.ts rename to src/main/core/tasks/operations/updateTaskStatus.ts index 4b89ac7fc8..67ddb11dc2 100644 --- a/src/main/core/tasks/updateTaskStatus.ts +++ b/src/main/core/tasks/operations/updateTaskStatus.ts @@ -1,8 +1,8 @@ import { eq, sql } from 'drizzle-orm'; -import { TaskLifecycleStatus } from '@shared/tasks'; +import { type TaskLifecycleStatus } from '@shared/tasks'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export async function updateTaskStatus(taskId: string, status: TaskLifecycleStatus): Promise { const [row] = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1); @@ -18,7 +18,7 @@ export async function updateTaskStatus(taskId: string, status: TaskLifecycleStat }) .where(eq(tasks.id, taskId)); - capture('task_status_changed', { + telemetryService.capture('task_status_changed', { from_status: row.status as TaskLifecycleStatus, to_status: status, project_id: row.projectId, diff --git a/src/main/core/tasks/provision-task-error.ts b/src/main/core/tasks/provision-task-error.ts new file mode 100644 index 0000000000..091b909a6e --- /dev/null +++ b/src/main/core/tasks/provision-task-error.ts @@ -0,0 +1,73 @@ +import type { ProvisionStep } from '@shared/events/taskEvents'; +import { TimeoutSignal } from '../projects/utils'; +import type { ServeWorktreeError } from '../projects/worktrees/worktree-service'; + +export const TASK_TIMEOUT_MS = 600_000; +export const TEARDOWN_SCRIPT_WAIT_MS = 10_000; + +export type ProvisionTaskError = + | { type: 'timeout'; message: string; timeout: number; step: ProvisionStep | null } + | { type: 'branch-not-found'; branch: string } + | { type: 'worktree-setup-failed'; branch: string; message?: string } + | { type: 'error'; message: string }; + +export type TeardownTaskError = + | { type: 'timeout'; message: string; timeout: number } + | { type: 'error'; message: string }; + +export function toProvisionError( + e: unknown, + step: ProvisionStep | null = null +): ProvisionTaskError { + if (isProvisionTaskError(e)) return e; + if (e instanceof TimeoutSignal) + return { type: 'timeout', message: e.message, timeout: e.ms, step }; + return { type: 'error', message: e instanceof Error ? e.message : String(e) }; +} + +export function toTeardownError(e: unknown): TeardownTaskError { + if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; + return { type: 'error', message: e instanceof Error ? e.message : String(e) }; +} + +export function mapWorktreeErrorToProvisionError( + branch: string, + error: ServeWorktreeError +): ProvisionTaskError { + switch (error.type) { + case 'branch-not-found': + return { type: 'branch-not-found', branch: error.branch }; + case 'worktree-setup-failed': + return { + type: 'worktree-setup-failed', + branch, + message: error.cause instanceof Error ? error.cause.message : String(error.cause), + }; + } +} + +export function isProvisionTaskError(e: unknown): e is ProvisionTaskError { + if (!e || typeof e !== 'object' || !('type' in e)) return false; + const type = (e as { type?: string }).type; + return ( + type === 'timeout' || + type === 'error' || + type === 'branch-not-found' || + type === 'worktree-setup-failed' + ); +} + +export function formatProvisionTaskError(error: ProvisionTaskError): string { + switch (error.type) { + case 'timeout': + return error.step ? `${error.message} (step: ${error.step})` : error.message; + case 'error': + return error.message; + case 'branch-not-found': + return `Branch "${error.branch}" was not found locally or on remote`; + case 'worktree-setup-failed': + return error.message + ? `Failed to set up worktree for branch "${error.branch}": ${error.message}` + : `Failed to set up worktree for branch "${error.branch}"`; + } +} diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index 59e2c2ee88..8f12ad2ddc 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -1,13 +1,14 @@ import { eq, sql } from 'drizzle-orm'; -import { workspaceKey } from '@shared/workspace-key'; import { mapConversationRowToConversation } from '@main/core/conversations/utils'; import { projectManager } from '@main/core/projects/project-manager'; -import { formatProvisionTaskError } from '@main/core/projects/provision-task-error'; +import { formatProvisionTaskError } from '@main/core/tasks/provision-task-error'; +import { taskManager } from '@main/core/tasks/task-manager'; import { mapTerminalRowToTerminal } from '@main/core/terminals/core'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; import { conversations, tasks, terminals } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; -import { mapTaskRowToTask } from './core'; +import { telemetryService } from '@main/lib/telemetry'; +import { mapTaskRowToTask } from './utils/utils'; export async function provisionTask(taskId: string) { const [row] = await db.select().from(tasks).where(eq(tasks.id, taskId)); @@ -17,11 +18,15 @@ export async function provisionTask(taskId: string) { const project = projectManager.getProject(task.projectId); if (!project) throw new Error(`Project not found: ${task.projectId}`); - const existingTask = project.getTask(taskId); + const existingTask = taskManager.getTask(taskId); if (existingTask) { - const wsId = workspaceKey(existingTask.taskBranch); - return { path: project.getWorkspace(wsId)?.path ?? '', workspaceId: wsId }; + const wsId = taskManager.getWorkspaceId(taskId) ?? ''; + return { + path: workspaceRegistry.get(wsId)?.path ?? '', + workspaceId: wsId, + sshConnectionId: undefined, + }; } const [existingTerminals, existingConversations] = await Promise.all([ @@ -37,20 +42,36 @@ export async function provisionTask(taskId: string) { .then((rows) => rows.map((r) => mapConversationRowToConversation(r, true))), ]); - const result = await project.provisionTask(task, existingConversations, existingTerminals); + const result = await taskManager.provisionTask( + project, + task, + existingConversations, + existingTerminals + ); if (!result.success) { throw new Error(`Failed to provision task: ${formatProvisionTaskError(result.error)}`); } + const { persistData } = result.data; + await db .update(tasks) - .set({ lastInteractedAt: sql`CURRENT_TIMESTAMP` }) + .set({ + lastInteractedAt: sql`CURRENT_TIMESTAMP`, + workspaceId: persistData.workspaceId, + workspaceProviderData: persistData.workspaceProviderData + ? JSON.stringify(persistData.workspaceProviderData) + : null, + }) .where(eq(tasks.id, taskId)); - capture('task_provisioned', { + telemetryService.capture('task_provisioned', { project_id: task.projectId, task_id: task.id, }); - const wsId = workspaceKey(task.taskBranch); - return { path: project.getWorkspace(wsId)?.path ?? '', workspaceId: wsId }; + return { + path: workspaceRegistry.get(persistData.workspaceId)?.path ?? '', + workspaceId: persistData.workspaceId, + sshConnectionId: persistData.sshConnectionId, + }; } diff --git a/src/main/core/tasks/task-builder.ts b/src/main/core/tasks/task-builder.ts new file mode 100644 index 0000000000..eab74a31d2 --- /dev/null +++ b/src/main/core/tasks/task-builder.ts @@ -0,0 +1,207 @@ +import type { Conversation } from '@shared/conversations'; +import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; +import type { Task } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import type { ConversationProvider } from '@main/core/conversations/types'; +import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { GitRepositoryService } from '@main/core/git/repository-service'; +import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; +import type { Workspace } from '@main/core/workspaces/workspace'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; +import type { ProvisionResult, TaskProvider } from '../projects/project-provider'; +import type { ProjectSettingsProvider } from '../projects/settings/schema'; +import { resolveTaskWorkDir } from '../projects/worktrees/utils'; +import type { WorktreeService } from '../projects/worktrees/worktree-service'; +import { + buildTaskProviders, + createWorkspaceFactory, + resolveTaskEnv, + type WorkspaceType, +} from '../workspaces/workspace-factory'; + +export type BuildTaskResult = { + taskProvider: TaskProvider; + conversationProvider: ConversationProvider; + terminalProvider: TerminalProvider; +}; + +export type ProvisionLocalTaskParams = { + task: Task; + conversations: Conversation[]; + terminals: Terminal[]; + workspaceId: string; + type: WorkspaceType; + projectId: string; + projectPath: string; + settings: ProjectSettingsProvider; + worktreeService: WorktreeService; + fetchService: GitFetchService; + repository: GitRepositoryService; + logPrefix: string; +}; + +export type ProvisionLocalTaskResult = { + provisionResult: ProvisionResult; + workspace: Workspace; + buildTaskResult: BuildTaskResult; +}; + +/** + * Shared provision scaffolding for tasks whose workspace lives local to the + * repository — either a worktree alongside the repo or the project root itself. + * Works for both local and SSH transports (transport is encoded in `type`). + * + * Returns workspace and buildTaskResult so callers can perform their own + * post-provision setup (e.g. git watcher registration, reconnect map population) + * without lifecycle hook callbacks. + */ +export async function provisionLocalTask( + params: ProvisionLocalTaskParams +): Promise { + const { + task, + conversations, + terminals, + workspaceId, + type, + projectId, + projectPath, + settings, + worktreeService, + fetchService, + repository, + logPrefix, + } = params; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'resolving-worktree', + message: 'Resolving worktree…', + }); + const workDir = await resolveTaskWorkDir(task, projectPath, worktreeService); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'initialising-workspace', + message: 'Initialising workspace…', + }); + const workspace = await workspaceRegistry.acquire( + workspaceId, + projectId, + createWorkspaceFactory(workspaceId, type, { + task, + workDir, + projectId, + projectPath, + settings, + logPrefix, + repository, + fetchService, + }) + ); + + let provisionSucceeded = false; + try { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const buildTaskResult = await buildTaskFromWorkspace( + task, + workspace, + type, + projectId, + projectPath, + settings, + { conversations, terminals }, + logPrefix + ); + log.debug(`${logPrefix}: provisionLocalTask DONE`, { taskId: task.id }); + provisionSucceeded = true; + return { + provisionResult: { taskProvider: buildTaskResult.taskProvider, persistData: { workspaceId } }, + workspace, + buildTaskResult, + }; + } finally { + if (!provisionSucceeded) { + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); + } + } +} + +/** + * Shared tail of doProvisionTask — builds and hydrates a TaskProvider from + * an already-acquired workspace. Works for both local and SSH transports. + * + * Returns all three provider objects so callers (e.g. SshProjectProvider) + * can keep references for reconnect rehydration. + */ +export async function buildTaskFromWorkspace( + task: Task, + workspace: Workspace, + type: WorkspaceType, + projectId: string, + projectPath: string, + settings: ProjectSettingsProvider, + hydrate: { conversations: Conversation[]; terminals: Terminal[] }, + logPrefix: string +): Promise { + const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( + task, + workspace, + projectPath, + settings + ); + + const { conversations: conversationProvider, terminals: terminalProvider } = buildTaskProviders( + type, + { + projectId, + taskId: task.id, + taskPath: workspace.path, + tmuxEnabled, + shellSetup, + taskEnvVars, + } + ); + + const taskProvider: TaskProvider = { + taskId: task.id, + taskBranch: task.taskBranch, + sourceBranch: task.sourceBranch, + taskEnvVars, + conversations: conversationProvider, + terminals: terminalProvider, + }; + + void Promise.all( + hydrate.terminals.map((term) => + terminalProvider.spawnTerminal(term).catch((e) => { + log.error(`${logPrefix}: failed to hydrate terminal`, { + terminalId: term.id, + error: String(e), + }); + }) + ) + ); + + void Promise.all( + hydrate.conversations.map((conv) => + conversationProvider.startSession(conv, undefined, true).catch((e) => { + log.error(`${logPrefix}: failed to hydrate conversation`, { + conversationId: conv.id, + error: String(e), + }); + }) + ) + ); + + return { taskProvider, conversationProvider, terminalProvider }; +} diff --git a/src/main/core/tasks/task-events.ts b/src/main/core/tasks/task-events.ts new file mode 100644 index 0000000000..143ed142ea --- /dev/null +++ b/src/main/core/tasks/task-events.ts @@ -0,0 +1,26 @@ +import type { Task } from '@shared/tasks'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { log } from '@main/lib/logger'; + +export type TaskCrudHooks = { + 'task:created': (task: Task) => void | Promise; + 'task:updated': (task: Task) => void | Promise; + 'task:archived': (taskId: string, projectId: string) => void | Promise; + 'task:deleted': (taskId: string, projectId: string) => void | Promise; +}; + +class TaskEvents implements Hookable { + private readonly _core = new HookCore((name, e) => + log.error(`TaskEvents: ${String(name)} hook error`, e) + ); + + on(name: K, handler: TaskCrudHooks[K]) { + return this._core.on(name, handler); + } + + _emit(name: K, ...args: Parameters): void { + this._core.callHookBackground(name, ...args); + } +} + +export const taskEvents = new TaskEvents(); diff --git a/src/main/core/tasks/task-manager.ts b/src/main/core/tasks/task-manager.ts new file mode 100644 index 0000000000..559daafb0e --- /dev/null +++ b/src/main/core/tasks/task-manager.ts @@ -0,0 +1,275 @@ +import path from 'node:path'; +import type { Conversation } from '@shared/conversations'; +import { taskProvisionProgressChannel, type ProvisionStep } from '@shared/events/taskEvents'; +import { makePtySessionId } from '@shared/ptySessionId'; +import { err, ok, type Result } from '@shared/result'; +import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; +import { provisionBYOITask } from '@main/core/workspaces/byoi/provision-byoi-task'; +import { localWorkspaceId, sshWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; +import { events } from '@main/lib/events'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { LifecycleMap } from '@main/lib/lifecycle-map'; +import { log } from '@main/lib/logger'; +import type { ProjectProvider, ProvisionResult, TaskProvider } from '../projects/project-provider'; +import { withTimeout } from '../projects/utils'; +import { + formatProvisionTaskError, + TASK_TIMEOUT_MS, + toProvisionError, + toTeardownError, + type ProvisionTaskError, + type TeardownTaskError, +} from './provision-task-error'; +import { provisionLocalTask } from './task-builder'; + +type StoredTask = ProvisionResult & { projectId: string; ctx: IExecutionContext }; + +export type TaskManagerHooks = { + 'task:provisioned': (info: { + projectId: string; + taskId: string; + taskBranch: string | undefined; + workspaceId: string; + worktreeGitDir?: string; + }) => void | Promise; + 'task:torn-down': (info: { + projectId: string; + taskId: string; + workspaceId: string; + }) => void | Promise; +}; + +async function executeProvision( + provider: ProjectProvider, + task: Task, + conversations: Conversation[], + terminals: Terminal[] +): Promise { + if (task.workspaceProvider === 'byoi') { + const projectSettings = await provider.settings.get(); + if (projectSettings.workspaceProvider?.type !== 'script') { + throw new Error( + 'Task has workspaceProvider=byoi but project has no script provider configured' + ); + } + return provisionBYOITask({ + task, + conversations, + terminals, + wpConfig: projectSettings.workspaceProvider, + ctx: provider.ctx, + projectId: provider.projectId, + projectPath: provider.repoPath, + settings: provider.settings, + logPrefix: `${provider.type}ProjectProvider[byoi]`, + }); + } + + const workspaceId = + provider.defaultWorkspaceType.kind === 'local' + ? localWorkspaceId(provider.projectId, task.taskBranch) + : sshWorkspaceId(provider.projectId, task.taskBranch); + + const { provisionResult, workspace } = await provisionLocalTask({ + task, + conversations, + terminals, + workspaceId, + type: provider.defaultWorkspaceType, + projectId: provider.projectId, + projectPath: provider.repoPath, + settings: provider.settings, + worktreeService: provider.worktreeService, + fetchService: provider.gitFetchService, + repository: provider.repository, + logPrefix: `${provider.type}ProjectProvider`, + }); + + if (provider.defaultWorkspaceType.kind === 'local') { + const mainDotGitAbs = path.resolve(provider.repoPath, '.git'); + const worktreeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); + return { + ...provisionResult, + persistData: { ...provisionResult.persistData, worktreeGitDir }, + }; + } + + return { + ...provisionResult, + persistData: { + ...provisionResult.persistData, + sshConnectionId: provider.defaultWorkspaceType.connectionId, + }, + }; +} + +async function executeTeardown( + task: TaskProvider, + workspaceId: string, + mode: TeardownMode +): Promise { + if (mode === 'detach') { + await task.conversations.detachAll(); + await task.terminals.detachAll(); + } else { + await task.conversations.destroyAll(); + await task.terminals.destroyAll(); + } + await workspaceRegistry.release(workspaceId, mode); +} + +async function cleanupDetachedSessions( + projectId: string, + taskId: string, + ctx: IExecutionContext +): Promise { + const { conversationIds, terminalIds } = await getTaskSessionLeafIds(projectId, taskId); + const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => + makePtySessionId(projectId, taskId, leafId) + ); + await Promise.all( + sessionIds.map((sessionId) => killTmuxSession(ctx, makeTmuxSessionName(sessionId))) + ); +} + +class TaskManager { + private readonly _hooks = new HookCore((name, e) => + log.error(`TaskManager: ${String(name)} hook error`, e) + ); + private readonly _lifecycle = new LifecycleMap({ + postTeardown: (taskId, stored) => { + this._tasksByProject.get(stored.projectId)?.delete(taskId); + this._hooks.callHookBackground('task:torn-down', { + projectId: stored.projectId, + taskId, + workspaceId: stored.persistData.workspaceId, + }); + }, + }); + private readonly _tasksByProject = new Map>(); + + readonly hooks: Hookable = this._hooks; + + async provisionTask( + provider: ProjectProvider, + task: Task, + conversations: Conversation[], + terminals: Terminal[] + ): Promise> { + return this._lifecycle.provision(task.id, async () => { + let lastStep: ProvisionStep | null = null; + const unsubscribe = events.on(taskProvisionProgressChannel, (progress) => { + if (progress.taskId === task.id) lastStep = progress.step; + }); + try { + const result = await withTimeout( + executeProvision(provider, task, conversations, terminals), + TASK_TIMEOUT_MS + ); + const stored: StoredTask = { + ...result, + projectId: provider.projectId, + ctx: provider.ctx, + }; + + const byProject = this._tasksByProject.get(provider.projectId) ?? new Set(); + byProject.add(task.id); + this._tasksByProject.set(provider.projectId, byProject); + + this._hooks.callHookBackground('task:provisioned', { + projectId: provider.projectId, + taskId: task.id, + taskBranch: task.taskBranch, + workspaceId: result.persistData.workspaceId, + worktreeGitDir: result.persistData.worktreeGitDir, + }); + + return ok(stored); + } catch (e) { + const provisionError = toProvisionError(e, lastStep); + log.error('TaskManager: failed to provision task', { + taskId: task.id, + projectId: provider.projectId, + error: String(e), + }); + return err(provisionError); + } finally { + unsubscribe(); + } + }); + } + + async teardownTask( + taskId: string, + mode: TeardownMode = 'terminate' + ): Promise> { + const result = this._lifecycle.teardown( + taskId, + async ({ taskProvider, persistData, projectId, ctx }) => { + try { + await withTimeout( + executeTeardown(taskProvider, persistData.workspaceId, mode), + TASK_TIMEOUT_MS + ); + return ok(); + } catch (e) { + log.error('TaskManager: failed to teardown task', { taskId, error: String(e) }); + await cleanupDetachedSessions(projectId, taskId, ctx).catch((cleanupError) => { + log.warn('TaskManager: fallback cleanup failed', { + taskId, + error: String(cleanupError), + }); + }); + return err(toTeardownError(e)); + } + } + ); + + return result ?? ok(); + } + + async teardownAllForProject(projectId: string, mode: TeardownMode): Promise { + const taskIds = Array.from(this._tasksByProject.get(projectId) ?? []); + if (mode === 'detach') { + // Detach sessions but leave workspaces alive; provider.cleanup() will call + // workspaceRegistry.releaseAllForProject to handle workspace teardown. + await Promise.all( + taskIds.flatMap((id) => { + const stored = this._lifecycle.get(id); + if (!stored) return []; + return [ + stored.taskProvider.conversations.detachAll(), + stored.taskProvider.terminals.detachAll(), + ]; + }) + ); + // Remove entries from lifecycle maps without running workspace teardown. + this._tasksByProject.delete(projectId); + await Promise.all( + taskIds.map((id) => this._lifecycle.teardown(id, async () => ok()) ?? Promise.resolve(ok())) + ); + } else { + // teardownTask handles _tasksByProject cleanup in onFinally. + await Promise.all(taskIds.map((id) => this.teardownTask(id, 'terminate'))); + } + } + + getTask(taskId: string): TaskProvider | undefined { + return this._lifecycle.get(taskId)?.taskProvider; + } + + getWorkspaceId(taskId: string): string | undefined { + return this._lifecycle.get(taskId)?.persistData.workspaceId; + } + + getBootstrapStatus(taskId: string): TaskBootstrapStatus { + return this._lifecycle.bootstrapStatus(taskId, formatProvisionTaskError); + } +} + +export const taskManager = new TaskManager(); diff --git a/src/main/core/tasks/teardownTask.ts b/src/main/core/tasks/teardownTask.ts deleted file mode 100644 index ec32c82d22..0000000000 --- a/src/main/core/tasks/teardownTask.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { projectManager } from '../projects/project-manager'; - -export async function teardownTask(projectId: string, taskId: string) { - const project = projectManager.getProject(projectId); - if (!project) throw new Error(`Project not found: ${projectId}`); - return await project.teardownTask(taskId); -} diff --git a/src/main/core/tasks/updateLinkedIssue.ts b/src/main/core/tasks/updateLinkedIssue.ts deleted file mode 100644 index ff36a42ca2..0000000000 --- a/src/main/core/tasks/updateLinkedIssue.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { Issue } from '@shared/tasks'; -import { db } from '@main/db/client'; -import { tasks } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; - -export async function updateLinkedIssue(taskId: string, issue?: Issue) { - const [task] = await db - .select({ id: tasks.id, projectId: tasks.projectId }) - .from(tasks) - .where(eq(tasks.id, taskId)) - .limit(1); - if (!task) return; - - await db - .update(tasks) - .set({ - linkedIssue: issue ? JSON.stringify(issue) : null, - }) - .where(eq(tasks.id, taskId)); - - if (issue) { - capture('issue_linked_to_task', { - provider: issue.provider, - project_id: task.projectId, - task_id: task.id, - }); - } -} diff --git a/src/main/core/tasks/core.ts b/src/main/core/tasks/utils/utils.ts similarity index 64% rename from src/main/core/tasks/core.ts rename to src/main/core/tasks/utils/utils.ts index 7f143e0778..41e6a8cfae 100644 --- a/src/main/core/tasks/core.ts +++ b/src/main/core/tasks/utils/utils.ts @@ -1,7 +1,7 @@ -import { PullRequest } from '@shared/pull-requests'; -import { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; -import { TaskRow } from '@main/db/schema'; -import { fromStoredBranch } from './stored-branch'; +import type { PullRequest } from '@shared/pull-requests'; +import type { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; +import type { TaskRow } from '@main/db/schema'; +import { fromStoredBranch } from '../stored-branch'; export function mapTaskRowToTask( row: TaskRow, @@ -25,5 +25,8 @@ export function mapTaskRowToTask( updatedAt: row.updatedAt, statusChangedAt: row.statusChangedAt, isPinned: row.isPinned === 1, + workspaceProvider: (row.workspaceProvider as 'byoi') ?? undefined, + workspaceId: row.workspaceId ?? undefined, + workspaceProviderData: row.workspaceProviderData ?? undefined, }; } diff --git a/src/main/core/telemetry/controller.ts b/src/main/core/telemetry/controller.ts index 4cacb4b649..ddcda1c7ab 100644 --- a/src/main/core/telemetry/controller.ts +++ b/src/main/core/telemetry/controller.ts @@ -1,23 +1,16 @@ import { createRPCController } from '@shared/ipc/rpc'; import type { TelemetryEvent } from '@shared/telemetry'; -import { - capture, - getTelemetryStatus, - identify, - setTelemetryEnabledViaUser, -} from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; export const telemetryController = createRPCController({ capture: (args: { event: TelemetryEvent; properties?: Record }) => { - capture(args.event, args.properties); + telemetryService.capture(args.event, args.properties); }, getStatus: () => { - return { status: getTelemetryStatus() }; + return { status: telemetryService.getTelemetryStatus() }; }, setEnabled: (enabled: boolean) => { - setTelemetryEnabledViaUser(enabled); - }, - identify: (username: string) => { - identify(username); + telemetryService.setTelemetryEnabledViaUser(enabled); }, + getFeatureFlags: () => telemetryService.getFeatureFlags(), }); diff --git a/src/main/core/terminals/core.ts b/src/main/core/terminals/core.ts index 4446eaf27e..cac06efe53 100644 --- a/src/main/core/terminals/core.ts +++ b/src/main/core/terminals/core.ts @@ -1,5 +1,5 @@ -import { Terminal } from '@shared/terminals'; -import { TerminalRow } from '@main/db/schema'; +import { type Terminal } from '@shared/terminals'; +import { type TerminalRow } from '@main/db/schema'; export function mapTerminalRowToTerminal(row: TerminalRow): Terminal { return { diff --git a/src/main/core/terminals/createTerminal.ts b/src/main/core/terminals/createTerminal.ts index 0284565b78..6f3840a023 100644 --- a/src/main/core/terminals/createTerminal.ts +++ b/src/main/core/terminals/createTerminal.ts @@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm'; import type { CreateTerminalParams, Terminal } from '@shared/terminals'; import { db } from '@main/db/client'; import { terminals } from '@main/db/schema'; -import { capture } from '@main/lib/telemetry'; +import { telemetryService } from '@main/lib/telemetry'; import { resolveTask } from '../projects/utils'; import { mapTerminalRowToTerminal } from './core'; @@ -27,7 +27,7 @@ export async function createTerminal(params: CreateTerminalParams): Promise void): () => void { return; } } - timer = setTimeout(tick, PROBE_INTERVAL_MS); + timer = setTimeout(() => { + void tick(); + }, PROBE_INTERVAL_MS); }; - timer = setTimeout(tick, 0); + timer = setTimeout(() => { + void tick(); + }, 0); return () => { stopped = true; diff --git a/src/main/core/terminals/getTerminalsForTask.ts b/src/main/core/terminals/getTerminalsForTask.ts index c9a4693c7b..f1f4d4810e 100644 --- a/src/main/core/terminals/getTerminalsForTask.ts +++ b/src/main/core/terminals/getTerminalsForTask.ts @@ -1,5 +1,5 @@ import { eq } from 'drizzle-orm'; -import { Terminal } from '@shared/terminals'; +import { type Terminal } from '@shared/terminals'; import { db } from '@main/db/client'; import { terminals } from '@main/db/schema'; import { mapTerminalRowToTerminal } from './core'; diff --git a/src/main/core/terminals/impl/local-terminal-provider.ts b/src/main/core/terminals/impl/local-terminal-provider.ts index 028af29044..58b3f0a2b8 100644 --- a/src/main/core/terminals/impl/local-terminal-provider.ts +++ b/src/main/core/terminals/impl/local-terminal-provider.ts @@ -1,13 +1,17 @@ -import type { GeneralSessionConfig } from '@shared/general-session'; import { makePtySessionId } from '@shared/ptySessionId'; -import { Terminal } from '@shared/terminals'; +import type { Terminal } from '@shared/terminals'; +import type { IExecutionContext } from '@main/core/execution-context/types'; import { spawnLocalPty } from '@main/core/pty/local-pty'; -import { Pty } from '@main/core/pty/pty'; +import type { Pty } from '@main/core/pty/pty'; import { buildTerminalEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; -import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; +import { + logLocalPtySpawnWarnings, + resolveLocalPtySpawn, + type PtyCommandSpec, + type PtySpawnIntent, +} from '@main/core/pty/pty-spawn-platform'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { wireTerminalDevServerWatcher } from '../dev-server-watcher'; import { type LifecycleScriptSpawnRequest, type TerminalProvider } from '../terminal-provider'; @@ -31,7 +35,7 @@ export class LocalTerminalProvider implements TerminalProvider { private readonly taskPath: string; private readonly tmux: boolean; private readonly shellSetup?: string; - private readonly exec: ExecFn; + private readonly ctx: IExecutionContext; private readonly taskEnvVars: Record; constructor({ @@ -40,7 +44,7 @@ export class LocalTerminalProvider implements TerminalProvider { taskPath, tmux = false, shellSetup, - exec, + ctx, taskEnvVars = {}, }: { projectId: string; @@ -48,7 +52,7 @@ export class LocalTerminalProvider implements TerminalProvider { taskPath: string; tmux?: boolean; shellSetup?: string; - exec: ExecFn; + ctx: IExecutionContext; taskEnvVars?: Record; }) { this.projectId = projectId; @@ -56,7 +60,7 @@ export class LocalTerminalProvider implements TerminalProvider { this.taskPath = taskPath; this.tmux = tmux; this.shellSetup = shellSetup; - this.exec = exec; + this.ctx = ctx; this.taskEnvVars = taskEnvVars; } @@ -65,11 +69,16 @@ export class LocalTerminalProvider implements TerminalProvider { initialSize: { cols: number; rows: number } = { cols: DEFAULT_COLS, rows: DEFAULT_ROWS }, command?: { command: string; args: string[] } ): Promise { - return this.spawnWithPolicy(terminal, initialSize, command, { - respawnOnExit: true, - preserveBufferOnExit: false, - watchDevServer: true, - }); + return this.spawnWithPolicy( + terminal, + initialSize, + command ? { kind: 'argv', command: command.command, args: command.args } : undefined, + { + respawnOnExit: true, + preserveBufferOnExit: false, + watchDevServer: true, + } + ); } async spawnLifecycleScript({ @@ -83,7 +92,7 @@ export class LocalTerminalProvider implements TerminalProvider { return this.spawnWithPolicy( terminal, initialSize, - { command, args: [] }, + command === undefined ? undefined : { kind: 'shell-line', commandLine: command }, { respawnOnExit, preserveBufferOnExit, @@ -95,28 +104,43 @@ export class LocalTerminalProvider implements TerminalProvider { private async spawnWithPolicy( terminal: Terminal, initialSize: { cols: number; rows: number }, - command: { command: string; args: string[] } | undefined, + command: PtyCommandSpec | undefined, policy: SpawnPolicy ): Promise { const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); this.knownSessionIds.add(sessionId); if (this.sessions.has(sessionId)) return; - const cfg: GeneralSessionConfig = { - taskId: this.scopeId, - cwd: this.taskPath, - shellSetup: this.shellSetup, - tmuxSessionName: this.tmux ? makeTmuxSessionName(sessionId) : undefined, - command: command?.command, - args: command?.args, - }; - const params = resolveSpawnParams('general', cfg); + const intent: PtySpawnIntent = command + ? { + kind: 'run-command', + cwd: this.taskPath, + command, + shellSetup: this.shellSetup, + tmuxSessionName: this.tmux ? makeTmuxSessionName(sessionId) : undefined, + } + : { + kind: 'interactive-shell', + cwd: this.taskPath, + shellSetup: this.shellSetup, + tmuxSessionName: this.tmux ? makeTmuxSessionName(sessionId) : undefined, + }; + const resolved = resolveLocalPtySpawn({ + platform: process.platform, + env: process.env, + intent, + }); + + logLocalPtySpawnWarnings('LocalTerminalProvider', resolved.warnings, { + terminalId: terminal.id, + sessionId, + }); const pty = spawnLocalPty({ id: sessionId, - command: params.command, - args: params.args, - cwd: this.taskPath, + command: resolved.command, + args: resolved.args, + cwd: resolved.cwd, env: { ...buildTerminalEnv(), ...this.taskEnvVars }, cols: initialSize.cols, rows: initialSize.rows, @@ -174,7 +198,7 @@ export class LocalTerminalProvider implements TerminalProvider { ptySessionRegistry.unregister(sessionId); } if (this.tmux) { - await killTmuxSession(this.exec, makeTmuxSessionName(sessionId)); + await killTmuxSession(this.ctx, makeTmuxSessionName(sessionId)); } } @@ -182,9 +206,7 @@ export class LocalTerminalProvider implements TerminalProvider { const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { - await Promise.all( - sessionIds.map((id) => killTmuxSession(this.exec, makeTmuxSessionName(id))) - ); + await Promise.all(sessionIds.map((id) => killTmuxSession(this.ctx, makeTmuxSessionName(id)))); } this.knownSessionIds.clear(); } diff --git a/src/main/core/terminals/impl/ssh-terminal-provider.ts b/src/main/core/terminals/impl/ssh-terminal-provider.ts index a40ad0675d..2f4a41764d 100644 --- a/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -1,17 +1,21 @@ import type { GeneralSessionConfig } from '@shared/general-session'; import { makePtySessionId } from '@shared/ptySessionId'; -import { Terminal } from '@shared/terminals'; -import { Pty } from '@main/core/pty/pty'; +import type { Terminal } from '@shared/terminals'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { Pty } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { + sshConnectionManager, + type SshConnectionEvent, +} from '@main/core/ssh/ssh-connection-manager'; import { type LifecycleScriptSpawnRequest, type TerminalProvider, } from '@main/core/terminals/terminal-provider'; -import { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { wireTerminalDevServerWatcher } from '../dev-server-watcher'; @@ -37,8 +41,10 @@ export class SshTerminalProvider implements TerminalProvider { private readonly taskEnvVars: Record; private readonly tmux: boolean; private readonly shellSetup?: string; - private readonly exec: ExecFn; + private readonly ctx: IExecutionContext; private readonly proxy: SshClientProxy; + private readonly connectionId: string; + private readonly _handleReconnect: (evt: SshConnectionEvent) => void; constructor({ projectId, @@ -47,8 +53,9 @@ export class SshTerminalProvider implements TerminalProvider { taskEnvVars = {}, tmux = false, shellSetup, - exec, + ctx, proxy, + connectionId, }: { projectId: string; scopeId: string; @@ -56,8 +63,9 @@ export class SshTerminalProvider implements TerminalProvider { taskEnvVars?: Record; tmux?: boolean; shellSetup?: string; - exec: ExecFn; + ctx: IExecutionContext; proxy: SshClientProxy; + connectionId: string; }) { this.projectId = projectId; this.scopeId = scopeId; @@ -65,8 +73,21 @@ export class SshTerminalProvider implements TerminalProvider { this.taskEnvVars = taskEnvVars; this.tmux = tmux; this.shellSetup = shellSetup; - this.exec = exec; + this.ctx = ctx; this.proxy = proxy; + this.connectionId = connectionId; + this._handleReconnect = (evt: SshConnectionEvent) => { + if (evt.type === 'reconnected' && evt.connectionId === this.connectionId) { + this.rehydrate().catch((e: unknown) => { + log.error('SshTerminalProvider: rehydrate failed after reconnect', { + scopeId: this.scopeId, + connectionId: this.connectionId, + error: String(e), + }); + }); + } + }; + sshConnectionManager.on('connection-event', this._handleReconnect); } async spawnTerminal( @@ -93,7 +114,7 @@ export class SshTerminalProvider implements TerminalProvider { return this.spawnWithPolicy( terminal, initialSize, - { command, args: [] }, + command === undefined ? undefined : { command, args: [] }, { respawnOnExit, preserveBufferOnExit, @@ -125,7 +146,8 @@ export class SshTerminalProvider implements TerminalProvider { args: command?.args, }; - const sshCommand = resolveSshCommand('general', cfg, this.taskEnvVars); + const profile = await this.proxy.getRemoteShellProfile(); + const sshCommand = resolveSshCommand('general', cfg, this.taskEnvVars, profile); const result = await openSsh2Pty(this.proxy.client, { id: sessionId, @@ -222,17 +244,16 @@ export class SshTerminalProvider implements TerminalProvider { } this.terminals.delete(terminalId); if (this.tmux) { - await killTmuxSession(this.exec, makeTmuxSessionName(sessionId)); + await killTmuxSession(this.ctx, makeTmuxSessionName(sessionId)); } } async destroyAll(): Promise { + sshConnectionManager.off('connection-event', this._handleReconnect); const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { - await Promise.all( - sessionIds.map((id) => killTmuxSession(this.exec, makeTmuxSessionName(id))) - ); + await Promise.all(sessionIds.map((id) => killTmuxSession(this.ctx, makeTmuxSessionName(id)))); } this.knownSessionIds.clear(); this.terminals.clear(); diff --git a/src/main/core/terminals/terminal-provider.ts b/src/main/core/terminals/terminal-provider.ts index f656ff6b84..573e7bb550 100644 --- a/src/main/core/terminals/terminal-provider.ts +++ b/src/main/core/terminals/terminal-provider.ts @@ -1,8 +1,8 @@ -import { Terminal } from '@shared/terminals'; +import type { Terminal } from '@shared/terminals'; export type LifecycleScriptSpawnRequest = { terminal: Terminal; - command: string; + command?: string; initialSize?: { cols: number; rows: number }; respawnOnExit?: boolean; preserveBufferOnExit?: boolean; @@ -12,7 +12,7 @@ export type LifecycleScriptSpawnRequest = { export interface TerminalProvider { spawnTerminal( terminal: Terminal, - initialSize: { cols: number; rows: number }, + initialSize?: { cols: number; rows: number }, command?: { command: string; args: string[] } ): Promise; spawnLifecycleScript(request: LifecycleScriptSpawnRequest): Promise; diff --git a/src/main/core/updates/update-service.ts b/src/main/core/updates/update-service.ts index 9cc09ed093..df6205b172 100644 --- a/src/main/core/updates/update-service.ts +++ b/src/main/core/updates/update-service.ts @@ -16,6 +16,7 @@ import { } from '@shared/events/updateEvents'; import { resolveAppVersion } from '@main/core/app/utils'; import { events } from '@main/lib/events'; +import type { IDisposable, IInitializable } from '@main/lib/lifecycle'; import { log } from '@main/lib/logger'; import { formatUpdaterError, sanitizeUpdaterLogArgs } from './utils'; @@ -45,7 +46,7 @@ export interface UpdateState { releaseNotes?: string; } -class UpdateService { +class UpdateService implements IInitializable, IDisposable { private updateState: UpdateState; private checkTimer?: NodeJS.Timeout; private currentCheckPromise: Promise | null = null; @@ -336,7 +337,7 @@ class UpdateService { return { ...this.updateState }; } - shutdown(): void { + dispose(): void { if (this.checkTimer) { clearTimeout(this.checkTimer); this.checkTimer = undefined; diff --git a/src/main/core/utils/exec.ts b/src/main/core/utils/exec.ts index cd586f7acc..4cf92f9ec9 100644 --- a/src/main/core/utils/exec.ts +++ b/src/main/core/utils/exec.ts @@ -1,10 +1,4 @@ -import { execFile } from 'node:child_process'; import fs from 'node:fs'; -import { promisify } from 'node:util'; -import { quoteShellArg } from '../../utils/shellEscape'; -import type { SshClientProxy } from '../ssh/ssh-client-proxy'; - -const execFileAsync = promisify(execFile); function resolveGitBin(): string { const candidates = [ @@ -21,27 +15,17 @@ function resolveGitBin(): string { return 'git'; } -/** Resolved path to the `git` binary — use for `spawn` when local exec is bypassed. */ +/** Resolved path to the `git` binary — use for all git exec calls. */ export const GIT_EXECUTABLE = resolveGitBin(); -export type ExecFn = ( - command: string, - args?: string[], - options?: { cwd?: string; timeout?: number; maxBuffer?: number } -) => Promise<{ stdout: string; stderr: string }>; - -export function getLocalExec(): ExecFn { - return ( - command: string, - args: string[] = [], - options: { cwd?: string; timeout?: number; maxBuffer?: number } = {} - ) => { - const bin = command === 'git' ? GIT_EXECUTABLE : command; - return execFileAsync(bin, args, options); - }; +function shouldUseHttpRemote(args: string[]): boolean { + const subcommand = args[0]; + if (!subcommand) return false; + if (['clone', 'fetch', 'pull', 'push', 'ls-remote'].includes(subcommand)) return true; + return subcommand === 'remote' && args[1] === 'show'; } -async function addGithubTokenHeader( +export async function addGitHubAuthConfig( args: string[], getToken: () => Promise ): Promise { @@ -51,67 +35,18 @@ async function addGithubTokenHeader( const token = Buffer.from(`x-access-token:${rawToken}`).toString('base64'); if (!token) return args; - return ['-c', `http.https://github.com/.extraHeader=Authorization: Basic ${token}`, ...args]; -} - -export function getGitLocalExec(getToken: () => Promise): ExecFn { - const baseExec = getLocalExec(); - return async (command, args = [], options = {}) => { - if (command === 'git') { - args = await addGithubTokenHeader(args, getToken); - } - return baseExec(command, args, options); - }; -} - -export function getSshExec(proxy: SshClientProxy): ExecFn { - return ( - command: string, - args: string[] = [], - { cwd }: { cwd?: string; timeout?: number; maxBuffer?: number } = {} - ) => { - const escaped = args.map(quoteShellArg).join(' '); - const inner = args.length ? `${command} ${escaped}` : command; - const withCwd = cwd ? `cd ${quoteShellArg(cwd)} && ${inner}` : inner; - const full = `bash -l -c ${quoteShellArg(withCwd)}`; - - return new Promise((resolve, reject) => { - proxy.client.exec(full, (execErr, stream) => { - if (execErr) return reject(execErr); - let stdout = ''; - let stderr = ''; - stream.on('close', (code: number | null) => { - if ((code ?? 0) === 0) { - resolve({ stdout, stderr }); - } else { - const e = Object.assign(new Error(stderr || `Process exited with code ${code}`), { - stdout, - stderr, - }); - reject(e); - } - }); - stream.on('data', (d: Buffer) => { - stdout += d.toString('utf-8'); - }); - stream.stderr.on('data', (d: Buffer) => { - stderr += d.toString('utf-8'); - }); - stream.on('error', reject); - }); - }); - }; -} + const withAuth = ['-c', `http.https://github.com/.extraHeader=Authorization: Basic ${token}`]; + + if (shouldUseHttpRemote(args)) { + withAuth.push( + '-c', + 'url.https://github.com/.insteadOf=git@github.com:', + '-c', + 'url.https://github.com/.insteadOf=ssh://git@github.com:', + '-c', + 'url.https://github.com/.insteadOf=ssh://git@github.com/' + ); + } -export function getGitSshExec( - proxy: SshClientProxy, - getToken: () => Promise -): ExecFn { - const baseExec = getSshExec(proxy); - return async (command, args = [], options = {}) => { - if (command === 'git') { - args = await addGithubTokenHeader(args, getToken); - } - return baseExec(command, args, options); - }; + return [...withAuth, ...args]; } diff --git a/src/main/core/utils/ttl-cache.test.ts b/src/main/core/utils/ttl-cache.test.ts new file mode 100644 index 0000000000..c40db05949 --- /dev/null +++ b/src/main/core/utils/ttl-cache.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TTLCache } from './ttl-cache'; + +const TTL = 1000; + +describe('TTLCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls fetch on the first get() and returns the value', async () => { + const cache = new TTLCache(TTL); + const fetch = vi.fn().mockResolvedValue('hello'); + + const result = await cache.get(fetch); + + expect(result).toBe('hello'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('returns the cached value on subsequent calls without re-fetching', async () => { + const cache = new TTLCache(TTL); + const fetch = vi.fn().mockResolvedValue('hello'); + + await cache.get(fetch); + const result = await cache.get(fetch); + + expect(result).toBe('hello'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('shares a single in-flight promise across concurrent calls', async () => { + const cache = new TTLCache(TTL); + let resolve!: (v: string) => void; + const pending = new Promise((res) => { + resolve = res; + }); + const fetch = vi.fn().mockReturnValue(pending); + + const p1 = cache.get(fetch); + const p2 = cache.get(fetch); + resolve('shared'); + + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1).toBe('shared'); + expect(r2).toBe('shared'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('re-fetches after the TTL has expired', async () => { + const cache = new TTLCache(TTL); + const fetch = vi.fn().mockResolvedValue('value'); + + await cache.get(fetch); + vi.advanceTimersByTime(TTL + 1); + await cache.get(fetch); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not re-fetch before the TTL has expired', async () => { + const cache = new TTLCache(TTL); + const fetch = vi.fn().mockResolvedValue('value'); + + await cache.get(fetch); + vi.advanceTimersByTime(TTL - 1); + await cache.get(fetch); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('invalidate() forces re-fetch on the next get()', async () => { + const cache = new TTLCache(TTL); + const fetch = vi.fn().mockResolvedValue('value'); + + await cache.get(fetch); + cache.invalidate(); + await cache.get(fetch); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not populate the cache when fetch throws, and retries on the next call', async () => { + const cache = new TTLCache(TTL); + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('transient failure')) + .mockResolvedValue('recovered'); + + await expect(cache.get(fetch)).rejects.toThrow('transient failure'); + const result = await cache.get(fetch); + + expect(result).toBe('recovered'); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/main/core/utils/ttl-cache.ts b/src/main/core/utils/ttl-cache.ts new file mode 100644 index 0000000000..0be5e3585d --- /dev/null +++ b/src/main/core/utils/ttl-cache.ts @@ -0,0 +1,35 @@ +/** + * Single-value async cache with TTL and in-flight deduplication. + * + * - Concurrent `get()` calls while a fetch is in-progress share one promise. + * - On fetch error the cache is not populated; the next call retries. + * - Call `invalidate()` to force re-fetch on the next `get()`. + */ +export class TTLCache { + private _cached: { value: T; expiresAt: number } | null = null; + private _inFlight: Promise | null = null; + + constructor(private readonly ttlMs: number) {} + + get(fetch: () => Promise): Promise { + if (this._cached && this._cached.expiresAt > Date.now()) { + return Promise.resolve(this._cached.value); + } + if (this._inFlight) return this._inFlight; + + this._inFlight = fetch() + .then((value) => { + this._cached = { value, expiresAt: Date.now() + this.ttlMs }; + return value; + }) + .finally(() => { + this._inFlight = null; + }); + + return this._inFlight; + } + + invalidate(): void { + this._cached = null; + } +} diff --git a/src/main/core/view-state/controller.ts b/src/main/core/view-state/controller.ts index 4ec2647056..9c26373068 100644 --- a/src/main/core/view-state/controller.ts +++ b/src/main/core/view-state/controller.ts @@ -6,6 +6,7 @@ export const viewStateController = createRPCController({ return viewStateService.save(key, snapshot); }, get: (key: string): Promise => viewStateService.get(key), + getAll: (): Promise> => viewStateService.getAll(), del: (key: string): Promise => viewStateService.del(key), reset: (): Promise => viewStateService.reset(), }); diff --git a/src/main/core/view-state/view-state-service.ts b/src/main/core/view-state/view-state-service.ts index eb9c3d43be..b926902a6f 100644 --- a/src/main/core/view-state/view-state-service.ts +++ b/src/main/core/view-state/view-state-service.ts @@ -1,3 +1,5 @@ +import { sql } from 'drizzle-orm'; +import { db } from '@main/db/client'; import { KV } from '@main/db/kv'; const viewStateKV = new KV>('view-state'); @@ -7,7 +9,19 @@ export const viewStateService = { get: (key: string): Promise => viewStateKV.get(key), + getAll: (): Promise> => + viewStateKV.getAll() as Promise>, + del: (key: string): Promise => viewStateKV.del(key), reset: (): Promise => viewStateKV.clear(), + + pruneOrphans: (): void => { + db.run( + sql`DELETE FROM kv WHERE key LIKE 'view-state:task:%' AND SUBSTR(key, LENGTH('view-state:task:') + 1) NOT IN (SELECT id FROM tasks)` + ); + db.run( + sql`DELETE FROM kv WHERE key LIKE 'view-state:project:%' AND SUBSTR(key, LENGTH('view-state:project:') + 1) NOT IN (SELECT id FROM projects)` + ); + }, }; diff --git a/src/main/core/workspaces/byoi/provision-byoi-task.ts b/src/main/core/workspaces/byoi/provision-byoi-task.ts new file mode 100644 index 0000000000..dcefea3fb5 --- /dev/null +++ b/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -0,0 +1,155 @@ +import type { Conversation } from '@shared/conversations'; +import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; +import type { Task } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { ProvisionResult } from '@main/core/projects/project-provider'; +import type { ProjectSettings, ProjectSettingsProvider } from '@main/core/projects/settings/schema'; +import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import { buildTaskFromWorkspace } from '@main/core/tasks/task-builder'; +import { parseProvisionOutput } from '@main/core/workspaces/byoi/provision-output'; +import { createWorkspaceFactory } from '@main/core/workspaces/workspace-factory'; +import { remoteTaskWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; + +export type ProvisionBYOITaskParams = { + task: Task; + conversations: Conversation[]; + terminals: Terminal[]; + /** Workspace provider config read from project settings (`workspaceProvider.type === 'script'`). */ + wpConfig: NonNullable; + /** Execution context for running provision/terminate scripts. */ + ctx: IExecutionContext; + projectId: string; + projectPath: string; + settings: ProjectSettingsProvider; + logPrefix: string; +}; + +/** + * Runs the BYOI script-run → SSH-connect → workspace-acquire → build flow. + * Parameterised by `execFn` so both local and SSH project providers can use it: + * - Local project: pass `new LocalExecutionContext({ root: projectPath })` (scripts run on local machine) + * - SSH project: pass `new SshExecutionContext(proxy, { root: projectPath })` (scripts run on remote host) + */ +export async function provisionBYOITask(params: ProvisionBYOITaskParams): Promise { + const { + task, + conversations, + terminals, + wpConfig, + ctx, + projectId, + projectPath, + settings, + logPrefix, + } = params; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'running-provision-script', + message: 'Running provision script…', + }); + + const { stdout } = await ctx.exec('/bin/sh', ['-c', wpConfig.provisionCommand]); + + const parseResult = parseProvisionOutput(stdout); + if (!parseResult.success) { + throw new Error(parseResult.error.message); + } + const output = parseResult.data; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'connecting', + message: `Connecting to ${output.host}…`, + }); + + const connectionId = `task:${task.id}`; + const proxy = await sshConnectionManager.connectFromConfig(connectionId, { + host: output.host, + port: output.port ?? 22, + username: output.username ?? process.env['USER'], + ...(output.password ? { password: output.password } : { agent: process.env['SSH_AUTH_SOCK'] }), + }); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'setting-up-workspace', + message: 'Setting up workspace…', + }); + + const workDir = output.worktreePath ?? projectPath; + const workspaceId = remoteTaskWorkspaceId(output.id ?? task.id); + + const workspace = await workspaceRegistry.acquire( + workspaceId, + projectId, + createWorkspaceFactory( + workspaceId, + { kind: 'ssh', proxy, connectionId }, + { + task, + workDir, + projectId, + projectPath, + settings, + logPrefix, + extraHooks: { + onDestroy: async () => { + const cmd = output.id + ? `REMOTE_WORKSPACE_ID=${quoteShellArg(output.id)} ${wpConfig.terminateCommand}` + : wpConfig.terminateCommand; + await ctx.exec('/bin/sh', ['-c', cmd]).catch((e) => { + log.warn(`${logPrefix}: terminate command failed`, { error: String(e) }); + }); + await sshConnectionManager.disconnect(connectionId); + }, + onDetach: async () => { + await sshConnectionManager.disconnect(connectionId); + }, + }, + } + ) + ); + + let provisionSucceeded = false; + try { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const { taskProvider } = await buildTaskFromWorkspace( + task, + workspace, + { kind: 'ssh', proxy, connectionId }, + projectId, + projectPath, + settings, + { conversations, terminals }, + logPrefix + ); + log.debug(`${logPrefix}: provisionBYOITask DONE`, { taskId: task.id }); + provisionSucceeded = true; + return { + taskProvider, + persistData: { + workspaceId: workspace.id, + workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, + sshConnectionId: connectionId, + }, + }; + } finally { + if (!provisionSucceeded) { + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); + } + } +} diff --git a/src/main/core/workspaces/byoi/provision-output.ts b/src/main/core/workspaces/byoi/provision-output.ts new file mode 100644 index 0000000000..770b057bd2 --- /dev/null +++ b/src/main/core/workspaces/byoi/provision-output.ts @@ -0,0 +1,42 @@ +import z from 'zod'; +import { err, ok, type Result } from '@shared/result'; + +const provisionOutputSchema = z.object({ + id: z.string(), + host: z.string().min(1, 'Provisioner output must contain a non-empty "host" field').trim(), + port: z.number().optional(), + username: z.string().optional(), + worktreePath: z.string().optional(), + password: z.string().optional(), +}); + +export type ProvisionOutput = z.infer; + +export type ParseError = { type: 'parse-error'; message: string }; + +export function parseProvisionOutput(stdout: string): Result { + const trimmed = stdout.trim(); + if (!trimmed) { + return err({ type: 'parse-error', message: 'Provisioner returned empty output' }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return err({ + type: 'parse-error', + message: `Could not parse provisioner output as JSON: ${trimmed.slice(0, 200)}`, + }); + } + + const result = provisionOutputSchema.safeParse(parsed); + if (!result.success) { + return err({ + type: 'parse-error', + message: result.error.message, + }); + } + + return ok(result.data); +} diff --git a/src/main/core/workspaces/workspace-factory.ts b/src/main/core/workspaces/workspace-factory.ts new file mode 100644 index 0000000000..d010abd37a --- /dev/null +++ b/src/main/core/workspaces/workspace-factory.ts @@ -0,0 +1,315 @@ +import { getTaskEnvVars } from '@shared/task/envVars'; +import type { Task } from '@shared/tasks'; +import { LocalConversationProvider } from '@main/core/conversations/impl/local-conversation'; +import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; +import type { ConversationProvider } from '@main/core/conversations/types'; +import { GitHubAuthExecutionContext } from '@main/core/execution-context/github-auth-execution-context'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; +import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import { GitFetchService } from '@main/core/git/git-fetch-service'; +import { GitService } from '@main/core/git/impl/git-service'; +import { GitRepositoryService } from '@main/core/git/repository-service'; +import { githubConnectionService } from '@main/core/github/services/github-connection-service'; +import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; +import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; +import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; +import type { Workspace } from '@main/core/workspaces/workspace'; +import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; +import { type WorkspaceFactoryResult } from '@main/core/workspaces/workspace-registry'; +import { log } from '@main/lib/logger'; +import type { ProjectSettingsProvider } from '../projects/settings/schema'; +import { getEffectiveTaskSettings } from '../projects/settings/task-settings'; +import { TimeoutSignal, withTimeout } from '../projects/utils'; +import { TEARDOWN_SCRIPT_WAIT_MS } from '../tasks/provision-task-error'; + +export type WorkspaceType = + | { kind: 'local' } + | { kind: 'ssh'; proxy: SshClientProxy; connectionId: string }; + +type WorkspaceFactoryContext = { + task: Pick; + workDir: string; + projectId: string; + projectPath: string; + settings: ProjectSettingsProvider; + logPrefix: string; + /** Inject an existing repository service (e.g. the project-level singleton). + * When absent, the factory creates a fresh instance from the workspace's GitService. */ + repository?: GitRepositoryService; + /** Inject an existing fetch service. When absent, the factory creates and manages one. + * Lifecycle (start/stop) is only managed by the factory when it creates the instance. */ + fetchService?: GitFetchService; + extraHooks?: { + onCreate?: (ws: Workspace) => Promise; + onDestroy?: (ws: Workspace) => Promise; + onDetach?: (ws: Workspace) => Promise; + }; +}; + +/** + * Returns a factory function suitable for passing to `WorkspaceRegistry.acquire`. + * Handles all transport-specific construction (local vs SSH) and wires lifecycle + * script hooks. Provider-specific hooks (e.g. git watcher) are passed via `extraHooks`. + */ +export function createWorkspaceFactory( + workspaceId: string, + type: WorkspaceType, + context: WorkspaceFactoryContext +): () => Promise { + return async () => { + const workDir = context.workDir; + + // Transport-specific FS and exec + const workspaceFs = + type.kind === 'ssh' ? new SshFileSystem(type.proxy, workDir) : new LocalFileSystem(workDir); + + const ctx = + type.kind === 'ssh' ? new SshExecutionContext(type.proxy) : new LocalExecutionContext(); + + // Settings (shared) + const projectSettings = await context.settings.get(); + const defaultBranch = await context.settings.getDefaultBranch(); + const bootstrapTaskEnvVars = getTaskEnvVars({ + taskId: context.task.id, + taskName: context.task.name, + taskPath: workDir, + projectPath: context.projectPath, + defaultBranch, + portSeed: workDir, + }); + const tmuxEnabled = projectSettings.tmux ?? false; + const taskLevelSettings = await getEffectiveTaskSettings({ + projectSettings: context.settings, + taskFs: workspaceFs, + }); + const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; + const scripts = taskLevelSettings.scripts; + + // Transport-specific workspace terminal provider (used only by lifecycle scripts) + const workspaceTerminals = + type.kind === 'ssh' + ? new SshTerminalProvider({ + projectId: context.projectId, + scopeId: workspaceId, + taskPath: workDir, + tmux: tmuxEnabled, + shellSetup, + ctx, + proxy: type.proxy, + connectionId: type.connectionId, + taskEnvVars: bootstrapTaskEnvVars, + }) + : new LocalTerminalProvider({ + projectId: context.projectId, + scopeId: workspaceId, + taskPath: workDir, + tmux: tmuxEnabled, + shellSetup, + ctx, + taskEnvVars: bootstrapTaskEnvVars, + }); + + const lifecycleService = new LifecycleScriptService({ + projectId: context.projectId, + workspaceId, + terminals: workspaceTerminals, + }); + + const baseGitCtx = + type.kind === 'ssh' + ? new SshExecutionContext(type.proxy, { root: workDir }) + : new LocalExecutionContext({ root: workDir }); + const authGitCtx = new GitHubAuthExecutionContext(baseGitCtx, () => + githubConnectionService.getToken() + ); + const gitService = new GitService(baseGitCtx, authGitCtx, workspaceFs); + + const repository = context.repository ?? new GitRepositoryService(gitService, context.settings); + + const ownsFetchService = !context.fetchService; + const fetchService = + context.fetchService ?? + new GitFetchService( + gitService, + async () => (await githubConnectionService.getToken()) !== null + ); + + const workspace: Workspace = { + id: workspaceId, + path: workDir, + fs: workspaceFs, + git: gitService, + settings: context.settings, + lifecycleService, + repository, + fetchService, + }; + + const { logPrefix } = context; + + return { + workspace, + + onCreateSideEffect: (ws) => { + if (ownsFetchService) { + fetchService.start(); + } + if (scripts?.setup) { + void ws.lifecycleService.prepareAndRunLifecycleScript({ + type: 'setup', + script: scripts.setup, + }); + } + if (scripts?.run) { + void ws.lifecycleService.prepareLifecycleScript({ type: 'run', script: scripts.run }); + } + if (scripts?.teardown) { + void ws.lifecycleService.prepareLifecycleScript({ + type: 'teardown', + script: scripts.teardown, + }); + } + }, + + onCreate: context.extraHooks?.onCreate, + + onDestroy: async (ws) => { + if (ownsFetchService) { + fetchService.stop(); + } + if (scripts?.teardown) { + try { + await withTimeout( + ws.lifecycleService.runLifecycleScript( + { type: 'teardown', script: scripts.teardown }, + { waitForExit: true, exit: true } + ), + TEARDOWN_SCRIPT_WAIT_MS + ); + } catch (error) { + if (error instanceof TimeoutSignal) { + log.debug(`${logPrefix}: teardown script wait timed out`, { + workspaceId, + timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, + }); + } else { + log.warn(`${logPrefix}: teardown script failed (continuing cleanup)`, { + workspaceId, + error: String(error), + }); + } + } + } + await context.extraHooks?.onDestroy?.(ws); + }, + + onDetach: context.extraHooks?.onDetach + ? (ws) => context.extraHooks!.onDetach!(ws) + : undefined, + }; + }; +} + +type TaskProviderOpts = { + projectId: string; + taskId: string; + taskPath: string; + tmuxEnabled: boolean; + shellSetup?: string; + taskEnvVars: Record; +}; + +/** + * Creates task-scoped conversation and terminal providers for the given transport type. + * The exec function is derived internally from the WorkspaceType. + */ +export function buildTaskProviders( + type: WorkspaceType, + opts: TaskProviderOpts +): { conversations: ConversationProvider; terminals: TerminalProvider } { + if (type.kind === 'ssh') { + const ctx = new SshExecutionContext(type.proxy); + return { + conversations: new SshConversationProvider({ + projectId: opts.projectId, + taskPath: opts.taskPath, + taskId: opts.taskId, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + ctx, + proxy: type.proxy, + taskEnvVars: opts.taskEnvVars, + }), + terminals: new SshTerminalProvider({ + projectId: opts.projectId, + scopeId: opts.taskId, + taskPath: opts.taskPath, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + ctx, + proxy: type.proxy, + connectionId: type.connectionId, + taskEnvVars: opts.taskEnvVars, + }), + }; + } + + const ctx = new LocalExecutionContext(); + return { + conversations: new LocalConversationProvider({ + projectId: opts.projectId, + taskPath: opts.taskPath, + taskId: opts.taskId, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + ctx, + taskEnvVars: opts.taskEnvVars, + }), + terminals: new LocalTerminalProvider({ + projectId: opts.projectId, + scopeId: opts.taskId, + taskPath: opts.taskPath, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + ctx, + taskEnvVars: opts.taskEnvVars, + }), + }; +} + +/** + * Resolves the task-level environment variables and settings from an already-acquired workspace. + * Used by providers after `workspaceRegistry.acquire` to avoid duplicating settings reads. + */ +export async function resolveTaskEnv( + task: Pick, + workspace: Pick, + projectPath: string, + settings: ProjectSettingsProvider +): Promise<{ + taskEnvVars: Record; + tmuxEnabled: boolean; + shellSetup?: string; +}> { + const projectSettings = await settings.get(); + const defaultBranch = await settings.getDefaultBranch(); + const taskLevelSettings = await getEffectiveTaskSettings({ + projectSettings: settings, + taskFs: workspace.fs, + }); + return { + taskEnvVars: getTaskEnvVars({ + taskId: task.id, + taskName: task.name, + taskPath: workspace.path, + projectPath, + defaultBranch, + portSeed: workspace.path, + }), + tmuxEnabled: projectSettings.tmux ?? false, + shellSetup: taskLevelSettings.shellSetup ?? projectSettings.shellSetup, + }; +} diff --git a/src/main/core/workspaces/workspace-id.ts b/src/main/core/workspaces/workspace-id.ts new file mode 100644 index 0000000000..63d5da85ad --- /dev/null +++ b/src/main/core/workspaces/workspace-id.ts @@ -0,0 +1,29 @@ +/** + * Typed workspace ID utilities. + * + * Key scheme: + * local:{projectId}:branch:{branch} — local worktree, shared across tasks on the same branch + * local:{projectId}:root — local project root (no worktree) + * ssh:{projectId}:branch:{branch} — SSH project worktree + * ssh:{projectId}:root — SSH project root + * remote:{remoteId} — BYOI remote task; keyed by output.id when available, + * else task ID. Tasks sharing the same output.id share + * the same workspace entry with ref-counting. + */ + +export function localWorkspaceId(projectId: string, taskBranch: string | undefined): string { + return taskBranch ? `local:${projectId}:branch:${taskBranch}` : `local:${projectId}:root`; +} + +export function sshWorkspaceId(projectId: string, taskBranch: string | undefined): string { + return taskBranch ? `ssh:${projectId}:branch:${taskBranch}` : `ssh:${projectId}:root`; +} + +/** + * BYOI remote task workspace. + * Pass `output.id` when the provision script returns one; fall back to the task ID. + * Caller: `remoteTaskWorkspaceId(output.id ?? task.id)` + */ +export function remoteTaskWorkspaceId(remoteId: string): string { + return `remote:${remoteId}`; +} diff --git a/src/main/core/workspaces/workspace-lifecycle-service.test.ts b/src/main/core/workspaces/workspace-lifecycle-service.test.ts index 21fb5d84c6..3cbd482f34 100644 --- a/src/main/core/workspaces/workspace-lifecycle-service.test.ts +++ b/src/main/core/workspaces/workspace-lifecycle-service.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import type { Pty, PtyExitInfo } from '../pty/pty'; import type { TerminalProvider } from '../terminals/terminal-provider'; -import { WorkspaceLifecycleService } from './workspace-lifecycle-service'; +import { LifecycleScriptService } from './workspace-lifecycle-service'; vi.mock('@main/lib/events', () => ({ events: { @@ -62,7 +62,7 @@ function makeTerminalProvider(): { describe('WorkspaceLifecycleService', () => { it('respawns an interactive lifecycle shell after an exit-backed script finishes', async () => { const { provider, spawned } = makeTerminalProvider(); - const service = new WorkspaceLifecycleService({ + const service = new LifecycleScriptService({ projectId: 'project-1', workspaceId: 'branch:feature', terminals: provider, diff --git a/src/main/core/workspaces/workspace-lifecycle-service.ts b/src/main/core/workspaces/workspace-lifecycle-service.ts index 26d9da025e..b8681ca7b1 100644 --- a/src/main/core/workspaces/workspace-lifecycle-service.ts +++ b/src/main/core/workspaces/workspace-lifecycle-service.ts @@ -2,6 +2,7 @@ import { ptyExitChannel } from '@shared/events/ptyEvents'; import { makePtySessionId } from '@shared/ptySessionId'; import { createScriptTerminalId } from '@shared/terminals'; import { events } from '@main/lib/events'; +import type { IDisposable } from '@main/lib/lifecycle'; import { ptySessionRegistry } from '../pty/pty-session-registry'; import type { TerminalProvider } from '../terminals/terminal-provider'; @@ -13,7 +14,7 @@ type LifecycleScript = { script: string; }; -export class WorkspaceLifecycleService { +export class LifecycleScriptService implements IDisposable { private readonly projectId: string; private readonly workspaceId: string; private readonly terminals: TerminalProvider; @@ -61,7 +62,6 @@ export class WorkspaceLifecycleService { taskId: this.workspaceId, name: script.type, }, - command: '', initialSize, respawnOnExit: false, preserveBufferOnExit: true, @@ -99,7 +99,7 @@ export class WorkspaceLifecycleService { if (exit && !waitForExit) { pty.onExit(() => { if (this.disposed) return; - this.prepareLifecycleScript(script, { initialSize }); + void this.prepareLifecycleScript(script, { initialSize }); }); } diff --git a/src/main/core/workspaces/workspace-registry.test.ts b/src/main/core/workspaces/workspace-registry.test.ts index 66266c0333..bba77996f1 100644 --- a/src/main/core/workspaces/workspace-registry.test.ts +++ b/src/main/core/workspaces/workspace-registry.test.ts @@ -20,6 +20,8 @@ function makeWorkspace(id: string): { lifecycleService: { dispose, } as unknown as Workspace['lifecycleService'], + repository: {} as Workspace['repository'], + fetchService: {} as Workspace['fetchService'], }, dispose, gitDispose, @@ -30,10 +32,10 @@ describe('WorkspaceRegistry', () => { it('creates once and increments ref count on repeated acquire', async () => { const registry = new WorkspaceRegistry(); const { workspace } = makeWorkspace('branch:main'); - const factory = vi.fn(async () => workspace); + const factory = vi.fn(async () => ({ workspace })); - const first = await registry.acquire('branch:main', factory); - const second = await registry.acquire('branch:main', factory); + const first = await registry.acquire('branch:main', 'test-project', factory); + const second = await registry.acquire('branch:main', 'test-project', factory); expect(first).toBe(workspace); expect(second).toBe(workspace); @@ -45,19 +47,19 @@ describe('WorkspaceRegistry', () => { it('coalesces concurrent acquires for the same key', async () => { const registry = new WorkspaceRegistry(); const { workspace } = makeWorkspace('branch:main'); - let resolveFactory: ((value: Workspace) => void) | undefined; + let resolveFactory: ((value: { workspace: Workspace }) => void) | undefined; const factory = vi.fn( () => - new Promise((resolve) => { + new Promise<{ workspace: Workspace }>((resolve) => { resolveFactory = resolve; }) ); - const first = registry.acquire('branch:main', factory); - const second = registry.acquire('branch:main', factory); + const first = registry.acquire('branch:main', 'test-project', factory); + const second = registry.acquire('branch:main', 'test-project', factory); expect(factory).toHaveBeenCalledTimes(1); - resolveFactory?.(workspace); + resolveFactory?.({ workspace }); await expect(first).resolves.toBe(workspace); await expect(second).resolves.toBe(workspace); @@ -67,10 +69,10 @@ describe('WorkspaceRegistry', () => { it('disposes workspace resources when ref count reaches zero', async () => { const registry = new WorkspaceRegistry(); const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); - const factory = vi.fn(async () => workspace); + const factory = vi.fn(async () => ({ workspace })); - await registry.acquire('branch:main', factory); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); await registry.release('branch:main'); expect(dispose).not.toHaveBeenCalled(); @@ -89,9 +91,13 @@ describe('WorkspaceRegistry', () => { const first = makeWorkspace('branch:main'); const second = makeWorkspace('root:'); - await registry.acquire('branch:main', async () => first.workspace); - await registry.acquire('branch:main', async () => first.workspace); - await registry.acquire('root:', async () => second.workspace); + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace: first.workspace, + })); + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace: first.workspace, + })); + await registry.acquire('root:', 'test-project', async () => ({ workspace: second.workspace })); await registry.releaseAll(); @@ -107,4 +113,181 @@ describe('WorkspaceRegistry', () => { const registry = new WorkspaceRegistry(); await expect(registry.release('missing')).resolves.toBeUndefined(); }); + + it('calls onCreateSideEffect once on first acquire and not on re-acquire', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onCreateSideEffect = vi.fn(); + const factory = vi.fn(async () => ({ workspace, onCreateSideEffect })); + + await registry.acquire('branch:main', 'test-project', factory); + expect(onCreateSideEffect).toHaveBeenCalledTimes(1); + expect(onCreateSideEffect).toHaveBeenCalledWith(workspace); + + await registry.acquire('branch:main', 'test-project', factory); + expect(onCreateSideEffect).toHaveBeenCalledTimes(1); + }); + + it('awaits onCreate before acquire resolves', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const order: string[] = []; + + const onCreate = vi.fn(async () => { + order.push('onCreate'); + }); + const factory = vi.fn(async () => ({ workspace, onCreate })); + + const acquired = registry.acquire('branch:main', 'test-project', factory).then((ws) => { + order.push('acquired'); + return ws; + }); + + await acquired; + + expect(order).toEqual(['onCreate', 'acquired']); + expect(onCreate).toHaveBeenCalledWith(workspace); + }); + + it('does not call onCreate on re-acquire', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onCreate = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onCreate })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); + + expect(onCreate).toHaveBeenCalledTimes(1); + }); + + it('calls onDestroy once at final release, not on earlier releases', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDestroy })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); + + await registry.release('branch:main'); + expect(onDestroy).not.toHaveBeenCalled(); + + await registry.release('branch:main'); + expect(onDestroy).toHaveBeenCalledTimes(1); + expect(onDestroy).toHaveBeenCalledWith(workspace); + }); + + it('calls onDestroy before git.dispose and lifecycleService.dispose', async () => { + const registry = new WorkspaceRegistry(); + const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); + const order: string[] = []; + + dispose.mockImplementation(() => { + order.push('lifecycleDispose'); + return undefined; + }); + gitDispose.mockImplementation(() => { + order.push('gitDispose'); + }); + + const onDestroy = vi.fn(() => { + order.push('onDestroy'); + return Promise.resolve(); + }); + const factory = vi.fn(async () => ({ workspace, onDestroy })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.release('branch:main'); + + expect(order).toEqual(['onDestroy', 'gitDispose', 'lifecycleDispose']); + }); + + it('calls onDestroy for each entry in releaseAll', async () => { + const registry = new WorkspaceRegistry(); + const first = makeWorkspace('branch:main'); + const second = makeWorkspace('root:'); + const onDestroyFirst = vi.fn(async () => {}); + const onDestroySecond = vi.fn(async () => {}); + + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace: first.workspace, + onDestroy: onDestroyFirst, + })); + await registry.acquire('root:', 'test-project', async () => ({ + workspace: second.workspace, + onDestroy: onDestroySecond, + })); + + await registry.releaseAll(); + + expect(onDestroyFirst).toHaveBeenCalledTimes(1); + expect(onDestroyFirst).toHaveBeenCalledWith(first.workspace); + expect(onDestroySecond).toHaveBeenCalledTimes(1); + expect(onDestroySecond).toHaveBeenCalledWith(second.workspace); + }); + + it('calls onDetach (not onDestroy) when releasing with detach mode', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const onDetach = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDestroy, onDetach })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.release('branch:main', 'detach'); + + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDetach).toHaveBeenCalledWith(workspace); + expect(onDestroy).not.toHaveBeenCalled(); + }); + + it('calls onDestroy (not onDetach) when releasing with terminate mode', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const onDetach = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDestroy, onDetach })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.release('branch:main', 'terminate'); + + expect(onDestroy).toHaveBeenCalledTimes(1); + expect(onDestroy).toHaveBeenCalledWith(workspace); + expect(onDetach).not.toHaveBeenCalled(); + }); + + it('does not call onDetach when ref count has not reached zero', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDetach = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDetach })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); + + await registry.release('branch:main', 'detach'); + expect(onDetach).not.toHaveBeenCalled(); + + await registry.release('branch:main', 'detach'); + expect(onDetach).toHaveBeenCalledTimes(1); + }); + + it('releaseAllForProject passes detach mode to hooks', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const onDetach = vi.fn(async () => {}); + + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace, + onDestroy, + onDetach, + })); + + await registry.releaseAllForProject('test-project', 'detach'); + + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDestroy).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/core/workspaces/workspace-registry.ts b/src/main/core/workspaces/workspace-registry.ts index 30d259ed5f..f4107fac34 100644 --- a/src/main/core/workspaces/workspace-registry.ts +++ b/src/main/core/workspaces/workspace-registry.ts @@ -1,15 +1,33 @@ import type { Workspace } from './workspace'; +export type TeardownMode = 'detach' | 'terminate'; + +type WorkspaceHooks = { + onCreate?: (workspace: Workspace) => Promise; + onCreateSideEffect?: (workspace: Workspace) => void; + onDestroy?: (workspace: Workspace) => Promise; + onDetach?: (workspace: Workspace) => Promise; +}; + +export type WorkspaceFactoryResult = { workspace: Workspace } & WorkspaceHooks; + type WorkspaceEntry = { workspace: Workspace; refCount: number; + projectId: string; + onDestroy?: (workspace: Workspace) => Promise; + onDetach?: (workspace: Workspace) => Promise; }; export class WorkspaceRegistry { private entries = new Map(); private acquiring = new Map>(); - async acquire(key: string, factory: () => Promise): Promise { + async acquire( + key: string, + projectId: string, + factory: () => Promise + ): Promise { const existing = this.entries.get(key); if (existing) { existing.refCount += 1; @@ -25,9 +43,17 @@ export class WorkspaceRegistry { } const pending = factory() - .then((workspace) => { - this.entries.set(key, { workspace, refCount: 1 }); - return workspace; + .then(async (result) => { + this.entries.set(key, { + workspace: result.workspace, + refCount: 1, + projectId, + onDestroy: result.onDestroy, + onDetach: result.onDetach, + }); + result.onCreateSideEffect?.(result.workspace); + await result.onCreate?.(result.workspace); + return result.workspace; }) .finally(() => { this.acquiring.delete(key); @@ -37,13 +63,13 @@ export class WorkspaceRegistry { return pending; } - async release(key: string): Promise { + async release(key: string, mode: TeardownMode = 'terminate'): Promise { const entry = this.entries.get(key); if (!entry) { const inFlight = this.acquiring.get(key); if (inFlight) { await inFlight; - await this.release(key); + await this.release(key, mode); } return; } @@ -54,8 +80,14 @@ export class WorkspaceRegistry { } this.entries.delete(key); + if (mode === 'terminate') { + await entry.onDestroy?.(entry.workspace); + } entry.workspace.git.dispose(); await entry.workspace.lifecycleService.dispose(); + if (mode === 'detach') { + await entry.onDetach?.(entry.workspace); + } } get(key: string): Workspace | undefined { @@ -66,14 +98,29 @@ export class WorkspaceRegistry { return this.entries.get(key)?.refCount ?? 0; } - async releaseAll(): Promise { + async releaseAllForProject(projectId: string, mode: TeardownMode = 'terminate'): Promise { + const keys = Array.from(this.entries.entries()) + .filter(([, e]) => e.projectId === projectId) + .map(([k]) => k); + await Promise.all(keys.map((k) => this.release(k, mode))); + } + + async releaseAll(mode: TeardownMode = 'terminate'): Promise { const entries = Array.from(this.entries.values()); this.entries.clear(); await Promise.all( entries.map(async (entry) => { + if (mode === 'terminate') { + await entry.onDestroy?.(entry.workspace); + } entry.workspace.git.dispose(); await entry.workspace.lifecycleService.dispose(); + if (mode === 'detach') { + await entry.onDetach?.(entry.workspace); + } }) ); } } + +export const workspaceRegistry = new WorkspaceRegistry(); diff --git a/src/main/core/workspaces/workspace.ts b/src/main/core/workspaces/workspace.ts index 4e08902c05..3b1495b043 100644 --- a/src/main/core/workspaces/workspace.ts +++ b/src/main/core/workspaces/workspace.ts @@ -1,7 +1,9 @@ import type { FileSystemProvider } from '@main/core/fs/types'; +import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { GitRepositoryService } from '@main/core/git/repository-service'; import type { WorkspaceGitProvider } from '@main/core/git/workspace-git-provider'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/schema'; -import type { WorkspaceLifecycleService } from './workspace-lifecycle-service'; +import type { LifecycleScriptService } from './workspace-lifecycle-service'; export interface Workspace { readonly id: string; @@ -9,5 +11,7 @@ export interface Workspace { readonly fs: FileSystemProvider; readonly git: WorkspaceGitProvider; readonly settings: ProjectSettingsProvider; - readonly lifecycleService: WorkspaceLifecycleService; + readonly lifecycleService: LifecycleScriptService; + readonly repository: GitRepositoryService; + readonly fetchService: GitFetchService; } diff --git a/src/main/db/initialize.ts b/src/main/db/initialize.ts index a713df77ab..1c661a0404 100644 --- a/src/main/db/initialize.ts +++ b/src/main/db/initialize.ts @@ -49,6 +49,40 @@ function runBundledMigrations(connection: BetterSqlite3.Database): void { })(); } +/** + * Creates the FTS5 full-text search virtual table used by the command palette. + * This is managed outside the Drizzle migration system because Drizzle cannot + * generate FTS5 virtual table DDL. The table is version-gated via the `kv` + * table so it can be safely dropped and recreated when the schema changes. + */ +function ensureSearchIndex(connection: BetterSqlite3.Database): void { + const SEARCH_INDEX_VERSION = '2'; + + const row = connection.prepare(`SELECT value FROM kv WHERE key = 'fts_version'`).get() as + | { value: string } + | undefined; + + if (row?.value !== SEARCH_INDEX_VERSION) { + connection.exec(`DROP TABLE IF EXISTS search_index`); + connection.exec(` + CREATE VIRTUAL TABLE search_index USING fts5( + item_type, + item_id UNINDEXED, + project_id UNINDEXED, + task_id UNINDEXED, + title, + keywords, + tokenize = 'unicode61 remove_diacritics 1' + ) + `); + connection + .prepare( + `INSERT OR REPLACE INTO kv (key, value, updated_at) VALUES ('fts_version', ?, unixepoch())` + ) + .run(SEARCH_INDEX_VERSION); + } +} + /** * Runs all pending migrations against the shared SQLite connection and validates * the schema contract. Call this once in main.ts before any db queries run. @@ -60,5 +94,6 @@ function runBundledMigrations(connection: BetterSqlite3.Database): void { */ export async function initializeDatabase(): Promise { runBundledMigrations(sqlite); + ensureSearchIndex(sqlite); return sqlite; } diff --git a/src/main/db/kv.ts b/src/main/db/kv.ts index 0647797b70..f41955c0b6 100644 --- a/src/main/db/kv.ts +++ b/src/main/db/kv.ts @@ -1,4 +1,5 @@ import { eq, like } from 'drizzle-orm'; +import { log } from '@main/lib/logger'; import { db } from './client'; import { kv } from './schema'; @@ -36,23 +37,40 @@ export class KV> { .values({ key: this.prefixed(key), value: serialised, updatedAt: now }) .onConflictDoUpdate({ target: kv.key, set: { value: serialised, updatedAt: now } }); } catch (e) { - // kv table may not exist yet during the first-run migration window + log.error('Failed to set KV', { key, value, error: e }); } } async del(key: K): Promise { try { await db.delete(kv).where(eq(kv.key, this.prefixed(key))); - } catch { - // kv table may not exist yet during the first-run migration window + } catch (e) { + log.error('Failed to delete KV', { key, error: e }); } } async clear(): Promise { try { await db.delete(kv).where(like(kv.key, `${this.namespace}:%`)); - } catch { - // kv table may not exist yet during the first-run migration window + } catch (e) { + log.error('Failed to clear KV', { namespace: this.namespace, error: e }); + } + } + + async getAll(): Promise> { + const rows = await db + .select() + .from(kv) + .where(like(kv.key, `${this.namespace}:%`)); + const result: Record = {}; + for (const row of rows) { + const shortKey = row.key.slice(this.namespace.length + 1); + try { + result[shortKey] = JSON.parse(row.value); + } catch { + // skip malformed entries + } } + return result as Partial; } } diff --git a/src/main/db/legacy-port/beta-import.ts b/src/main/db/legacy-port/beta-import.ts new file mode 100644 index 0000000000..5a25f3f5e0 --- /dev/null +++ b/src/main/db/legacy-port/beta-import.ts @@ -0,0 +1,85 @@ +import type Database from 'better-sqlite3'; +import { clearDestinationDataPreservingSignIn } from './reset'; +import { + columnsForTable, + quoteIdentifier, + quoteSqliteString, + tableExists, + withForeignKeysDisabled, +} from './sqlite-utils'; + +const COPY_TABLE_ORDER = [ + 'app_settings', + 'pull_request_users', + 'pull_requests', + 'pull_request_labels', + 'pull_request_assignees', + 'pull_request_checks', + 'ssh_connections', + 'projects', + 'project_remotes', + 'tasks', + 'conversations', + 'terminals', + 'messages', + 'editor_buffers', +] as const; + +function copyTable(sqlite: Database.Database, tableName: string): void { + if (!tableExists(sqlite, tableName) || !tableExists(sqlite, tableName, 'beta')) return; + + const destinationColumns = new Set(columnsForTable(sqlite, 'main', tableName)); + const sourceColumns = columnsForTable(sqlite, 'beta', tableName); + const columns = sourceColumns.filter((column) => destinationColumns.has(column)); + if (columns.length === 0) return; + + const columnSql = columns.map(quoteIdentifier).join(', '); + const quotedTable = quoteIdentifier(tableName); + sqlite + .prepare( + `INSERT OR IGNORE INTO ${quotedTable} (${columnSql}) SELECT ${columnSql} FROM beta.${quotedTable}` + ) + .run(); +} + +function copyAttachedBetaTables(sqlite: Database.Database): void { + clearDestinationDataPreservingSignIn(sqlite); + for (const tableName of COPY_TABLE_ORDER) { + copyTable(sqlite, tableName); + } +} + +export function copyAttachedBetaDatabaseIntoDestination(sqlite: Database.Database): void { + copyAttachedBetaTables(sqlite); +} + +export async function withBetaDatabaseAttached( + sqlite: Database.Database, + betaDatabasePath: string, + action: () => Promise +): Promise { + sqlite.exec(`ATTACH DATABASE ${quoteSqliteString(betaDatabasePath)} AS beta`); + + try { + return await action(); + } finally { + sqlite.exec('DETACH DATABASE beta'); + } +} + +export function importBetaDatabaseIntoDestination( + sqlite: Database.Database, + betaDatabasePath: string +): void { + withForeignKeysDisabled(sqlite, () => { + sqlite.exec(`ATTACH DATABASE ${quoteSqliteString(betaDatabasePath)} AS beta`); + + try { + sqlite.transaction(() => { + copyAttachedBetaTables(sqlite); + })(); + } finally { + sqlite.exec('DETACH DATABASE beta'); + } + }); +} diff --git a/src/main/db/legacy-port/controller.ts b/src/main/db/legacy-port/controller.ts index f98ba4ce85..72ee83b8b9 100644 --- a/src/main/db/legacy-port/controller.ts +++ b/src/main/db/legacy-port/controller.ts @@ -1,57 +1,87 @@ +import { join } from 'node:path'; import { count } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; import { app } from 'electron'; import { createRPCController } from '@shared/ipc/rpc'; +import type { LegacyImportSource } from '@shared/legacy-port'; import { db } from '@main/db/client'; +import { PREVIOUS_DB_FILENAME } from '@main/db/default-path'; +import * as schema from '@main/db/schema'; import { projects, tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { legacyTableExists } from './importers/relational/helpers'; import { openLegacyReadOnly } from './legacy-source/open-readonly'; -import { hasLegacyDatabaseFile, resolveLegacyDatabasePath } from './legacy-source/path'; +import { + hasBetaDatabaseFile, + hasLegacyDatabaseFile, + resolveLegacyDatabasePath, +} from './legacy-source/path'; import { createDefaultLegacyPortStateStore, runLegacyPort } from './service'; +import { createLegacyPortPreview } from './source-analysis'; export const legacyPortController = createRPCController({ checkStatus: async () => { const userDataPath = app.getPath('userData'); const hasLegacyDb = hasLegacyDatabaseFile(userDataPath); + const hasBetaDb = hasBetaDatabaseFile(userDataPath); const stateStore = await createDefaultLegacyPortStateStore(); const portStatus = await stateStore.getStatus(); const [{ value: projectCount }] = await db.select({ value: count() }).from(projects); const [{ value: taskCount }] = await db.select({ value: count() }).from(tasks); const hasExistingData = projectCount > 0 || taskCount > 0; - return { hasLegacyDb, portStatus: portStatus ?? null, hasExistingData }; + return { + hasLegacyDb, + hasBetaDb, + hasImportSources: hasLegacyDb || hasBetaDb, + portStatus: portStatus ?? null, + hasExistingData, + }; }, getPreview: async () => { const userDataPath = app.getPath('userData'); - if (!hasLegacyDatabaseFile(userDataPath)) { - return { projects: 0, tasks: 0 }; - } + const hasLegacyDb = hasLegacyDatabaseFile(userDataPath); + const hasBetaDb = hasBetaDatabaseFile(userDataPath); const legacyPath = resolveLegacyDatabasePath(userDataPath); + const betaPath = join(userDataPath, PREVIOUS_DB_FILENAME); let legacyDb; + let betaSqlite; try { - legacyDb = openLegacyReadOnly(legacyPath); - const projectCount = legacyTableExists(legacyDb, 'projects') - ? (legacyDb.prepare('SELECT COUNT(*) as count FROM projects').get() as { count: number }) - .count - : 0; - const taskCount = legacyTableExists(legacyDb, 'tasks') - ? (legacyDb.prepare('SELECT COUNT(*) as count FROM tasks').get() as { count: number }).count - : 0; - return { projects: projectCount, tasks: taskCount }; + legacyDb = hasLegacyDb ? openLegacyReadOnly(legacyPath) : null; + betaSqlite = hasBetaDb ? openLegacyReadOnly(betaPath) : null; + return await createLegacyPortPreview({ + appDb: db, + betaDb: betaSqlite ? drizzle(betaSqlite, { schema }) : null, + legacyDb, + hasLegacyDb, + hasBetaDb, + }); } catch (error) { log.warn('legacy-port controller: failed to read preview counts', { error: error instanceof Error ? error.message : String(error), }); - return { projects: 0, tasks: 0 }; + return await createLegacyPortPreview({ + appDb: db, + betaDb: null, + legacyDb: null, + hasLegacyDb, + hasBetaDb, + }); } finally { legacyDb?.close(); + betaSqlite?.close(); } }, - runImport: async () => { + runImport: async (args?: { + sources?: LegacyImportSource[]; + conflictChoices?: Record; + }) => { const userDataPath = app.getPath('userData'); try { - await runLegacyPort(userDataPath); + await runLegacyPort(userDataPath, { + sources: args?.sources, + conflictChoices: args?.conflictChoices, + }); return { success: true }; } catch (error) { log.error('legacy-port controller: import failed', { diff --git a/src/main/db/legacy-port/destination-cleanup.ts b/src/main/db/legacy-port/destination-cleanup.ts new file mode 100644 index 0000000000..ff0e3f80e1 --- /dev/null +++ b/src/main/db/legacy-port/destination-cleanup.ts @@ -0,0 +1,56 @@ +import type Database from 'better-sqlite3'; +import { tableExists } from './sqlite-utils'; + +function runDelete(sqlite: Database.Database, tableName: string, sql: string, ids: string[]): void { + if (!tableExists(sqlite, tableName)) return; + sqlite.prepare(sql).run(...ids); +} + +export function deleteProjectsById( + sqlite: Database.Database, + projectIds: ReadonlySet +): void { + if (projectIds.size === 0) return; + if (!tableExists(sqlite, 'projects')) return; + + const ids = [...projectIds]; + const placeholders = ids.map(() => '?').join(', '); + + if (tableExists(sqlite, 'conversations')) { + runDelete( + sqlite, + 'messages', + `DELETE FROM messages WHERE conversation_id IN ( + SELECT id FROM conversations WHERE project_id IN (${placeholders}) + )`, + ids + ); + } + + runDelete( + sqlite, + 'terminals', + `DELETE FROM terminals WHERE project_id IN (${placeholders})`, + ids + ); + runDelete( + sqlite, + 'conversations', + `DELETE FROM conversations WHERE project_id IN (${placeholders})`, + ids + ); + runDelete( + sqlite, + 'editor_buffers', + `DELETE FROM editor_buffers WHERE project_id IN (${placeholders})`, + ids + ); + runDelete( + sqlite, + 'project_remotes', + `DELETE FROM project_remotes WHERE project_id IN (${placeholders})`, + ids + ); + runDelete(sqlite, 'tasks', `DELETE FROM tasks WHERE project_id IN (${placeholders})`, ids); + sqlite.prepare(`DELETE FROM projects WHERE id IN (${placeholders})`).run(...ids); +} diff --git a/src/main/db/legacy-port/importers/auth/importer.test.ts b/src/main/db/legacy-port/importers/auth/importer.test.ts deleted file mode 100644 index b5fb36fb51..0000000000 --- a/src/main/db/legacy-port/importers/auth/importer.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import Database from 'better-sqlite3'; -import { afterEach, describe, expect, it } from 'vitest'; -import { createDrizzleClient } from '../../../drizzleClient'; -import { portLegacyAuthState } from './importer'; - -function createAppDbWithConfigTables(): { - appSqlite: Database.Database; - appDb: ReturnType['db']; -} { - const appSqlite = new Database(':memory:'); - appSqlite.exec(` - CREATE TABLE kv ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE app_secrets ( - key TEXT PRIMARY KEY, - secret TEXT NOT NULL - ); - `); - return { - appSqlite, - appDb: createDrizzleClient({ database: appSqlite }).db, - }; -} - -function readKv(appSqlite: Database.Database, fullKey: string): T | null { - const row = appSqlite.prepare('SELECT value FROM kv WHERE key = ?').get(fullKey) as - | { value: string } - | undefined; - if (!row) return null; - return JSON.parse(row.value) as T; -} - -function readSecret(appSqlite: Database.Database, key: string): string | null { - const row = appSqlite.prepare('SELECT secret FROM app_secrets WHERE key = ?').get(key) as - | { secret: string } - | undefined; - return row?.secret ?? null; -} - -function upsertKv( - appSqlite: Database.Database, - namespace: string, - key: string, - value: unknown -): void { - appSqlite - .prepare( - ` - INSERT INTO kv (key, value, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE - SET value = excluded.value, updated_at = excluded.updated_at - ` - ) - .run(`${namespace}:${key}`, JSON.stringify(value), Date.now()); -} - -function upsertSecret(appSqlite: Database.Database, key: string, secret: string): void { - appSqlite - .prepare( - ` - INSERT INTO app_secrets (key, secret) - VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET secret = excluded.secret - ` - ) - .run(key, secret); -} - -describe('portLegacyAuthState', () => { - const tempDirs: string[] = []; - const openDbs: Database.Database[] = []; - - afterEach(() => { - for (const db of openDbs.splice(0)) db.close(); - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it('ports keychain secrets + legacy JSON files into app_secrets and kv', async () => { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-auth-port-')); - tempDirs.push(userDataDir); - - fs.writeFileSync( - path.join(userDataDir, 'jira.json'), - JSON.stringify({ siteUrl: 'https://jira.example.com', email: 'me@example.com' }), - 'utf8' - ); - fs.writeFileSync( - path.join(userDataDir, 'forgejo.json'), - JSON.stringify({ siteUrl: 'https://forgejo.example.com/' }), - 'utf8' - ); - fs.writeFileSync( - path.join(userDataDir, 'gitlab.json'), - JSON.stringify({ siteUrl: 'https://gitlab.example.com/' }), - 'utf8' - ); - fs.writeFileSync( - path.join(userDataDir, 'emdash-account.json'), - JSON.stringify({ - hasAccount: true, - userId: 'user-1', - username: 'jona', - avatarUrl: 'https://example.com/avatar.png', - email: 'jona@example.com', - lastValidated: '2026-04-23T12:00:00.000Z', - }), - 'utf8' - ); - - const secretMap = new Map([ - ['emdash-github:github-token', 'gh_123'], - ['emdash-linear:api-token', 'lin_123'], - ['emdash-jira:api-token', 'jira_123'], - ['emdash-plain:api-token', 'plain_123'], - ['emdash-forgejo:forgejo-token', 'forgejo_123'], - ['emdash-gitlab:gitlab-token', 'gitlab_123'], - ['emdash-account:session-token', 'session_123'], - ['emdash-ssh:legacy-ssh-1:password', 'ssh_pwd_123'], - ]); - - const { appSqlite, appDb } = createAppDbWithConfigTables(); - openDbs.push(appSqlite); - - const summary = await portLegacyAuthState(userDataDir, { - appDb, - appSqlite, - readLegacySecret: async (service, account) => secretMap.get(`${service}:${account}`) ?? null, - encryptSecret: (secret) => Buffer.from(`enc:${secret}`, 'utf8').toString('base64'), - legacyToAppSshConnectionId: new Map([['legacy-ssh-1', 'ssh-app-1']]), - writeKv: async (namespace, key, value) => { - upsertKv(appSqlite, namespace, key, value); - }, - secretsStore: { - async setEncryptedSecret(key, encryptedSecret) { - upsertSecret(appSqlite, key, encryptedSecret); - }, - }, - }); - - expect(summary.importedSecrets).toEqual([ - 'github', - 'linear', - 'jira', - 'plain', - 'forgejo', - 'gitlab', - 'account', - ]); - expect(summary.importedSshPasswords).toBe(1); - - expect(readSecret(appSqlite, 'emdash-github-token')).toBe( - Buffer.from('enc:gh_123', 'utf8').toString('base64') - ); - expect(readSecret(appSqlite, 'emdash-account-token')).toBe( - Buffer.from('enc:session_123', 'utf8').toString('base64') - ); - expect(readSecret(appSqlite, 'ssh:ssh-app-1:password')).toBe( - Buffer.from('enc:ssh_pwd_123', 'utf8').toString('base64') - ); - - expect(readKv(appSqlite, 'github:tokenSource')).toBe('secure_storage'); - expect(readKv<{ siteUrl: string; email: string }>(appSqlite, 'jira:creds')).toEqual({ - siteUrl: 'https://jira.example.com', - email: 'me@example.com', - }); - expect(readKv<{ instanceUrl: string }>(appSqlite, 'forgejo:connection')).toEqual({ - instanceUrl: 'https://forgejo.example.com', - }); - expect(readKv<{ instanceUrl: string }>(appSqlite, 'gitlab:connection')).toEqual({ - instanceUrl: 'https://gitlab.example.com', - }); - expect( - readKv<{ userId: string; username: string }>(appSqlite, 'account:profile') - ).toMatchObject({ - userId: 'user-1', - username: 'jona', - }); - }); - - it('skips malformed legacy config without throwing', async () => { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-auth-port-invalid-')); - tempDirs.push(userDataDir); - - fs.writeFileSync(path.join(userDataDir, 'jira.json'), '{bad-json', 'utf8'); - fs.writeFileSync( - path.join(userDataDir, 'emdash-account.json'), - JSON.stringify({ hasAccount: true, userId: '', username: '' }), - 'utf8' - ); - - const { appSqlite, appDb } = createAppDbWithConfigTables(); - openDbs.push(appSqlite); - - const summary = await portLegacyAuthState(userDataDir, { - appDb, - appSqlite, - readLegacySecret: async () => null, - encryptSecret: (secret) => Buffer.from(secret, 'utf8').toString('base64'), - legacyToAppSshConnectionId: new Map([['legacy-ssh-1', 'ssh-app-1']]), - writeKv: async (namespace, key, value) => { - upsertKv(appSqlite, namespace, key, value); - }, - secretsStore: { - async setEncryptedSecret(key, encryptedSecret) { - upsertSecret(appSqlite, key, encryptedSecret); - }, - }, - }); - - expect(summary.importedSecrets).toEqual([]); - expect(summary.importedKv).toEqual([]); - expect(summary.importedSshPasswords).toBe(0); - expect(summary.skipped.length).toBeGreaterThan(0); - - const secretCount = ( - appSqlite.prepare('SELECT COUNT(*) AS count FROM app_secrets').get() as { - count: number; - } - ).count; - const kvCount = ( - appSqlite.prepare('SELECT COUNT(*) AS count FROM kv').get() as { count: number } - ).count; - - expect(secretCount).toBe(0); - expect(kvCount).toBe(0); - }); - - it('does not overwrite an existing app ssh password on dedup remap', async () => { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-auth-port-ssh-dedup-')); - tempDirs.push(userDataDir); - - const { appSqlite, appDb } = createAppDbWithConfigTables(); - openDbs.push(appSqlite); - - appSqlite - .prepare('INSERT INTO app_secrets (key, secret) VALUES (?, ?)') - .run('ssh:ssh-app-1:password', Buffer.from('enc:existing_pwd', 'utf8').toString('base64')); - - const secretMap = new Map([['emdash-ssh:legacy-ssh-1:password', 'legacy_pwd']]); - - const summary = await portLegacyAuthState(userDataDir, { - appDb, - appSqlite, - readLegacySecret: async (service, account) => secretMap.get(`${service}:${account}`) ?? null, - encryptSecret: (secret) => Buffer.from(`enc:${secret}`, 'utf8').toString('base64'), - legacyToAppSshConnectionId: new Map([['legacy-ssh-1', 'ssh-app-1']]), - writeKv: async (namespace, key, value) => { - upsertKv(appSqlite, namespace, key, value); - }, - secretsStore: { - async setEncryptedSecret(key, encryptedSecret) { - upsertSecret(appSqlite, key, encryptedSecret); - }, - }, - }); - - expect(summary.importedSshPasswords).toBe(0); - expect(summary.skipped).toContain('ssh.password:ssh-app-1:already-present'); - expect(readSecret(appSqlite, 'ssh:ssh-app-1:password')).toBe( - Buffer.from('enc:existing_pwd', 'utf8').toString('base64') - ); - }); -}); diff --git a/src/main/db/legacy-port/importers/auth/importer.ts b/src/main/db/legacy-port/importers/auth/importer.ts deleted file mode 100644 index daf4923f58..0000000000 --- a/src/main/db/legacy-port/importers/auth/importer.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { execFile } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import type Database from 'better-sqlite3'; -import { eq } from 'drizzle-orm'; -import { appSecrets, kv } from '@main/db/schema'; -import type { RelationalImportDb } from '../relational/types'; - -const execFileAsync = promisify(execFile); - -type LegacySecretSpec = { - label: string; - legacyService: string; - legacyAccount: string; - appSecretKey: string; -}; - -type KvWrite = { - namespace: string; - key: string; - value: unknown; - label: string; -}; - -type SecretWrite = { - key: string; - encryptedSecret: string; - label: string; -}; - -type LegacyAccountProfile = { - hasAccount: boolean; - userId: string; - username: string; - avatarUrl: string; - email: string; - lastValidated: string; -}; - -export type LegacySecretReader = (service: string, account: string) => Promise; -export type LegacySecretEncryptor = (secret: string) => string | null | Promise; - -export type PortLegacyAuthStateOptions = { - appDb: RelationalImportDb; - appSqlite: Database.Database; - legacyToAppSshConnectionId?: ReadonlyMap; - readLegacySecret?: LegacySecretReader; - encryptSecret?: LegacySecretEncryptor; - writeKv?: (namespace: string, key: string, value: unknown) => Promise; - secretsStore?: { - setEncryptedSecret(key: string, encryptedSecret: string): Promise; - }; -}; - -export type LegacyAuthPortSummary = { - importedSecrets: string[]; - importedKv: string[]; - importedSshPasswords: number; - skipped: string[]; -}; - -const LEGACY_SECRET_SPECS: LegacySecretSpec[] = [ - { - label: 'github', - legacyService: 'emdash-github', - legacyAccount: 'github-token', - appSecretKey: 'emdash-github-token', - }, - { - label: 'linear', - legacyService: 'emdash-linear', - legacyAccount: 'api-token', - appSecretKey: 'emdash-linear-token', - }, - { - label: 'jira', - legacyService: 'emdash-jira', - legacyAccount: 'api-token', - appSecretKey: 'emdash-jira-token', - }, - { - label: 'plain', - legacyService: 'emdash-plain', - legacyAccount: 'api-token', - appSecretKey: 'emdash-plain-token', - }, - { - label: 'forgejo', - legacyService: 'emdash-forgejo', - legacyAccount: 'forgejo-token', - appSecretKey: 'emdash-forgejo-token', - }, - { - label: 'gitlab', - legacyService: 'emdash-gitlab', - legacyAccount: 'gitlab-token', - appSecretKey: 'emdash-gitlab-token', - }, - { - label: 'account', - legacyService: 'emdash-account', - legacyAccount: 'session-token', - appSecretKey: 'emdash-account-token', - }, -]; - -function hasTable(appSqlite: Database.Database, tableName: string): boolean { - const row = appSqlite - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName) as { 1: number } | undefined; - return !!row; -} - -async function hasStoredSecret(appDb: RelationalImportDb, key: string): Promise { - const [row] = await appDb - .select({ key: appSecrets.key }) - .from(appSecrets) - .where(eq(appSecrets.key, key)) - .limit(1) - .execute(); - return Boolean(row); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readTrimmedString(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -type JsonReadResult = { kind: 'missing' } | { kind: 'invalid' } | { kind: 'ok'; value: unknown }; - -function readJsonFile(filePath: string): JsonReadResult { - try { - if (!existsSync(filePath)) return { kind: 'missing' }; - return { kind: 'ok', value: JSON.parse(readFileSync(filePath, 'utf8')) as unknown }; - } catch { - return { kind: 'invalid' }; - } -} - -function normalizeHostedInstanceUrl(instanceUrl: string): string | null { - const trimmed = instanceUrl.trim(); - if (!trimmed) return null; - - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return null; - } - if (parsed.search || parsed.hash) { - return null; - } - - const pathname = parsed.pathname.replace(/\/+$/, ''); - return pathname && pathname !== '/' - ? `${parsed.protocol}//${parsed.host}${pathname}` - : `${parsed.protocol}//${parsed.host}`; - } catch { - return null; - } -} - -function parseJiraCreds(raw: unknown): { siteUrl: string; email: string } | null { - if (!isRecord(raw)) return null; - const siteUrl = readTrimmedString(raw.siteUrl); - const email = readTrimmedString(raw.email); - if (!siteUrl || !email) return null; - return { siteUrl, email }; -} - -function parseHostedConnection(raw: unknown): { instanceUrl: string } | null { - if (!isRecord(raw)) return null; - - const siteUrl = - readTrimmedString(raw.instanceUrl) ?? - readTrimmedString(raw.siteUrl) ?? - readTrimmedString(raw.url); - if (!siteUrl) return null; - - const instanceUrl = normalizeHostedInstanceUrl(siteUrl); - if (!instanceUrl) return null; - - return { instanceUrl }; -} - -function parseLegacyAccountProfile(raw: unknown): LegacyAccountProfile | null { - if (!isRecord(raw)) return null; - if (typeof raw.hasAccount !== 'boolean') return null; - - const userId = readTrimmedString(raw.userId); - const username = readTrimmedString(raw.username); - const avatarUrl = readTrimmedString(raw.avatarUrl); - const email = readTrimmedString(raw.email); - const lastValidated = readTrimmedString(raw.lastValidated) ?? new Date().toISOString(); - - if (!userId || !username || !avatarUrl || !email) return null; - - return { - hasAccount: raw.hasAccount, - userId, - username, - avatarUrl, - email, - lastValidated, - }; -} - -async function defaultReadLegacySecret(service: string, account: string): Promise { - if (process.platform === 'darwin') { - try { - const { stdout } = await execFileAsync('security', [ - 'find-generic-password', - '-s', - service, - '-a', - account, - '-w', - ]); - const secret = stdout.trim(); - return secret.length > 0 ? secret : null; - } catch { - return null; - } - } - - if (process.platform === 'linux') { - try { - const { stdout } = await execFileAsync('secret-tool', [ - 'lookup', - 'service', - service, - 'account', - account, - ]); - const secret = stdout.trim(); - return secret.length > 0 ? secret : null; - } catch { - return null; - } - } - - return null; -} - -type SafeStorageLike = { - isEncryptionAvailable: () => boolean; - encryptString: (secret: string) => Buffer; - getSelectedStorageBackend?: () => string; -}; - -async function createDefaultEncryptor(): Promise { - if (!process.versions.electron) { - return null; - } - - try { - const electron = (await import('electron')) as { safeStorage?: SafeStorageLike }; - const safeStorage = electron.safeStorage; - if (!safeStorage || !safeStorage.isEncryptionAvailable()) { - return null; - } - - if ( - process.platform === 'linux' && - typeof safeStorage.getSelectedStorageBackend === 'function' && - safeStorage.getSelectedStorageBackend() === 'basic_text' - ) { - return null; - } - - return (secret: string) => safeStorage.encryptString(secret).toString('base64'); - } catch { - return null; - } -} - -function addKvWrite( - kvWrites: KvWrite[], - summary: LegacyAuthPortSummary, - hasKvTable: boolean, - write: KvWrite -): void { - if (!hasKvTable) { - summary.skipped.push(`${write.label}:kv-table-missing`); - return; - } - kvWrites.push(write); - summary.importedKv.push(write.label); -} - -export async function portLegacyAuthState( - userDataPath: string, - options: PortLegacyAuthStateOptions -): Promise { - const { appDb, appSqlite, legacyToAppSshConnectionId } = options; - const summary: LegacyAuthPortSummary = { - importedSecrets: [], - importedKv: [], - importedSshPasswords: 0, - skipped: [], - }; - - const hasKvTable = hasTable(appSqlite, 'kv'); - const hasSecretsTable = hasTable(appSqlite, 'app_secrets'); - - if (!hasKvTable && !hasSecretsTable) { - summary.skipped.push('auth-port:missing-kv-and-app-secrets-tables'); - return summary; - } - - const readLegacySecret = options.readLegacySecret ?? defaultReadLegacySecret; - const encryptSecret = options.encryptSecret ?? (await createDefaultEncryptor()); - const writeKv = - options.writeKv ?? - (async (namespace: string, key: string, value: unknown) => { - const namespaceKey = `${namespace}:${key}`; - const serialized = JSON.stringify(value); - const now = Date.now(); - - await appDb - .insert(kv) - .values({ key: namespaceKey, value: serialized, updatedAt: now }) - .onConflictDoUpdate({ - target: kv.key, - set: { value: serialized, updatedAt: now }, - }) - .execute(); - }); - - const secretWrites: SecretWrite[] = []; - const kvWrites: KvWrite[] = []; - - if (hasSecretsTable && encryptSecret) { - for (const spec of LEGACY_SECRET_SPECS) { - const rawSecret = await readLegacySecret(spec.legacyService, spec.legacyAccount); - const secret = readTrimmedString(rawSecret); - - if (!secret) { - continue; - } - - const encryptedSecret = await encryptSecret(secret); - if (!encryptedSecret) { - summary.skipped.push(`${spec.label}:secret-encryption-failed`); - continue; - } - - secretWrites.push({ key: spec.appSecretKey, encryptedSecret, label: spec.label }); - summary.importedSecrets.push(spec.label); - } - - if (legacyToAppSshConnectionId && legacyToAppSshConnectionId.size > 0) { - const migratedTargetIds = new Set(); - - for (const [legacyConnectionId, appConnectionId] of legacyToAppSshConnectionId.entries()) { - if (migratedTargetIds.has(appConnectionId)) { - summary.skipped.push(`ssh.password:${appConnectionId}:duplicate-target`); - continue; - } - - const targetKey = `ssh:${appConnectionId}:password`; - if (await hasStoredSecret(appDb, targetKey)) { - summary.skipped.push(`ssh.password:${appConnectionId}:already-present`); - continue; - } - - const rawPassword = await readLegacySecret('emdash-ssh', `${legacyConnectionId}:password`); - const password = readTrimmedString(rawPassword); - if (!password) { - continue; - } - - const encryptedSecret = await encryptSecret(password); - if (!encryptedSecret) { - summary.skipped.push(`ssh.password:${appConnectionId}:secret-encryption-failed`); - continue; - } - - secretWrites.push({ - key: targetKey, - encryptedSecret, - label: `ssh.password:${appConnectionId}`, - }); - migratedTargetIds.add(appConnectionId); - summary.importedSshPasswords += 1; - } - } - } else if (!hasSecretsTable) { - summary.skipped.push('auth-port:app-secrets-table-missing'); - } else { - summary.skipped.push('auth-port:secret-encryption-unavailable'); - } - - if (summary.importedSecrets.includes('github')) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'github', - key: 'tokenSource', - value: 'secure_storage', - label: 'github.tokenSource', - }); - } - - const jiraResult = readJsonFile(join(userDataPath, 'jira.json')); - if (jiraResult.kind === 'invalid') { - summary.skipped.push('jira.creds:invalid-json'); - } - const jiraCreds = jiraResult.kind === 'ok' ? parseJiraCreds(jiraResult.value) : null; - if (jiraCreds) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'jira', - key: 'creds', - value: jiraCreds, - label: 'jira.creds', - }); - } - - const forgejoResult = readJsonFile(join(userDataPath, 'forgejo.json')); - if (forgejoResult.kind === 'invalid') { - summary.skipped.push('forgejo.connection:invalid-json'); - } - const forgejoConnection = - forgejoResult.kind === 'ok' ? parseHostedConnection(forgejoResult.value) : null; - if (forgejoConnection) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'forgejo', - key: 'connection', - value: forgejoConnection, - label: 'forgejo.connection', - }); - } - - const gitlabResult = readJsonFile(join(userDataPath, 'gitlab.json')); - if (gitlabResult.kind === 'invalid') { - summary.skipped.push('gitlab.connection:invalid-json'); - } - const gitlabConnection = - gitlabResult.kind === 'ok' ? parseHostedConnection(gitlabResult.value) : null; - if (gitlabConnection) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'gitlab', - key: 'connection', - value: gitlabConnection, - label: 'gitlab.connection', - }); - } - - const accountResult = readJsonFile(join(userDataPath, 'emdash-account.json')); - if (accountResult.kind === 'invalid') { - summary.skipped.push('account.profile:invalid-json'); - } - const accountProfile = - accountResult.kind === 'ok' ? parseLegacyAccountProfile(accountResult.value) : null; - if (accountProfile) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'account', - key: 'profile', - value: accountProfile, - label: 'account.profile', - }); - } - - if (secretWrites.length === 0 && kvWrites.length === 0) { - return summary; - } - - const secretsStore = - options.secretsStore ?? - (hasSecretsTable - ? new ( - await import('@main/core/secrets/encrypted-app-secrets-store') - ).EncryptedAppSecretsStore(appDb) - : null); - - for (const row of secretWrites) { - await secretsStore?.setEncryptedSecret(row.key, row.encryptedSecret); - } - - for (const row of kvWrites) { - await writeKv(row.namespace, row.key, row.value); - } - - return summary; -} diff --git a/src/main/db/legacy-port/importers/relational/conversations.ts b/src/main/db/legacy-port/importers/relational/conversations.ts index 0ccc516280..9686fe98a5 100644 --- a/src/main/db/legacy-port/importers/relational/conversations.ts +++ b/src/main/db/legacy-port/importers/relational/conversations.ts @@ -1,19 +1,19 @@ -import { randomUUID } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { makePtySessionId } from '@shared/ptySessionId'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import { makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { conversations, tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { - isUniqueConstraintError, - readLegacyRows, - toIsoTimestamp, - toTrimmedString, -} from './helpers'; +import { readLegacyRows, toIsoTimestamp, toTrimmedString } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; const LEGACY_PTY_SESSION_MAP_FILE = 'pty-session-map.json'; const LEGACY_CLAUDE_CHAT_PREFIX = 'claude-chat-'; const LEGACY_CLAUDE_MAIN_PREFIX = 'claude-main-'; +const LEGACY_CHAT_SEPARATOR = '-chat-'; +const LEGACY_MAIN_SEPARATOR = '-main-'; const LEGACY_OPTIMISTIC_TASK_PREFIX = 'optimistic-'; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const CONVERSATION_ID_TIMESTAMP_PATTERN = /-(\d{10,})$/; @@ -24,10 +24,17 @@ type LegacyPtySessionMapEntry = { resumeTarget?: unknown; }; -type LegacyClaudeResumeTargets = { +type LegacyPtySessionTargets = { chatConversationIdToUuid: Map; mainTaskIdToUuid: Map; optimisticMainByTimestamp: Array<{ timestampMs: number; resumeUuid: string }>; + chatPtyIdByProviderAndConversationId: Map; + mainPtyIdByProviderAndTaskId: Map; + optimisticMainPtyByProviderAndTimestamp: Array<{ + providerId: string; + timestampMs: number; + legacyPtyId: string; + }>; }; function isPlainRecord(value: unknown): value is Record { @@ -38,11 +45,14 @@ function isValidResumeUuid(value: string): boolean { return UUID_PATTERN.test(value); } -function readLegacyClaudeResumeTargets(userDataPath?: string): LegacyClaudeResumeTargets { - const targets: LegacyClaudeResumeTargets = { +function readLegacyPtySessionTargets(userDataPath?: string): LegacyPtySessionTargets { + const targets: LegacyPtySessionTargets = { chatConversationIdToUuid: new Map(), mainTaskIdToUuid: new Map(), optimisticMainByTimestamp: [], + chatPtyIdByProviderAndConversationId: new Map(), + mainPtyIdByProviderAndTaskId: new Map(), + optimisticMainPtyByProviderAndTimestamp: [], }; if (!userDataPath) return targets; @@ -64,6 +74,36 @@ function readLegacyClaudeResumeTargets(userDataPath?: string): LegacyClaudeResum if (!isPlainRecord(rawJson)) return targets; for (const [ptyKey, rawEntry] of Object.entries(rawJson)) { + const parsedPtyKey = parseLegacyPtyKey(ptyKey); + if (parsedPtyKey) { + const providerId = parsedPtyKey.providerId.toLowerCase(); + const lookupKey = legacyPtyLookupKey(providerId, parsedPtyKey.suffix); + + if (parsedPtyKey.kind === 'chat') { + if (!targets.chatPtyIdByProviderAndConversationId.has(lookupKey)) { + targets.chatPtyIdByProviderAndConversationId.set(lookupKey, ptyKey); + } + } else { + if (!targets.mainPtyIdByProviderAndTaskId.has(lookupKey)) { + targets.mainPtyIdByProviderAndTaskId.set(lookupKey, ptyKey); + } + + if (parsedPtyKey.suffix.startsWith(LEGACY_OPTIMISTIC_TASK_PREFIX)) { + const optimisticTimestampPart = toTrimmedString( + parsedPtyKey.suffix.slice(LEGACY_OPTIMISTIC_TASK_PREFIX.length) + ); + const optimisticTimestampMs = Number.parseInt(optimisticTimestampPart ?? '', 10); + if (Number.isFinite(optimisticTimestampMs)) { + targets.optimisticMainPtyByProviderAndTimestamp.push({ + providerId, + timestampMs: optimisticTimestampMs, + legacyPtyId: ptyKey, + }); + } + } + } + } + if (!isPlainRecord(rawEntry)) continue; const entry = rawEntry as LegacyPtySessionMapEntry; @@ -104,10 +144,47 @@ function readLegacyClaudeResumeTargets(userDataPath?: string): LegacyClaudeResum } targets.optimisticMainByTimestamp.sort((a, b) => a.timestampMs - b.timestampMs); + targets.optimisticMainPtyByProviderAndTimestamp.sort((a, b) => a.timestampMs - b.timestampMs); return targets; } +function parseLegacyPtyKey( + ptyKey: string +): { providerId: string; kind: 'main' | 'chat'; suffix: string } | undefined { + const chatIndex = ptyKey.indexOf(LEGACY_CHAT_SEPARATOR); + if (chatIndex > 0) { + const suffix = toTrimmedString(ptyKey.slice(chatIndex + LEGACY_CHAT_SEPARATOR.length)); + if (!suffix) return undefined; + return { + providerId: ptyKey.slice(0, chatIndex), + kind: 'chat', + suffix, + }; + } + + const mainIndex = ptyKey.indexOf(LEGACY_MAIN_SEPARATOR); + if (mainIndex > 0) { + const suffix = toTrimmedString(ptyKey.slice(mainIndex + LEGACY_MAIN_SEPARATOR.length)); + if (!suffix) return undefined; + return { + providerId: ptyKey.slice(0, mainIndex), + kind: 'main', + suffix, + }; + } + + return undefined; +} + +function legacyPtyLookupKey(providerId: string, suffix: string): string { + return `${providerId}:${suffix}`; +} + +function makeLegacyTmuxSessionName(legacyPtyId: string): string { + return `emdash-${legacyPtyId.replace(/[^a-zA-Z0-9._-]/g, '-')}`; +} + function parseConversationTimestampMs(conversationId: string): number | undefined { const match = conversationId.match(CONVERSATION_ID_TIMESTAMP_PATTERN); if (!match) return undefined; @@ -129,7 +206,7 @@ function parseTaskIdFromConversationId(conversationId: string): string | undefin function findOptimisticMainResumeUuidForConversation( conversationId: string, - targets: LegacyClaudeResumeTargets + targets: LegacyPtySessionTargets ): string | undefined { const conversationTimestampMs = parseConversationTimestampMs(conversationId); if (!conversationTimestampMs || targets.optimisticMainByTimestamp.length === 0) { @@ -163,19 +240,132 @@ function findOptimisticMainResumeUuidForConversation( return bestMatch.resumeUuid; } +function findOptimisticMainPtyIdForConversation( + conversationId: string, + providerId: string, + targets: LegacyPtySessionTargets +): string | undefined { + const conversationTimestampMs = parseConversationTimestampMs(conversationId); + if (!conversationTimestampMs || targets.optimisticMainPtyByProviderAndTimestamp.length === 0) { + return undefined; + } + + let bestMatch: { distanceMs: number; legacyPtyId: string } | undefined; + let secondBestDistanceMs: number | undefined; + + for (const candidate of targets.optimisticMainPtyByProviderAndTimestamp) { + if (candidate.providerId !== providerId) continue; + + const distanceMs = Math.abs(candidate.timestampMs - conversationTimestampMs); + if (distanceMs > MAX_OPTIMISTIC_MAIN_TIMESTAMP_DRIFT_MS) continue; + + if (!bestMatch || distanceMs < bestMatch.distanceMs) { + secondBestDistanceMs = bestMatch?.distanceMs; + bestMatch = { distanceMs, legacyPtyId: candidate.legacyPtyId }; + continue; + } + + if (secondBestDistanceMs === undefined || distanceMs < secondBestDistanceMs) { + secondBestDistanceMs = distanceMs; + } + } + + if (!bestMatch) return undefined; + + if (secondBestDistanceMs !== undefined && secondBestDistanceMs === bestMatch.distanceMs) { + return undefined; + } + + return bestMatch.legacyPtyId; +} + +function pickLegacyPtyIdForConversation(params: { + legacyConversationId: string; + legacyTaskId: string; + legacyProvider: string | null; + legacyPtySessionTargets: LegacyPtySessionTargets; +}): string | undefined { + const { legacyConversationId, legacyTaskId, legacyProvider, legacyPtySessionTargets } = params; + const providerId = legacyProvider?.toLowerCase(); + if (!providerId) return undefined; + + return ( + legacyPtySessionTargets.chatPtyIdByProviderAndConversationId.get( + legacyPtyLookupKey(providerId, legacyConversationId) + ) ?? + legacyPtySessionTargets.mainPtyIdByProviderAndTaskId.get( + legacyPtyLookupKey(providerId, legacyTaskId) + ) ?? + (() => { + const taskIdFromConversationId = parseTaskIdFromConversationId(legacyConversationId); + return taskIdFromConversationId + ? legacyPtySessionTargets.mainPtyIdByProviderAndTaskId.get( + legacyPtyLookupKey(providerId, taskIdFromConversationId) + ) + : undefined; + })() ?? + findOptimisticMainPtyIdForConversation( + legacyConversationId, + providerId, + legacyPtySessionTargets + ) + ); +} + +async function renameLegacyTmuxSession(params: { + tmuxExec: IExecutionContext | undefined; + legacyPtyId: string | undefined; + mappedProjectId: string; + mappedTaskId: string; + conversationId: string; +}): Promise { + const { tmuxExec, legacyPtyId, mappedProjectId, mappedTaskId, conversationId } = params; + if (!tmuxExec || !legacyPtyId) return; + + const oldName = makeLegacyTmuxSessionName(legacyPtyId); + const newName = makeTmuxSessionName( + makePtySessionId(mappedProjectId, mappedTaskId, conversationId) + ); + if (oldName === newName) return; + + try { + await tmuxExec.exec('tmux', ['has-session', '-t', oldName]); + } catch { + return; + } + + try { + await tmuxExec.exec('tmux', ['has-session', '-t', newName]); + return; + } catch { + // Expected when the v1 session name has not been created yet. + } + + try { + await tmuxExec.exec('tmux', ['rename-session', '-t', oldName, newName]); + } catch (error) { + log.debug('legacy-port: conversations: failed to rename legacy tmux session', { + legacyPtyId, + oldName, + newName, + error: error instanceof Error ? error.message : String(error), + }); + } +} + function pickConversationIdForInsert(params: { legacyConversationId: string; legacyTaskId: string; legacyProvider: string | null; conversationIds: Set; - claudeResumeTargets: LegacyClaudeResumeTargets; + legacyPtySessionTargets: LegacyPtySessionTargets; }): string { const { legacyConversationId, legacyTaskId, legacyProvider, conversationIds, - claudeResumeTargets, + legacyPtySessionTargets, } = params; if (legacyProvider?.toLowerCase() !== 'claude') { @@ -183,15 +373,15 @@ function pickConversationIdForInsert(params: { } const candidateResumeUuid = - claudeResumeTargets.chatConversationIdToUuid.get(legacyConversationId) ?? - claudeResumeTargets.mainTaskIdToUuid.get(legacyTaskId) ?? + legacyPtySessionTargets.chatConversationIdToUuid.get(legacyConversationId) ?? + legacyPtySessionTargets.mainTaskIdToUuid.get(legacyTaskId) ?? (() => { const taskIdFromConversationId = parseTaskIdFromConversationId(legacyConversationId); return taskIdFromConversationId - ? claudeResumeTargets.mainTaskIdToUuid.get(taskIdFromConversationId) + ? legacyPtySessionTargets.mainTaskIdToUuid.get(taskIdFromConversationId) : undefined; })() ?? - findOptimisticMainResumeUuidForConversation(legacyConversationId, claudeResumeTargets); + findOptimisticMainResumeUuidForConversation(legacyConversationId, legacyPtySessionTargets); if (!candidateResumeUuid || !isValidResumeUuid(candidateResumeUuid)) { return legacyConversationId; @@ -215,13 +405,15 @@ export async function portConversations({ remap, mergedLegacyTaskIds, userDataPath, + tmuxExec, }: PortContext & { mergedLegacyTaskIds: Set; userDataPath?: string; + tmuxExec?: IExecutionContext; }): Promise { const summary = createPortSummary('conversations'); const nowIso = new Date().toISOString(); - const claudeResumeTargets = readLegacyClaudeResumeTargets(userDataPath); + const legacyPtySessionTargets = readLegacyPtySessionTargets(userDataPath); const taskRows = await appDb .select({ @@ -285,15 +477,17 @@ export async function portConversations({ legacyTaskId, legacyProvider, conversationIds, - claudeResumeTargets, + legacyPtySessionTargets, + }); + const legacyPtyId = pickLegacyPtyIdForConversation({ + legacyConversationId, + legacyTaskId, + legacyProvider, + legacyPtySessionTargets, }); - - let nextConversationId = conversationIds.has(preferredConversationId) - ? randomUUID() - : preferredConversationId; const insertValues = { - id: nextConversationId, + id: preferredConversationId, projectId: mappedProjectId, taskId: mappedTaskId, title: @@ -304,32 +498,37 @@ export async function portConversations({ updatedAt: toIsoTimestamp(row.updated_at, nowIso), }; - let inserted = false; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextConversationId; - await appDb.insert(conversations).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'conversations.id')) { - nextConversationId = randomUUID(); - continue; - } + const insertResult = await insertWithRegeneratedId({ + initialId: preferredConversationId, + existingIds: conversationIds, + uniqueConstraintDetail: 'conversations.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(conversations).values(insertValues).execute(), + }); - summary.skippedError += 1; - log.warn('legacy-port: conversations: failed to insert row', { - legacyConversationId, - error: error instanceof Error ? error.message : String(error), - }); - break; - } + if (!insertResult.inserted) { + summary.skippedError += 1; + log.warn('legacy-port: conversations: failed to insert row', { + legacyConversationId, + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), + }); + continue; } - if (!inserted) continue; + await renameLegacyTmuxSession({ + tmuxExec, + legacyPtyId, + mappedProjectId, + mappedTaskId, + conversationId: insertResult.id, + }); - conversationIds.add(nextConversationId); + conversationIds.add(insertResult.id); summary.inserted += 1; } diff --git a/src/main/db/legacy-port/importers/relational/helpers.ts b/src/main/db/legacy-port/importers/relational/helpers.ts index 646a599864..b2f9383c40 100644 --- a/src/main/db/legacy-port/importers/relational/helpers.ts +++ b/src/main/db/legacy-port/importers/relational/helpers.ts @@ -1,11 +1,5 @@ import type Database from 'better-sqlite3'; - -function quoteIdentifier(identifier: string): string { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) { - throw new Error(`Invalid SQL identifier: ${identifier}`); - } - return `"${identifier}"`; -} +import { quoteIdentifier } from '../../sqlite-utils'; export function legacyTableExists(legacyDb: Database.Database, tableName: string): boolean { const row = legacyDb diff --git a/src/main/db/legacy-port/importers/relational/insert.ts b/src/main/db/legacy-port/importers/relational/insert.ts new file mode 100644 index 0000000000..317da8d81d --- /dev/null +++ b/src/main/db/legacy-port/importers/relational/insert.ts @@ -0,0 +1,34 @@ +import { randomUUID } from 'node:crypto'; +import { isUniqueConstraintError } from './helpers'; + +export type InsertWithRegeneratedIdResult = { + inserted: boolean; + id: string; + error?: unknown; +}; + +export async function insertWithRegeneratedId(args: { + initialId: string; + existingIds: ReadonlySet; + uniqueConstraintDetail: string; + setId: (id: string) => void; + insert: () => Promise; +}): Promise { + let nextId = args.existingIds.has(args.initialId) ? randomUUID() : args.initialId; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + args.setId(nextId); + await args.insert(); + return { inserted: true, id: nextId }; + } catch (error) { + if (attempt === 0 && isUniqueConstraintError(error, args.uniqueConstraintDetail)) { + nextId = randomUUID(); + continue; + } + return { inserted: false, id: nextId, error }; + } + } + + return { inserted: false, id: nextId }; +} diff --git a/src/main/db/legacy-port/importers/relational/projects.ts b/src/main/db/legacy-port/importers/relational/projects.ts index 1f87848174..1cfd526fb7 100644 --- a/src/main/db/legacy-port/importers/relational/projects.ts +++ b/src/main/db/legacy-port/importers/relational/projects.ts @@ -1,14 +1,16 @@ -import { randomUUID } from 'node:crypto'; import { basename } from 'node:path'; import { eq } from 'drizzle-orm'; import { projects, sshConnections } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { makeSshFingerprint, - normalizeLocalPath, normalizePort, normalizeRemotePath, } from '../../legacy-source/normalize'; +import { + localProjectIdentityKey, + sshProjectIdentityKey, +} from '../../legacy-source/project-identity'; import { isUniqueConstraintError, readLegacyRows, @@ -16,6 +18,7 @@ import { toIsoTimestamp, toTrimmedString, } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; type ExistingProjectRow = { @@ -35,14 +38,6 @@ type ConnectionFingerprintRow = { username: string; }; -function localProjectKey(projectPath: string): string { - return `local:${normalizeLocalPath(projectPath)}`; -} - -function sshProjectKey(fingerprint: string, projectPath: string): string { - return `ssh:${fingerprint}:${normalizeRemotePath(projectPath)}`; -} - function pickDefaultProjectName(projectPath: string, fallbackId: string): string { const derived = basename(projectPath.trim()); return derived.length > 0 ? derived : `Legacy Project ${fallbackId.slice(0, 8)}`; @@ -68,7 +63,14 @@ async function loadConnectionFingerprintById( return result; } -export async function portProjects({ appDb, legacyDb, remap }: PortContext): Promise { +export async function portProjects({ + appDb, + legacyDb, + remap, + skipLegacyProjectIds, +}: PortContext & { + skipLegacyProjectIds?: ReadonlySet; +}): Promise { const summary = createPortSummary('projects'); const nowIso = new Date().toISOString(); @@ -95,11 +97,11 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro if (row.workspaceProvider === 'ssh' && row.sshConnectionId && row.host && row.username) { const fingerprint = makeSshFingerprint(row.host, normalizePort(row.port), row.username); - sshKeyToProjectId.set(sshProjectKey(fingerprint, row.path), row.id); + sshKeyToProjectId.set(sshProjectIdentityKey(fingerprint, row.path), row.id); continue; } - localKeyToProjectId.set(localProjectKey(row.path), row.id); + localKeyToProjectId.set(localProjectIdentityKey(row.path), row.id); } const connectionFingerprintById = await loadConnectionFingerprintById(appDb); @@ -126,6 +128,11 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro continue; } + if (skipLegacyProjectIds?.has(legacyProjectId)) { + summary.skippedDedup += 1; + continue; + } + const isRemote = toInteger(row.is_remote) === 1; const createdAt = toIsoTimestamp(row.created_at, nowIso); const updatedAt = toIsoTimestamp(row.updated_at, nowIso); @@ -181,7 +188,7 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro } projectPath = remotePath; - dedupKey = sshProjectKey(fingerprint, normalizedRemotePath); + dedupKey = sshProjectIdentityKey(fingerprint, normalizedRemotePath); const existingProjectId = sshKeyToProjectId.get(dedupKey); if (existingProjectId) { @@ -200,7 +207,7 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro } projectPath = localPath; - dedupKey = localProjectKey(localPath); + dedupKey = localProjectIdentityKey(localPath); const existingProjectId = localKeyToProjectId.get(dedupKey); if (existingProjectId) { @@ -215,10 +222,8 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro continue; } - let nextProjectId = projectIds.has(legacyProjectId) ? randomUUID() : legacyProjectId; - const insertValues = { - id: nextProjectId, + id: legacyProjectId, name: toTrimmedString(row.name) ?? pickDefaultProjectName(projectPath, legacyProjectId), path: projectPath, workspaceProvider, @@ -228,54 +233,50 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro updatedAt, }; - let inserted = false; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextProjectId; - await appDb.insert(projects).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'projects.id')) { - nextProjectId = randomUUID(); - continue; - } - - if (isUniqueConstraintError(error, 'projects.path')) { - const [existingByPath] = await appDb - .select({ id: projects.id }) - .from(projects) - .where(eq(projects.path, projectPath)) - .limit(1) - .execute(); - - if (existingByPath) { - remap.projectId.set(legacyProjectId, existingByPath.id); - summary.skippedDedup += 1; - } else { - summary.skippedError += 1; - log.warn('legacy-port: projects: path conflict but no surviving row found', { - legacyProjectId, - projectPath, - }); - } - break; + const insertResult = await insertWithRegeneratedId({ + initialId: legacyProjectId, + existingIds: projectIds, + uniqueConstraintDetail: 'projects.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(projects).values(insertValues).execute(), + }); + + if (!insertResult.inserted) { + if (isUniqueConstraintError(insertResult.error, 'projects.path')) { + const [existingByPath] = await appDb + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.path, projectPath)) + .limit(1) + .execute(); + + if (existingByPath) { + remap.projectId.set(legacyProjectId, existingByPath.id); + summary.skippedDedup += 1; + } else { + summary.skippedError += 1; + log.warn('legacy-port: projects: path conflict but no surviving row found', { + legacyProjectId, + projectPath, + }); } - + } else { summary.skippedError += 1; log.warn('legacy-port: projects: failed to insert row', { legacyProjectId, - error: error instanceof Error ? error.message : String(error), + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), }); - break; } + continue; } - if (!inserted) continue; - - remap.projectId.set(legacyProjectId, nextProjectId); - projectIds.add(nextProjectId); + remap.projectId.set(legacyProjectId, insertResult.id); + projectIds.add(insertResult.id); summary.inserted += 1; if (workspaceProvider === 'ssh') { @@ -283,10 +284,10 @@ export async function portProjects({ appDb, legacyDb, remap }: PortContext): Pro ? connectionFingerprintById.get(mappedSshConnectionId) : undefined; if (fingerprint) { - sshKeyToProjectId.set(sshProjectKey(fingerprint, projectPath), nextProjectId); + sshKeyToProjectId.set(sshProjectIdentityKey(fingerprint, projectPath), insertResult.id); } } else { - localKeyToProjectId.set(localProjectKey(projectPath), nextProjectId); + localKeyToProjectId.set(localProjectIdentityKey(projectPath), insertResult.id); } } diff --git a/src/main/db/legacy-port/importers/relational/relational.test.ts b/src/main/db/legacy-port/importers/relational/relational.test.ts index 2057946cc7..d4e6b6adbf 100644 --- a/src/main/db/legacy-port/importers/relational/relational.test.ts +++ b/src/main/db/legacy-port/importers/relational/relational.test.ts @@ -3,6 +3,8 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; +import { makePtySessionId } from '@shared/ptySessionId'; +import { makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { createDrizzleClient } from '../../../drizzleClient'; import { portConversations } from './conversations'; import { portProjects } from './projects'; @@ -55,7 +57,10 @@ function createAppDb(): { updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_interacted_at TEXT, status_changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_pinned INTEGER NOT NULL DEFAULT 0 + is_pinned INTEGER NOT NULL DEFAULT 0, + workspace_provider TEXT, + workspace_id TEXT, + workspace_provider_data TEXT ); CREATE TABLE conversations ( @@ -622,4 +627,95 @@ describe('legacy-port table passes', () => { { id: 'conv-legacy-codex', title: 'Legacy Codex Conversation', provider: 'codex' }, ]); }); + + it('renames legacy tmux sessions to v1 deterministic names when importing conversations', async () => { + const { appSqlite, appDb } = createAppDb(); + const legacyDb = createLegacyDb(); + openDbs.push(appSqlite, legacyDb); + + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-port-tmux-')); + tempDirs.push(userDataDir); + + const mappedChatUuid = '6ba95736-36d7-401e-9ef6-01655fb9162a'; + fs.writeFileSync( + path.join(userDataDir, 'pty-session-map.json'), + JSON.stringify({ + 'claude-chat-conv-legacy-chat': { uuid: mappedChatUuid }, + }), + 'utf8' + ); + + legacyDb + .prepare( + `INSERT INTO projects (id, name, path, base_ref, is_remote, remote_path, ssh_connection_id) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run('proj-legacy-tmux', 'Legacy Tmux', '/legacy/tmux', 'main', 0, null, null); + legacyDb + .prepare( + `INSERT INTO tasks (id, project_id, name, status, branch, updated_at) VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + 'task-legacy-tmux', + 'proj-legacy-tmux', + 'Legacy Tmux Task', + 'running', + 'feature/tmux', + '2026-01-03T12:00:00.000Z' + ); + legacyDb + .prepare(`INSERT INTO conversations (id, task_id, title, provider) VALUES (?, ?, ?, ?)`) + .run('conv-legacy-chat', 'task-legacy-tmux', 'Legacy Claude Chat', 'claude'); + + const calls: Array<{ command: string; args?: string[] }> = []; + const tmuxExec = { + root: undefined, + supportsLocalSpawn: false, + exec: async (command: string, args: string[] = []) => { + calls.push({ command, args }); + if ( + command === 'tmux' && + args?.[0] === 'has-session' && + args[2] !== 'emdash-claude-chat-conv-legacy-chat' + ) { + throw new Error('missing'); + } + return { stdout: '', stderr: '' }; + }, + execStreaming: async () => {}, + dispose: () => {}, + }; + + const remap = createRemapTables(); + await portSshConnections({ appDb, legacyDb, remap }); + await portProjects({ appDb, legacyDb, remap }); + const taskResult = await portTasks({ appDb, legacyDb, remap }); + + await portConversations({ + appDb, + legacyDb, + remap, + mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, + userDataPath: userDataDir, + tmuxExec, + }); + + const newTmuxName = makeTmuxSessionName( + makePtySessionId('proj-legacy-tmux', 'task-legacy-tmux', mappedChatUuid) + ); + + expect(calls).toEqual([ + { + command: 'tmux', + args: ['has-session', '-t', 'emdash-claude-chat-conv-legacy-chat'], + }, + { + command: 'tmux', + args: ['has-session', '-t', newTmuxName], + }, + { + command: 'tmux', + args: ['rename-session', '-t', 'emdash-claude-chat-conv-legacy-chat', newTmuxName], + }, + ]); + }); }); diff --git a/src/main/db/legacy-port/importers/relational/ssh-connections.ts b/src/main/db/legacy-port/importers/relational/ssh-connections.ts index ccf5dbd909..c9d3a03d90 100644 --- a/src/main/db/legacy-port/importers/relational/ssh-connections.ts +++ b/src/main/db/legacy-port/importers/relational/ssh-connections.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { sshConnections } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { @@ -7,13 +6,8 @@ import { normalizePort, normalizeUsername, } from '../../legacy-source/normalize'; -import { - isUniqueConstraintError, - readLegacyRows, - toInteger, - toIsoTimestamp, - toTrimmedString, -} from './helpers'; +import { readLegacyRows, toInteger, toIsoTimestamp, toTrimmedString } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; type ExistingSshConnection = { @@ -47,7 +41,10 @@ export async function portSshConnections({ appDb, legacyDb, remap, -}: PortContext): Promise { + allowedLegacyConnectionIds, +}: PortContext & { + allowedLegacyConnectionIds?: ReadonlySet; +}): Promise { const summary = createPortSummary('ssh_connections'); const nowIso = new Date().toISOString(); @@ -105,6 +102,11 @@ export async function portSshConnections({ continue; } + if (allowedLegacyConnectionIds && !allowedLegacyConnectionIds.has(legacyId)) { + summary.skippedDedup += 1; + continue; + } + const normalizedPort = normalizePort(toInteger(row.port)); const fingerprint = makeSshFingerprint(host, normalizedPort, username); const existingConnectionId = fingerprintToConnectionId.get(fingerprint); @@ -115,14 +117,12 @@ export async function portSshConnections({ continue; } - let nextConnectionId = existingConnectionIds.has(legacyId) ? randomUUID() : legacyId; - const preferredName = toTrimmedString(row.name) ?? `${normalizeUsername(username)}@${normalizeHost(host)}:${normalizedPort}`; const insertValues = { - id: nextConnectionId, + id: legacyId, name: pickUniqueConnectionName(preferredName, usedConnectionNames), host, port: normalizedPort, @@ -135,33 +135,31 @@ export async function portSshConnections({ updatedAt: toIsoTimestamp(row.updated_at, nowIso), }; - let inserted = false; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextConnectionId; - await appDb.insert(sshConnections).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'ssh_connections.id')) { - nextConnectionId = randomUUID(); - continue; - } - - summary.skippedError += 1; - log.warn('legacy-port: ssh_connections: failed to insert row', { - legacyId, - error: error instanceof Error ? error.message : String(error), - }); - break; - } + const insertResult = await insertWithRegeneratedId({ + initialId: legacyId, + existingIds: existingConnectionIds, + uniqueConstraintDetail: 'ssh_connections.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(sshConnections).values(insertValues).execute(), + }); + + if (!insertResult.inserted) { + summary.skippedError += 1; + log.warn('legacy-port: ssh_connections: failed to insert row', { + legacyId, + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), + }); + continue; } - if (!inserted) continue; - - remap.sshConnectionId.set(legacyId, nextConnectionId); - fingerprintToConnectionId.set(fingerprint, nextConnectionId); - existingConnectionIds.add(nextConnectionId); + remap.sshConnectionId.set(legacyId, insertResult.id); + fingerprintToConnectionId.set(fingerprint, insertResult.id); + existingConnectionIds.add(insertResult.id); summary.inserted += 1; } diff --git a/src/main/db/legacy-port/importers/relational/tasks.ts b/src/main/db/legacy-port/importers/relational/tasks.ts index b841e80b91..b3f437c518 100644 --- a/src/main/db/legacy-port/importers/relational/tasks.ts +++ b/src/main/db/legacy-port/importers/relational/tasks.ts @@ -1,13 +1,7 @@ -import { randomUUID } from 'node:crypto'; import { tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { - isUniqueConstraintError, - readLegacyRows, - toInteger, - toIsoTimestamp, - toTrimmedString, -} from './helpers'; +import { readLegacyRows, toInteger, toIsoTimestamp, toTrimmedString } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; export type TaskPortResult = { @@ -145,13 +139,11 @@ export async function portTasks({ appDb, legacyDb, remap }: PortContext): Promis } } - let nextTaskId = existingTaskIds.has(legacyTaskId) ? randomUUID() : legacyTaskId; - const updatedAt = toIsoTimestamp(row.updated_at, nowIso); const createdAt = toIsoTimestamp(row.created_at, updatedAt); const insertValues = { - id: nextTaskId, + id: legacyTaskId, projectId: mappedProjectId, name: toTrimmedString(row.name) ?? branch ?? `Legacy Task ${legacyTaskId.slice(0, 8)}`, status: coerceTaskStatus(toTrimmedString(row.status)), @@ -165,37 +157,34 @@ export async function portTasks({ appDb, legacyDb, remap }: PortContext): Promis isPinned: 0, }; - let inserted = false; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextTaskId; - await appDb.insert(tasks).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'tasks.id')) { - nextTaskId = randomUUID(); - continue; - } - - summary.skippedError += 1; - log.warn('legacy-port: tasks: failed to insert row', { - legacyTaskId, - error: error instanceof Error ? error.message : String(error), - }); - break; - } - } + const insertResult = await insertWithRegeneratedId({ + initialId: legacyTaskId, + existingIds: existingTaskIds, + uniqueConstraintDetail: 'tasks.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(tasks).values(insertValues).execute(), + }); - if (!inserted) continue; + if (!insertResult.inserted) { + summary.skippedError += 1; + log.warn('legacy-port: tasks: failed to insert row', { + legacyTaskId, + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), + }); + continue; + } - remap.taskId.set(legacyTaskId, nextTaskId); - existingTaskIds.add(nextTaskId); + remap.taskId.set(legacyTaskId, insertResult.id); + existingTaskIds.add(insertResult.id); summary.inserted += 1; if (taskBranch) { - branchKeyToTaskId.set(`${mappedProjectId}::${taskBranch}`, nextTaskId); + branchKeyToTaskId.set(`${mappedProjectId}::${taskBranch}`, insertResult.id); } } diff --git a/src/main/db/legacy-port/importers/settings/importer.ts b/src/main/db/legacy-port/importers/settings/importer.ts index 64d2c8fab1..177ad0b68e 100644 --- a/src/main/db/legacy-port/importers/settings/importer.ts +++ b/src/main/db/legacy-port/importers/settings/importer.ts @@ -5,6 +5,7 @@ import { isValidProviderId } from '@shared/agent-provider-registry'; import type { AppSettings, AppSettingsKey } from '@shared/app-settings'; import { getDefaultForKey } from '@main/core/settings/settings-registry'; import { isPlainObject, mergeDeep } from '@main/core/settings/utils'; +import { tableExists } from '../../sqlite-utils'; import type { RelationalImportDb } from '../relational/types'; const LEGACY_SETTINGS_FILE = 'settings.json'; @@ -25,13 +26,6 @@ export type PortLegacySettingsOptions = { type LegacyTheme = 'light' | 'dark' | 'dark-black' | 'system'; -function hasTable(appSqlite: Database.Database, tableName: string): boolean { - const row = appSqlite - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName) as { 1: number } | undefined; - return !!row; -} - function readJsonFile(filePath: string): unknown | null { if (!existsSync(filePath)) return null; try { @@ -97,7 +91,7 @@ export async function portLegacySettings( skipped: [], }; - if (!hasTable(appSqlite, 'app_settings')) { + if (!tableExists(appSqlite, 'app_settings')) { summary.skipped.push('settings:app_settings-table-missing'); return summary; } diff --git a/src/main/db/legacy-port/legacy-source/path.ts b/src/main/db/legacy-port/legacy-source/path.ts index 71fd3ac52f..bc8dd830da 100644 --- a/src/main/db/legacy-port/legacy-source/path.ts +++ b/src/main/db/legacy-port/legacy-source/path.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { PREVIOUS_DB_FILENAME } from '@main/db/default-path'; export function resolveLegacyDatabasePath(userDataPath: string): string { return join(userDataPath, 'emdash.db'); @@ -8,3 +9,11 @@ export function resolveLegacyDatabasePath(userDataPath: string): string { export function hasLegacyDatabaseFile(userDataPath: string): boolean { return existsSync(resolveLegacyDatabasePath(userDataPath)); } + +export function resolveBetaDatabasePath(userDataPath: string) { + return join(userDataPath, PREVIOUS_DB_FILENAME); +} + +export function hasBetaDatabaseFile(userDataPath: string): boolean { + return existsSync(resolveBetaDatabasePath(userDataPath)); +} diff --git a/src/main/db/legacy-port/legacy-source/project-identity.ts b/src/main/db/legacy-port/legacy-source/project-identity.ts new file mode 100644 index 0000000000..0669f0eb35 --- /dev/null +++ b/src/main/db/legacy-port/legacy-source/project-identity.ts @@ -0,0 +1,34 @@ +import { parseGitHubRepository } from '@shared/github-repository'; +import { normalizeLocalPath, normalizeRemotePath } from './normalize'; + +export function localProjectIdentityKey(projectPath: string): string { + return `local:${normalizeLocalPath(projectPath)}`; +} + +export function sshProjectIdentityKey(fingerprint: string, projectPath: string): string { + return `ssh:${fingerprint}:${normalizeRemotePath(projectPath)}`; +} + +function gitRemoteIdentityKey(remote: string): string | null { + const input = remote.trim(); + const normalized = (parseGitHubRepository(input)?.repositoryUrl ?? input) + .replace(/\.git$/i, '') + .replace(/\/+$/g, ''); + if (!normalized) return null; + return `git:${normalized.toLowerCase()}`; +} + +function githubRepositoryIdentityKey(repository: string): string | null { + const normalized = repository + .trim() + .replace(/^\/+|\/+$/g, '') + .replace(/\.git$/i, ''); + if (!normalized.includes('/')) return null; + return gitRemoteIdentityKey(`https://github.com/${normalized}`); +} + +export function gitRemoteIdentityKeys(value: string): string[] { + return [gitRemoteIdentityKey(value), githubRepositoryIdentityKey(value)].filter( + (key, index, keys): key is string => Boolean(key) && keys.indexOf(key) === index + ); +} diff --git a/src/main/db/legacy-port/reset.test.ts b/src/main/db/legacy-port/reset.test.ts new file mode 100644 index 0000000000..c13a41fa7c --- /dev/null +++ b/src/main/db/legacy-port/reset.test.ts @@ -0,0 +1,20 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; +import { listUserTables } from './reset'; + +describe('listUserTables', () => { + it('excludes SQLite shadow tables for virtual tables', () => { + const db = new Database(':memory:'); + try { + db.exec(` + CREATE TABLE app_data (id TEXT PRIMARY KEY); + CREATE VIRTUAL TABLE search_index USING fts5(title); + CREATE VIRTUAL TABLE legacy_index USING fts4(title); + `); + + expect(listUserTables(db).sort()).toEqual(['app_data', 'legacy_index', 'search_index']); + } finally { + db.close(); + } + }); +}); diff --git a/src/main/db/legacy-port/reset.ts b/src/main/db/legacy-port/reset.ts new file mode 100644 index 0000000000..3a96078b6e --- /dev/null +++ b/src/main/db/legacy-port/reset.ts @@ -0,0 +1,63 @@ +import type Database from 'better-sqlite3'; +import { quoteIdentifier, tableExists, withForeignKeysDisabled } from './sqlite-utils'; + +export const PRESERVED_SECRET_KEYS = ['emdash-account-token', 'emdash-github-token'] as const; +export const PRESERVED_KV_KEYS = ['account:profile', 'github:tokenSource'] as const; + +function placeholders(values: readonly string[]): string { + return values.map(() => '?').join(', '); +} + +function listShadowTables(sqlite: Database.Database): Set { + const rows = sqlite.prepare(`PRAGMA table_list`).all() as Array<{ name: string; type: string }>; + + return new Set(rows.filter((row) => row.type === 'shadow').map((row) => row.name)); +} + +export function listUserTables(sqlite: Database.Database): string[] { + const shadowTables = listShadowTables(sqlite); + const rows = sqlite + .prepare( + ` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != '__drizzle_migrations' + ` + ) + .all() as Array<{ name: string }>; + + return rows.map((row) => row.name).filter((name) => !shadowTables.has(name)); +} + +export function clearDestinationDataPreservingSignIn(sqlite: Database.Database): void { + withForeignKeysDisabled(sqlite, () => { + const tables = listUserTables(sqlite); + const clear = sqlite.transaction(() => { + for (const table of tables) { + if (table === 'app_secrets' && tableExists(sqlite, 'app_secrets')) { + sqlite + .prepare( + `DELETE FROM ${quoteIdentifier(table)} WHERE key NOT IN (${placeholders(PRESERVED_SECRET_KEYS)})` + ) + .run(...PRESERVED_SECRET_KEYS); + continue; + } + + if (table === 'kv' && tableExists(sqlite, 'kv')) { + sqlite + .prepare( + `DELETE FROM ${quoteIdentifier(table)} WHERE key NOT IN (${placeholders(PRESERVED_KV_KEYS)})` + ) + .run(...PRESERVED_KV_KEYS); + continue; + } + + sqlite.prepare(`DELETE FROM ${quoteIdentifier(table)}`).run(); + } + }); + + clear(); + }); +} diff --git a/src/main/db/legacy-port/service.test.ts b/src/main/db/legacy-port/service.test.ts index 17ce1cb228..b1e6df17ae 100644 --- a/src/main/db/legacy-port/service.test.ts +++ b/src/main/db/legacy-port/service.test.ts @@ -53,7 +53,10 @@ function createAppDb(): Database.Database { updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_interacted_at TEXT, status_changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_pinned INTEGER NOT NULL DEFAULT 0 + is_pinned INTEGER NOT NULL DEFAULT 0, + workspace_provider TEXT, + workspace_id TEXT, + workspace_provider_data TEXT ); CREATE TABLE conversations ( @@ -70,6 +73,24 @@ function createAppDb(): Database.Database { return db; } +function createSearchIndex(db: Database.Database): void { + db.exec(` + INSERT OR REPLACE INTO kv (key, value, updated_at) VALUES ('fts_version', '1', unixepoch()); + + CREATE VIRTUAL TABLE search_index USING fts5( + item_type, + item_id UNINDEXED, + project_id UNINDEXED, + title, + keywords, + tokenize = 'unicode61 remove_diacritics 1' + ); + + INSERT INTO search_index(item_type, item_id, project_id, title, keywords) + VALUES ('task', 'stale-task', 'stale-project', 'Stale task', 'stale'); + `); +} + function seedLegacyDb(legacyPath: string): void { const legacy = new Database(legacyPath); legacy.exec(` @@ -227,4 +248,91 @@ describe('runLegacyPort', () => { }; expect(projectsAfterSecondRun.count).toBe(1); }); + + it('ports v0 when destination contains an FTS search index', async () => { + const appDb = createAppDb(); + createSearchIndex(appDb); + openDbs.push(appDb); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-port-fts-')); + tempDirs.push(tmpDir); + + seedLegacyDb(path.join(tmpDir, 'emdash.db')); + + const stateStore = new InMemoryLegacyPortStateStore(); + + await runLegacyPort(tmpDir, { appDb, stateStore }); + + const projectsAfterImport = appDb.prepare(`SELECT COUNT(*) AS count FROM projects`).get() as { + count: number; + }; + const searchRowsAfterImport = appDb + .prepare(`SELECT COUNT(*) AS count FROM search_index`) + .get() as { + count: number; + }; + + expect(await stateStore.getStatus()).toBe('completed'); + expect(projectsAfterImport.count).toBe(1); + expect(searchRowsAfterImport.count).toBe(0); + }); + + it('rolls back destination changes when a fatal import error happens', async () => { + const appDb = new Database(':memory:'); + openDbs.push(appDb); + appDb.pragma('foreign_keys = ON'); + appDb.exec(` + CREATE TABLE ssh_connections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + username TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'agent', + private_key_path TEXT, + use_agent INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + workspace_provider TEXT NOT NULL DEFAULT 'local', + base_ref TEXT, + ssh_connection_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + appDb + .prepare( + `INSERT INTO projects (id, name, path, workspace_provider, base_ref) VALUES (?, ?, ?, ?, ?)` + ) + .run('existing-project', 'Existing Project', '/existing/repo', 'local', 'main'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-port-rollback-')); + tempDirs.push(tmpDir); + + seedLegacyDb(path.join(tmpDir, 'emdash.db')); + + const stateStore = new InMemoryLegacyPortStateStore(); + + await runLegacyPort(tmpDir, { appDb, stateStore }); + + const projects = appDb + .prepare(`SELECT id, name, path FROM projects ORDER BY id ASC`) + .all() as Array<{ id: string; name: string; path: string }>; + + expect(await stateStore.getStatus()).toBeNull(); + expect(projects).toEqual([ + { + id: 'existing-project', + name: 'Existing Project', + path: '/existing/repo', + }, + ]); + }); }); diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index a631a545e0..41e4188ebd 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -1,9 +1,16 @@ import type Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { LegacyImportSource } from '@shared/legacy-port'; import type { StartupDataGateStatus } from '@shared/startup-data-gate'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { log } from '../../lib/logger'; import * as schema from '../schema'; -import { portLegacyAuthState } from './importers/auth/importer'; +import { + copyAttachedBetaDatabaseIntoDestination, + importBetaDatabaseIntoDestination, + withBetaDatabaseAttached, +} from './beta-import'; +import { deleteProjectsById } from './destination-cleanup'; import { portConversations } from './importers/relational/conversations'; import { portProjects } from './importers/relational/projects'; import { createRemapTables } from './importers/relational/remap'; @@ -12,7 +19,14 @@ import { portTasks } from './importers/relational/tasks'; import type { PortSummary } from './importers/relational/types'; import { portLegacySettings } from './importers/settings/importer'; import { openLegacyReadOnly } from './legacy-source/open-readonly'; -import { hasLegacyDatabaseFile, resolveLegacyDatabasePath } from './legacy-source/path'; +import { + hasBetaDatabaseFile, + hasLegacyDatabaseFile, + resolveBetaDatabasePath, + resolveLegacyDatabasePath, +} from './legacy-source/path'; +import { clearDestinationDataPreservingSignIn } from './reset'; +import { buildLegacyProjectSelection } from './source-analysis'; import { createLegacyPortStateStore } from './state-store'; type LegacyPortDb = ReturnType>; @@ -32,6 +46,8 @@ export interface LegacyPortStateStore { export type RunLegacyPortOptions = { appDb?: Database.Database; stateStore?: LegacyPortStateStore; + sources?: LegacyImportSource[]; + conflictChoices?: Record; }; async function resolveAppTarget(appSqlite?: Database.Database): Promise { @@ -54,7 +70,7 @@ function logSummary(summary: PortSummary): void { async function markStatus( stateStore: LegacyPortStateStore, - status: 'completed' | 'no-legacy-file' + status: LegacyPortStatus ): Promise { try { await stateStore.setStatus(status); @@ -66,16 +82,30 @@ async function markStatus( } } -export async function createDefaultLegacyPortStateStore(): Promise { - return createLegacyPortStateStore(); -} +async function withAtomicDestinationImport( + sqlite: Database.Database, + action: () => Promise +): Promise { + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; + sqlite.pragma('foreign_keys = OFF'); + sqlite.exec('BEGIN IMMEDIATE'); -export function resolveLegacyPath(userDataPath: string): string { - return resolveLegacyDatabasePath(userDataPath); + try { + const result = await action(); + sqlite.exec('COMMIT'); + return result; + } catch (error) { + if (sqlite.inTransaction) { + sqlite.exec('ROLLBACK'); + } + throw error; + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } } -export function hasLegacyFile(userDataPath: string): boolean { - return hasLegacyDatabaseFile(userDataPath); +export async function createDefaultLegacyPortStateStore(): Promise { + return createLegacyPortStateStore(); } export async function runLegacyPort( @@ -84,6 +114,7 @@ export async function runLegacyPort( ): Promise { const appTarget = await resolveAppTarget(options.appDb); const stateStore = options.stateStore ?? (await createDefaultLegacyPortStateStore()); + const selectedSources = new Set(options.sources ?? ['v0']); try { const status = await stateStore.getStatus(); @@ -96,13 +127,33 @@ export async function runLegacyPort( }); } - if (!hasLegacyFile(userDataPath)) { + if (selectedSources.size === 0) { + clearDestinationDataPreservingSignIn(appTarget.sqlite); + await markStatus(stateStore, 'wiped-beta'); + return; + } + + if (selectedSources.has('v1-beta') && !selectedSources.has('v0')) { + const betaPath = resolveBetaDatabasePath(userDataPath); + if (hasBetaDatabaseFile(userDataPath)) { + importBetaDatabaseIntoDestination(appTarget.sqlite, betaPath); + } else { + log.warn('legacy-port: v1-beta source selected but emdash3.db was not found', { betaPath }); + } + } + + if (!selectedSources.has('v0')) { + await markStatus(stateStore, selectedSources.has('v1-beta') ? 'kept-beta' : 'skipped-legacy'); + return; + } + + if (!hasLegacyDatabaseFile(userDataPath)) { log.info('legacy-port: no legacy emdash.db found, marking complete'); await markStatus(stateStore, 'no-legacy-file'); return; } - const legacyPath = resolveLegacyPath(userDataPath); + const legacyPath = resolveLegacyDatabasePath(userDataPath); let legacyDb: Database.Database; try { @@ -117,40 +168,74 @@ export async function runLegacyPort( const start = Date.now(); - try { - const remap = createRemapTables(); - - const sshSummary = await portSshConnections({ appDb: appTarget.db, legacyDb, remap }); - const projectsSummary = await portProjects({ appDb: appTarget.db, legacyDb, remap }); - const taskResult = await portTasks({ appDb: appTarget.db, legacyDb, remap }); - const conversationsSummary = await portConversations({ - appDb: appTarget.db, - legacyDb, - remap, - mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, - userDataPath, + const betaPath = resolveBetaDatabasePath(userDataPath); + const shouldCopyBeta = selectedSources.has('v1-beta') && hasBetaDatabaseFile(userDataPath); + const runImport = async (): Promise<{ + sshSummary: PortSummary; + projectsSummary: PortSummary; + taskResult: Awaited>; + conversationsSummary: PortSummary; + }> => + await withAtomicDestinationImport(appTarget.sqlite, async () => { + const remap = createRemapTables(); + if (selectedSources.has('v1-beta')) { + if (shouldCopyBeta) { + copyAttachedBetaDatabaseIntoDestination(appTarget.sqlite); + } else { + log.warn('legacy-port: v1-beta source selected but emdash3.db was not found', { + betaPath, + }); + } + } else { + clearDestinationDataPreservingSignIn(appTarget.sqlite); + } + + const selection = await buildLegacyProjectSelection({ + appDb: appTarget.db, + legacyDb, + selectedSources, + conflictChoices: options.conflictChoices ?? {}, + }); + + if (selectedSources.has('v1-beta')) { + deleteProjectsById(appTarget.sqlite, selection.replaceAppProjectIds); + } + + const sshSummary = await portSshConnections({ + appDb: appTarget.db, + legacyDb, + remap, + allowedLegacyConnectionIds: selection.allowedLegacySshConnectionIds, + }); + const projectsSummary = await portProjects({ + appDb: appTarget.db, + legacyDb, + remap, + skipLegacyProjectIds: selection.skipLegacyProjectIds, + }); + const taskResult = await portTasks({ appDb: appTarget.db, legacyDb, remap }); + const conversationsSummary = await portConversations({ + appDb: appTarget.db, + legacyDb, + remap, + mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, + userDataPath, + tmuxExec: new LocalExecutionContext(), + }); + + return { sshSummary, projectsSummary, taskResult, conversationsSummary }; }); + try { + const { sshSummary, projectsSummary, taskResult, conversationsSummary } = shouldCopyBeta + ? await withBetaDatabaseAttached(appTarget.sqlite, betaPath, runImport) + : await runImport(); + logSummary(sshSummary); logSummary(projectsSummary); logSummary(taskResult.summary); logSummary(conversationsSummary); - try { - const authSummary = await portLegacyAuthState(userDataPath, { - appDb: appTarget.db, - appSqlite: appTarget.sqlite, - legacyToAppSshConnectionId: remap.sshConnectionId, - }); - log.info( - `legacy-port: auth: imported_secrets=${authSummary.importedSecrets.length}, imported_kv=${authSummary.importedKv.length}, imported_ssh_passwords=${authSummary.importedSshPasswords}, skipped=${authSummary.skipped.length}` - ); - } catch (error) { - log.warn('legacy-port: auth: failed to port legacy credentials, continuing', { - error: error instanceof Error ? error.message : String(error), - }); - } - try { const settingsSummary = await portLegacySettings(userDataPath, { appDb: appTarget.db, diff --git a/src/main/db/legacy-port/source-analysis.ts b/src/main/db/legacy-port/source-analysis.ts new file mode 100644 index 0000000000..fe0efa6021 --- /dev/null +++ b/src/main/db/legacy-port/source-analysis.ts @@ -0,0 +1,363 @@ +import type Database from 'better-sqlite3'; +import { eq } from 'drizzle-orm'; +import type { + LegacyImportSource, + LegacyPortPreview, + LegacyProjectConflict, + SourceProjectInfo, +} from '@shared/legacy-port'; +import { projectRemotes, projects, sshConnections, tasks } from '@main/db/schema'; +import { + legacyTableExists, + readLegacyRows, + toInteger, + toTrimmedString, +} from './importers/relational/helpers'; +import type { RelationalImportDb } from './importers/relational/types'; +import { makeSshFingerprint, normalizePort } from './legacy-source/normalize'; +import { + gitRemoteIdentityKeys, + localProjectIdentityKey, + sshProjectIdentityKey, +} from './legacy-source/project-identity'; +import { quoteIdentifier } from './sqlite-utils'; + +export type LegacyProjectSelection = { + skipLegacyProjectIds: Set; + replaceAppProjectIds: Set; + allowedLegacySshConnectionIds: Set; +}; + +type AppProjectRow = { + id: string; + name: string; + path: string; + workspaceProvider: string; + sshConnectionId: string | null; + host: string | null; + port: number | null; + username: string | null; + updatedAt: string; +}; + +type AppProjectRemoteRow = { + projectId: string; + remoteUrl: string; +}; + +async function readAppProjectRemoteRows(appDb: RelationalImportDb): Promise { + try { + return (await appDb + .select({ + projectId: projectRemotes.projectId, + remoteUrl: projectRemotes.remoteUrl, + }) + .from(projectRemotes) + .execute()) as AppProjectRemoteRow[]; + } catch (error) { + if (error instanceof Error && error.message.includes('no such table: project_remotes')) { + return []; + } + throw error; + } +} + +function countLegacyTasksByProject(legacyDb: Database.Database): Map { + const rows = readLegacyRows(legacyDb, 'tasks', ['project_id']); + const result = new Map(); + + for (const row of rows) { + const projectId = toTrimmedString(row.project_id); + if (!projectId) continue; + result.set(projectId, (result.get(projectId) ?? 0) + 1); + } + + return result; +} + +function countRows(legacyDb: Database.Database, tableName: string): number { + if (!legacyTableExists(legacyDb, tableName)) return 0; + const row = legacyDb + .prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(tableName)}`) + .get() as { count: number }; + return row.count; +} + +function legacySshFingerprintsById(legacyDb: Database.Database): Map { + const rows = readLegacyRows(legacyDb, 'ssh_connections', ['id', 'host', 'port', 'username']); + const result = new Map(); + + for (const row of rows) { + const id = toTrimmedString(row.id); + const host = toTrimmedString(row.host); + const username = toTrimmedString(row.username); + if (!id || !host || !username) continue; + result.set(id, makeSshFingerprint(host, normalizePort(toInteger(row.port)), username)); + } + + return result; +} + +export function readLegacyProjectInfos(legacyDb: Database.Database): SourceProjectInfo[] { + const taskCountByProject = countLegacyTasksByProject(legacyDb); + const sshFingerprintById = legacySshFingerprintsById(legacyDb); + const rows = readLegacyRows(legacyDb, 'projects', [ + 'id', + 'name', + 'path', + 'git_remote', + 'github_repository', + 'is_remote', + 'remote_path', + 'ssh_connection_id', + 'updated_at', + ]); + + const result: SourceProjectInfo[] = []; + for (const row of rows) { + const id = toTrimmedString(row.id); + if (!id) continue; + + const isRemote = toInteger(row.is_remote) === 1; + const name = toTrimmedString(row.name) ?? id; + const updatedAt = toTrimmedString(row.updated_at) ?? null; + const gitRemoteKeys = [ + toTrimmedString(row.git_remote), + toTrimmedString(row.github_repository), + ].flatMap((value) => { + if (!value) return []; + return gitRemoteIdentityKeys(value); + }); + + if (isRemote) { + const sshConnectionId = toTrimmedString(row.ssh_connection_id); + const remotePath = toTrimmedString(row.remote_path); + if (!sshConnectionId || !remotePath) continue; + + const fingerprint = sshFingerprintById.get(sshConnectionId); + if (!fingerprint) continue; + + result.push({ + id, + identityKey: sshProjectIdentityKey(fingerprint, remotePath), + kind: 'ssh', + name, + path: remotePath, + taskCount: taskCountByProject.get(id) ?? 0, + updatedAt, + sshConnectionId, + gitRemoteKeys, + }); + continue; + } + + const localPath = toTrimmedString(row.path); + if (!localPath) continue; + + result.push({ + id, + identityKey: localProjectIdentityKey(localPath), + kind: 'local', + name, + path: localPath, + taskCount: taskCountByProject.get(id) ?? 0, + updatedAt, + sshConnectionId: null, + gitRemoteKeys, + }); + } + + return result; +} + +export async function readAppProjectInfos(appDb: RelationalImportDb): Promise { + const taskCounts = await appDb.select({ projectId: tasks.projectId }).from(tasks).execute(); + const taskCountByProject = new Map(); + for (const task of taskCounts) { + taskCountByProject.set(task.projectId, (taskCountByProject.get(task.projectId) ?? 0) + 1); + } + + const remoteRows = await readAppProjectRemoteRows(appDb); + const gitRemoteKeysByProject = new Map(); + for (const row of remoteRows) { + const keys = gitRemoteKeysByProject.get(row.projectId) ?? []; + keys.push(...gitRemoteIdentityKeys(row.remoteUrl)); + gitRemoteKeysByProject.set(row.projectId, [...new Set(keys)]); + } + + const rows = (await appDb + .select({ + id: projects.id, + name: projects.name, + path: projects.path, + workspaceProvider: projects.workspaceProvider, + sshConnectionId: projects.sshConnectionId, + host: sshConnections.host, + port: sshConnections.port, + username: sshConnections.username, + updatedAt: projects.updatedAt, + }) + .from(projects) + .leftJoin(sshConnections, eq(projects.sshConnectionId, sshConnections.id)) + .execute()) as AppProjectRow[]; + + const result: SourceProjectInfo[] = []; + for (const row of rows) { + if (row.workspaceProvider === 'ssh' && row.sshConnectionId && row.host && row.username) { + const fingerprint = makeSshFingerprint(row.host, normalizePort(row.port), row.username); + result.push({ + id: row.id, + identityKey: sshProjectIdentityKey(fingerprint, row.path), + kind: 'ssh', + name: row.name, + path: row.path, + taskCount: taskCountByProject.get(row.id) ?? 0, + updatedAt: row.updatedAt, + sshConnectionId: row.sshConnectionId, + gitRemoteKeys: gitRemoteKeysByProject.get(row.id) ?? [], + }); + continue; + } + + result.push({ + id: row.id, + identityKey: localProjectIdentityKey(row.path), + kind: 'local', + name: row.name, + path: row.path, + taskCount: taskCountByProject.get(row.id) ?? 0, + updatedAt: row.updatedAt, + sshConnectionId: null, + gitRemoteKeys: gitRemoteKeysByProject.get(row.id) ?? [], + }); + } + + return result; +} + +export async function findLegacyProjectConflicts( + appDb: RelationalImportDb, + legacyDb: Database.Database +): Promise { + const legacyProjects = readLegacyProjectInfos(legacyDb); + const appProjects = await readAppProjectInfos(appDb); + return findProjectConflicts(legacyProjects, appProjects); +} + +function findProjectConflicts( + legacyProjects: SourceProjectInfo[], + appProjects: SourceProjectInfo[] +): LegacyProjectConflict[] { + const appByIdentity = new Map(appProjects.map((project) => [project.identityKey, project])); + const appByPath = new Map(appProjects.map((project) => [project.path, project])); + const appLocalByGitRemote = new Map(); + for (const project of appProjects) { + if (project.kind !== 'local') continue; + for (const key of project.gitRemoteKeys) { + if (!appLocalByGitRemote.has(key)) appLocalByGitRemote.set(key, project); + } + } + + const conflicts: LegacyProjectConflict[] = []; + for (const legacyProject of legacyProjects) { + const appProjectByIdentity = appByIdentity.get(legacyProject.identityKey); + const appProjectByPath = appByPath.get(legacyProject.path); + const appProjectByGitRemote = + legacyProject.kind === 'local' + ? legacyProject.gitRemoteKeys + .map((key) => ({ key, project: appLocalByGitRemote.get(key) })) + .find((match): match is { key: string; project: SourceProjectInfo } => + Boolean(match.project) + ) + : undefined; + const appProject = appProjectByIdentity ?? appProjectByPath ?? appProjectByGitRemote?.project; + if (!appProject) continue; + conflicts.push({ + identityKey: + appProjectByIdentity || appProjectByPath + ? legacyProject.identityKey + : (appProjectByGitRemote?.key ?? legacyProject.identityKey), + kind: legacyProject.kind, + v0: legacyProject, + v1Beta: appProject, + }); + } + + return conflicts; +} + +export async function buildLegacyProjectSelection(args: { + appDb: RelationalImportDb; + legacyDb: Database.Database; + selectedSources: ReadonlySet; + conflictChoices: Record; +}): Promise { + const legacyProjects = readLegacyProjectInfos(args.legacyDb); + const legacyByIdentity = new Map(legacyProjects.map((project) => [project.identityKey, project])); + const conflicts = await findLegacyProjectConflicts(args.appDb, args.legacyDb); + const skipLegacyProjectIds = new Set(); + const replaceAppProjectIds = new Set(); + const allowedLegacySshConnectionIds = new Set(); + + if (args.selectedSources.has('v1-beta')) { + for (const conflict of conflicts) { + const choice = args.conflictChoices[conflict.identityKey] ?? 'v1-beta'; + if (choice === 'v0') { + replaceAppProjectIds.add(conflict.v1Beta.id); + } else { + skipLegacyProjectIds.add(conflict.v0.id); + } + } + } + + for (const legacyProject of legacyByIdentity.values()) { + if (skipLegacyProjectIds.has(legacyProject.id)) continue; + if (legacyProject.sshConnectionId) { + allowedLegacySshConnectionIds.add(legacyProject.sshConnectionId); + } + } + + return { + skipLegacyProjectIds, + replaceAppProjectIds, + allowedLegacySshConnectionIds, + }; +} + +export async function createLegacyPortPreview(args: { + appDb: RelationalImportDb; + betaDb?: RelationalImportDb | null; + legacyDb: Database.Database | null; + hasLegacyDb: boolean; + hasBetaDb: boolean; +}): Promise { + const betaProjects = args.hasBetaDb && args.betaDb ? await readAppProjectInfos(args.betaDb) : []; + const betaTaskCount = betaProjects.reduce((total, project) => total + project.taskCount, 0); + + if (!args.legacyDb) { + return { + sources: { + v0: { available: args.hasLegacyDb, projects: 0, tasks: 0 }, + v1Beta: { available: args.hasBetaDb, projects: betaProjects.length, tasks: betaTaskCount }, + }, + conflicts: [], + projects: 0, + tasks: 0, + }; + } + + const legacyProjects = readLegacyProjectInfos(args.legacyDb); + const legacyTaskCount = countRows(args.legacyDb, 'tasks'); + const conflicts = + args.hasBetaDb && args.hasLegacyDb ? findProjectConflicts(legacyProjects, betaProjects) : []; + + return { + sources: { + v0: { available: args.hasLegacyDb, projects: legacyProjects.length, tasks: legacyTaskCount }, + v1Beta: { available: args.hasBetaDb, projects: betaProjects.length, tasks: betaTaskCount }, + }, + conflicts, + projects: legacyProjects.length, + tasks: legacyTaskCount, + }; +} diff --git a/src/main/db/legacy-port/sqlite-utils.ts b/src/main/db/legacy-port/sqlite-utils.ts new file mode 100644 index 0000000000..d68699fa5c --- /dev/null +++ b/src/main/db/legacy-port/sqlite-utils.ts @@ -0,0 +1,56 @@ +import type Database from 'better-sqlite3'; + +export function quoteIdentifier(identifier: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) { + throw new Error(`Invalid SQL identifier: ${identifier}`); + } + return `"${identifier}"`; +} + +export function quoteSqliteString(value: string): string { + return `'${value.split("'").join("''")}'`; +} + +export function tableExists( + sqlite: Database.Database, + tableName: string, + schemaName: 'main' | 'beta' = 'main' +): boolean { + const row = sqlite + .prepare(`SELECT 1 FROM ${schemaName}.sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) + .get(tableName); + return Boolean(row); +} + +export function columnsForTable( + sqlite: Database.Database, + schemaName: 'main' | 'beta', + tableName: string +): string[] { + const rows = sqlite + .prepare(`PRAGMA ${schemaName}.table_info(${quoteIdentifier(tableName)})`) + .all() as Array<{ name: string }>; + return rows.map((row) => row.name); +} + +export function withForeignKeysDisabled(sqlite: Database.Database, action: () => T): T { + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; + sqlite.pragma('foreign_keys = OFF'); + + try { + return action(); + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } +} + +export function withForeignKeysEnabled(sqlite: Database.Database, action: () => T): T { + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; + sqlite.pragma('foreign_keys = ON'); + + try { + return action(); + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } +} diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index fb57be8235..3fa4021339 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -110,6 +110,9 @@ export const tasks = sqliteTable( .notNull() .default(sql`CURRENT_TIMESTAMP`), isPinned: integer('is_pinned').notNull().default(0), // boolean, 0=false, 1=true + workspaceProvider: text('workspace_provider'), // 'local' | 'ssh' | null (null = inherit from project settings) + workspaceId: text('workspace_id'), + workspaceProviderData: text('workspace_provider_data'), // JSON, BYOI only }, (table) => ({ projectIdIdx: index('idx_tasks_project_id').on(table.projectId), @@ -251,6 +254,8 @@ export const conversations = sqliteTable( updatedAt: text('updated_at') .notNull() .default(sql`CURRENT_TIMESTAMP`), + lastInteractedAt: text('last_interacted_at'), + isInitialConversation: integer('is_initial_conversation', { mode: 'boolean' }), }, (table) => ({ taskIdIdx: index('idx_conversations_task_id').on(table.taskId), diff --git a/src/main/db/sshRepository.ts b/src/main/db/sshRepository.ts deleted file mode 100644 index aea6e27ce1..0000000000 --- a/src/main/db/sshRepository.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { db } from './client'; -import { - projects, - sshConnections, - type SshConnectionInsert, - type SshConnectionRow, -} from './schema'; - -export class SshRepository { - private static instance: SshRepository; - - static getInstance(): SshRepository { - if (!SshRepository.instance) { - SshRepository.instance = new SshRepository(); - } - return SshRepository.instance; - } - - async createConnection( - data: Omit - ): Promise { - const id = `ssh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - const result = await db - .insert(sshConnections) - .values({ - ...data, - id, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - .returning(); - - return result[0]; - } - - async getConnection(id: string): Promise { - const result = await db.select().from(sshConnections).where(eq(sshConnections.id, id)); - return result[0]; - } - - async getAllConnections(): Promise { - return db.select().from(sshConnections); - } - - async updateConnection( - id: string, - data: Partial - ): Promise { - const result = await db - .update(sshConnections) - .set({ - ...data, - updatedAt: new Date().toISOString(), - }) - .where(eq(sshConnections.id, id)) - .returning(); - return result[0]; - } - - async deleteConnection(id: string): Promise { - // First update any projects using this connection - await db - .update(projects) - .set({ sshConnectionId: null }) - .where(eq(projects.sshConnectionId, id)); - - // Then delete the connection - await db.delete(sshConnections).where(eq(sshConnections.id, id)); - } - - async getProjectsForConnection(connectionId: string): Promise { - const result = await db - .select({ id: projects.id }) - .from(projects) - .where(eq(projects.sshConnectionId, connectionId)); - return result.map((r) => r.id); - } -} diff --git a/src/main/index.ts b/src/main/index.ts index fdba933579..96de2a56f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { config as dotenvConfig } from 'dotenv'; import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import dockIcon from '@/assets/images/emdash/icon-dock.png?asset'; import { PRODUCT_NAME } from '@shared/app-identity'; @@ -12,17 +13,24 @@ import { agentHookService } from './core/agent-hooks/agent-hook-service'; import { appService } from './core/app/service'; import { localDependencyManager } from './core/dependencies/dependency-manager'; import { editorBufferService } from './core/editor/editor-buffer-service'; +import { gitWatcherRegistry } from './core/git/git-watcher-registry'; import { githubConnectionService } from './core/github/services/github-connection-service'; import { projectManager } from './core/projects/project-manager'; import { prSyncScheduler } from './core/pull-requests/pr-sync-scheduler'; +import { searchService } from './core/search/search-service'; import { appSettingsService } from './core/settings/settings-service'; import { updateService } from './core/updates/update-service'; +import { viewStateService } from './core/view-state/view-state-service'; import { initializeDatabase } from './db/initialize'; import { log } from './lib/logger'; -import * as telemetry from './lib/telemetry'; +import { telemetryService } from './lib/telemetry'; import { rpcRouter } from './rpc'; import { resolveUserEnv } from './utils/userEnv'; +if (import.meta.env.DEV) { + dotenvConfig({ path: '.env.local', override: false }); +} + if (process.platform === 'linux') { app.commandLine.appendSwitch('ozone-platform-hint', 'auto'); } @@ -63,15 +71,18 @@ app.on('activate', () => { } }); -app.whenReady().then(async () => { +void app.whenReady().then(async () => { await resolveUserEnv(); try { await initializeDatabase(); - const BUFFER_STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days - editorBufferService.pruneStale(BUFFER_STALE_MS).catch((e) => { - log.warn('Failed to prune stale editor buffers:', e); - }); + searchService.initialize(); + void editorBufferService.pruneStale(); + try { + viewStateService.pruneOrphans(); + } catch (e: unknown) { + log.warn('view-state: failed to prune orphaned entries', { error: e }); + } } catch (error) { log.error('Failed to initialize database:', error); dialog.showErrorBox( @@ -83,16 +94,24 @@ app.whenReady().then(async () => { } try { - await telemetry.init({ installSource: app.isPackaged ? 'dmg' : 'dev' }); + await telemetryService.initialize({ installSource: app.isPackaged ? 'dmg' : 'dev' }); } catch (e) { log.warn('telemetry init failed:', e); } + emdashAccountService.on('accountChanged', (username, userId, email) => { + void telemetryService.identify(username, userId, email); + }); + emdashAccountService.on('accountCleared', () => { + telemetryService.clearIdentity(); + }); + + gitWatcherRegistry.initialize(); prSyncScheduler.initialize(); appService.initialize(); - appSettingsService.initialize(); + await appSettingsService.initialize(); - agentHookService.start().catch((e) => { + agentHookService.initialize().catch((e) => { log.error('Failed to start agent event service:', e); }); @@ -121,13 +140,17 @@ app.whenReady().then(async () => { } }); -app.on('before-quit', () => { - telemetry.capture('app_closed'); - telemetry.shutdown(); - - agentHookService.stop(); - updateService.shutdown(); - projectManager.shutdown().catch((e) => { - log.error('Failed to shutdown project manager:', e); +app.on('before-quit', (event) => { + event.preventDefault(); + telemetryService.capture('app_closed'); + void telemetryService.dispose().finally(() => { + agentHookService.dispose(); + updateService.dispose(); + prSyncScheduler.dispose(); + void gitWatcherRegistry.dispose(); + void projectManager.dispose().catch((e) => { + log.error('Failed to shutdown project manager:', e); + }); + app.exit(0); }); }); diff --git a/src/main/lib/env.ts b/src/main/lib/env.ts new file mode 100644 index 0000000000..f20ed78951 --- /dev/null +++ b/src/main/lib/env.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { log } from './logger'; + +const buildSchema = z.object({ + VITE_POSTHOG_KEY: z.string().optional(), + VITE_POSTHOG_HOST: z.string().optional(), + VITE_BUILD: z.enum(['canary', 'prod']).default('prod'), +}); + +// Dev-only overrides: read from process.env (supports non-VITE_ prefixed vars, +// loaded from .env.local via electron-vite's envDir but never shipped in prod) +const devSchema = z.object({ + POSTHOG_PROJECT_API_KEY: z.string().optional(), + POSTHOG_HOST: z.string().optional(), +}); + +const runtimeSchema = z.object({ + TELEMETRY_ENABLED: z.string().optional(), + INSTALL_SOURCE: z.string().optional(), +}); + +function parseSection( + schema: z.ZodObject, + source: Record, + label: string +): z.infer> { + const result = schema.safeParse(source); + if (!result.success) { + log.error(`[env:${label}] Failed to parse environment variables`, { error: result.error }); + return {} as z.infer>; + } + return result.data; +} + +export const env = { + build: parseSection(buildSchema, import.meta.env as unknown as Record, 'build'), + dev: import.meta.env.DEV + ? parseSection(devSchema, process.env, 'dev') + : ({} as z.infer), + runtime: parseSection(runtimeSchema, process.env, 'runtime'), +}; diff --git a/src/main/lib/hookable.ts b/src/main/lib/hookable.ts new file mode 100644 index 0000000000..2a068a66a4 --- /dev/null +++ b/src/main/lib/hookable.ts @@ -0,0 +1,46 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type HookSchema = Record void | Promise>; + +export interface Hookable { + on(name: K, handler: T[K]): () => void; +} + +export class HookCore implements Hookable { + private readonly _hooks = new Map>(); + + constructor(private readonly onError: (name: keyof T, error: unknown) => void) {} + + /** + * @param name - The name of the hook to register the handler for. + * @param handler - The handler to register. + * @returns A function to unregister the handler. + */ + on(name: K, handler: T[K]) { + if (!this._hooks.has(name)) this._hooks.set(name, new Set()); + this._hooks.get(name)!.add(handler); + return () => this._hooks.get(name)?.delete(handler); + } + + async callHook(name: K, ...args: Parameters): Promise { + for (const handler of this._hooks.get(name) ?? []) { + await (handler as (...a: unknown[]) => unknown)(...args); + } + } + + callHookSync(name: K, ...args: Parameters): void { + for (const handler of this._hooks.get(name) ?? []) { + const result = (handler as (...a: unknown[]) => unknown)(...args); + if (result instanceof Promise) { + throw new TypeError(`Hook "${String(name)}" returned a Promise in a sync context`); + } + } + } + + callHookBackground(name: K, ...args: Parameters): void { + for (const handler of this._hooks.get(name) ?? []) { + Promise.resolve((handler as (...a: unknown[]) => unknown)(...args)).catch((e) => + this.onError(name, { error: String(e) }) + ); + } + } +} diff --git a/src/main/lib/lifecycle-map.ts b/src/main/lib/lifecycle-map.ts new file mode 100644 index 0000000000..1074b45488 --- /dev/null +++ b/src/main/lib/lifecycle-map.ts @@ -0,0 +1,130 @@ +import { err, ok, type Result } from '@shared/result'; + +export type LifecycleStatus = + | { status: 'ready' } + | { status: 'bootstrapping' } + | { status: 'error'; message: string } + | { status: 'not-started' }; + +export type LifecycleHooks = { + preProvision?: (id: string) => Promise | void; + postProvision?: (id: string, value: T) => Promise | void; + preTeardown?: (id: string, value: T) => Promise | void; + postTeardown?: (id: string, value: T) => Promise | void; +}; + +/** + * Manages the lifecycle state machine for a collection of async resources. + * + * Encapsulates four maps (active, in-flight provision, in-flight teardown, errors) + * and provides deduplicated provision/teardown with a consistent bootstrap status query. + * + * Callers own timeout, error conversion, and logging — only the state transitions + * and deduplication logic live here. + * + * Hooks are awaited in sequence. To fire-and-forget, return void from the hook body + * without returning the Promise. + */ +export class LifecycleMap { + private readonly _active = new Map(); + private readonly _provisioning = new Map>>(); + private readonly _tearingDown = new Map>>(); + private readonly _errors = new Map(); + + constructor(private readonly _hooks: LifecycleHooks = {}) {} + + get(id: string): T | undefined { + return this._active.get(id); + } + + has(id: string): boolean { + return this._active.has(id); + } + + keys(): IterableIterator { + return this._active.keys(); + } + + values(): IterableIterator { + return this._active.values(); + } + + /** Clears the active map without running any teardown callbacks. Use for bulk detach operations. */ + clearActive(): void { + this._active.clear(); + } + + bootstrapStatus(id: string, formatError: (e: E) => string): LifecycleStatus { + if (this._active.has(id)) return { status: 'ready' }; + if (this._provisioning.has(id)) return { status: 'bootstrapping' }; + const error = this._errors.get(id); + if (error) return { status: 'error', message: formatError(error) }; + return { status: 'not-started' }; + } + + /** + * Provisions a resource with deduplication. + * - If already active, returns the existing value immediately. + * - If already in-flight, returns the existing promise. + * - Otherwise runs: preProvision → run() → _active.set() → postProvision. + */ + provision(id: string, run: () => Promise>): Promise> { + const existing = this._active.get(id); + if (existing !== undefined) return Promise.resolve(ok(existing)); + + const inFlight = this._provisioning.get(id); + if (inFlight) return inFlight; + + const promise = (async () => { + try { + await this._hooks.preProvision?.(id); + const result = await run(); + if (result.success) { + this._active.set(id, result.data); + await this._hooks.postProvision?.(id, result.data); + } else { + this._errors.set(id, result.error); + } + return result; + } finally { + this._provisioning.delete(id); + } + })(); + + this._provisioning.set(id, promise); + return promise; + } + + /** + * Tears down a resource with deduplication. + * - If already tearing down, returns the existing promise. + * - If not found in the active map, returns `null` — caller decides what to do. + * - Otherwise runs: preTeardown → run() → _active.delete() → postTeardown. + * - postTeardown always fires (via finally), even if run() fails. + */ + teardown( + id: string, + run: (value: T) => Promise> + ): Promise> | null { + const inFlight = this._tearingDown.get(id) as Promise> | undefined; + if (inFlight) return inFlight; + + const value = this._active.get(id); + if (value === undefined) return null; + + const promise = (async () => { + try { + await this._hooks.preTeardown?.(id, value); + const result = await run(value); + return result.success ? ok() : err(result.error); + } finally { + this._active.delete(id); + this._tearingDown.delete(id); + await this._hooks.postTeardown?.(id, value); + } + })(); + + this._tearingDown.set(id, promise as Promise>); + return promise; + } +} diff --git a/src/main/lib/lifecycle.ts b/src/main/lib/lifecycle.ts new file mode 100644 index 0000000000..d5b57de7fd --- /dev/null +++ b/src/main/lib/lifecycle.ts @@ -0,0 +1,7 @@ +export interface IInitializable { + initialize(): void | Promise; +} + +export interface IDisposable { + dispose(): void | Promise; +} diff --git a/src/main/lib/logger.ts b/src/main/lib/logger.ts index ffad11dca6..6f2adb3647 100644 --- a/src/main/lib/logger.ts +++ b/src/main/lib/logger.ts @@ -4,3 +4,5 @@ export const log = createLogger({ envLevel: process.env.LOG_LEVEL, debugFlag: process.argv.includes('--debug-logs'), }); + +export type Logger = ReturnType; diff --git a/src/main/lib/telemetry.ts b/src/main/lib/telemetry.ts index e65c735ac5..ef2f87cca3 100644 --- a/src/main/lib/telemetry.ts +++ b/src/main/lib/telemetry.ts @@ -1,454 +1,482 @@ import { randomUUID } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { app } from 'electron'; import type { TelemetryEnvelope, TelemetryEvent, TelemetryProperties } from '@shared/telemetry'; import { KV } from '@main/db/kv'; - -// Production-only: appConfig.json is injected into dist/main/ by the release pipeline. -const appConfig: { posthogHost?: string; posthogKey?: string } = (() => { - if (!import.meta.env.PROD) return {}; - try { - const raw = readFileSync(join(__dirname, 'appConfig.json'), 'utf-8'); - return JSON.parse(raw) as { posthogHost?: string; posthogKey?: string }; - } catch { - return {}; - } -})(); +import { env as appEnv } from '@main/lib/env'; +import type { IDisposable, IInitializable } from '@main/lib/lifecycle'; interface InitOptions { installSource?: string; } -// --------------------------------------------------------------------------- -// Module-level state -// --------------------------------------------------------------------------- - -let enabled = true; -let apiKey: string | undefined; -let host: string | undefined; -let instanceId: string | undefined; -let installSource: string | undefined; -let userOptOut: boolean | undefined; -let onboardingSeen = false; -let sessionId: string | undefined; -let lastActiveDate: string | undefined; -let cachedGithubUsername: string | null = null; -let cachedAccountId: string | null = null; -let heartbeatInterval: ReturnType | undefined; - -const libName = 'emdash'; - type TelemetryKVSchema = { instanceId: string; enabled: string; - onboardingSeen: string; lastActiveDate: string; - githubUsername: string; - accountId: string; lastSessionId: string; lastHeartbeatTs: string; }; -const telemetryKV = new KV('telemetry'); +const LIB_NAME = 'emdash'; const isViteDevBuild = import.meta.env.DEV; -function getVersionSafe(): string { - try { - return app.getVersion(); - } catch { - return 'unknown'; +class TelemetryService implements IInitializable, IDisposable { + private enabled = true; + private apiKey: string | undefined; + private host: string | undefined; + private instanceId: string | undefined; + private installSource: string | undefined; + private userOptOut: boolean | undefined; + private sessionId: string | undefined; + private lastActiveDate: string | undefined; + private cachedGithubUsername: string | null = null; + private cachedAccountId: string | null = null; + private cachedEmail: string | null = null; + private cachedFeatureFlags: Record = {}; + private heartbeatInterval: ReturnType | undefined; + private readonly kv = new KV('telemetry'); + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private isEnabled(): boolean { + return ( + !isViteDevBuild && + this.enabled === true && + this.userOptOut !== true && + !!this.apiKey && + !!this.host && + typeof this.instanceId === 'string' && + this.instanceId.length > 0 + ); } -} -function isEnabled(): boolean { - return ( - !isViteDevBuild && - enabled === true && - userOptOut !== true && - !!apiKey && - !!host && - typeof instanceId === 'string' && - instanceId.length > 0 - ); -} + private getVersionSafe(): string { + try { + return app.getVersion(); + } catch { + return 'unknown'; + } + } -function getBaseProps() { - return { - schema_version: 1, - app_version: getVersionSafe(), - source: 'desktop_app', - electron_version: process.versions.electron, - platform: process.platform, - arch: process.arch, - is_dev: !app.isPackaged, - install_source: installSource ?? (app.isPackaged ? 'dmg' : 'dev'), - $lib: libName, - ...(cachedGithubUsername ? { github_username: cachedGithubUsername } : {}), - ...(cachedAccountId ? { account_id: cachedAccountId } : {}), - }; -} + private getBaseProps() { + return { + schema_version: 1, + app_version: this.getVersionSafe(), + build_variant: appEnv.build.VITE_BUILD, + source: 'desktop_app', + electron_version: process.versions.electron, + platform: process.platform, + arch: process.arch, + is_dev: !app.isPackaged, + install_source: this.installSource ?? (app.isPackaged ? 'dmg' : 'dev'), + $lib: LIB_NAME, + ...(this.cachedGithubUsername ? { github_username: this.cachedGithubUsername } : {}), + ...(this.cachedAccountId ? { account_id: this.cachedAccountId } : {}), + }; + } + + /** + * Sanitize event properties to prevent PII leakage. + * Simple allowlist approach: only allow safe property names and primitive types. + */ + private sanitizeEventAndProps( + _event: TelemetryEvent, + props: Record | undefined + ) { + const sanitized: Record = {}; + + const allowedProps = new Set([ + 'active_view', + 'active_main_panel', + 'active_right_panel', + 'focused_region', + 'view', + 'from_view', + 'to_view', + 'main_panel', + 'right_panel', + 'trigger', + 'event_ts_ms', + 'session_id', + 'project_id', + 'task_id', + 'conversation_id', + 'side', + 'region', + 'panel', + 'from_status', + 'to_status', + 'has_issue', + 'is_first_in_task', + 'is_draft', + 'exit_code', + 'setting', + 'severity', + 'component', + 'action', + 'user_action', + 'operation', + 'endpoint', + 'session_errors', + 'error_timestamp', + 'schema_version', + 'provider', + 'source', + 'has_initial_prompt', + 'state', + 'success', + 'error_type', + 'github_username', + 'account_id', + 'enabled', + 'app', + 'applied_migrations_bucket', + 'recovered', + 'date', + 'timezone', + 'scope', + 'strategy', + 'conflicts', + 'count', + 'terminal_id', + 'was_crash', + 'type', + ]); + const passthroughProps = new Set([ + '$exception_message', + '$exception_type', + '$exception_stack_trace_raw', + '$exception_fingerprint', + ]); -/** - * Sanitize event properties to prevent PII leakage. - * Simple allowlist approach: only allow safe property names and primitive types. - */ -function sanitizeEventAndProps(_event: TelemetryEvent, props: Record | undefined) { - const sanitized: Record = {}; - - const allowedProps = new Set([ - 'active_view', - 'active_main_panel', - 'active_right_panel', - 'focused_region', - 'view', - 'from_view', - 'to_view', - 'main_panel', - 'right_panel', - 'trigger', - 'event_ts_ms', - 'session_id', - 'project_id', - 'task_id', - 'conversation_id', - 'side', - 'region', - 'panel', - 'from_status', - 'to_status', - 'has_issue', - 'is_first_in_task', - 'is_draft', - 'exit_code', - 'setting', - 'severity', - 'component', - 'action', - 'user_action', - 'operation', - 'endpoint', - 'session_errors', - 'error_timestamp', - 'schema_version', - 'provider', - 'source', - 'has_initial_prompt', - 'state', - 'success', - 'error_type', - 'github_username', - 'account_id', - 'enabled', - 'app', - 'applied_migrations_bucket', - 'recovered', - 'date', - 'timezone', - 'scope', - 'strategy', - 'conflicts', - 'count', - 'terminal_id', - 'was_crash', - 'type', - ]); - const passthroughProps = new Set([ - '$exception_message', - '$exception_type', - '$exception_stack_trace_raw', - '$exception_fingerprint', - ]); - - if (props) { - for (const [key, value] of Object.entries(props)) { - if (!allowedProps.has(key) && !passthroughProps.has(key)) continue; - - if (typeof value === 'string') { - const maxLength = passthroughProps.has(key) ? 2_000 : 100; - sanitized[key] = value.trim().slice(0, maxLength); - } else if (typeof value === 'number') { - if (key === 'event_ts_ms') { - sanitized[key] = Math.max(0, Math.min(Math.trunc(value), 9_999_999_999_999)); - } else { - sanitized[key] = Math.max(-1_000_000, Math.min(value, 1_000_000)); + if (props) { + for (const [key, value] of Object.entries(props)) { + if (!allowedProps.has(key) && !passthroughProps.has(key)) continue; + + if (typeof value === 'string') { + const maxLength = passthroughProps.has(key) ? 2_000 : 100; + sanitized[key] = value.trim().slice(0, maxLength); + } else if (typeof value === 'number') { + if (key === 'event_ts_ms') { + sanitized[key] = Math.max(0, Math.min(Math.trunc(value), 9_999_999_999_999)); + } else { + sanitized[key] = Math.max(-1_000_000, Math.min(value, 1_000_000)); + } + } else if (typeof value === 'boolean') { + sanitized[key] = value; + } else if (value === null) { + sanitized[key] = null; } - } else if (typeof value === 'boolean') { - sanitized[key] = value; - } else if (value === null) { - sanitized[key] = null; } } + + return sanitized; } - return sanitized; -} + private normalizeHost(h: string | undefined): string | undefined { + if (!h) return undefined; + let s = String(h).trim(); + if (!/^https?:\/\//i.test(s)) { + s = 'https://' + s; + } + return s.replace(/\/+$/, ''); + } -function normalizeHost(h: string | undefined): string | undefined { - if (!h) return undefined; - let s = String(h).trim(); - if (!/^https?:\/\//i.test(s)) { - s = 'https://' + s; + // --------------------------------------------------------------------------- + // PostHog transport + // --------------------------------------------------------------------------- + + private async posthogCapture( + event: TelemetryEvent, + properties?: Record + ): Promise { + if (!this.isEnabled()) return; + try { + const u = (this.host ?? '').replace(/\/$/, '') + '/capture/'; + const body = { + api_key: this.apiKey, + event, + properties: { + distinct_id: this.instanceId, + ...this.getBaseProps(), + ...this.sanitizeEventAndProps(event, properties), + }, + }; + await fetch(u, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(5_000), + }).catch(() => undefined); + } catch { + // swallow errors; telemetry must never crash the app + } } - return s.replace(/\/+$/, ''); -} -// --------------------------------------------------------------------------- -// PostHog transport -// --------------------------------------------------------------------------- - -async function posthogCapture( - event: TelemetryEvent, - properties?: Record -): Promise { - if (!isEnabled()) return; - try { - const u = (host ?? '').replace(/\/$/, '') + '/capture/'; - const body = { - api_key: apiKey, - event, - properties: { - distinct_id: instanceId, - ...getBaseProps(), - ...sanitizeEventAndProps(event, properties), - }, - }; - await fetch(u, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }).catch(() => undefined); - } catch { - // swallow errors; telemetry must never crash the app + private async posthogIdentify(username: string, email?: string): Promise { + if (!this.isEnabled() || !username) return; + try { + const u = (this.host ?? '').replace(/\/$/, '') + '/capture/'; + const body = { + api_key: this.apiKey, + event: '$identify', + properties: { + distinct_id: this.instanceId, + $set: { + ...(email ? { email } : {}), + ...this.getBaseProps(), + }, + }, + }; + await fetch(u, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(5_000), + }).catch(() => undefined); + } catch { + // swallow errors; telemetry must never crash the app + } } -} -async function posthogIdentify(username: string, accountId?: string): Promise { - if (!isEnabled() || !username) return; - try { - const u = (host ?? '').replace(/\/$/, '') + '/capture/'; - const body = { - api_key: apiKey, - event: '$identify', - properties: { - distinct_id: instanceId, - $set: { - github_username: username, - ...(accountId ? { account_id: accountId } : {}), - ...getBaseProps(), + private async posthogDecide(): Promise { + if (!this.isEnabled() || !this.instanceId) return; + try { + const u = (this.host ?? '').replace(/\/$/, '') + '/decide/?v=3'; + const body = { + api_key: this.apiKey, + distinct_id: this.instanceId, + person_properties: { + ...(this.cachedGithubUsername ? { github_username: this.cachedGithubUsername } : {}), + ...(this.cachedAccountId ? { account_id: this.cachedAccountId } : {}), + ...(this.cachedEmail ? { email: this.cachedEmail } : {}), }, - }, - }; - await fetch(u, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }).catch(() => undefined); - } catch { - // swallow errors; telemetry must never crash the app + }; + const response = await fetch(u, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (response.ok) { + const data = (await response.json()) as { featureFlags?: Record }; + const flags = data.featureFlags ?? {}; + const parsed: Record = {}; + for (const [key, value] of Object.entries(flags)) { + if (typeof value === 'boolean') { + parsed[key] = value; + } else if (value === 'true' || value === 'false') { + parsed[key] = value === 'true'; + } + } + this.cachedFeatureFlags = parsed; + } + } catch { + // swallow errors; telemetry must never crash the app + } } -} -// --------------------------------------------------------------------------- -// Daily active user -// --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Daily active user + // --------------------------------------------------------------------------- -async function checkDailyActiveUser(): Promise { - if (!isEnabled()) return; - try { - const today = new Date().toISOString().split('T')[0]!; - if (lastActiveDate === today) return; + private async checkDailyActiveUser(): Promise { + if (!this.isEnabled()) return; + try { + const today = new Date().toISOString().split('T')[0]!; + if (this.lastActiveDate === today) return; - void posthogCapture('daily_active_user', { - date: today, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown', - }); + void this.posthogCapture('daily_active_user', { + date: today, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown', + }); - lastActiveDate = today; - telemetryKV.set('lastActiveDate', today); - } catch { - // Never let telemetry errors crash the app + this.lastActiveDate = today; + void this.kv.set('lastActiveDate', today); + } catch { + // Never let telemetry errors crash the app + } } -} -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export async function init(options?: InitOptions): Promise { - const env = process.env; - const enabledEnv = (env.TELEMETRY_ENABLED ?? 'true').toString().toLowerCase(); - enabled = !isViteDevBuild && enabledEnv !== 'false' && enabledEnv !== '0' && enabledEnv !== 'no'; - apiKey = - env.POSTHOG_PROJECT_API_KEY || (appConfig?.posthogKey as string | undefined) || undefined; - host = normalizeHost( - env.POSTHOG_HOST || (appConfig?.posthogHost as string | undefined) || undefined - ); - installSource = options?.installSource || env.INSTALL_SOURCE || undefined; - sessionId = randomUUID(); - - // Load persisted state from SQLite KV (all reads are non-blocking best-effort) - let storedInstanceId: string | null = null; - let storedEnabled: string | null = null; - let storedOnboarding: string | null = null; - let storedActiveDate: string | null = null; - let storedGithubUsername: string | null = null; - let storedAccountId: string | null = null; - let storedLastSessionId: string | null = null; - let storedLastHeartbeatTs: string | null = null; - try { - [ - storedInstanceId, - storedEnabled, - storedOnboarding, - storedActiveDate, - storedGithubUsername, - storedAccountId, - storedLastSessionId, - storedLastHeartbeatTs, - ] = await Promise.all([ - telemetryKV.get('instanceId'), - telemetryKV.get('enabled'), - telemetryKV.get('onboardingSeen'), - telemetryKV.get('lastActiveDate'), - telemetryKV.get('githubUsername'), - telemetryKV.get('accountId'), - telemetryKV.get('lastSessionId'), - telemetryKV.get('lastHeartbeatTs'), - ]); - } catch { - // KV unavailable during startup (e.g. DB migration not yet applied) — use in-memory defaults + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + async initialize(options?: InitOptions): Promise { + const enabledEnv = (appEnv.runtime.TELEMETRY_ENABLED ?? 'true').toLowerCase(); + this.enabled = + !isViteDevBuild && enabledEnv !== 'false' && enabledEnv !== '0' && enabledEnv !== 'no'; + // build value wins (prod); dev fallback used locally without VITE_ vars set + this.apiKey = appEnv.build.VITE_POSTHOG_KEY ?? appEnv.dev.POSTHOG_PROJECT_API_KEY; + this.host = this.normalizeHost(appEnv.build.VITE_POSTHOG_HOST ?? appEnv.dev.POSTHOG_HOST); + this.installSource = options?.installSource ?? appEnv.runtime.INSTALL_SOURCE; + this.sessionId = randomUUID(); + + // Load persisted state from SQLite KV (all reads are non-blocking best-effort) + let storedInstanceId: string | null = null; + let storedEnabled: string | null = null; + let storedActiveDate: string | null = null; + let storedLastSessionId: string | null = null; + let storedLastHeartbeatTs: string | null = null; + try { + [ + storedInstanceId, + storedEnabled, + storedActiveDate, + storedLastSessionId, + storedLastHeartbeatTs, + ] = await Promise.all([ + this.kv.get('instanceId'), + this.kv.get('enabled'), + this.kv.get('lastActiveDate'), + this.kv.get('lastSessionId'), + this.kv.get('lastHeartbeatTs'), + ]); + } catch { + // KV unavailable during startup (e.g. DB migration not yet applied) — use in-memory defaults + } + + this.instanceId = storedInstanceId ?? (randomUUID().toString() as string); + if (!storedInstanceId) { + void this.kv.set('instanceId', this.instanceId); + } + + this.userOptOut = storedEnabled === 'false' ? true : undefined; + this.lastActiveDate = storedActiveDate ?? undefined; + + // Detect unclean exit from the previous session: if we have a recorded session ID + // that was never cleared by a clean shutdown, emit a synthetic app_closed so that + // session duration queries remain accurate. + if (storedLastSessionId && storedLastHeartbeatTs) { + const lastHeartbeatMs = Date.parse(storedLastHeartbeatTs); + if (!Number.isNaN(lastHeartbeatMs)) { + void this.posthogCapture('app_closed', { + was_crash: true, + event_ts_ms: lastHeartbeatMs, + session_id: storedLastSessionId, + }); + } + } + // Record the current session ID so the next startup can detect a crash. + // sessionId is guaranteed non-undefined at this point (set to randomUUID() above). + void this.kv.set('lastSessionId', this.sessionId!); + + void this.posthogCapture('app_started'); + void this.checkDailyActiveUser(); + + // Heartbeat: write lastHeartbeatTs to KV every 60 s so crash recovery can + // estimate session duration without firing any PostHog events. + this.heartbeatInterval = setInterval(() => { + void this.kv.set('lastHeartbeatTs', new Date().toISOString()); + }, 60_000); } - instanceId = storedInstanceId ?? (randomUUID().toString() as string); - if (!storedInstanceId) { - telemetryKV.set('instanceId', instanceId); + async dispose(): Promise { + if (this.heartbeatInterval !== undefined) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + } + // Await both deletes so the process cannot exit before they complete. + // If these are fire-and-forget, the next startup will see lastSessionId still + // in KV and incorrectly emit a synthetic app_closed with was_crash: true. + await Promise.all([this.kv.del('lastSessionId'), this.kv.del('lastHeartbeatTs')]); } - userOptOut = storedEnabled === 'false' ? true : undefined; - onboardingSeen = storedOnboarding === 'true'; - lastActiveDate = storedActiveDate ?? undefined; - cachedGithubUsername = storedGithubUsername ?? null; - cachedAccountId = storedAccountId ?? null; - if (cachedGithubUsername) { - void posthogIdentify(cachedGithubUsername, cachedAccountId ?? undefined); + /** + * Associate the current anonymous session with a known identity. Called via + * the accountChanged hook when sign-in succeeds or on cold boot if a session + * is already stored. Triggers a PostHog identify and a decide call to refresh + * cached feature flags. + */ + async identify(username: string, userId: string, email: string): Promise { + if (!username) return; + this.cachedGithubUsername = username; + this.cachedAccountId = userId; + this.cachedEmail = email; + await this.posthogIdentify(username, email); + await this.posthogDecide(); } - // Detect unclean exit from the previous session: if we have a recorded session ID - // that was never cleared by a clean shutdown, emit a synthetic app_closed so that - // session duration queries remain accurate. - if (storedLastSessionId && storedLastHeartbeatTs) { - const lastHeartbeatMs = Date.parse(storedLastHeartbeatTs); - if (!Number.isNaN(lastHeartbeatMs)) { - void posthogCapture('app_closed', { - was_crash: true, - event_ts_ms: lastHeartbeatMs, - session_id: storedLastSessionId, - }); - } + /** + * Clear the cached identity and feature flags. Called via the accountCleared + * hook when the user signs out. + */ + clearIdentity(): void { + this.cachedGithubUsername = null; + this.cachedAccountId = null; + this.cachedEmail = null; + this.cachedFeatureFlags = {}; } - // Record the current session ID so the next startup can detect a crash. - // sessionId is guaranteed non-undefined at this point (set to randomUUID() above). - void telemetryKV.set('lastSessionId', sessionId!); - - void posthogCapture('app_started'); - void checkDailyActiveUser(); - - // Heartbeat: write lastHeartbeatTs to KV every 60 s so crash recovery can - // estimate session duration without firing any PostHog events. - heartbeatInterval = setInterval(() => { - telemetryKV.set('lastHeartbeatTs', new Date().toISOString()); - }, 60_000); -} -/** - * Associate the current anonymous session with a known identity. Call this - * whenever authentication succeeds — pass the GitHub username and, optionally, - * the emdash account ID so both are linked in PostHog. - */ -export function identify(username: string, accountId?: string): void { - if (!username) return; - cachedGithubUsername = username; - void telemetryKV.set('githubUsername', username); - if (accountId) { - cachedAccountId = accountId; - void telemetryKV.set('accountId', accountId); + capture( + event: E, + properties?: TelemetryProperties | Record + ): void { + const captureSessionId = this.sessionId ?? randomUUID(); + this.sessionId = captureSessionId; + const envelope: TelemetryEnvelope = { + event_ts_ms: Date.now(), + session_id: captureSessionId, + }; + void this.posthogCapture(event, { + ...(properties as Record | undefined), + ...envelope, + }); } - void posthogIdentify(username, accountId ?? cachedAccountId ?? undefined); -} -export function capture( - event: E, - properties?: TelemetryProperties | Record -): void { - const captureSessionId = sessionId ?? randomUUID(); - sessionId = captureSessionId; - const envelope: TelemetryEnvelope = { - event_ts_ms: Date.now(), - session_id: captureSessionId, - }; - void posthogCapture(event, { - ...(properties as Record | undefined), - ...envelope, - }); -} + /** + * Capture an exception for PostHog error tracking. + */ + captureException(error: Error | unknown, additionalProperties?: Record): void { + if (!this.isEnabled()) return; -/** - * Capture an exception for PostHog error tracking. - */ -export function captureException( - error: Error | unknown, - additionalProperties?: Record -): void { - if (!isEnabled()) return; - - const errorObj = error instanceof Error ? error : new Error(String(error)); - - void posthogCapture('$exception', { - $exception_message: errorObj.message || 'Unknown error', - $exception_type: errorObj.name || 'Error', - $exception_stack_trace_raw: errorObj.stack || '', - ...additionalProperties, - }); -} + const errorObj = error instanceof Error ? error : new Error(String(error)); -export function shutdown(): void { - // Stop the heartbeat interval. - if (heartbeatInterval !== undefined) { - clearInterval(heartbeatInterval); - heartbeatInterval = undefined; + void this.posthogCapture('$exception', { + $exception_message: errorObj.message || 'Unknown error', + $exception_type: errorObj.name || 'Error', + $exception_stack_trace_raw: errorObj.stack || '', + ...additionalProperties, + }); } - // Clear the stored session ID so the next startup knows this was a clean exit - // and won't emit a synthetic crash app_closed event. - void telemetryKV.del('lastSessionId'); - void telemetryKV.del('lastHeartbeatTs'); -} -export function isTelemetryEnabled(): boolean { - return isEnabled(); -} + getTelemetryStatus() { + return { + enabled: this.isEnabled(), + envDisabled: isViteDevBuild || !this.enabled, + userOptOut: this.userOptOut === true, + hasKeyAndHost: !!this.apiKey && !!this.host, + session_id: this.sessionId ?? null, + instance_id: this.instanceId ?? null, + }; + } -export function getTelemetryStatus() { - return { - enabled: isEnabled(), - envDisabled: isViteDevBuild || !enabled, - userOptOut: userOptOut === true, - hasKeyAndHost: !!apiKey && !!host, - onboardingSeen, - session_id: sessionId ?? null, - }; -} + setTelemetryEnabledViaUser(enabledFlag: boolean): void { + this.userOptOut = !enabledFlag; + void this.kv.set('enabled', String(enabledFlag)); + } -export function setTelemetryEnabledViaUser(enabledFlag: boolean): void { - userOptOut = !enabledFlag; - telemetryKV.set('enabled', String(enabledFlag)); -} + async checkAndReportDailyActiveUser(): Promise { + return this.checkDailyActiveUser(); + } -export function setOnboardingSeen(flag: boolean): void { - onboardingSeen = Boolean(flag); - telemetryKV.set('onboardingSeen', String(onboardingSeen)); + /** + * Returns the current set of evaluated feature flags. In dev mode, FLAG_* + * environment variables (e.g. FLAG_my_flag=true) override any PostHog values. + */ + getFeatureFlags(): Record { + if (!isViteDevBuild) return this.cachedFeatureFlags; + + const overrides: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('FLAG_')) { + const flagName = key.slice(5).toLowerCase().replace(/_/g, '-'); + overrides[flagName] = value === 'true' || value === '1'; + } + } + return { ...this.cachedFeatureFlags, ...overrides }; + } } -export async function checkAndReportDailyActiveUser(): Promise { - return checkDailyActiveUser(); -} +export const telemetryService = new TelemetryService(); diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 6202860475..2e1152c138 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -18,6 +18,7 @@ import { projectController } from './core/projects/controller'; import { ptyController } from './core/pty/controller'; import { pullRequestController } from './core/pull-requests/controller'; import { repositoryController } from './core/repository/controller'; +import { searchController } from './core/search/controller'; import { appSettingsController } from './core/settings/controller'; import { providerSettingsController } from './core/settings/provider-settings-controller'; import { skillsController } from './core/skills/controller'; @@ -59,6 +60,7 @@ export const rpcRouter = createRPCRouter({ telemetry: telemetryController, pullRequests: pullRequestController, viewState: viewStateController, + search: searchController, }); export type RpcRouter = typeof rpcRouter; diff --git a/src/main/utils/externalLinks.ts b/src/main/utils/externalLinks.ts index 07da2d5343..58538b1edd 100644 --- a/src/main/utils/externalLinks.ts +++ b/src/main/utils/externalLinks.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, shell } from 'electron'; +import { shell, type BrowserWindow } from 'electron'; /** * Ensure any external HTTP(S) links open in the user's default browser @@ -16,7 +16,7 @@ export function registerExternalLinkHandlers(win: BrowserWindow, isDev: boolean) // Handle window.open and target="_blank" wc.setWindowOpenHandler(({ url }) => { if (!isInternalAppUrl(url) && /^https?:\/\//i.test(url)) { - shell.openExternal(url); + void shell.openExternal(url); return { action: 'deny' }; } return { action: 'allow' }; @@ -26,7 +26,7 @@ export function registerExternalLinkHandlers(win: BrowserWindow, isDev: boolean) wc.on('will-navigate', (event, url) => { if (!isInternalAppUrl(url) && /^https?:\/\//i.test(url)) { event.preventDefault(); - shell.openExternal(url); + void shell.openExternal(url); } }); } diff --git a/src/main/utils/remoteOpenIn.test.ts b/src/main/utils/remoteOpenIn.test.ts new file mode 100644 index 0000000000..27eee4ab15 --- /dev/null +++ b/src/main/utils/remoteOpenIn.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + buildRemoteEditorUrl, + buildRemoteSshAuthority, + buildRemoteTerminalExecArgs, +} from './remoteOpenIn'; + +describe('remoteOpenIn', () => { + describe('buildRemoteEditorUrl', () => { + it('builds VSCodium remote SSH URLs', () => { + expect(buildRemoteEditorUrl('vscodium', 'example.com', 'alice', '/repo')).toBe( + 'vscodium://vscode-remote/ssh-remote+alice%40example.com/repo' + ); + }); + }); + + describe('buildRemoteTerminalExecArgs', () => { + it('builds argv tokens for terminal app SSH launchers', () => { + const args = buildRemoteTerminalExecArgs({ + host: 'example.com', + username: 'arne', + port: 2222, + targetPath: "/tmp/with 'quote'", + }); + + expect(args).toEqual([ + 'ssh', + 'arne@example.com', + '-o', + 'ControlMaster=no', + '-o', + 'ControlPath=none', + '-p', + '2222', + '-t', + "cd '/tmp/with '\\''quote'\\''' && (if command -v infocmp >/dev/null 2>&1 && [ -n \"${TERM:-}\" ] && infocmp \"${TERM}\" >/dev/null 2>&1; then :; else export TERM=xterm-256color; fi) && (exec \"${SHELL:-/bin/bash}\" || exec /bin/bash || exec /bin/sh)", + ]); + }); + }); + + describe('buildRemoteSshAuthority', () => { + it('does not prepend the username when the host already includes one', () => { + expect(buildRemoteSshAuthority('git@example.com', 'arne')).toBe('git@example.com'); + }); + }); +}); diff --git a/src/main/utils/remoteOpenIn.ts b/src/main/utils/remoteOpenIn.ts index 8fa3ddeedd..37932695ee 100644 --- a/src/main/utils/remoteOpenIn.ts +++ b/src/main/utils/remoteOpenIn.ts @@ -1,6 +1,6 @@ import { quoteShellArg } from './shellEscape'; -type RemoteEditorScheme = 'vscode' | 'cursor'; +type RemoteEditorScheme = 'vscode' | 'vscodium' | 'cursor'; export function buildRemoteSshAuthority(host: string, username: string): string { const normalizedHost = host.trim(); @@ -27,7 +27,7 @@ export function buildRemoteEditorUrl( return `${scheme}://vscode-remote/ssh-remote+${encodedAuthority}${normalizedTargetPath}`; } -type GhosttyRemoteExecInput = { +type RemoteTerminalExecInput = { host: string; username: string; port: number | string; @@ -52,20 +52,20 @@ export function buildRemoteTerminalShellCommand(targetPath: string): string { * * Command text is shell-escaped because these launchers execute through a shell. */ -export function buildRemoteSshCommand(input: GhosttyRemoteExecInput): string { +export function buildRemoteSshCommand(input: RemoteTerminalExecInput): string { const sshAuthority = buildRemoteSshAuthority(input.host, input.username); const remoteCommand = buildRemoteTerminalShellCommand(input.targetPath); return `ssh ${quoteShellArg(sshAuthority)} -o ${quoteShellArg('ControlMaster=no')} -o ${quoteShellArg('ControlPath=none')} -p ${quoteShellArg(String(input.port))} -t ${quoteShellArg(remoteCommand)}`; } /** - * Builds argv tokens for Ghostty `-e` remote SSH execution. + * Builds argv tokens for terminal remote SSH execution. * - * We pass these tokens directly via child_process execFile/spawn (shell disabled), - * so host/port are not shell-quoted here. The remote command itself is still - * shell-escaped because it is parsed by the remote shell over SSH. + * We pass these tokens directly via child_process execFile/spawn (shell disabled), so host/port + * are not shell-quoted here. The remote command itself is still shell-escaped because it is + * parsed by the remote shell over SSH. */ -export function buildGhosttyRemoteExecArgs(input: GhosttyRemoteExecInput): string[] { +export function buildRemoteTerminalExecArgs(input: RemoteTerminalExecInput): string[] { const sshAuthority = buildRemoteSshAuthority(input.host, input.username); const remoteCommand = buildRemoteTerminalShellCommand(input.targetPath); return [ diff --git a/src/main/utils/userEnv.test.ts b/src/main/utils/userEnv.test.ts index 4bdb3004e3..e7e81ab69b 100644 --- a/src/main/utils/userEnv.test.ts +++ b/src/main/utils/userEnv.test.ts @@ -1,8 +1,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { ensureUserBinDirsInPath } from './userEnv'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const execSyncMock = vi.fn(); + +vi.mock('node:child_process', () => ({ + execSync: execSyncMock, +})); + +const { ensureUserBinDirsInPath, ensureWindowsNpmGlobalBinInPath, resolveUserEnv } = await import( + './userEnv' +); const originalPath = process.env.PATH; @@ -31,3 +40,77 @@ describe('ensureUserBinDirsInPath', () => { expect(process.env.PATH).toBe([dir, '/usr/bin'].join(path.delimiter)); }); }); + +describe('ensureWindowsNpmGlobalBinInPath', () => { + it('uses APPDATA case-insensitively when prepending npm global bin', () => { + const env: NodeJS.ProcessEnv = { + appdata: 'C:\\Users\\test\\AppData\\Roaming', + Path: 'C:\\Windows\\System32', + }; + + const added = ensureWindowsNpmGlobalBinInPath(env); + + expect(added).toBe('C:\\Users\\test\\AppData\\Roaming\\npm'); + expect(env.Path).toBe('C:\\Users\\test\\AppData\\Roaming\\npm;C:\\Windows\\System32'); + }); +}); + +describe('resolveUserEnv (AppImage env scrub)', () => { + const SCRUBBED_KEYS = [ + 'APPIMAGE', + 'APPDIR', + 'ARGV0', + 'OWD', + 'CHROME_DESKTOP', + 'GSETTINGS_SCHEMA_DIR', + ] as const; + const PATH_LIKE_KEYS = ['PATH', 'LD_LIBRARY_PATH', 'XDG_DATA_DIRS'] as const; + const savedEnv: Partial< + Record<(typeof SCRUBBED_KEYS)[number] | (typeof PATH_LIKE_KEYS)[number], string | undefined> + > = {}; + + beforeEach(() => { + execSyncMock.mockReset(); + execSyncMock.mockReturnValue(''); + for (const key of [...SCRUBBED_KEYS, ...PATH_LIKE_KEYS]) { + savedEnv[key] = process.env[key]; + } + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('strips AppImage runtime vars and /tmp/.mount_* path entries from the probe shell env and final PATH', async () => { + execSyncMock.mockReturnValue('PATH=/usr/local/bin:/usr/bin\n'); + process.env.APPIMAGE = '/home/user/emdash.AppImage'; + process.env.APPDIR = '/tmp/.mount_emdashTest'; + process.env.ARGV0 = '/home/user/emdash.AppImage'; + process.env.OWD = '/home/user'; + process.env.CHROME_DESKTOP = 'emdash.desktop'; + process.env.GSETTINGS_SCHEMA_DIR = '/tmp/.mount_emdashTest/usr/share/glib-2.0/schemas'; + process.env.PATH = '/tmp/.mount_emdashTest/usr/bin:/usr/local/bin:/usr/bin'; + process.env.LD_LIBRARY_PATH = '/tmp/.mount_emdashTest/usr/lib:/usr/lib'; + process.env.XDG_DATA_DIRS = '/tmp/.mount_emdashTest/usr/share:/usr/local/share:/usr/share'; + + await resolveUserEnv(); + + expect(execSyncMock).toHaveBeenCalledTimes(1); + const opts = execSyncMock.mock.calls[0]?.[1] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toBeDefined(); + const probeEnv = opts!.env!; + for (const key of SCRUBBED_KEYS) { + expect(probeEnv[key]).toBeUndefined(); + } + for (const key of PATH_LIKE_KEYS) { + expect(probeEnv[key] ?? '').not.toContain('/tmp/.mount_'); + } + expect(process.env.PATH ?? '').not.toContain('/tmp/.mount_'); + // Helper hint vars must still be set so oh-my-zsh / tmux plugins stay quiet. + expect(probeEnv.DISABLE_AUTO_UPDATE).toBe('true'); + expect(probeEnv.ZSH_TMUX_AUTOSTART).toBe('false'); + }); +}); diff --git a/src/main/utils/userEnv.ts b/src/main/utils/userEnv.ts index 3b7230bab3..6328163eb5 100644 --- a/src/main/utils/userEnv.ts +++ b/src/main/utils/userEnv.ts @@ -3,6 +3,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { log } from '@main/lib/logger'; +import { buildExternalToolEnv } from './childProcessEnv'; +import { getWindowsEnvValue, prependWindowsPathEntry } from './windows-env'; /** * Keys that must never be overwritten from the shell env capture. @@ -29,6 +31,12 @@ const PRESERVE_KEYS = new Set([ 'NODE_ENV', ]); +export const SHELL_ENV_CAPTURE_GUARD: Record = { + DISABLE_AUTO_UPDATE: 'true', + ZSH_TMUX_AUTOSTART: 'false', + ZSH_TMUX_AUTOSTARTED: 'true', +}; + const USER_BIN_DIRS = [path.join(os.homedir(), '.local', 'bin')]; function pathEntryExists(entry: string): boolean { @@ -80,6 +88,16 @@ export function ensureUserBinDirsInPath(candidates: string[] = USER_BIN_DIRS): s return additions; } +export function ensureWindowsNpmGlobalBinInPath( + env: NodeJS.ProcessEnv = process.env +): string | null { + const appData = getWindowsEnvValue(env, 'APPDATA'); + if (!appData) return null; + + const npmPath = path.win32.join(appData, 'npm'); + return prependWindowsPathEntry(env, npmPath) ? npmPath : null; +} + /** * Spawns `$SHELL -ilc 'env'` with a 5 s timeout. On any error (timeout, * missing shell, restricted environment) the function logs a warning and @@ -92,22 +110,25 @@ export function ensureUserBinDirsInPath(candidates: string[] = USER_BIN_DIRS): s export async function resolveUserEnv(): Promise { if (process.platform === 'win32') { // Windows PATH is managed differently; no login-shell capture needed. + ensureWindowsNpmGlobalBinInPath(); return; } const shell = process.env.SHELL ?? (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); + const baseEnv = buildExternalToolEnv(); try { const raw = execSync(`${shell} -ilc 'env'`, { encoding: 'utf8', timeout: 5_000, + // Route through buildExternalToolEnv so AppImage runtime vars (APPIMAGE, + // APPDIR, ARGV0, ...) and `/tmp/.mount_*` PATH entries don't leak into + // the probe shell. Otherwise login-shell hooks that resolve a binary by + // name through PATH (mise/starship/oh-my-zsh) can re-enter the AppImage + // and fork-bomb the app on Linux. See #1679. env: { - ...process.env, - // Prevent oh-my-zsh and tmux plugins from producing extra output or - // blocking the env capture. - DISABLE_AUTO_UPDATE: 'true', - ZSH_TMUX_AUTOSTART: 'false', - ZSH_TMUX_AUTOSTARTED: 'true', + ...baseEnv, + ...SHELL_ENV_CAPTURE_GUARD, }, }); @@ -117,7 +138,7 @@ export async function resolveUserEnv(): Promise { if (PRESERVE_KEYS.has(key)) continue; if (key === 'PATH') { - const current = process.env.PATH ?? ''; + const current = baseEnv.PATH ?? ''; process.env.PATH = mergePath(value, current); } else { process.env[key] = value; diff --git a/src/main/utils/windows-env.ts b/src/main/utils/windows-env.ts new file mode 100644 index 0000000000..76e2165a03 --- /dev/null +++ b/src/main/utils/windows-env.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; + +export function getWindowsEnvKey(env: NodeJS.ProcessEnv, key: string): string | undefined { + if (env[key] !== undefined) return key; + + const lowerKey = key.toLowerCase(); + return Object.keys(env).find((candidate) => candidate.toLowerCase() === lowerKey); +} + +export function getWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const envKey = getWindowsEnvKey(env, key); + return envKey ? env[envKey] : undefined; +} + +export function getWindowsPathEnvKey(env: NodeJS.ProcessEnv): string { + return getWindowsEnvKey(env, 'PATH') ?? 'PATH'; +} + +export function prependWindowsPathEntry(env: NodeJS.ProcessEnv, entry: string): boolean { + const pathKey = getWindowsPathEnvKey(env); + const entries = (env[pathKey] ?? '').split(path.win32.delimiter).filter(Boolean); + const existing = new Set(entries.map((item) => item.toLowerCase())); + + if (existing.has(entry.toLowerCase())) { + return false; + } + + env[pathKey] = [entry, ...entries].join(path.win32.delimiter); + return true; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d8a70a7acc..04ea57cef6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; @@ -8,7 +9,7 @@ import { useAccountSession } from './lib/hooks/useAccount'; import { useLegacyPortStatus } from './lib/hooks/useLegacyPort'; import { WorkspaceLayoutContextProvider } from './lib/layout/layout-provider'; import { WorkspaceViewProvider } from './lib/layout/provider'; -import { ModalProvider } from './lib/modal/modal-provider'; +import { FeatureFlagProvider } from './lib/providers/feature-flag-override-context'; import { GithubContextProvider } from './lib/providers/github-context-provider'; import { ThemeProvider } from './lib/providers/theme-provider'; import { TerminalPoolProvider } from './lib/pty/pty-pool-provider'; @@ -40,13 +41,9 @@ function AppContent() { if (!isLoading && view === 'onboarding' && frozenSteps === null) { const computed: OnboardingStep[] = []; if (!session?.isSignedIn) computed.push('sign-in'); - const needsImport = - legacyStatus?.hasLegacyDb && - legacyStatus.portStatus !== 'completed' && - legacyStatus.portStatus !== 'no-legacy-file' && - !legacyStatus.hasExistingData; + const needsImport = legacyStatus?.hasImportSources && !legacyStatus.portStatus; if (needsImport) computed.push('import'); - setFrozenSteps(computed); + setFrozenSteps(computed); // eslint-disable-line react-hooks/set-state-in-effect } }, [view, isLoading, frozenSteps, session, legacyStatus]); @@ -57,6 +54,12 @@ function AppContent() { setView('welcome'); }; + const handleOpenSettingsFromMenu = useCallback(() => { + if (view === 'onboarding' && stepsNeeded.length > 0) return false; + setView('workspace'); + return true; + }, [view, stepsNeeded.length]); + const renderContent = () => { if (isLoading || (view === 'onboarding' && frozenSteps === null)) { return null; @@ -74,21 +77,20 @@ function AppContent() { return ( - - - - - - - - {renderContent()} - - - - - - - + + + + + + + + {renderContent()} + + + + + + ); } @@ -96,7 +98,9 @@ function AppContent() { export function App() { return ( - + + + ); } diff --git a/src/renderer/app/app-menu-events.tsx b/src/renderer/app/app-menu-events.tsx new file mode 100644 index 0000000000..676e83fa29 --- /dev/null +++ b/src/renderer/app/app-menu-events.tsx @@ -0,0 +1,61 @@ +import { when } from 'mobx'; +import { useEffect } from 'react'; +import { menuOpenSettingsChannel, notificationFocusTaskChannel } from '@shared/events/appEvents'; +import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; +import { events } from '@renderer/lib/ipc'; +import { useNavigate, useWorkspaceSlots } from '@renderer/lib/layout/navigation-provider'; + +export function AppMenuEvents({ onOpenSettings }: { onOpenSettings?: () => boolean | void }) { + const { navigate } = useNavigate(); + const { currentView } = useWorkspaceSlots(); + + useEffect(() => { + return events.on(menuOpenSettingsChannel, () => { + const shouldOpen = onOpenSettings?.() ?? true; + if (shouldOpen === false) return; + if (currentView === 'settings') return; + + navigate('settings'); + }); + }, [navigate, onOpenSettings, currentView]); + + useEffect(() => { + const disposers = new Set<() => void>(); + + const unlisten = events.on( + notificationFocusTaskChannel, + ({ projectId, taskId, conversationId }) => { + navigate('task', { projectId, taskId }); + if (!conversationId) return; + + // Task view may not be provisioned yet — wait for the conversation tab to exist. + const dispose = when( + () => { + const view = getTaskView(projectId, taskId); + return ( + !!view && + view.tabManager.tabs.some( + (tab) => tab.kind === 'conversation' && tab.id === conversationId + ) + ); + }, + () => { + getTaskView(projectId, taskId)?.tabManager.setActiveTab(conversationId); + }, + { + timeout: 10_000, + } + ); + disposers.add(dispose); + } + ); + + return () => { + unlisten(); + disposers.forEach((dispose) => dispose()); + disposers.clear(); + }; + }, [navigate]); + + return null; +} diff --git a/src/renderer/app/modal-registry.ts b/src/renderer/app/modal-registry.ts index c4d40e9730..e531390250 100644 --- a/src/renderer/app/modal-registry.ts +++ b/src/renderer/app/modal-registry.ts @@ -1,3 +1,4 @@ +import { CommandPaletteModal } from '@renderer/features/command-palette/command-palette-modal'; import { IntegrationSetupModal } from '@renderer/features/integrations/integration-setup-modal'; import { McpModal } from '@renderer/features/mcp/components/McpModal'; import { AddProjectModal } from '@renderer/features/projects/components/add-project-modal/add-project-modal'; @@ -13,24 +14,26 @@ import { ChangeProjectConnectionModal } from '@renderer/lib/components/change-pr import { ConfirmActionDialog } from '@renderer/lib/components/confirm-action-dialog'; import { FeedbackModal } from '@renderer/lib/components/feedback-modal/feedback-modal'; import { GithubDeviceFlowModalOverlay } from '@renderer/lib/components/github-device-flow-modal'; -import { ModalComponent } from '@renderer/lib/modal/modal-provider'; +import { type ModalComponent } from '@renderer/lib/modal/modal-provider'; export type ModalSize = 'xs' | 'sm' | 'md' | 'lg'; +export type ModalPosition = 'center' | 'top'; -export type ModalRegistryEntry = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: ModalComponent; +export type ModalRegistryEntry = { + component: ModalComponent; size?: ModalSize; + position?: ModalPosition; }; export function createModal( component: ModalComponent, config: Omit = {} -): ModalRegistryEntry { +): ModalRegistryEntry { return { component, ...config }; } export const modalRegistry = { + commandPaletteModal: createModal(CommandPaletteModal, { size: 'md' }), taskModal: createModal(CreateTaskModal), addProjectModal: createModal(AddProjectModal), addSshConnModal: createModal(AddSshConnModal), @@ -46,4 +49,5 @@ export const modalRegistry = { renameTaskModal: createModal(RenameTaskModal, { size: 'xs' }), integrationSetupModal: createModal(IntegrationSetupModal, { size: 'md' }), addRemoteModal: createModal(AddRemoteModal), -} satisfies Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} satisfies Record>; diff --git a/src/renderer/app/view-registry.ts b/src/renderer/app/view-registry.ts index 1fe7b8c1e3..79e299ece1 100644 --- a/src/renderer/app/view-registry.ts +++ b/src/renderer/app/view-registry.ts @@ -5,6 +5,7 @@ import { projectView } from '@renderer/features/projects/view'; import { settingsView } from '@renderer/features/settings/settings-view'; import { skillsView } from '@renderer/features/skills/skills-view'; import { taskView } from '@renderer/features/tasks/view'; +import type { CommandProvider } from '@renderer/lib/commands/types'; // Define views here so we can use them in the navigate function export const views = { @@ -21,7 +22,12 @@ export type ViewDefinition> = { WrapView?: ComponentType<{ children: ReactNode } & TParams>; TitlebarSlot?: ComponentType; MainPanel: ComponentType; - RightPanel?: ComponentType; + /** + * Factory called by Workspace whenever this view becomes active. + * The returned CommandProvider is registered in commandRegistry and + * unregistered when the view changes or the params change. + */ + commandProvider?: (params: TParams) => CommandProvider; }; type Views = typeof views; diff --git a/src/renderer/app/workspace.tsx b/src/renderer/app/workspace.tsx index a84856a543..d9a4d502dd 100644 --- a/src/renderer/app/workspace.tsx +++ b/src/renderer/app/workspace.tsx @@ -1,8 +1,9 @@ import { LeftSidebar } from '@renderer/features/sidebar/left-sidebar'; +import { CommandShortcutBinder } from '@renderer/lib/commands/command-shortcut-binder'; import { AppKeyboardShortcuts } from '@renderer/lib/components/app-keyboard-shortcuts'; +import { MonacoKeyboardBridge } from '@renderer/lib/components/monaco-keyboard-bridge'; import { useTheme } from '@renderer/lib/hooks/useTheme'; import { - useViewLayoutOverride, useWorkspaceSlots, useWorkspaceWrapParams, } from '@renderer/lib/layout/navigation-provider'; @@ -14,9 +15,12 @@ export function Workspace() { useTheme(); const { WrapView } = useWorkspaceSlots(); const { wrapParams } = useWorkspaceWrapParams(); + return ( <> + + } mainContent={ @@ -32,14 +36,6 @@ export function Workspace() { } function WorkspaceViewContent() { - const { TitlebarSlot, MainPanel, RightPanel } = useWorkspaceSlots(); - const { hideRightPanel } = useViewLayoutOverride(); - const EffectiveRightPanel = hideRightPanel ? null : RightPanel; - return ( - } - mainPanel={} - rightPanel={EffectiveRightPanel ? : null} - /> - ); + const { TitlebarSlot, MainPanel } = useWorkspaceSlots(); + return } mainPanel={} />; } diff --git a/src/renderer/features/command-palette/command-palette-modal.tsx b/src/renderer/features/command-palette/command-palette-modal.tsx new file mode 100644 index 0000000000..a1961f4fe2 --- /dev/null +++ b/src/renderer/features/command-palette/command-palette-modal.tsx @@ -0,0 +1,247 @@ +import { useQuery } from '@tanstack/react-query'; +import { Command } from 'cmdk'; +import { FolderOpen, GitBranch, MessageSquare, Zap } from 'lucide-react'; +import { useObserver } from 'mobx-react-lite'; +import React, { useDeferredValue, useState } from 'react'; +import type { SearchItem } from '@shared/search'; +import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; +import { commandRegistry } from '@renderer/lib/commands/registry'; +import { APP_SHORTCUTS } from '@renderer/lib/hooks/useKeyboardShortcuts'; +import { rpc } from '@renderer/lib/ipc'; +import { useNavigate } from '@renderer/lib/layout/navigation-provider'; +import { type BaseModalProps } from '@renderer/lib/modal/modal-provider'; +import { cn } from '@renderer/utils/utils'; +import { applyContextAffinity, rrf } from './rrf'; + +interface CommandPaletteProps { + projectId?: string; + taskId?: string; +} + +interface PaletteAction { + kind: 'action'; + id: string; + title: string; + subtitle?: string; + shortcut?: string; + score: number; + execute: () => void; +} + +type MergedResult = SearchItem | PaletteAction; + +const KIND_ICON: Record = { + action: , + task: , + project: , + conversation: , +}; + +const GROUP_CLASS = cn( + '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5', + '[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium', + '[&_[cmdk-group-heading]]:text-foreground/50' +); + +/** Converts a TanStack hotkey string (e.g. 'Mod+Shift+C') to a display label. */ +function formatHotkey(hotkey: string | undefined): string | undefined { + if (!hotkey) return undefined; + return hotkey.replace('Mod', '⌘').replace('Shift', '⇧').replace('Alt', '⌥').replace(/\+/g, ''); +} + +function PaletteItem({ + value, + item, + onSelect, +}: { + value: string; + item: MergedResult; + onSelect: () => void; +}) { + const action = item.kind === 'action' ? (item as PaletteAction) : null; + return ( + + {KIND_ICON[item.kind]} + {item.title} + {action?.shortcut && ( + + {action.shortcut} + + )} + + ); +} + +export function CommandPaletteModal({ + projectId, + taskId, + onClose, +}: CommandPaletteProps & BaseModalProps) { + const [query, setQuery] = useState(''); + const deferred = useDeferredValue(query); + const { navigate } = useNavigate(); + + const { data: dbResults = [] } = useQuery({ + queryKey: ['cmdk-search', deferred, projectId, taskId], + queryFn: () => rpc.search.commandPalette({ query: deferred, context: { projectId, taskId } }), + staleTime: 0, + placeholderData: (prev) => prev, + }); + + const actions = useObserver((): PaletteAction[] => + commandRegistry.activeCommands + .filter((cmd) => cmd.enabled !== false) + .map((cmd) => ({ + kind: 'action' as const, + id: cmd.id, + title: cmd.label, + subtitle: cmd.description, + shortcut: cmd.shortcutKey + ? formatHotkey(APP_SHORTCUTS[cmd.shortcutKey]?.defaultHotkey) + : undefined, + score: 0, + execute: () => { + onClose(); + cmd.execute(); + }, + })) + ); + + const rankedDb = applyContextAffinity(dbResults, { projectId }); + const merged = rrf([rankedDb as MergedResult[], actions as MergedResult[]]); + + const actionResults = merged.filter((r): r is PaletteAction => r.kind === 'action'); + const taskResults = merged.filter((r): r is SearchItem => r.kind === 'task'); + const projectResults = merged.filter((r): r is SearchItem => r.kind === 'project'); + const conversationResults = merged.filter((r): r is SearchItem => r.kind === 'conversation'); + + const handleNavigateToTask = (item: SearchItem) => { + if (!item.projectId) return; + onClose(); + navigate('task', { projectId: item.projectId, taskId: item.id }); + }; + + const handleNavigateToProject = (item: SearchItem) => { + onClose(); + navigate('project', { projectId: item.id }); + }; + + const handleNavigateToConversation = (item: SearchItem) => { + if (!item.projectId || !item.taskId) return; + getTaskView(item.projectId, item.taskId)?.tabManager.openConversation(item.id); + onClose(); + navigate('task', { projectId: item.projectId, taskId: item.taskId }); + }; + + const handleSelect = (item: MergedResult) => { + if (item.kind === 'action') return (item as PaletteAction).execute(); + if (item.kind === 'task') return handleNavigateToTask(item as SearchItem); + if (item.kind === 'project') return handleNavigateToProject(item as SearchItem); + if (item.kind === 'conversation') return handleNavigateToConversation(item as SearchItem); + }; + + return ( + +
+ +
+ + {query ? ( + <> + + No results for “{query}” + + {merged.map((item) => ( + handleSelect(item)} + /> + ))} + + ) : ( + <> + {actionResults.length > 0 && ( + + {actionResults.map((item) => ( + + ))} + + )} + {taskResults.length > 0 && ( + + {taskResults.map((item) => ( + handleNavigateToTask(item)} + /> + ))} + + )} + {projectResults.length > 0 && ( + + {projectResults.map((item) => ( + handleNavigateToProject(item)} + /> + ))} + + )} + {taskId && conversationResults.length > 0 && ( + + {conversationResults.map((item) => ( + handleNavigateToConversation(item)} + /> + ))} + + )} + + )} + + +
+ + + ↑ + + + ↓ + + Navigate + + + + ↵ + + Select + + + + Esc + + Close + +
+
+ ); +} diff --git a/src/renderer/features/command-palette/rrf.ts b/src/renderer/features/command-palette/rrf.ts new file mode 100644 index 0000000000..49820deb5d --- /dev/null +++ b/src/renderer/features/command-palette/rrf.ts @@ -0,0 +1,44 @@ +import type { SearchItem } from '@shared/search'; + +type Rankable = { kind: string; id: string }; + +/** + * Reciprocal Rank Fusion merges multiple ranked lists into one. + * Uses rank position rather than raw score, so lists with incompatible + * scoring systems (e.g. BM25 and fuzzy match) can be combined safely. + * k=60 is the standard constant that dampens top-rank influence. + */ +export function rrf(lists: T[][], k = 60): T[] { + const scores = new Map(); + const items = new Map(); + + for (const list of lists) { + list.forEach((item, rank) => { + const key = `${item.kind}:${item.id}`; + scores.set(key, (scores.get(key) ?? 0) + 1 / (k + rank + 1)); + if (!items.has(key)) items.set(key, item); + }); + } + + return [...items.values()].sort( + (a, b) => (scores.get(`${b.kind}:${b.id}`) ?? 0) - (scores.get(`${a.kind}:${a.id}`) ?? 0) + ); +} + +/** + * Re-ranks FTS5 results by boosting items belonging to the active project + * before they enter RRF. Applied to List A (DB results) only — actions + * (List B) are already ordered by context relevance. + */ +export function applyContextAffinity( + items: SearchItem[], + context: { projectId?: string } +): SearchItem[] { + return [...items].sort((a, b) => { + const boost = (x: SearchItem) => + x.projectId === context.projectId && context.projectId != null ? 1 : 0; + const diff = boost(b) - boost(a); + // BM25: lower (more negative) is better + return diff !== 0 ? diff : a.score - b.score; + }); +} diff --git a/src/renderer/features/integrations/integration-setup-modal.tsx b/src/renderer/features/integrations/integration-setup-modal.tsx index 968409a956..16d16fb1c1 100644 --- a/src/renderer/features/integrations/integration-setup-modal.tsx +++ b/src/renderer/features/integrations/integration-setup-modal.tsx @@ -1,6 +1,6 @@ import { Loader2 } from 'lucide-react'; import { useCallback, useState } from 'react'; -import { BaseModalProps } from '@renderer/lib/modal/modal-provider'; +import { type BaseModalProps } from '@renderer/lib/modal/modal-provider'; import { Button } from '@renderer/lib/ui/button'; import { ConfirmButton } from '@renderer/lib/ui/confirm-button'; import { diff --git a/src/renderer/features/integrations/use-issues.ts b/src/renderer/features/integrations/use-issues.ts index 2da9625719..9029f75005 100644 --- a/src/renderer/features/integrations/use-issues.ts +++ b/src/renderer/features/integrations/use-issues.ts @@ -24,7 +24,7 @@ export interface UseIssuesResult { interface UseIssuesOptions { projectId?: string; projectPath?: string; - nameWithOwner?: string; + repositoryUrl?: string; enabled?: boolean; initialLimit?: number; searchLimit?: number; @@ -40,7 +40,7 @@ export function useIssues( { projectId, projectPath, - nameWithOwner, + repositoryUrl, enabled = true, initialLimit = INITIAL_FETCH_LIMIT, searchLimit = SEARCH_LIMIT, @@ -66,7 +66,7 @@ export function useIssues( provider, projectId ?? '', projectPath ?? '', - nameWithOwner ?? '', + repositoryUrl ?? '', initialLimit, ], queryFn: async () => { @@ -76,7 +76,7 @@ export function useIssues( limit: initialLimit, projectId, projectPath, - nameWithOwner, + repositoryUrl, }); if (!result?.success) { @@ -98,7 +98,7 @@ export function useIssues( provider, projectId ?? '', projectPath ?? '', - nameWithOwner ?? '', + repositoryUrl ?? '', debouncedTerm.trim(), searchLimit, ], @@ -110,7 +110,7 @@ export function useIssues( searchTerm: debouncedTerm.trim(), projectId, projectPath, - nameWithOwner, + repositoryUrl, }); if (result?.success) { diff --git a/src/renderer/features/mcp/components/McpCard.tsx b/src/renderer/features/mcp/components/McpCard.tsx index a1d75f5f5f..9accb27c0b 100644 --- a/src/renderer/features/mcp/components/McpCard.tsx +++ b/src/renderer/features/mcp/components/McpCard.tsx @@ -1,7 +1,7 @@ import { motion } from 'framer-motion'; import { ExternalLink, Globe, Pencil, Plus, Terminal } from 'lucide-react'; import React from 'react'; -import { AgentProviderId } from '@shared/agent-provider-registry'; +import { type AgentProviderId } from '@shared/agent-provider-registry'; import type { McpCatalogEntry, McpServer } from '@shared/mcp/types'; import AgentLogo from '@renderer/lib/components/agent-logo'; import { agentConfig } from '@renderer/utils/agentConfig'; diff --git a/src/renderer/features/mcp/components/McpModal.tsx b/src/renderer/features/mcp/components/McpModal.tsx index 4080c52889..daa36dcff5 100644 --- a/src/renderer/features/mcp/components/McpModal.tsx +++ b/src/renderer/features/mcp/components/McpModal.tsx @@ -2,6 +2,7 @@ import { useForm } from '@tanstack/react-form'; import { Trash2 } from 'lucide-react'; import React, { useRef, useState } from 'react'; import type { McpCatalogEntry, McpProvidersResponse, McpServer } from '@shared/mcp/types'; +import type { BaseModalProps } from '@renderer/lib/modal/modal-provider'; import { Button } from '@renderer/lib/ui/button'; import { ConfirmButton } from '@renderer/lib/ui/confirm-button'; import { @@ -27,12 +28,11 @@ export type McpModalMode = | { type: 'add-custom' } | { type: 'edit'; server: McpServer }; -export interface McpModalProps { +interface McpModalProps extends BaseModalProps { mode: McpModalMode; providers: McpProvidersResponse[]; onSave: (server: McpServer) => Promise; onRemove?: (serverName: string) => void; - onSuccess: (result: unknown) => void; } export const McpModal: React.FC = ({ diff --git a/src/renderer/features/mcp/components/ProviderSelect.tsx b/src/renderer/features/mcp/components/ProviderSelect.tsx index a6ae65f873..aded2756da 100644 --- a/src/renderer/features/mcp/components/ProviderSelect.tsx +++ b/src/renderer/features/mcp/components/ProviderSelect.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AgentProviderId } from '@shared/agent-provider-registry'; +import { type AgentProviderId } from '@shared/agent-provider-registry'; import type { McpProvidersResponse } from '@shared/mcp/types'; import AgentLogo from '@renderer/lib/components/agent-logo'; import { Button } from '@renderer/lib/ui/button'; diff --git a/src/renderer/features/mcp/components/useMcps.ts b/src/renderer/features/mcp/components/useMcps.ts index d74cf9315c..2f9fa38a65 100644 --- a/src/renderer/features/mcp/components/useMcps.ts +++ b/src/renderer/features/mcp/components/useMcps.ts @@ -50,7 +50,7 @@ export function useMcps() { if (payload.source) { captureTelemetry('mcp_server_added', { source: payload.source }); } - queryClient.invalidateQueries({ queryKey: MCP_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: MCP_QUERY_KEY }); }, onError: (error) => { toast({ @@ -75,7 +75,7 @@ export function useMcps() { }, onSuccess: () => { captureTelemetry('mcp_server_removed'); - queryClient.invalidateQueries({ queryKey: MCP_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: MCP_QUERY_KEY }); }, onError: (error) => { toast({ @@ -101,7 +101,7 @@ export function useMcps() { }, onSuccess: (data) => { queryClient.setQueryData(PROVIDERS_QUERY_KEY, data); - queryClient.invalidateQueries({ queryKey: MCP_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: MCP_QUERY_KEY }); }, onError: () => { toast({ title: 'Failed to refresh MCP data', variant: 'destructive' }); diff --git a/src/renderer/features/onboarding/components/import-format.ts b/src/renderer/features/onboarding/components/import-format.ts new file mode 100644 index 0000000000..cae8021134 --- /dev/null +++ b/src/renderer/features/onboarding/components/import-format.ts @@ -0,0 +1,9 @@ +import type { LegacyImportSource } from '@shared/legacy-port'; + +export function sourceLabel(source: LegacyImportSource): string { + return source === 'v0' ? 'v0' : 'v1-beta'; +} + +export function formatCount(count: number, singular: string): string { + return `${count} ${count === 1 ? singular : `${singular}s`}`; +} diff --git a/src/renderer/features/onboarding/components/import-header.tsx b/src/renderer/features/onboarding/components/import-header.tsx new file mode 100644 index 0000000000..b5437d5dd3 --- /dev/null +++ b/src/renderer/features/onboarding/components/import-header.tsx @@ -0,0 +1,51 @@ +import { Import } from 'lucide-react'; +import type { LegacyImportSource, LegacyPortPreviewSource } from '@shared/legacy-port'; +import { formatCount, sourceLabel } from './import-format'; + +export type SingleSourceImport = { + source: LegacyImportSource; + preview: LegacyPortPreviewSource; +}; + +export type ImportHeaderProps = { + isLoading: boolean; + singleSource?: SingleSourceImport | null; +}; + +function singleSourceTitle(source: LegacyImportSource): string { + return `Import your Emdash ${sourceLabel(source)} data`; +} + +function singleSourceDescription(preview: LegacyPortPreviewSource): string { + return `Found ${formatCount(preview.projects, 'project')} and ${formatCount( + preview.tasks, + 'task' + )} from your previous Emdash installation`; +} + +export function ImportHeader({ isLoading, singleSource = null }: ImportHeaderProps) { + const title = singleSource + ? singleSourceTitle(singleSource.source) + : 'Do you want to import projects and tasks from other Emdash versions?'; + const description = singleSource + ? singleSourceDescription(singleSource.preview) + : 'Select one or more sources.'; + + return ( +
+
+ +
+

{title}

+ {isLoading ? ( +

+ Scanning existing Emdash data... +

+ ) : ( +

{description}

+ )} +
+
+
+ ); +} diff --git a/src/renderer/features/onboarding/components/import-progress.tsx b/src/renderer/features/onboarding/components/import-progress.tsx new file mode 100644 index 0000000000..3bc2b3a6b6 --- /dev/null +++ b/src/renderer/features/onboarding/components/import-progress.tsx @@ -0,0 +1,10 @@ +export function ImportProgress({ progress }: { progress: number }) { + return ( +
+
+
+
+

{progress}%

+
+ ); +} diff --git a/src/renderer/features/onboarding/components/import-source-selector.tsx b/src/renderer/features/onboarding/components/import-source-selector.tsx new file mode 100644 index 0000000000..06102c7a1b --- /dev/null +++ b/src/renderer/features/onboarding/components/import-source-selector.tsx @@ -0,0 +1,90 @@ +import { Check } from 'lucide-react'; +import { type LegacyImportSource, type LegacyPortPreviewSource } from '@shared/legacy-port'; +import { cn } from '@renderer/utils/utils'; +import { formatCount, sourceLabel } from './import-format'; + +function SourceCard({ + source, + preview, + selected, + disabled, + onToggle, +}: { + source: LegacyImportSource; + preview: LegacyPortPreviewSource; + selected: boolean; + disabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +export function ImportSourceSelector({ + sources, + v0Preview, + betaPreview, + selectedSources, + disabled = false, + onToggle, +}: { + sources: LegacyImportSource[]; + v0Preview: LegacyPortPreviewSource; + betaPreview: LegacyPortPreviewSource; + selectedSources: LegacyImportSource[]; + disabled?: boolean; + onToggle: (source: LegacyImportSource) => void; +}) { + if (sources.length === 0) return null; + + return ( +
+ {sources.includes('v0') && ( + onToggle('v0')} + /> + )} + {sources.includes('v1-beta') && ( + onToggle('v1-beta')} + /> + )} +
+ ); +} diff --git a/src/renderer/features/onboarding/components/project-conflicts.tsx b/src/renderer/features/onboarding/components/project-conflicts.tsx new file mode 100644 index 0000000000..10e81271cb --- /dev/null +++ b/src/renderer/features/onboarding/components/project-conflicts.tsx @@ -0,0 +1,119 @@ +import { Check } from 'lucide-react'; +import { type LegacyImportSource, type LegacyProjectConflict } from '@shared/legacy-port'; +import { cn } from '@renderer/utils/utils'; +import { formatCount, sourceLabel } from './import-format'; + +function ConflictChoice({ + source, + conflict, + selected, + disabled, + onSelect, +}: { + source: LegacyImportSource; + conflict: LegacyProjectConflict; + selected: boolean; + disabled: boolean; + onSelect: () => void; +}) { + const details = source === 'v0' ? conflict.v0 : conflict.v1Beta; + + return ( + + ); +} + +function ConflictCard({ + conflict, + selectedSource, + disabled, + onSelect, +}: { + conflict: LegacyProjectConflict; + selectedSource: LegacyImportSource; + disabled: boolean; + onSelect: (source: LegacyImportSource) => void; +}) { + return ( +
+
+ {conflict.v1Beta.name} + + {conflict.kind === 'ssh' ? 'SSH' : 'Local'} + +
+
+ onSelect('v0')} + /> + onSelect('v1-beta')} + /> +
+
+ ); +} + +export function ProjectConflicts({ + conflicts, + choices, + disabled = false, + onChoiceChange, +}: { + conflicts: LegacyProjectConflict[]; + choices: Record; + disabled?: boolean; + onChoiceChange: (identityKey: string, source: LegacyImportSource) => void; +}) { + if (conflicts.length === 0) return null; + + return ( +
+
+

Project conflicts

+

+ These projects exist in both selected versions. Choose which version to keep. +

+
+
+ {conflicts.map((conflict) => ( + onChoiceChange(conflict.identityKey, source)} + /> + ))} +
+
+ ); +} diff --git a/src/renderer/features/onboarding/import-state.ts b/src/renderer/features/onboarding/import-state.ts new file mode 100644 index 0000000000..a4b2a0fd5a --- /dev/null +++ b/src/renderer/features/onboarding/import-state.ts @@ -0,0 +1,25 @@ +import type { LegacyImportSource, LegacyPortPreview } from '@shared/legacy-port'; + +export type ImportStepPreview = LegacyPortPreview; + +export function availableSources(preview: ImportStepPreview | undefined): LegacyImportSource[] { + const sources: LegacyImportSource[] = []; + if (preview?.sources.v0.available) sources.push('v0'); + if (preview?.sources.v1Beta.available) sources.push('v1-beta'); + return sources; +} + +export function shouldShowSourceSelector(preview: ImportStepPreview | undefined): boolean { + return availableSources(preview).length > 1; +} + +export function singleAvailableSource( + preview: ImportStepPreview | undefined +): LegacyImportSource | null { + const sources = availableSources(preview); + return sources.length === 1 ? sources[0] : null; +} + +export function shouldCenterImportContent(preview: ImportStepPreview | undefined): boolean { + return singleAvailableSource(preview) !== null; +} diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index 6b6fab26f0..e5f0ec18c5 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -1,142 +1,161 @@ -import { Import } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import { useLegacyPortImport, useLegacyPortPreview } from '@renderer/lib/hooks/useLegacyPort'; +import { useMemo, useState } from 'react'; +import type { LegacyImportSource } from '@shared/legacy-port'; +import { useImportProgress } from '@renderer/lib/hooks/useImportProgress'; +import { + useLegacyPortImport, + useLegacyPortPreview, + useLegacyPortStartFresh, +} from '@renderer/lib/hooks/useLegacyPort'; import { Button } from '@renderer/lib/ui/button'; - -const PROGRESS_DURATION_MS = 4000; -const COMPLETE_DELAY_MS = 1000; +import { cn } from '@renderer/utils/utils'; +import { ImportHeader } from './components/import-header'; +import { ImportProgress } from './components/import-progress'; +import { ImportSourceSelector } from './components/import-source-selector'; +import { ProjectConflicts } from './components/project-conflicts'; +import { + availableSources, + shouldCenterImportContent, + shouldShowSourceSelector, + singleAvailableSource, +} from './import-state'; + +function toggleSourceSelection( + sources: LegacyImportSource[], + source: LegacyImportSource +): LegacyImportSource[] { + if (sources.includes(source)) { + return sources.filter((candidate) => candidate !== source); + } + return [...sources, source]; +} export function ImportStep({ onComplete }: { onComplete: () => void }) { const { data: preview, isLoading: previewLoading } = useLegacyPortPreview(true); const importMutation = useLegacyPortImport(); - - const [isImporting, setIsImporting] = useState(false); - const [progress, setProgress] = useState(0); - const [importError, setImportError] = useState(null); - - const onCompleteRef = useRef(onComplete); - useEffect(() => { - onCompleteRef.current = onComplete; - }, [onComplete]); - - const animationRef = useRef(null); - const completeTimerRef = useRef | null>(null); - const importDoneRef = useRef(false); - const animationDoneRef = useRef(false); - - useEffect(() => { - return () => { - if (animationRef.current !== null) cancelAnimationFrame(animationRef.current); - if (completeTimerRef.current !== null) clearTimeout(completeTimerRef.current); - }; - }, []); - - const maybeScheduleComplete = () => { - if (importDoneRef.current && animationDoneRef.current && completeTimerRef.current === null) { - completeTimerRef.current = setTimeout(() => { - onCompleteRef.current(); - }, COMPLETE_DELAY_MS); - } + const startFreshMutation = useLegacyPortStartFresh(); + const importProgress = useImportProgress(); + + const sourceOptions = useMemo(() => availableSources(preview), [preview]); + const [selectedSourcesOverride, setSelectedSourcesOverride] = useState< + LegacyImportSource[] | null + >(null); + const [conflictChoiceOverrides, setConflictChoiceOverrides] = useState< + Record + >({}); + const [startFreshError, setStartFreshError] = useState(null); + + const selectedSources = selectedSourcesOverride ?? sourceOptions; + const visibleConflicts = useMemo(() => { + if (!selectedSources.includes('v0') || !selectedSources.includes('v1-beta')) return []; + return preview?.conflicts ?? []; + }, [preview?.conflicts, selectedSources]); + + const v0Preview = preview?.sources.v0 ?? { available: false, projects: 0, tasks: 0 }; + const betaPreview = preview?.sources.v1Beta ?? { available: false, projects: 0, tasks: 0 }; + const canImport = selectedSources.length > 0 && !previewLoading; + const singleSource = singleAvailableSource(preview); + const showSourceSelector = shouldShowSourceSelector(preview); + const centerContent = shouldCenterImportContent(preview); + + const toggleSource = (source: LegacyImportSource) => { + setSelectedSourcesOverride((current) => + toggleSourceSelection(current ?? selectedSources, source) + ); }; - const startAnimation = () => { - const startTime = performance.now(); - - const tick = (now: number) => { - const elapsed = now - startTime; - const pct = Math.min(elapsed / PROGRESS_DURATION_MS, 1); - setProgress(Math.round(pct * 100)); - - if (pct < 1) { - animationRef.current = requestAnimationFrame(tick); - } else { - animationRef.current = null; - animationDoneRef.current = true; - maybeScheduleComplete(); - } - }; - - animationRef.current = requestAnimationFrame(tick); + const updateConflictChoice = (identityKey: string, source: LegacyImportSource) => { + setConflictChoiceOverrides((current) => ({ + ...current, + [identityKey]: source, + })); }; const handleImport = async () => { - setImportError(null); - setIsImporting(true); - setProgress(0); - importDoneRef.current = false; - animationDoneRef.current = false; - completeTimerRef.current = null; - - startAnimation(); + setStartFreshError(null); + const conflictChoices = Object.fromEntries( + visibleConflicts.map((conflict) => [ + conflict.identityKey, + conflictChoiceOverrides[conflict.identityKey] ?? 'v1-beta', + ]) + ) as Record; + + await importProgress.run( + () => + importMutation.mutateAsync({ + sources: selectedSources, + conflictChoices, + }), + { onComplete } + ); + }; + const handleStartFresh = async () => { + setStartFreshError(null); + importProgress.clearError(); try { - const result = await importMutation.mutateAsync(); + const result = await startFreshMutation.mutateAsync(); if (!result.success) { - if (animationRef.current !== null) { - cancelAnimationFrame(animationRef.current); - animationRef.current = null; - } - setImportError(result.error ?? 'Import failed'); - setIsImporting(false); - setProgress(0); + setStartFreshError(result.error ?? 'Start fresh failed'); return; } - importDoneRef.current = true; - maybeScheduleComplete(); + onComplete(); } catch (err) { - if (animationRef.current !== null) { - cancelAnimationFrame(animationRef.current); - animationRef.current = null; - } - setImportError(err instanceof Error ? err.message : 'Import failed'); - setIsImporting(false); - setProgress(0); + setStartFreshError(err instanceof Error ? err.message : 'Start fresh failed'); } }; - const projectCount = preview?.projects ?? 0; - const taskCount = preview?.tasks ?? 0; + const isBusy = importProgress.isImporting || startFreshMutation.isPending; return ( -
-
-
- -
-

Import your Emdash v0 data

- {previewLoading ? ( -

- Scanning legacy database... -

- ) : ( -

- Found {projectCount}{' '} - {projectCount === 1 ? 'project' : 'projects'} and{' '} - {taskCount}{' '} - {taskCount === 1 ? 'task' : 'tasks'} from your previous Emdash installation -

- )} -
-
-
- - {isImporting && ( -
-
-
-
-

{progress}%

-
+
+ + + {!previewLoading && showSourceSelector && ( + + )} + + - {importError &&

{importError}

} + {importProgress.isImporting && } + + {importProgress.error && ( +

{importProgress.error}

+ )} + {startFreshError &&

{startFreshError}

} -
- -
diff --git a/src/renderer/features/onboarding/onboarding-shell.tsx b/src/renderer/features/onboarding/onboarding-shell.tsx index ee0feed647..4b37f841ea 100644 --- a/src/renderer/features/onboarding/onboarding-shell.tsx +++ b/src/renderer/features/onboarding/onboarding-shell.tsx @@ -22,25 +22,23 @@ const stepConfig: Record< function StepHeader({ label, isActive, - onClick, isLast, }: { label: string; isActive: boolean; - onClick: () => void; isLast: boolean; }) { return ( - +
); } @@ -65,7 +63,7 @@ export function OnboardingShell({ }; return ( -
+
{steps.map((step, index) => ( setActiveIndex(index)} /> ))}
-
+
diff --git a/src/renderer/features/projects/components/add-project-modal/add-project-modal.tsx b/src/renderer/features/projects/components/add-project-modal/add-project-modal.tsx index 4adbdf29d7..63b53d3c19 100644 --- a/src/renderer/features/projects/components/add-project-modal/add-project-modal.tsx +++ b/src/renderer/features/projects/components/add-project-modal/add-project-modal.tsx @@ -261,7 +261,6 @@ export const AddProjectModal = observer(function AddProjectModal({ onValueChange={([value]) => { if (value) setStrategy(value as Strategy); }} - size="sm" > diff --git a/src/renderer/features/projects/components/add-project-modal/button-card.tsx b/src/renderer/features/projects/components/add-project-modal/button-card.tsx deleted file mode 100644 index 268fc5be22..0000000000 --- a/src/renderer/features/projects/components/add-project-modal/button-card.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ButtonHTMLAttributes, forwardRef } from 'react'; -import { cn } from '@renderer/utils/utils'; - -interface ButtonCardProps extends ButtonHTMLAttributes {} - -export const ButtonCard = forwardRef( - ({ children, className, ...props }, ref) => { - return ( - - ); - } -); - -export function ButtonCardGroup({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return
{children}
; -} diff --git a/src/renderer/features/projects/components/add-project-modal/content.tsx b/src/renderer/features/projects/components/add-project-modal/content.tsx index 4df055b87a..cd69c4ccb5 100644 --- a/src/renderer/features/projects/components/add-project-modal/content.tsx +++ b/src/renderer/features/projects/components/add-project-modal/content.tsx @@ -8,9 +8,9 @@ import { Label } from '@renderer/lib/ui/label'; import { RadioGroup, RadioGroupItem } from '@renderer/lib/ui/radio-group'; import { Separator } from '@renderer/lib/ui/separator'; import { Switch } from '@renderer/lib/ui/switch'; -import { Strategy } from './add-project-modal'; +import { type Strategy } from './add-project-modal'; import { LocalDirectorySelector } from './local-directory-selector'; -import { CloneModeState, NewModeState, PickModeState } from './modes'; +import { type CloneModeState, type NewModeState, type PickModeState } from './modes'; import { RemoteDirectorySelector } from './remote-directory-selector'; export function PickExistingPanel({ diff --git a/src/renderer/features/projects/components/add-project-modal/mode-tabs.tsx b/src/renderer/features/projects/components/add-project-modal/mode-tabs.tsx deleted file mode 100644 index 4971f78cd9..0000000000 --- a/src/renderer/features/projects/components/add-project-modal/mode-tabs.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Tabs } from '@base-ui/react'; -import { Folder, Github, Plus } from 'lucide-react'; -import { type Mode } from './add-project-modal'; -import { ButtonCard } from './button-card'; - -export function ModeTabs({ - mode, - onModeChange, - children, -}: { - mode: Mode; - onModeChange: (mode: Mode) => void; - children: React.ReactNode; -}) { - return ( - onModeChange(v as Mode)} - className="flex flex-col gap-6" - > - - - - Pick existing - - } - /> - - - New - - } - /> - - - Clone - - } - /> - -
{children}
-
- ); -} diff --git a/src/renderer/features/projects/components/add-project-modal/modes.ts b/src/renderer/features/projects/components/add-project-modal/modes.ts index a69bd72a11..fab039c0d2 100644 --- a/src/renderer/features/projects/components/add-project-modal/modes.ts +++ b/src/renderer/features/projects/components/add-project-modal/modes.ts @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; +import { basenameFromAnyPath } from '@shared/path-name'; import { rpc } from '@renderer/lib/ipc'; import { useGithubContext } from '@renderer/lib/providers/github-context-provider'; -import { ComboboxSelectOption } from '@renderer/lib/ui/combobox-popover'; +import type { ComboboxSelectOption } from '@renderer/lib/ui/combobox-popover'; export function usePickMode() { const [path, setPath] = useState(''); @@ -14,7 +15,7 @@ export function usePickMode() { setPath(newPath); setinitGitRepository(false); if (!nameIsTouched) { - const dirName = newPath.split('/').filter(Boolean).pop() ?? ''; + const dirName = basenameFromAnyPath(newPath); if (dirName && !nameIsTouched) setName(dirName); } }; diff --git a/src/renderer/features/projects/components/main-panel/pending-project.tsx b/src/renderer/features/projects/components/main-panel/pending-project.tsx index 0ca6fbe41e..bbd79830e6 100644 --- a/src/renderer/features/projects/components/main-panel/pending-project.tsx +++ b/src/renderer/features/projects/components/main-panel/pending-project.tsx @@ -1,6 +1,6 @@ import { AlertCircle, Check, Loader2, X } from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import { UnregisteredProject } from '@renderer/features/projects/stores/project'; +import { type UnregisteredProject } from '@renderer/features/projects/stores/project'; import { getProjectManagerStore } from '@renderer/features/projects/stores/project-selectors'; import { useNavigate } from '@renderer/lib/layout/navigation-provider'; import { Button } from '@renderer/lib/ui/button'; @@ -35,8 +35,8 @@ export const PendingProjectStatus = observer(function PendingProjectStatus({ }; return ( -
-
+
+

{project.name}

{stages.map((stage, i) => { @@ -69,10 +69,10 @@ export const PendingProjectStatus = observer(function PendingProjectStatus({ })} {isError && ( -
-
+
+
- + {project.error ?? 'An error occurred'}
diff --git a/src/renderer/features/projects/components/pr-view/pr-sync-status-card.tsx b/src/renderer/features/projects/components/pr-view/pr-sync-status-card.tsx index 5a8bdf0ecc..508b9dd1eb 100644 --- a/src/renderer/features/projects/components/pr-view/pr-sync-status-card.tsx +++ b/src/renderer/features/projects/components/pr-view/pr-sync-status-card.tsx @@ -1,6 +1,6 @@ import { AlertCircle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import { ReactNode, useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { getPrSyncStore } from '@renderer/features/projects/stores/project-selectors'; import { ListPopoverCard } from '@renderer/lib/components/list-popover-card'; import { Button } from '@renderer/lib/ui/button'; diff --git a/src/renderer/features/projects/components/pr-view/usePullRequests.ts b/src/renderer/features/projects/components/pr-view/usePullRequests.ts index 76c69987c9..4018aae2d4 100644 --- a/src/renderer/features/projects/components/pr-view/usePullRequests.ts +++ b/src/renderer/features/projects/components/pr-view/usePullRequests.ts @@ -1,11 +1,11 @@ import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; -import type { - ListPrOptions, - PrFilterOptions, - PrFilters, - PrSortField, - PullRequest, +import { + pullRequestErrorMessage, + type ListPrOptions, + type PrFilterOptions, + type PrFilters, + type PrSortField, } from '@shared/pull-requests'; import { rpc } from '@renderer/lib/ipc'; @@ -52,9 +52,11 @@ export function usePullRequests( }; const response = await rpc.pullRequests.listPullRequests(projectId!, listOptions); if (!response?.success) { - throw new Error(response?.error || 'Failed to load pull requests'); + throw new Error( + response ? pullRequestErrorMessage(response.error) : 'Failed to load pull requests' + ); } - const prs = (response.prs ?? []) as PullRequest[]; + const prs = response.data.prs; return { prs, nextOffset: prs.length === PAGE_SIZE ? pageParam + PAGE_SIZE : undefined, @@ -99,13 +101,11 @@ export function useFilterOptions(projectId?: string, repositoryUrl?: string) { queryFn: async () => { const response = await rpc.pullRequests.getFilterOptions(projectId!); if (!response?.success) { - throw new Error(response?.error || 'Failed to load filter options'); + throw new Error( + response ? pullRequestErrorMessage(response.error) : 'Failed to load filter options' + ); } - const { authors, labels, assignees } = response as { - authors: PrFilterOptions['authors']; - labels: PrFilterOptions['labels']; - assignees: PrFilterOptions['assignees']; - }; + const { authors, labels, assignees } = response.data; return { authors, labels, assignees }; }, enabled: !!repositoryUrl, diff --git a/src/renderer/features/projects/components/project-titlebar.tsx b/src/renderer/features/projects/components/project-titlebar.tsx index 7c1b4fab34..579b665f04 100644 --- a/src/renderer/features/projects/components/project-titlebar.tsx +++ b/src/renderer/features/projects/components/project-titlebar.tsx @@ -1,5 +1,6 @@ import { ChevronDown, Ellipsis, ExternalLink, GithubIcon, Globe, Trash2 } from 'lucide-react'; import { observer } from 'mobx-react-lite'; +import { parseGitHubRepository } from '@shared/github-repository'; import { asMounted, getProjectManagerStore, @@ -37,11 +38,10 @@ const MountedProjectTitlebarLeft = observer(function ProjectTitlebarLeft({ const configuredRemote = repo?.configuredRemote; const remoteUrl = configuredRemote?.url; const repositoryUrl = repo?.repositoryUrl; + const repository = parseGitHubRepository(repositoryUrl); - const isGithubUrl = repositoryUrl?.includes('github.com'); - const repoLabel = repositoryUrl - ? repositoryUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '') - : remoteUrl?.replace(/^https?:\/\//, ''); + const isGithubUrl = Boolean(repository); + const repoLabel = repository?.nameWithOwner ?? remoteUrl?.replace(/^https?:\/\//, ''); return (
diff --git a/src/renderer/features/projects/components/settings-view/project-settings-form.tsx b/src/renderer/features/projects/components/settings-view/project-settings-form.tsx index c0103ee127..cc0a6b3b1f 100644 --- a/src/renderer/features/projects/components/settings-view/project-settings-form.tsx +++ b/src/renderer/features/projects/components/settings-view/project-settings-form.tsx @@ -7,6 +7,7 @@ import { err, type Result } from '@shared/result'; import type { ProjectSettings } from '@main/core/projects/settings/schema'; import { getRepositoryStore } from '@renderer/features/projects/stores/project-selectors'; import { ProjectBranchSelector } from '@renderer/lib/components/project-branch-selector'; +import { useFeatureFlag } from '@renderer/lib/hooks/useFeatureFlag'; import { rpc } from '@renderer/lib/ipc'; import { Button } from '@renderer/lib/ui/button'; import { ConfirmButton } from '@renderer/lib/ui/confirm-button'; @@ -34,6 +35,8 @@ type FormState = { worktreeDirectory: string; defaultBranch: Branch | null; remote: string; + provisionCommand: string; + terminateCommand: string; }; function normalizeScript(val: string | string[] | undefined): string { @@ -72,6 +75,8 @@ export function settingsToForm( worktreeDirectory: s.worktreeDirectory ?? '', defaultBranch, remote: s.remote ?? '', + provisionCommand: s.workspaceProvider?.provisionCommand ?? '', + terminateCommand: s.workspaceProvider?.terminateCommand ?? '', }; } @@ -98,6 +103,14 @@ export function formToSettings(f: FormState): ProjectSettings { worktreeDirectory: f.worktreeDirectory || undefined, defaultBranch, remote: f.remote || undefined, + workspaceProvider: + f.provisionCommand && f.terminateCommand + ? { + type: 'script' as const, + provisionCommand: f.provisionCommand, + terminateCommand: f.terminateCommand, + } + : undefined, }; } @@ -123,13 +136,13 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ const baseline = useMemo( () => settingsToForm(initial, configuredRemote, remotes), - // eslint-disable-next-line react-hooks/exhaustive-deps [initial, configuredRemote, remotes] ); const [form, setForm] = useState(baseline); const [savedForm, setSavedForm] = useState(baseline); const [saveStatus, setSaveStatus] = useState('idle'); const [worktreeDirectoryError, setWorktreeDirectoryError] = useState(null); + const isWorkspaceProviderEnabled = useFeatureFlag('workspace-provider'); const formSnapshot = useMemo(() => JSON.stringify(form), [form]); const savedSnapshot = useMemo(() => JSON.stringify(savedForm), [savedForm]); @@ -171,9 +184,12 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ } return ( -
-

Project Settings

-
+
+

Project Settings

+
Preserve patterns @@ -343,8 +359,43 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({
+ {isWorkspaceProviderEnabled && ( + <> + +
+
+ Workspace provider + + Commands used to provision and terminate BYOI infrastructure for tasks. + +
+ + + Provision command + +