Merge pull request #1401 from AndyMik90/develop
Version 2.7.5 Release (Proper Merge)
This commit is contained in:
@@ -39,10 +39,23 @@ Follow conventional commits: `<type>: <subject>`
|
||||
- [ ] I've tested my changes locally
|
||||
- [ ] I've followed the code principles (SOLID, DRY, KISS)
|
||||
- [ ] My PR is small and focused (< 400 lines ideally)
|
||||
- [ ] **(Python only)** All file operations specify `encoding="utf-8"` for text files
|
||||
|
||||
## Platform Testing Checklist
|
||||
|
||||
**CRITICAL:** This project supports Windows, macOS, and Linux. Platform-specific bugs are a common source of breakage.
|
||||
|
||||
- [ ] **Windows tested** (either on Windows or via CI)
|
||||
- [ ] **macOS tested** (either on macOS or via CI)
|
||||
- [ ] **Linux tested** (CI covers this)
|
||||
- [ ] Used centralized `platform/` module instead of direct `process.platform` checks
|
||||
- [ ] No hardcoded paths (used `findExecutable()` or platform abstractions)
|
||||
|
||||
**If you only have access to one OS:** CI now tests on all platforms. Ensure all checks pass before submitting.
|
||||
|
||||
## CI/Testing Requirements
|
||||
|
||||
- [ ] All CI checks pass
|
||||
- [ ] All CI checks pass on **all platforms** (Windows, macOS, Linux)
|
||||
- [ ] All existing tests pass
|
||||
- [ ] New features include test coverage
|
||||
- [ ] Bug fixes include regression tests
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
name: 'Finalize macOS Notarization'
|
||||
description: 'Wait for Apple notarization to complete and staple tickets to DMG files'
|
||||
|
||||
inputs:
|
||||
apple-id:
|
||||
description: 'Apple ID for notarization'
|
||||
required: true
|
||||
apple-app-specific-password:
|
||||
description: 'Apple app-specific password'
|
||||
required: true
|
||||
apple-team-id:
|
||||
description: 'Apple Team ID'
|
||||
required: true
|
||||
intel-notarization-id:
|
||||
description: 'Notarization request ID for Intel build'
|
||||
required: false
|
||||
default: ''
|
||||
arm64-notarization-id:
|
||||
description: 'Notarization request ID for ARM64 build'
|
||||
required: false
|
||||
default: ''
|
||||
intel-dmg-file:
|
||||
description: 'Filename of the Intel DMG'
|
||||
required: false
|
||||
default: ''
|
||||
arm64-dmg-file:
|
||||
description: 'Filename of the ARM64 DMG'
|
||||
required: false
|
||||
default: ''
|
||||
intel-artifact-path:
|
||||
description: 'Path to Intel build artifacts'
|
||||
required: false
|
||||
default: 'intel'
|
||||
arm64-artifact-path:
|
||||
description: 'Path to ARM64 build artifacts'
|
||||
required: false
|
||||
default: 'arm64'
|
||||
timeout:
|
||||
description: 'Timeout in seconds for notarization wait'
|
||||
required: false
|
||||
default: '3600'
|
||||
|
||||
outputs:
|
||||
intel-stapled:
|
||||
description: 'Whether Intel DMG was successfully stapled'
|
||||
value: ${{ steps.staple.outputs.intel_stapled }}
|
||||
arm64-stapled:
|
||||
description: 'Whether ARM64 DMG was successfully stapled'
|
||||
value: ${{ steps.staple.outputs.arm64_stapled }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Wait for notarization and staple
|
||||
id: staple
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_ID: ${{ inputs.apple-id }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
|
||||
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
|
||||
INTEL_NOTARIZATION_ID: ${{ inputs.intel-notarization-id }}
|
||||
ARM64_NOTARIZATION_ID: ${{ inputs.arm64-notarization-id }}
|
||||
INTEL_DMG: ${{ inputs.intel-dmg-file }}
|
||||
ARM64_DMG: ${{ inputs.arm64-dmg-file }}
|
||||
INTEL_PATH: ${{ inputs.intel-artifact-path }}
|
||||
ARM64_PATH: ${{ inputs.arm64-artifact-path }}
|
||||
TIMEOUT: ${{ inputs.timeout }}
|
||||
run: |
|
||||
intel_stapled=false
|
||||
arm64_stapled=false
|
||||
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization wait: APPLE_ID not configured"
|
||||
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Warn if no notarization IDs provided (could indicate submission failure)
|
||||
if [ -z "$INTEL_NOTARIZATION_ID" ] && [ -z "$ARM64_NOTARIZATION_ID" ]; then
|
||||
echo "::warning::No notarization IDs provided - nothing to finalize. Check if notarization submission succeeded."
|
||||
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wait for Intel notarization
|
||||
if [ -n "$INTEL_NOTARIZATION_ID" ]; then
|
||||
echo "Waiting for Intel notarization: $INTEL_NOTARIZATION_ID"
|
||||
if ! xcrun notarytool wait "$INTEL_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--timeout "$TIMEOUT"; then
|
||||
echo "::error::Intel notarization failed or timed out"
|
||||
exit 1
|
||||
fi
|
||||
# Verify notarization was accepted (not just processed)
|
||||
INTEL_STATUS=$(xcrun notarytool info "$INTEL_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--output-format json | jq -r '.status // "Unknown"')
|
||||
if [ "$INTEL_STATUS" != "Accepted" ]; then
|
||||
echo "::error::Intel notarization status is '$INTEL_STATUS', expected 'Accepted'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Intel notarization status: $INTEL_STATUS"
|
||||
# Verify DMG file exists before stapling
|
||||
if [ ! -f "$INTEL_PATH/$INTEL_DMG" ]; then
|
||||
echo "::error::Intel DMG not found at $INTEL_PATH/$INTEL_DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling Intel DMG: $INTEL_PATH/$INTEL_DMG"
|
||||
if ! xcrun stapler staple "$INTEL_PATH/$INTEL_DMG"; then
|
||||
echo "::error::Failed to staple Intel DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Successfully stapled Intel DMG"
|
||||
intel_stapled=true
|
||||
fi
|
||||
|
||||
# Wait for ARM64 notarization
|
||||
if [ -n "$ARM64_NOTARIZATION_ID" ]; then
|
||||
echo "Waiting for ARM64 notarization: $ARM64_NOTARIZATION_ID"
|
||||
if ! xcrun notarytool wait "$ARM64_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--timeout "$TIMEOUT"; then
|
||||
echo "::error::ARM64 notarization failed or timed out"
|
||||
exit 1
|
||||
fi
|
||||
# Verify notarization was accepted (not just processed)
|
||||
ARM64_STATUS=$(xcrun notarytool info "$ARM64_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--output-format json | jq -r '.status // "Unknown"')
|
||||
if [ "$ARM64_STATUS" != "Accepted" ]; then
|
||||
echo "::error::ARM64 notarization status is '$ARM64_STATUS', expected 'Accepted'"
|
||||
exit 1
|
||||
fi
|
||||
echo "ARM64 notarization status: $ARM64_STATUS"
|
||||
# Verify DMG file exists before stapling
|
||||
if [ ! -f "$ARM64_PATH/$ARM64_DMG" ]; then
|
||||
echo "::error::ARM64 DMG not found at $ARM64_PATH/$ARM64_DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling ARM64 DMG: $ARM64_PATH/$ARM64_DMG"
|
||||
if ! xcrun stapler staple "$ARM64_PATH/$ARM64_DMG"; then
|
||||
echo "::error::Failed to staple ARM64 DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Successfully stapled ARM64 DMG"
|
||||
arm64_stapled=true
|
||||
fi
|
||||
|
||||
echo "intel_stapled=$intel_stapled" >> "$GITHUB_OUTPUT"
|
||||
echo "arm64_stapled=$arm64_stapled" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,193 @@
|
||||
name: 'Merge macOS Manifests'
|
||||
description: 'Merge Intel and ARM64 macOS manifests for electron-updater'
|
||||
|
||||
inputs:
|
||||
dist-path:
|
||||
description: 'Path to the dist directory containing build artifacts'
|
||||
required: false
|
||||
default: 'dist'
|
||||
output-path:
|
||||
description: 'Path to output the merged manifest'
|
||||
required: false
|
||||
default: 'release-assets'
|
||||
copy-other-manifests:
|
||||
description: 'Whether to copy Windows/Linux manifests as well'
|
||||
required: false
|
||||
default: 'true'
|
||||
yq-version:
|
||||
description: 'Version of yq to use for YAML merging'
|
||||
required: false
|
||||
default: 'v4.44.3'
|
||||
|
||||
outputs:
|
||||
merged:
|
||||
description: 'Whether manifests were merged (true) or single architecture used (false)'
|
||||
value: ${{ steps.merge.outputs.merged }}
|
||||
file-count:
|
||||
description: 'Number of files in the merged manifest'
|
||||
value: ${{ steps.validate.outputs.file_count }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Merge macOS manifests
|
||||
id: merge
|
||||
shell: bash
|
||||
env:
|
||||
# yq SHA256 checksum for v4.44.3 linux_amd64
|
||||
# When updating yq-version, update this checksum and the one in validate step
|
||||
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
|
||||
run: |
|
||||
echo "=== Merging macOS update manifests ==="
|
||||
|
||||
# Find all latest-mac.yml files from different build artifacts
|
||||
intel_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-intel-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
|
||||
arm64_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-arm64-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
|
||||
|
||||
echo "Intel manifest: ${intel_manifest:-not found}"
|
||||
echo "ARM64 manifest: ${arm64_manifest:-not found}"
|
||||
|
||||
mkdir -p "${{ inputs.output-path }}"
|
||||
|
||||
if [ -n "$intel_manifest" ] && [ -n "$arm64_manifest" ]; then
|
||||
echo "Both architectures found - merging manifests..."
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Install yq for YAML merging (pinned version with checksum verification)
|
||||
YQ_VERSION="${{ inputs.yq-version }}"
|
||||
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||
|
||||
echo "Downloading yq ${YQ_VERSION}..."
|
||||
if ! wget -qO /tmp/yq "$YQ_URL"; then
|
||||
echo "::error::Failed to download yq ${YQ_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify checksum
|
||||
echo "Verifying yq checksum..."
|
||||
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
|
||||
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
|
||||
echo "::error::yq checksum verification failed!"
|
||||
echo "Expected: $YQ_SHA256"
|
||||
echo "Actual: $ACTUAL_SHA256"
|
||||
rm -f /tmp/yq
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verified successfully"
|
||||
|
||||
sudo mv /tmp/yq /usr/local/bin/yq
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
echo "Installed yq version:"
|
||||
yq --version
|
||||
|
||||
# Merge the files arrays from both manifests using eval-all
|
||||
# This avoids shell expansion issues with multiline YAML
|
||||
yq eval-all '
|
||||
select(fileIndex == 0) * {"files": ([.[].files] | add)}
|
||||
' "$intel_manifest" "$arm64_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
echo "Merged manifest contents:"
|
||||
cat "${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
elif [ -n "$intel_manifest" ]; then
|
||||
echo "Only Intel manifest found - using as-is"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
cp "$intel_manifest" "${{ inputs.output-path }}/latest-mac.yml"
|
||||
elif [ -n "$arm64_manifest" ]; then
|
||||
echo "Only ARM64 manifest found - using as-is"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
cp "$arm64_manifest" "${{ inputs.output-path }}/latest-mac.yml"
|
||||
else
|
||||
echo "::error::No macOS manifests found - this will cause auto-update to fail"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate merged manifest
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
# Single source of truth for yq checksum - must match merge step
|
||||
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
|
||||
run: |
|
||||
manifest_file="${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
echo "=== Validating merged manifest ==="
|
||||
|
||||
# Check file exists
|
||||
if [ ! -f "$manifest_file" ]; then
|
||||
echo "::error::Merged manifest file not found at $manifest_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install yq if not already installed (for single-arch case)
|
||||
if ! command -v yq &> /dev/null; then
|
||||
YQ_VERSION="${{ inputs.yq-version }}"
|
||||
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||
|
||||
echo "Downloading yq ${YQ_VERSION}..."
|
||||
wget -qO /tmp/yq "$YQ_URL"
|
||||
|
||||
# Verify checksum (YQ_SHA256 from env)
|
||||
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
|
||||
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
|
||||
echo "::error::yq checksum verification failed!"
|
||||
echo "Expected: $YQ_SHA256"
|
||||
echo "Actual: $ACTUAL_SHA256"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo mv /tmp/yq /usr/local/bin/yq
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
fi
|
||||
|
||||
# Validate YAML is parseable
|
||||
if ! yq eval '.' "$manifest_file" > /dev/null 2>&1; then
|
||||
echo "::error::Merged manifest is not valid YAML"
|
||||
cat "$manifest_file"
|
||||
exit 1
|
||||
fi
|
||||
echo "YAML syntax is valid"
|
||||
|
||||
# Count files in manifest
|
||||
file_count=$(yq eval '.files | length' "$manifest_file")
|
||||
echo "file_count=$file_count" >> "$GITHUB_OUTPUT"
|
||||
echo "Manifest contains $file_count file entries"
|
||||
|
||||
# Validate file count
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "::error::Merged manifest contains no files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If we merged both architectures, expect at least 2 files (one per arch)
|
||||
if [ "${{ steps.merge.outputs.merged }}" = "true" ] && [ "$file_count" -lt 2 ]; then
|
||||
echo "::warning::Merged manifest has fewer than 2 files - merge may have failed"
|
||||
fi
|
||||
|
||||
# Validate required fields exist
|
||||
if ! yq eval '.version' "$manifest_file" | grep -q .; then
|
||||
echo "::error::Manifest missing 'version' field"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version field present: $(yq eval '.version' "$manifest_file")"
|
||||
|
||||
echo "Manifest validation passed"
|
||||
|
||||
- name: Copy other manifests
|
||||
if: inputs.copy-other-manifests == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Copying other update manifests ==="
|
||||
|
||||
# Copy other manifests (Windows, Linux) - these don't have the duplicate issue
|
||||
for manifest in latest.yml latest-linux.yml latest-linux-arm64.yml; do
|
||||
found=$(find "${{ inputs.dist-path }}" -name "$manifest" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$found" ]; then
|
||||
echo "Copying $manifest"
|
||||
cp "$found" "${{ inputs.output-path }}/"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Manifest files in ${{ inputs.output-path }} ==="
|
||||
ls -la "${{ inputs.output-path }}"/*.yml 2>/dev/null || echo "No manifest files found"
|
||||
@@ -0,0 +1,87 @@
|
||||
name: 'Setup Node.js Frontend'
|
||||
description: 'Set up Node.js with npm and cached dependencies for the frontend'
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '24'
|
||||
ignore-scripts:
|
||||
description: 'Whether to use --ignore-scripts flag during npm ci'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'Whether npm cache was hit'
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache-dir
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache npm dependencies
|
||||
id: cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Run npm ci from root to properly handle workspace dependencies.
|
||||
# With npm workspaces, the lock file is at root and dependencies are hoisted there.
|
||||
# Running npm ci in apps/frontend would fail to populate node_modules correctly.
|
||||
run: |
|
||||
if [ "${{ inputs.ignore-scripts }}" == "true" ]; then
|
||||
npm ci --ignore-scripts
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
- name: Link node_modules for electron-builder
|
||||
shell: bash
|
||||
# electron-builder expects node_modules in apps/frontend for native module rebuilding.
|
||||
# With npm workspaces, packages are hoisted to root. Create a link so electron-builder
|
||||
# can find the modules during packaging and code signing.
|
||||
# Uses symlink on Unix, directory junction on Windows (works without admin privileges).
|
||||
run: |
|
||||
# Verify npm ci succeeded
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "::error::Root node_modules does not exist. npm ci may have failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create link if needed
|
||||
if [ ! -d "apps/frontend/node_modules" ] && [ ! -L "apps/frontend/node_modules" ]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
# Use directory junction on Windows (works without admin privileges)
|
||||
cmd //c "mklink /J apps\\frontend\\node_modules ..\\..\\node_modules"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Created junction: apps/frontend/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "::error::Failed to create directory junction on Windows"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Use symlink on Unix (macOS/Linux)
|
||||
if ln -s ../../node_modules apps/frontend/node_modules; then
|
||||
echo "Created symlink: apps/frontend/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "::error::Failed to create symlink"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "apps/frontend/node_modules already exists, skipping"
|
||||
fi
|
||||
@@ -0,0 +1,52 @@
|
||||
name: 'Setup Python Backend'
|
||||
description: 'Set up Python with uv package manager and cached dependencies for the backend'
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: 'Python version to use'
|
||||
required: false
|
||||
default: '3.12'
|
||||
install-test-deps:
|
||||
description: 'Whether to install test dependencies'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'Whether cache was hit'
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Install uv package manager
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
id: cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/AppData/Local/uv/cache
|
||||
~/Library/Caches/uv
|
||||
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-${{ hashFiles('apps/backend/requirements.txt', 'tests/requirements-test.txt') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
if [ "${{ inputs.install-test-deps }}" == "true" ]; then
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
fi
|
||||
@@ -0,0 +1,87 @@
|
||||
name: 'Submit macOS Notarization'
|
||||
description: 'Submit a macOS DMG file for Apple notarization asynchronously'
|
||||
|
||||
inputs:
|
||||
apple-id:
|
||||
description: 'Apple ID for notarization'
|
||||
required: true
|
||||
apple-app-specific-password:
|
||||
description: 'Apple app-specific password'
|
||||
required: true
|
||||
apple-team-id:
|
||||
description: 'Apple Team ID'
|
||||
required: true
|
||||
dmg-path:
|
||||
description: 'Path to the dist directory containing the DMG file'
|
||||
required: false
|
||||
default: 'apps/frontend/dist'
|
||||
|
||||
outputs:
|
||||
notarization-id:
|
||||
description: 'The notarization request ID'
|
||||
value: ${{ steps.submit.outputs.notarization_id }}
|
||||
dmg-file:
|
||||
description: 'The DMG filename that was submitted'
|
||||
value: ${{ steps.submit.outputs.dmg_file }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Submit notarization (async)
|
||||
id: submit
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_ID: ${{ inputs.apple-id }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
|
||||
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
|
||||
DMG_PATH: ${{ inputs.dmg-path }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
echo "notarization_id=" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_file=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the DMG file
|
||||
DMG_FILE=$(find "$DMG_PATH" -name "*.dmg" -type f | head -1)
|
||||
if [ -z "$DMG_FILE" ]; then
|
||||
echo "::error::No DMG file found in $DMG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Submitting $DMG_FILE for notarization (async)..."
|
||||
|
||||
# Submit for notarization without waiting
|
||||
# Capture both stdout and exit code
|
||||
set +e
|
||||
RESULT=$(xcrun notarytool submit "$DMG_FILE" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--no-wait \
|
||||
--output-format json 2>&1)
|
||||
SUBMIT_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
echo "$RESULT"
|
||||
|
||||
# Check if submission command itself failed (not just missing ID)
|
||||
if [ $SUBMIT_EXIT_CODE -ne 0 ]; then
|
||||
echo "::error::notarytool submit failed with exit code $SUBMIT_EXIT_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the notarization ID from JSON response
|
||||
# jq is always available on macOS runners
|
||||
NOTARIZATION_ID=$(echo "$RESULT" | jq -r '.id // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$NOTARIZATION_ID" ]; then
|
||||
echo "::error::Failed to get notarization ID from response"
|
||||
echo "Response was: $RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Notarization submitted with ID: $NOTARIZATION_ID"
|
||||
echo "notarization_id=$NOTARIZATION_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_file=$(basename "$DMG_FILE")" >> "$GITHUB_OUTPUT"
|
||||
+230
-117
@@ -65,6 +65,9 @@ jobs:
|
||||
build-macos-intel:
|
||||
needs: create-tag
|
||||
runs-on: macos-15-intel
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -76,23 +79,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Rust toolchain (for building native Python packages)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -132,27 +120,13 @@ jobs:
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -167,6 +141,9 @@ jobs:
|
||||
build-macos-arm64:
|
||||
needs: create-tag
|
||||
runs-on: macos-15
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -178,23 +155,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
@@ -231,27 +193,13 @@ jobs:
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -282,24 +230,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
@@ -470,23 +402,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
@@ -540,8 +457,50 @@ jobs:
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
|
||||
finalize-notarization:
|
||||
needs: [build-macos-intel, build-macos-arm64]
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Intel DMG
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: intel
|
||||
|
||||
- name: Download ARM64 DMG
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: arm64
|
||||
|
||||
- name: Wait for notarization and staple
|
||||
uses: ./.github/actions/finalize-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
|
||||
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
|
||||
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
|
||||
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
|
||||
|
||||
- name: Upload stapled Intel DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-intel-stapled
|
||||
path: intel/*.dmg
|
||||
|
||||
- name: Upload stapled ARM64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-stapled
|
||||
path: arm64/*.dmg
|
||||
|
||||
create-release:
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
needs: [create-tag, finalize-notarization, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
permissions:
|
||||
@@ -557,10 +516,24 @@ jobs:
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Flatten and validate artifacts
|
||||
- name: Flatten binary artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Copy stapled macOS DMGs (from finalize-notarization job)
|
||||
# Validate that stapled DMGs exist before copying
|
||||
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
|
||||
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
else
|
||||
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy Windows and Linux artifacts (including blockmap for delta updates)
|
||||
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Validate that at least one artifact was copied
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
@@ -569,9 +542,84 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $artifact_count artifact(s):"
|
||||
echo "Found $artifact_count binary artifact(s):"
|
||||
ls -la release-assets/
|
||||
|
||||
# Merge macOS manifests from Intel and ARM64 builds
|
||||
# See: https://github.com/electron-userland/electron-builder/issues/5592
|
||||
- name: Merge macOS manifests
|
||||
uses: ./.github/actions/merge-macos-manifests
|
||||
with:
|
||||
dist-path: dist
|
||||
output-path: release-assets
|
||||
copy-other-manifests: 'true'
|
||||
|
||||
- name: Rename and validate beta manifests
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
echo "=== Current manifest files ==="
|
||||
ls -la *.yml 2>/dev/null || echo "No yml files found yet"
|
||||
|
||||
# electron-builder generates latest*.yml files by default
|
||||
# For beta channel, electron-updater expects beta*.yml files
|
||||
# Rename: latest.yml -> beta.yml, latest-mac.yml -> beta-mac.yml, latest-linux.yml -> beta-linux.yml
|
||||
|
||||
# Windows: latest.yml -> beta.yml
|
||||
if [ -f "latest.yml" ]; then
|
||||
echo "Renaming latest.yml -> beta.yml (Windows)"
|
||||
mv latest.yml beta.yml
|
||||
fi
|
||||
|
||||
# macOS: latest-mac.yml -> beta-mac.yml
|
||||
if [ -f "latest-mac.yml" ]; then
|
||||
echo "Renaming latest-mac.yml -> beta-mac.yml (macOS)"
|
||||
mv latest-mac.yml beta-mac.yml
|
||||
fi
|
||||
|
||||
# Linux: latest-linux.yml -> beta-linux.yml
|
||||
if [ -f "latest-linux.yml" ]; then
|
||||
echo "Renaming latest-linux.yml -> beta-linux.yml (Linux)"
|
||||
mv latest-linux.yml beta-linux.yml
|
||||
fi
|
||||
|
||||
# Linux ARM64: latest-linux-arm64.yml -> beta-linux-arm64.yml (if exists)
|
||||
if [ -f "latest-linux-arm64.yml" ]; then
|
||||
echo "Renaming latest-linux-arm64.yml -> beta-linux-arm64.yml (Linux ARM64)"
|
||||
mv latest-linux-arm64.yml beta-linux-arm64.yml
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Beta manifest files after rename ==="
|
||||
ls -la *.yml 2>/dev/null || echo "No yml files found"
|
||||
|
||||
# Validate required beta manifests exist
|
||||
missing_manifests=""
|
||||
|
||||
if [ ! -f "beta-mac.yml" ]; then
|
||||
missing_manifests="$missing_manifests beta-mac.yml"
|
||||
fi
|
||||
|
||||
if [ ! -f "beta.yml" ]; then
|
||||
missing_manifests="$missing_manifests beta.yml"
|
||||
fi
|
||||
|
||||
if [ ! -f "beta-linux.yml" ]; then
|
||||
missing_manifests="$missing_manifests beta-linux.yml"
|
||||
fi
|
||||
|
||||
if [ -n "$missing_manifests" ]; then
|
||||
echo "::error::Missing required beta manifests:$missing_manifests"
|
||||
echo "::error::Auto-update will fail on affected platforms without these files!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All required beta manifests present:"
|
||||
echo " - beta-mac.yml (macOS)"
|
||||
echo " - beta.yml (Windows)"
|
||||
echo " - beta-linux.yml (Linux)"
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
@@ -606,24 +654,89 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
dry-run-summary:
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
needs: [create-tag, finalize-notarization, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Flatten binary artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
|
||||
# Copy stapled macOS DMGs (from finalize-notarization job)
|
||||
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy Windows and Linux artifacts (including blockmap for delta updates)
|
||||
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Merge macOS manifests (same logic as real release)
|
||||
- name: Merge macOS manifests
|
||||
uses: ./.github/actions/merge-macos-manifests
|
||||
with:
|
||||
dist-path: dist
|
||||
output-path: release-assets
|
||||
copy-other-manifests: 'true'
|
||||
|
||||
- name: Validate and rename beta manifests
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
# Rename latest*.yml to beta*.yml
|
||||
[ -f "latest.yml" ] && mv latest.yml beta.yml
|
||||
[ -f "latest-mac.yml" ] && mv latest-mac.yml beta-mac.yml
|
||||
[ -f "latest-linux.yml" ] && mv latest-linux.yml beta-linux.yml
|
||||
[ -f "latest-linux-arm64.yml" ] && mv latest-linux-arm64.yml beta-linux-arm64.yml
|
||||
|
||||
# Validate required manifests
|
||||
missing=""
|
||||
[ ! -f "beta-mac.yml" ] && missing="$missing beta-mac.yml"
|
||||
[ ! -f "beta.yml" ] && missing="$missing beta.yml"
|
||||
[ ! -f "beta-linux.yml" ] && missing="$missing beta-linux.yml"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::warning::DRY RUN: Missing required beta manifests:$missing"
|
||||
echo "MANIFEST_STATUS=FAILED" >> $GITHUB_ENV
|
||||
else
|
||||
echo "MANIFEST_STATUS=PASSED" >> $GITHUB_ENV
|
||||
|
||||
# Show merged manifest content for verification
|
||||
echo ""
|
||||
echo "=== beta-mac.yml content (should have both architectures) ==="
|
||||
cat beta-mac.yml
|
||||
fi
|
||||
|
||||
- name: Dry run summary
|
||||
run: |
|
||||
echo "## Beta Release Dry Run Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ needs.create-tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "### Build Artifacts" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "### Update Manifests (Required for Auto-Update)" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$MANIFEST_STATUS" = "PASSED" ]; then
|
||||
echo "All required beta manifests present:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta-mac.yml (macOS)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta.yml (Windows)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta-linux.yml (Linux)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**WARNING: Missing required manifests! Auto-update will fail.**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Check build logs for details." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+105
-52
@@ -1,10 +1,38 @@
|
||||
# Cross-Platform CI Pipeline
|
||||
#
|
||||
# Tests on all target platforms (Linux, Windows, macOS) to catch
|
||||
# platform-specific bugs before they merge. ALL platforms must pass.
|
||||
#
|
||||
# Optimized: Reduced matrix (4 jobs vs 6), merged integration tests,
|
||||
# coverage on Linux only, path filters to skip on docs-only changes.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -15,53 +43,63 @@ permissions:
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Python tests
|
||||
# --------------------------------------------------------------------------
|
||||
# Python Backend Tests - Optimized Matrix (4 jobs instead of 6)
|
||||
# --------------------------------------------------------------------------
|
||||
test-python:
|
||||
runs-on: ubuntu-latest
|
||||
name: test-python (${{ matrix.python-version }}, ${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13']
|
||||
# 3.12 on all OS for cross-platform coverage
|
||||
# 3.13 on Linux only for compatibility check (saves 2 jobs)
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: '3.12'
|
||||
- os: ubuntu-latest
|
||||
python-version: '3.13'
|
||||
- os: windows-latest
|
||||
python-version: '3.12'
|
||||
- os: macos-latest
|
||||
python-version: '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
- name: Setup Python backend
|
||||
uses: ./.github/actions/setup-python-backend
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
install-test-deps: 'true'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
- name: Run all tests (including platform-specific)
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
source .venv/Scripts/activate
|
||||
else
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
pytest ../../tests/ -v --tb=short -x
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.python-version == '3.12'
|
||||
- name: Run coverage (Linux + Python 3.12 only)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=10
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: matrix.python-version == '3.12'
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./apps/backend/coverage.xml
|
||||
@@ -69,44 +107,59 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Frontend lint, typecheck, test, and build
|
||||
# --------------------------------------------------------------------------
|
||||
# Frontend Tests - All Platforms
|
||||
# --------------------------------------------------------------------------
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
name: test-frontend (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js frontend
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
node-version: '24'
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/frontend
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Lint
|
||||
working-directory: apps/frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Type check
|
||||
- name: Run TypeScript type check
|
||||
working-directory: apps/frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
- name: Run unit tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
- name: Build application
|
||||
working-directory: apps/frontend
|
||||
run: npm run build
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
ci-complete:
|
||||
name: CI Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-python, test-frontend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all CI jobs passed
|
||||
run: |
|
||||
echo "CI Job Results:"
|
||||
echo " test-python: ${{ needs.test-python.result }}"
|
||||
echo " test-frontend: ${{ needs.test-frontend.result }}"
|
||||
echo ""
|
||||
|
||||
if [[ "${{ needs.test-python.result }}" != "success" ]] || \
|
||||
[[ "${{ needs.test-frontend.result }}" != "success" ]]; then
|
||||
echo "❌ One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All CI checks passed"
|
||||
|
||||
@@ -3,16 +3,31 @@ name: Lint
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/frontend/biome.jsonc'
|
||||
- '.pre-commit-config.yaml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/frontend/biome.jsonc'
|
||||
- '.pre-commit-config.yaml'
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Python linting
|
||||
# Python linting (Ruff) - already fast, no changes needed
|
||||
python:
|
||||
name: Python (Ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -23,7 +38,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
|
||||
# Pin ruff version to match .pre-commit-config.yaml
|
||||
- name: Install ruff
|
||||
run: pip install ruff==0.14.10
|
||||
|
||||
@@ -32,3 +47,43 @@ jobs:
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
|
||||
typescript:
|
||||
name: TypeScript (Biome)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Pin version to match package.json for consistent behavior
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
with:
|
||||
version: 2.3.11
|
||||
|
||||
- name: Run Biome
|
||||
working-directory: apps/frontend
|
||||
# biome ci fails on errors by default; warnings are reported but don't block
|
||||
# Use --error-on-warnings when ready to enforce all rules
|
||||
run: biome ci .
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
lint-complete:
|
||||
name: Lint Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python, typescript]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check lint results
|
||||
run: |
|
||||
if [[ "${{ needs.python.result }}" != "success" ]] || \
|
||||
[[ "${{ needs.typescript.result }}" != "success" ]]; then
|
||||
echo "❌ Linting failed"
|
||||
echo " Python: ${{ needs.python.result }}"
|
||||
echo " TypeScript: ${{ needs.typescript.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All linting passed"
|
||||
|
||||
@@ -64,9 +64,7 @@ jobs:
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'],
|
||||
STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'],
|
||||
REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review']
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
@@ -230,7 +228,6 @@ jobs:
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title || '';
|
||||
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
|
||||
@@ -271,25 +268,9 @@ jobs:
|
||||
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
|
||||
|
||||
// 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes)
|
||||
// Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('🔄 Checking');
|
||||
CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 🔄 Status: Checking`);
|
||||
} else {
|
||||
console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`);
|
||||
}
|
||||
|
||||
// 5. Add review label for new PRs only
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('Missing AC Approval');
|
||||
console.log(` ⏳ Review: Missing AC Approval`);
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// 6. Apply label changes
|
||||
// 4. Apply label changes
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove labels that should be replaced (exclude ones we're adding)
|
||||
@@ -302,7 +283,7 @@ jobs:
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} labeled successfully`);
|
||||
|
||||
// 7. Write job summary
|
||||
// 5. Write job summary
|
||||
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
|
||||
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
|
||||
|
||||
@@ -312,9 +293,7 @@ jobs:
|
||||
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
|
||||
['Type', summaryType],
|
||||
['Area', summaryArea],
|
||||
['Size', sizeLabel],
|
||||
['Status', isNewPR ? '🔄 Checking' : '(unchanged)'],
|
||||
['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)']
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
|
||||
.write();
|
||||
|
||||
@@ -1,585 +0,0 @@
|
||||
name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
types: [completed]
|
||||
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
concurrency:
|
||||
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
env:
|
||||
# Shared configuration - single source of truth
|
||||
REQUIRED_CHECKS: |
|
||||
CI / test-frontend
|
||||
CI / test-python (3.12)
|
||||
CI / test-python (3.13)
|
||||
Lint / python
|
||||
Quality Security / CodeQL (javascript-typescript)
|
||||
Quality Security / CodeQL (python)
|
||||
Quality Security / Python Security (Bandit)
|
||||
Quality Security / Security Summary
|
||||
|
||||
jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 1: CI STATUS (triggered by workflow_run)
|
||||
# Updates CI status labels when monitored workflows complete
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-ci-status:
|
||||
name: Update CI Status
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function fetchCheckRuns(sha) {
|
||||
const { owner, repo } = context.repo;
|
||||
// Let the configured retries (retries: 3) handle transient failures
|
||||
// Don't catch errors - allow them to propagate for retry logic
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: sha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
return checkRuns;
|
||||
}
|
||||
|
||||
function analyzeChecks(checkRuns) {
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
return { allComplete, anyFailed, results };
|
||||
}
|
||||
|
||||
async function updateStatusLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
const allLabels = Object.values(STATUS_LABELS);
|
||||
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of allLabels) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
const checkRuns = await fetchCheckRuns(headSha);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
|
||||
|
||||
for (const r of results) {
|
||||
console.log(` ${r.status} ${r.name}`);
|
||||
}
|
||||
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`);
|
||||
// Update to CHECKING status if checks are still running (prevents stale Ready/Failed status)
|
||||
await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
await updateStatusLabels(prNumber, newLabel);
|
||||
|
||||
const passedCount = results.filter(r => r.status === '✅ Passed').length;
|
||||
const failedCount = results.filter(r => r.failed).length;
|
||||
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`);
|
||||
} else {
|
||||
console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 2: /check-status COMMAND
|
||||
# Manual status check - anyone can trigger by commenting /check-status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
check-status-command:
|
||||
name: Check Status Command
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Run status check and post report
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.issue.number;
|
||||
const requestedBy = context.payload.comment.user.login;
|
||||
|
||||
// Get PR details
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber
|
||||
});
|
||||
const headSha = pr.head.sha;
|
||||
|
||||
console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
// Fetch check runs with pagination to handle >100 checks
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: headSha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
|
||||
// Analyze results
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, emoji: '⏳', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, emoji: '🔄', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, emoji: '✅', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = currentLabels.map(l => l.name);
|
||||
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
|
||||
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
|
||||
|
||||
// Update label if all checks complete
|
||||
let newStatusLabel = STATUS_LABELS.CHECKING;
|
||||
let statusChanged = false;
|
||||
|
||||
if (allComplete) {
|
||||
newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
|
||||
if (newStatusLabel !== currentStatusLabel) {
|
||||
statusChanged = true;
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of Object.values(STATUS_LABELS)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] });
|
||||
}
|
||||
}
|
||||
|
||||
// Build status report
|
||||
const passedCount = results.filter(r => r.emoji === '✅').length;
|
||||
let statusEmoji = '🔄';
|
||||
if (allComplete && !anyFailed) statusEmoji = '✅';
|
||||
else if (allComplete && anyFailed) statusEmoji = '❌';
|
||||
|
||||
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
|
||||
|
||||
const lines = [
|
||||
`## ${statusEmoji} PR Status Report`,
|
||||
'',
|
||||
`| Label | Value |`,
|
||||
`|-------|-------|`,
|
||||
`| CI Status | ${newStatusLabel} |`,
|
||||
`| AC Review | ${currentReviewLabel} |`,
|
||||
''
|
||||
];
|
||||
|
||||
if (statusChanged) {
|
||||
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
lines.push('');
|
||||
lines.push('| Status | Check |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(checksTable);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: lines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted status report to PR #${prNumber}`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 3: AUTO-CLAUDE REVIEW
|
||||
# Processes Auto-Claude review comments from trusted sources
|
||||
# Security: Only bots and collaborators can update labels
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-review-status:
|
||||
name: Update Review Status
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
!contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check for Auto-Claude review
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// Security configuration
|
||||
// SECURITY: Only [bot] suffixed accounts are protected by GitHub.
|
||||
// Regular usernames can be registered by anyone and are NOT trusted.
|
||||
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
|
||||
'github-actions[bot]',
|
||||
'auto-claude[bot]'
|
||||
]);
|
||||
|
||||
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
|
||||
'COLLABORATOR',
|
||||
'MEMBER',
|
||||
'OWNER'
|
||||
]);
|
||||
|
||||
const IDENTIFIER_PATTERNS = Object.freeze([
|
||||
'🤖 Auto Claude PR Review',
|
||||
'Auto Claude Review',
|
||||
'Auto-Claude Review'
|
||||
]);
|
||||
|
||||
// SECURITY: Regex patterns are tightened to prevent false matches
|
||||
// Using \s* instead of .* and requiring specific emoji + verdict format
|
||||
const VERDICTS = Object.freeze({
|
||||
APPROVED: {
|
||||
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE
|
||||
regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i,
|
||||
label: 'AC: Approved'
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
patterns: ['NEEDS REVISION', 'Needs Revision'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠
|
||||
regex: /Merge Verdict:\s*🟠/,
|
||||
label: 'AC: Changes Requested'
|
||||
},
|
||||
BLOCKED: {
|
||||
patterns: ['BLOCKED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴
|
||||
regex: /Merge Verdict:\s*🔴/,
|
||||
label: 'AC: Blocked'
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
// SECURITY: Verify both username AND account type to prevent spoofing
|
||||
function isTrustedBot(username, userType) {
|
||||
const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
|
||||
// Only trust if it's a known bot account AND GitHub confirms it's a Bot type
|
||||
return isKnownBot && userType === 'Bot';
|
||||
}
|
||||
|
||||
function isTrustedAssociation(assoc) {
|
||||
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
|
||||
}
|
||||
|
||||
function isAutoClaudeComment(body) {
|
||||
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
|
||||
}
|
||||
|
||||
function parseVerdict(body) {
|
||||
const safeBody = body.slice(0, 5000);
|
||||
for (const [key, config] of Object.entries(VERDICTS)) {
|
||||
const patternMatch = config.patterns.some(p => safeBody.includes(p));
|
||||
const regexMatch = config.regex && config.regex.test(safeBody);
|
||||
if (patternMatch || regexMatch) {
|
||||
return { verdict: key, label: config.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateReviewLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Remove all review labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of REVIEW_LABELS) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
console.log(` Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
console.log(` Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.issue.number;
|
||||
const comment = context.payload.comment;
|
||||
const commenter = comment.user.login;
|
||||
const commenterType = comment.user.type;
|
||||
const authorAssociation = comment.author_association;
|
||||
const body = comment.body || '';
|
||||
|
||||
console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`);
|
||||
|
||||
// Security checks
|
||||
// SECURITY: Bot status requires BOTH username match AND verified Bot type
|
||||
const isBot = isTrustedBot(commenter, commenterType);
|
||||
const isCollaborator = isTrustedAssociation(authorAssociation);
|
||||
const isACComment = isAutoClaudeComment(body);
|
||||
|
||||
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
|
||||
|
||||
if (!isBot && !isCollaborator) {
|
||||
console.log('Skipping: Not a trusted bot or collaborator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isACComment) {
|
||||
console.log('Skipping: Not an Auto-Claude comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdictResult = parseVerdict(body);
|
||||
if (!verdictResult) {
|
||||
console.log('Skipping: Could not parse verdict');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
|
||||
await updateReviewLabels(prNumber, verdictResult.label);
|
||||
console.log(`✅ PR #${prNumber} review status updated`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 4: RE-REVIEW ON PUSH
|
||||
# When new commits pushed after AC approval, require re-review
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
require-re-review:
|
||||
name: Require Re-review on Push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check and reset AC approval if needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pusher = context.payload.sender.login;
|
||||
|
||||
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
|
||||
|
||||
// Get current labels
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = labels.map(l => l.name);
|
||||
|
||||
// Check if PR was approved
|
||||
const wasApproved = labelNames.includes('AC: Approved');
|
||||
|
||||
if (!wasApproved) {
|
||||
console.log('PR was not AC-approved, no action needed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PR was AC-approved, resetting to require re-review');
|
||||
|
||||
// Remove AC: Approved - throw on non-404 errors to prevent conflicting labels
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'AC: Approved'
|
||||
});
|
||||
console.log(' Removed: AC: Approved');
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels)
|
||||
core.error(`Failed to remove 'AC: Approved' label: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Add AC: Needs Re-review
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
|
||||
});
|
||||
console.log(' Added: AC: Needs Re-review');
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning("Label 'AC: Needs Re-review' does not exist");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Post notification comment
|
||||
const commentLines = [
|
||||
'## 🔄 Re-review Required',
|
||||
'',
|
||||
'New commits were pushed after Auto-Claude approval.',
|
||||
'',
|
||||
'| Previous | Current |',
|
||||
'|----------|---------|',
|
||||
'| `AC: Approved` | `AC: Needs Re-review` |',
|
||||
'',
|
||||
'Please run Auto-Claude review again or request a manual review.',
|
||||
'',
|
||||
'---',
|
||||
`<sub>Triggered by push from @${pusher}</sub>`
|
||||
];
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
|
||||
@@ -29,10 +29,23 @@ jobs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
new_version: ${{ steps.check.outputs.new_version }}
|
||||
steps:
|
||||
# Fail fast with clear error if PAT_TOKEN is not configured
|
||||
- name: Validate PAT_TOKEN is configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.PAT_TOKEN }}" ]; then
|
||||
echo "::error::PAT_TOKEN secret is not configured."
|
||||
echo "::error::This secret is required for automatic release triggering."
|
||||
echo "::error::See https://github.com/AndyMik90/Auto-Claude/pull/1043 for setup instructions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# IMPORTANT: Use PAT_TOKEN instead of GITHUB_TOKEN
|
||||
# When GITHUB_TOKEN pushes a tag, it does NOT trigger other workflows (GitHub security feature)
|
||||
# PAT_TOKEN allows the tag push to trigger release.yml automatically
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Get package version
|
||||
id: package
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
name: Quality Security
|
||||
|
||||
# CodeQL runs on all PRs, pushes to main, and weekly schedule
|
||||
# Note: CodeQL takes 20-30 min per language (40-60 min total)
|
||||
# Bandit is fast (5-10 min)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -45,6 +60,7 @@ jobs:
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# Bandit runs on all PRs - it's fast (5-10 min)
|
||||
python-security:
|
||||
name: Python Security (Bandit)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -65,8 +81,6 @@ jobs:
|
||||
id: bandit
|
||||
run: |
|
||||
echo "::group::Running Bandit security scan"
|
||||
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
|
||||
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
|
||||
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
|
||||
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
|
||||
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
|
||||
@@ -80,7 +94,6 @@ jobs:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// Check if report exists
|
||||
if (!fs.existsSync('bandit-report.json')) {
|
||||
core.setFailed('Bandit report not found - scan may have failed');
|
||||
return;
|
||||
@@ -89,38 +102,23 @@ jobs:
|
||||
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
|
||||
const results = report.results || [];
|
||||
|
||||
// Categorize by severity
|
||||
const high = results.filter(r => r.issue_severity === 'HIGH');
|
||||
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
|
||||
const low = results.filter(r => r.issue_severity === 'LOW');
|
||||
|
||||
console.log(`::group::Bandit Security Scan Results`);
|
||||
console.log(`Found ${results.length} issues:`);
|
||||
console.log(` 🔴 HIGH: ${high.length}`);
|
||||
console.log(` 🟡 MEDIUM: ${medium.length}`);
|
||||
console.log(` 🟢 LOW: ${low.length}`);
|
||||
console.log('');
|
||||
|
||||
// Print high severity issues
|
||||
if (high.length > 0) {
|
||||
console.log('High Severity Issues:');
|
||||
console.log('─'.repeat(60));
|
||||
for (const issue of high) {
|
||||
console.log(` ${issue.filename}:${issue.line_number}`);
|
||||
console.log(` ${issue.issue_text}`);
|
||||
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
console.log(` HIGH: ${high.length}`);
|
||||
console.log(` MEDIUM: ${medium.length}`);
|
||||
console.log(` LOW: ${low.length}`);
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Build summary
|
||||
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
|
||||
let summary = `## Python Security Scan (Bandit)\n\n`;
|
||||
summary += `| Severity | Count |\n`;
|
||||
summary += `|----------|-------|\n`;
|
||||
summary += `| 🔴 High | ${high.length} |\n`;
|
||||
summary += `| 🟡 Medium | ${medium.length} |\n`;
|
||||
summary += `| 🟢 Low | ${low.length} |\n\n`;
|
||||
summary += `| High | ${high.length} |\n`;
|
||||
summary += `| Medium | ${medium.length} |\n`;
|
||||
summary += `| Low | ${low.length} |\n\n`;
|
||||
|
||||
if (high.length > 0) {
|
||||
summary += `### High Severity Issues\n\n`;
|
||||
@@ -134,14 +132,15 @@ jobs:
|
||||
core.summary.addRaw(summary);
|
||||
await core.summary.write();
|
||||
|
||||
// Fail if high severity issues found
|
||||
if (high.length > 0) {
|
||||
core.setFailed(`Found ${high.length} high severity security issue(s)`);
|
||||
} else {
|
||||
console.log('✅ No high severity security issues found');
|
||||
console.log('No high severity security issues found');
|
||||
}
|
||||
|
||||
# Summary job that waits for all security checks
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
@@ -160,7 +159,7 @@ jobs:
|
||||
console.log(` CodeQL: ${codeql}`);
|
||||
console.log(` Bandit: ${bandit}`);
|
||||
|
||||
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
|
||||
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters, PR skipping CodeQL)
|
||||
const acceptable = ['success', 'skipped'];
|
||||
const codeqlOk = acceptable.includes(codeql);
|
||||
const banditOk = acceptable.includes(bandit);
|
||||
|
||||
+132
-206
@@ -1,5 +1,10 @@
|
||||
name: Release
|
||||
# Triggers on version tags (v*) to build and publish releases
|
||||
#
|
||||
# IMPORTANT: If branch protection is enabled on 'main', the update-readme job
|
||||
# requires a PAT or GitHub App token with bypass permissions to push directly.
|
||||
# Currently uses GITHUB_TOKEN which works if "Allow GitHub Actions to create
|
||||
# and approve pull requests" is enabled OR branch protection is not configured.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,7 +15,7 @@ on:
|
||||
dry_run:
|
||||
description: 'Test build without creating release'
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
@@ -18,6 +23,9 @@ jobs:
|
||||
# Note: macos-15-intel is the last Intel runner, supported until Fall 2027
|
||||
build-macos-intel:
|
||||
runs-on: macos-15-intel
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -26,23 +34,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Rust toolchain (for building native Python packages)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -80,27 +73,13 @@ jobs:
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -115,6 +94,9 @@ jobs:
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
runs-on: macos-15
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -123,23 +105,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
@@ -174,27 +141,13 @@ jobs:
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -222,24 +175,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
@@ -404,23 +341,8 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
@@ -472,8 +394,50 @@ jobs:
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
|
||||
finalize-notarization:
|
||||
needs: [build-macos-intel, build-macos-arm64]
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Intel DMG
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: intel
|
||||
|
||||
- name: Download ARM64 DMG
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: arm64
|
||||
|
||||
- name: Wait for notarization and staple
|
||||
uses: ./.github/actions/finalize-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
|
||||
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
|
||||
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
|
||||
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
|
||||
|
||||
- name: Upload stapled Intel DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-intel-stapled
|
||||
path: intel/*.dmg
|
||||
|
||||
- name: Upload stapled ARM64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-stapled
|
||||
path: arm64/*.dmg
|
||||
|
||||
create-release:
|
||||
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
needs: [build-macos-intel, build-macos-arm64, finalize-notarization, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -487,31 +451,74 @@ jobs:
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Flatten and validate artifacts
|
||||
- name: Flatten binary artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Validate that installer files exist (not just manifests)
|
||||
# Copy stapled macOS DMGs (from finalize-notarization job)
|
||||
# Validate that stapled DMGs exist before copying
|
||||
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
|
||||
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
else
|
||||
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Copy other macOS artifacts (zip, yml, blockmap) from original build
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy Windows and Linux artifacts
|
||||
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Validate that installer files exist
|
||||
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
if [ "$installer_count" -eq 0 ]; then
|
||||
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $installer_count installer(s):"
|
||||
echo "Found $installer_count binary artifact(s):"
|
||||
find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \;
|
||||
|
||||
# Merge macOS manifests from Intel and ARM64 builds
|
||||
# See: https://github.com/electron-userland/electron-builder/issues/5592
|
||||
- name: Merge macOS manifests
|
||||
uses: ./.github/actions/merge-macos-manifests
|
||||
with:
|
||||
dist-path: dist
|
||||
output-path: release-assets
|
||||
copy-other-manifests: 'true'
|
||||
|
||||
- name: Validate manifests
|
||||
run: |
|
||||
# Validate that electron-updater manifest files are present (required for auto-updates)
|
||||
yml_count=$(find release-assets -type f -name "*.yml" | wc -l)
|
||||
if [ "$yml_count" -eq 0 ]; then
|
||||
echo "::error::No update manifest (.yml) files found! Auto-update architecture detection will not work."
|
||||
echo "::error::No update manifest (.yml) files found! Auto-update will not work."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $yml_count manifest file(s):"
|
||||
find release-assets -type f -name "*.yml" -exec basename {} \;
|
||||
|
||||
# Validate required manifests exist
|
||||
missing=""
|
||||
[ ! -f "release-assets/latest-mac.yml" ] && missing="$missing latest-mac.yml"
|
||||
[ ! -f "release-assets/latest.yml" ] && missing="$missing latest.yml"
|
||||
[ ! -f "release-assets/latest-linux.yml" ] && missing="$missing latest-linux.yml"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::Missing required manifests:$missing"
|
||||
echo "::error::Auto-update will fail on affected platforms!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All required manifests present:"
|
||||
echo " - latest-mac.yml (macOS)"
|
||||
echo " - latest.yml (Windows)"
|
||||
echo " - latest-linux.yml (Linux)"
|
||||
|
||||
echo ""
|
||||
echo "All release assets:"
|
||||
ls -la release-assets/
|
||||
@@ -644,95 +651,14 @@ jobs:
|
||||
|
||||
- name: Update README.md
|
||||
run: |
|
||||
python3 << 'EOF'
|
||||
import re
|
||||
import sys
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
|
||||
|
||||
version = "${{ steps.version.outputs.version }}"
|
||||
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
|
||||
|
||||
# Shields.io escapes hyphens as --
|
||||
version_badge = version.replace("-", "--")
|
||||
|
||||
# Read README
|
||||
with open("README.md", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
|
||||
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
|
||||
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
# Shields.io escaped pattern (hyphens as --)
|
||||
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
|
||||
def update_section(text, start_marker, end_marker, replacements):
|
||||
"""Update content between markers with given replacements."""
|
||||
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
|
||||
def replace_section(match):
|
||||
section = match.group(2)
|
||||
for old_pattern, new_value in replacements:
|
||||
section = re.sub(old_pattern, new_value, section)
|
||||
return match.group(1) + section + match.group(3)
|
||||
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
|
||||
|
||||
if is_prerelease:
|
||||
print(f"Updating BETA section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update beta badge
|
||||
content = re.sub(
|
||||
rf'beta-{semver_badge}-orange',
|
||||
f'beta-{version_badge}-orange',
|
||||
content
|
||||
)
|
||||
|
||||
# Update beta version badge link
|
||||
content = update_section(content,
|
||||
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update beta downloads
|
||||
content = update_section(content,
|
||||
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
else:
|
||||
print(f"Updating STABLE section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update top version badge
|
||||
content = update_section(content,
|
||||
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
|
||||
[
|
||||
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
|
||||
(rf'tag/v{semver}\)', f'tag/v{version})'),
|
||||
])
|
||||
|
||||
# Update stable badge
|
||||
content = re.sub(
|
||||
rf'stable-{semver_badge}-blue',
|
||||
f'stable-{version_badge}-blue',
|
||||
content
|
||||
)
|
||||
|
||||
# Update stable version badge link
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update stable downloads
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
|
||||
# Write updated README
|
||||
with open("README.md", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"README.md updated for {version} (prerelease={is_prerelease})")
|
||||
EOF
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
python3 scripts/update-readme.py "$VERSION" --prerelease
|
||||
else
|
||||
python3 scripts/update-readme.py "$VERSION"
|
||||
fi
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Test on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# Python tests
|
||||
test-python:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --tb=short
|
||||
|
||||
# Frontend tests
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/frontend
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Validate Version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
name: Validate package.json version matches tag
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: tag_version
|
||||
run: |
|
||||
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package_version
|
||||
run: |
|
||||
# Read version from package.json
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
|
||||
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package.json version: $PACKAGE_VERSION"
|
||||
|
||||
- name: Compare versions
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
|
||||
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Validation"
|
||||
echo "=========================================="
|
||||
echo "Git tag version: v$TAG_VERSION"
|
||||
echo "package.json version: $PACKAGE_VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Version mismatch detected!"
|
||||
echo ""
|
||||
echo "The version in package.json ($PACKAGE_VERSION) does not match"
|
||||
echo "the git tag version ($TAG_VERSION)."
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
|
||||
echo " 2. Update package.json version to $TAG_VERSION"
|
||||
echo " 3. Commit the change"
|
||||
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
|
||||
echo ""
|
||||
echo "Or use the automated script:"
|
||||
echo " node scripts/bump-version.js $TAG_VERSION"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ SUCCESS: Versions match!"
|
||||
echo ""
|
||||
|
||||
- name: Version validation result
|
||||
if: success()
|
||||
run: |
|
||||
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
|
||||
+6
-2
@@ -56,6 +56,7 @@ lerna-debug.log*
|
||||
# Auto Claude Generated
|
||||
# ===========================
|
||||
.auto-claude/
|
||||
.planning/
|
||||
.auto-build-security.json
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
@@ -107,7 +108,8 @@ dmypy.json
|
||||
# ===========================
|
||||
# Node.js (apps/frontend)
|
||||
# ===========================
|
||||
node_modules/
|
||||
node_modules
|
||||
apps/frontend/node_modules
|
||||
.npm
|
||||
.yarn/
|
||||
.pnp.*
|
||||
@@ -168,4 +170,6 @@ OPUS_ANALYSIS_AND_IDEAS.md
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
/shared_docs
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
|
||||
+140
-43
@@ -1,11 +1,52 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Preserve git worktree context - prevent HEAD corruption in worktrees
|
||||
# =============================================================================
|
||||
# GIT WORKTREE CONTEXT HANDLING
|
||||
# =============================================================================
|
||||
# When running in a worktree, we need to preserve git context to prevent HEAD
|
||||
# corruption. However, we must also CLEAR these variables when NOT in a worktree
|
||||
# to prevent cross-worktree contamination (files leaking between worktrees).
|
||||
#
|
||||
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
|
||||
# and this hook runs in the main repo (where .git is a directory, not a file),
|
||||
# git commands will target the wrong repository, causing files to appear as
|
||||
# untracked in the wrong location.
|
||||
#
|
||||
# Fix: Explicitly unset these variables when NOT in a worktree context.
|
||||
# =============================================================================
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
WORKTREE_GIT_DIR=$(cat .git | sed 's/gitdir: //')
|
||||
if [ -n "$WORKTREE_GIT_DIR" ]; then
|
||||
# We're in a worktree (.git is a file pointing to the actual git dir)
|
||||
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
|
||||
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
|
||||
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
|
||||
export GIT_DIR="$WORKTREE_GIT_DIR"
|
||||
export GIT_WORK_TREE="$(pwd)"
|
||||
else
|
||||
# .git file exists but is malformed or points to non-existent directory
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
else
|
||||
# We're in the main repo (.git is a directory)
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# If core.worktree is set in the main repo's config (pointing to a worktree),
|
||||
# this indicates previous corruption. Fix it automatically.
|
||||
if [ ! -f ".git" ]; then
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting, removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -62,8 +103,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
perl -i -pe 'if (/<!-- BETA_DOWNLOADS -->/ .. /<!-- BETA_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
@@ -78,8 +120,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
perl -i -pe 'if (/<!-- STABLE_DOWNLOADS -->/ .. /<!-- STABLE_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -140,18 +183,31 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
(
|
||||
cd apps/backend
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
|
||||
if [ -d ".venv" ]; then
|
||||
# Use venv if it exists
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
# Windows
|
||||
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
# Also skip tests that require optional dependencies (pydantic structured outputs)
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py --ignore=../../tests/test_finding_validation.py --ignore=../../tests/test_sdk_structured_output.py --ignore=../../tests/test_structured_outputs.py"
|
||||
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
if [ -f ".venv/bin/python" ]; then
|
||||
VENV_PYTHON=".venv/bin/python"
|
||||
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON=".venv/Scripts/python.exe"
|
||||
fi
|
||||
|
||||
if [ -n "$VENV_PYTHON" ]; then
|
||||
# Check if pytest is installed in venv
|
||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
echo "Warning: pytest not installed in venv. Installing test dependencies..."
|
||||
$VENV_PYTHON -m pip install -q -r ../../tests/requirements-test.txt
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
elif [ -d ".venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
@@ -170,45 +226,86 @@ fi
|
||||
# Check if there are staged files in apps/frontend
|
||||
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Frontend changes detected, running frontend checks..."
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
(
|
||||
cd apps/frontend
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "lint-staged failed. Please fix linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
# Detect if we're in a worktree and check if dependencies are available
|
||||
IS_WORKTREE=false
|
||||
DEPS_AVAILABLE=true
|
||||
|
||||
# Run TypeScript type check
|
||||
echo "Running type check..."
|
||||
npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
echo "Detected git worktree environment"
|
||||
fi
|
||||
|
||||
# Check if node_modules has actual dependencies by looking for a known package
|
||||
# @lydell/node-pty is required for terminal code and is a common source of TypeScript errors
|
||||
# It may be in root node_modules (hoisted) or apps/frontend/node_modules
|
||||
# Note: -d follows symlinks automatically, so this works for both real dirs and symlinks
|
||||
# We check for the full package path (@lydell/node-pty) rather than just the namespace
|
||||
# for precise detection - ensures the actual dependency is installed, not just any @lydell package
|
||||
if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/frontend/node_modules/@lydell/node-pty" ]; then
|
||||
DEPS_AVAILABLE=false
|
||||
fi
|
||||
|
||||
if [ "$DEPS_AVAILABLE" = false ]; then
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
# In worktree without dependencies - warn but allow commit
|
||||
echo ""
|
||||
echo "⚠️ WARNING: node_modules not available in this worktree."
|
||||
echo " TypeScript and lint checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
# Main repo without dependencies - this is an error
|
||||
echo "Error: node_modules not found. Run 'npm install' first."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Dependencies available - run full frontend checks
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
(
|
||||
cd apps/frontend
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "lint-staged failed. Please fix linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run linting
|
||||
echo "Running lint..."
|
||||
npm run lint
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
|
||||
exit 1
|
||||
fi
|
||||
# Run TypeScript type check
|
||||
echo "Running type check..."
|
||||
npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run linting
|
||||
echo "Running lint..."
|
||||
npm run lint
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for vulnerabilities (only high severity)
|
||||
echo "Checking for vulnerabilities..."
|
||||
npm audit --audit-level=high
|
||||
# Check for vulnerabilities (only critical severity)
|
||||
# Note: Using critical level because electron-builder has a known high-severity
|
||||
# tar vulnerability (CVE-2026-23745) that cannot be fixed until electron-builder
|
||||
# releases an update with [email protected] support. This is a build dependency, not runtime.
|
||||
echo "Checking for vulnerabilities..."
|
||||
npm audit --audit-level=critical
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Critical severity vulnerabilities found. Run 'npm audit fix' to resolve."
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
echo "Frontend checks passed!"
|
||||
fi
|
||||
echo "Frontend checks passed!"
|
||||
fi
|
||||
|
||||
echo "All pre-commit checks passed!"
|
||||
|
||||
+18
-7
@@ -76,6 +76,17 @@ repos:
|
||||
files: ^package\.json$
|
||||
pass_filenames: false
|
||||
|
||||
# Python encoding check - prevent regression of UTF-8 encoding fixes (PR #782)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-file-encoding
|
||||
name: Check file encoding parameters
|
||||
entry: python scripts/check_encoding.py
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^apps/backend/
|
||||
description: Ensures all file operations specify encoding="utf-8"
|
||||
|
||||
# Python linting (apps/backend/)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
@@ -126,24 +137,24 @@ repos:
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/)
|
||||
# Frontend linting (apps/frontend/) - Biome is 15-25x faster than ESLint
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: eslint
|
||||
name: ESLint
|
||||
- id: biome
|
||||
name: Biome (lint + format)
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping ESLint in worktree (node_modules not found)"
|
||||
echo "Skipping Biome in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run lint
|
||||
cd apps/frontend && npx biome check --write --no-errors-on-unmatched .
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx|json)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
|
||||
+391
@@ -1,3 +1,394 @@
|
||||
## 2.7.5 - Security & Platform Improvements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- One-time version 2.7.5 reauthentication warning modal for improved security awareness
|
||||
|
||||
- Enhanced authentication failure detection and handling with improved error recovery
|
||||
|
||||
- PR review validation pipeline with context enrichment and cross-validation support
|
||||
|
||||
- Terminal "Others" section in worktree dropdown for better organization
|
||||
|
||||
- Keyboard shortcut to toggle terminal expand/collapse for improved usability
|
||||
|
||||
- Searchable branch combobox in worktree creation dialog for easier branch selection
|
||||
|
||||
- Update Branch button in PR detail view for streamlined workflow
|
||||
|
||||
- Bulk select and create PR functionality for human review column
|
||||
|
||||
- Draggable Kanban task reordering for flexible task management
|
||||
|
||||
- YOLO mode to invoke Claude with --dangerously-skip-permissions for advanced users
|
||||
|
||||
- File and screenshot upload to QA feedback interface for better feedback submission
|
||||
|
||||
- Task worktrees section with terminal limit removal for expanded parallel work
|
||||
|
||||
- Claude Code version rollback feature for version management
|
||||
|
||||
- Linux secret-service support for OAuth token storage (ACS-293)
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Replace setup-token with embedded /login terminal flow for streamlined authentication
|
||||
|
||||
- Refactored authentication using platform abstraction for cross-platform reliability
|
||||
|
||||
- Removed redundant backend CLI detection (~230 lines) for cleaner codebase
|
||||
|
||||
- Replaced Select with Combobox for branch selection UI improvements
|
||||
|
||||
- Replace dangerouslySetInnerHTML with Trans component for better security practice
|
||||
|
||||
- Wait for CI checks before starting AI PR review for more accurate results
|
||||
|
||||
- Improved Claude CLI detection with installation selector
|
||||
|
||||
- Terminal rendering, persistence, and link handling improvements
|
||||
|
||||
- Enhanced terminal recreation logic with retry mechanism for reliability
|
||||
|
||||
- Improved worktree name input UX with better validation
|
||||
|
||||
- Made worktree isolation prominent in UI for user awareness
|
||||
|
||||
- Reduce ultrathink value from 65536 to 60000 for Opus 4.5 compatibility
|
||||
|
||||
- Standardized workflow naming and consolidated linting workflow
|
||||
|
||||
- Added gate jobs to CI/CD pipeline for better quality control
|
||||
|
||||
- Fast-path detection for merge commits without finding overlap in PR review
|
||||
|
||||
- Show progress percentage during planning phase on task cards
|
||||
|
||||
- PTY write improvements using PtyManager.writeToPty for safer terminal operations
|
||||
|
||||
- Consolidated package-lock.json to root level for simpler dependency management
|
||||
|
||||
- Graphiti memory feature fixes on macOS
|
||||
|
||||
- Model versions updated to Claude 4.5 with connected insights to frontend settings
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fixed Kanban board status flip-flopping and multi-location task deletion
|
||||
|
||||
- Fixed Windows CLI detection and version selection UX issues
|
||||
|
||||
- Fixed Windows coding phase not starting after spec/planning
|
||||
|
||||
- Fixed Windows UTF-8 encoding errors across entire backend (251 instances)
|
||||
|
||||
- Fixed 401 authentication errors by reading tokens from profile configDir
|
||||
|
||||
- Fixed Windows packaging by using SDK bundled Claude CLI
|
||||
|
||||
- Fixed false stuck detection during planning phase
|
||||
|
||||
- Fixed PR list update on post status click
|
||||
|
||||
- Fixed screenshot state persistence bug in task modals
|
||||
|
||||
- Fixed non-functional '+ Add' button for multiple Claude accounts
|
||||
|
||||
- Fixed GitHub Issues/PRs infinite scroll auto-fetch behavior
|
||||
|
||||
- Fixed GitHub PR state management and follow-up review trigger bug
|
||||
|
||||
- Fixed terminal output freezing on project switch
|
||||
|
||||
- Fixed terminal rendering on app close to prevent zombie processes
|
||||
|
||||
- Fixed stale terminal metadata filtering with auto-cleanup
|
||||
|
||||
- Fixed worktree configuration sync after PTY creation
|
||||
|
||||
- Fixed cross-worktree file leakage via environment variables
|
||||
|
||||
- Fixed .gitignore auto-commit during project initialization
|
||||
|
||||
- Fixed PR review verdict message contradiction and blocked status limbo
|
||||
|
||||
- Fixed re-review functionality when previous review failed
|
||||
|
||||
- Fixed agent profile resolution before falling back to defaults
|
||||
|
||||
- Fixed Windows shell command support in Claude CLI invocation
|
||||
|
||||
- Fixed model resolution using resolve_model_id() instead of hardcoded fallbacks
|
||||
|
||||
- Fixed ultrathink token budget correction from 64000 to 63999
|
||||
|
||||
- Fixed Windows pywin32 DLL loading failure on Python 3.8+
|
||||
|
||||
- Fixed circular import between spec.pipeline and core.client
|
||||
|
||||
- Fixed pywin32 bundling in Windows binary
|
||||
|
||||
- Fixed secretstorage bundling in Linux binary
|
||||
|
||||
- Fixed gh CLI detection for PR creation
|
||||
|
||||
- Fixed PYTHONPATH isolation to prevent pollution of external projects
|
||||
|
||||
- Fixed structured output capture from SDK ResultMessage in PR review
|
||||
|
||||
- Fixed CI status refresh before returning cached verdict
|
||||
|
||||
- Fixed Python environment readiness before spawning tasks
|
||||
|
||||
- Fixed pywintypes import errors during dependency validation
|
||||
|
||||
- Fixed Node.js and npm path detection on Windows packaged apps
|
||||
|
||||
- Fixed Windows PowerShell command separator usage
|
||||
|
||||
- Fixed require is not defined error in terminal handler
|
||||
|
||||
- Fixed Sentry DSN initialization error handling
|
||||
|
||||
- Fixed requestAnimationFrame fallback for flaky Ubuntu CI tests
|
||||
|
||||
- Fixed file drag-and-drop to terminals and task modals with branch status refresh
|
||||
|
||||
- Fixed GitHub issues pagination and infinite scroll
|
||||
|
||||
- Fixed delete worktree status regression
|
||||
|
||||
- Fixed Mac crash on Invoke Claude button
|
||||
|
||||
- Fixed worktree symlink for node_modules to enable TypeScript support
|
||||
|
||||
- Fixed PTY wait on Windows before recreating terminal
|
||||
|
||||
- Fixed terminal aggressive renaming on Claude invocation
|
||||
|
||||
- Fixed worktree dropdown scroll area to prevent overflow
|
||||
|
||||
- Fixed GitHub PR preloading currently under review
|
||||
|
||||
- Fixed actual base branch name display instead of hardcoded main
|
||||
|
||||
- Fixed Claude CLI detection with improved installation selector
|
||||
|
||||
- Fixed broken pipe errors with Sentry integration
|
||||
|
||||
- Fixed app update persistence for Install button visibility
|
||||
|
||||
- Fixed Claude exit detection and label reset
|
||||
|
||||
- Fixed file merging to include files with content changes
|
||||
|
||||
- Fixed worktree config sync on terminal restoration
|
||||
|
||||
- Fixed security profile inheritance in worktrees and shell -c validation
|
||||
|
||||
- Fixed terminal drag and drop reordering collision detection
|
||||
|
||||
- Fixed "already up to date" case handling in worktree operations
|
||||
|
||||
- Fixed Windows UTF-8 encoding and path handling issues
|
||||
|
||||
- Fixed Terminal label persistence after app restart
|
||||
|
||||
- Fixed worktree dropdown enhancement with scrolling support
|
||||
|
||||
- Fixed enforcement of 12 terminal limit per project
|
||||
|
||||
- Fixed macOS UTF-8 encoding errors (251 instances)
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Added fork configuration guidance to CONTRIBUTING.md
|
||||
|
||||
- Updated README download links to v2.7.4
|
||||
|
||||
### 🔧 Other Changes
|
||||
|
||||
- Removed node_modules symlink and cleaned up package-lock.json
|
||||
|
||||
- Added .planning/ to gitignore
|
||||
|
||||
- Migrated ESLint to Biome with optimized workflows
|
||||
|
||||
- Fixed tar vulnerability in dependencies
|
||||
|
||||
- Added minimatch to externalized dependencies
|
||||
|
||||
- Added exception handling for malformed DSN during Sentry initialization
|
||||
|
||||
- Corrected roadmap import path in roadmap_runner.py
|
||||
|
||||
- Added require polyfill for ESM/Sentry compatibility
|
||||
|
||||
- Addressed CodeQL security alerts and code quality issues
|
||||
|
||||
- Added shell: true and argument sanitization for Windows packaging
|
||||
|
||||
- Packaged runtime dependencies with pydantic_core validation
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- test(subprocess): add comprehensive auth failure detection tests by @AndyMik90 in ccaf82db
|
||||
- fix(security): replace dangerouslySetInnerHTML with Trans component and persist version warning by @AndyMik90 in 7aec35c3
|
||||
- chore: remove node_modules symlink and clean up package-lock.json by @AndyMik90 in 9768af8e
|
||||
- fix: address PR review issues and improve code quality by @AndyMik90 in 23a7e5a2
|
||||
- fix(auth): read tokens from profile configDir to fix 401 errors (#1385) by @Andy in 55857d6d
|
||||
- fix: Kanban board status flip-flopping and multi-location task deletion (#1387) by @Adam Slaker in 7dcb7bbe
|
||||
- fix(windows): use SDK bundled Claude CLI for Windows packaged apps (#1382) by @Andy in cd4e2d38
|
||||
- feat(auth): enhance authentication failure detection and handling by @AndyMik90 in 7ab10cd5
|
||||
- refactor(subprocess): use platform abstraction for auth failure process killing by @AndyMik90 in 17cffecc
|
||||
- feat(ui): add one-time version 2.7.5 reauthentication warning modal by @AndyMik90 in f49ef92a
|
||||
- refactor: remove redundant backend CLI detection (~230 lines) (#1367) by @Andy in c7bc01d5
|
||||
- feat(pr-review): add validation pipeline, context enrichment, and cross-validation (#1354) by @Andy in d8f4de9a
|
||||
- fix(terminal): rename Claude terminals only once on initial message (#1366) by @Andy in b2d2d7e9
|
||||
- feat(auth): add auth failure detection modal for Claude CLI 401 errors (#1361) by @Andy in 317d5e94
|
||||
- docs: add fork configuration guidance to CONTRIBUTING.md (#1364) by @Andy in c57534c3
|
||||
- Fix #609: Windows coding phase not starting after spec/planning (#1347) by @TamerineSky in 6da1b170
|
||||
- Fix Windows UTF-8 encoding errors across entire backend (251 instances) (#782) by @TamerineSky in 6a6247bb
|
||||
- chore: add .planning/ to gitignore by @AndyMik90 in 8df66245
|
||||
- feat(auth): replace setup-token with embedded /login terminal flow (#1321) by @Andy in 11f8d572
|
||||
- fix: Windows CLI detection and version selection UX improvements (#1341) by @StillKnotKnown in 8a2f3acd
|
||||
- fix: add shell: true and argument sanitization for Windows packaging (#1340) by @StillKnotKnown in e482fdf1
|
||||
- fix: package runtime deps and validate pydantic_core (#1336) by @StillKnotKnown in 141f44f6
|
||||
- fix(test): update mock profile manager and relax audit level by @Test User in 86ba0246
|
||||
- 2.7.4 release stable by @Test User in 3e2d6ef4
|
||||
- fix(tests): update claude-integration-handler tests for PtyManager.writeToPty by @Test User in 56743ff7
|
||||
- chore: consolidate package-lock.json to root level by @Test User in d4044d26
|
||||
- build: add minimatch to externalized dependencies by @Test User in 95f7f222
|
||||
- refactor(terminal): use PtyManager.writeToPty for safer PTY writes by @Test User in 4637a1a9
|
||||
- fix: correct ultrathink token budget from 64000 to 63999 by @Test User in efdb8c71
|
||||
- ci: migrate ESLint to Biome, optimize workflows, fix tar vulnerability (#1289) by @Andy in 0b2cf9b0
|
||||
- Fix API 401 - Token Decryption Before SDK Initialization (#1283) by @Andy in 4b740928
|
||||
- Fix Ultrathink Token Limit Bug (#1284) by @Andy in e989300b
|
||||
- fix(security): address CodeQL security alerts and code quality issues (#1286) by @Andy in f700b18d
|
||||
- fix(ui): make prose-invert conditional on dark mode for light theme support (#1160) by @youngmrz in 439ed86a
|
||||
- fix(terminal): add require polyfill for ESM/Sentry compatibility (#1275) by @VDT-91 in eb739afe
|
||||
- fix: add retry logic for planning-to-coding transition (#1276) by @kaigler in b8655904
|
||||
- fix(worktree): prevent cross-worktree file leakage via environment variables (#1267) by @Andy in 7cb9e0a3
|
||||
- Fix/cleanup 2.7.5 (#1271) by @Andy in f0c3e508
|
||||
- Fix False Stuck Detection During Planning Phase (#1236) by @Andy in 44304a61
|
||||
- fix(pr-review): allow re-review when previous review failed (#1268) by @Andy in 4cc8f4db
|
||||
- fix: enforce 12 terminal limit per project (#1264) by @Andy in d7ed770e
|
||||
- Draggable Kanban Task Reordering (#1217) by @Andy in 3606a632
|
||||
- fix(terminal): sync worktree config after PTY creation to fix first-attempt failure (#1213) by @Andy in 39236f18
|
||||
- fix: auto-commit .gitignore changes during project initialization (#1087) (#1124) by @youngmrz in ba089c5b
|
||||
- Fix terminal rendering, persistence, and link handling (#1215) by @Andy in 75a3684c
|
||||
- fix(windows): prevent zombie process accumulation on app close (#1259) by @VDT-91 in 90204469
|
||||
- update gitignore by @AndyMik90 in c13d9a40
|
||||
- Fix PR List Update on Post Status Click (#1207) by @Andy in 3085e392
|
||||
- Fix screenshot state persistence bug in task modals (#1235) by @Andy in 3024d547
|
||||
- Fix non-functional '+ Add' button for multiple Claude accounts (#1216) by @Andy in e27ff344
|
||||
- Fix GitHub Issues/PRs Infinite Scroll Auto-Fetch (#1239) by @Andy in b74b628b
|
||||
- Add bulk delete functionality to worktree overview (#1208) by @Andy in 8833feb2
|
||||
- Fix GitHub PR State Management - Follow-up Review Trigger Bug (#1238) by @Andy in 76f07720
|
||||
- auto-claude: subtask-1-1 - Add useEffect hook to reset expandedTerminalId when projectPath changes (#1240) by @Andy in d1131080
|
||||
- Fix Terminal Output Freezing on Project Switch (#1241) by @Andy in 193d2ed9
|
||||
- Add Update Branch Button to PR Detail View (#1242) by @Andy in 87c84073
|
||||
- Bulk Select All & Create PR for Human Review Column (#1248) by @Andy in 715202b8
|
||||
- fix(windows): resolve pywin32 DLL loading failure on Python 3.8+ (#1244) by @VDT-91 in cb786cac
|
||||
- fix(gh-cli): use get_gh_executable() and pass GITHUB_CLI_PATH from GUI (ACS-321) (#1232) by @StillKnotKnown in 14fbc2eb
|
||||
- auto-claude: subtask-1-1 - Replace Select with Combobox for branch selection (#1250) by @Andy in ed45ece5
|
||||
- fix(sentry): add exception handling for malformed DSN during Sentry initialization by @AndyMik90 in 4f86742b
|
||||
- dev dependecnies using npm install all by @AndyMik90 in e52a1ba4
|
||||
- hotfix/dev-dependency-missing by @AndyMik90 in a0033b1e
|
||||
- fix(frontend): resolve require is not defined error in terminal handler (#1243) by @Antti in 9117b59e
|
||||
- hotfix/node by @AndyMik90 in bb620044
|
||||
- fix(windows): add Node.js and npm paths to COMMON_BIN_PATHS for packaged apps (#1158) by @youngmrz in f0319bc8
|
||||
- fix/stale-task-creation by @AndyMik90 in 9612cf8d
|
||||
- fix/sentry-local-build by @AndyMik90 in b822797f
|
||||
- hotfix/tar-vurnability by @AndyMik90 in 2096b0e2
|
||||
- fix(tests): add requestAnimationFrame fallback for flaky Ubuntu CI tests by @AndyMik90 in 9739b338
|
||||
- fix(windows): use correct command separator for PowerShell terminals (#1159) by @youngmrz in cb8e46ca
|
||||
- fix(ui): show progress percentage during planning phase on task cards (#1162) by @youngmrz in 515aada1
|
||||
- fix(tests): isolate git operations in test fixtures from parent repository (#1205) by @Andy in 596b1e0c
|
||||
- feat(terminal): add "Others" section to worktree dropdown (#1209) by @Andy in 219cc068
|
||||
- fix(linux): ensure secretstorage is bundled in Linux binary (ACS-310) (#1211) by @StillKnotKnown in 48bd4a9c
|
||||
- fix(terminal): persist worktree label after app restart (#1210) by @Andy in ba7358af
|
||||
- fix: Graphiti memory feature on macOS (#1174) by @Alexander Penzin in c2e53d58
|
||||
- fix(windows): ensure pywin32 is bundled in Windows binary (ACS-306) (#1197) by @StillKnotKnown in 76af0aaa
|
||||
- fix(spec): resolve circular import between spec.pipeline and core.client (ACS-302) (#1192) by @StillKnotKnown in 648cf3fc
|
||||
- Fix Mac Crash on Invoke Claude Button (#1185) by @Andy in ae40f819
|
||||
- fix(worktree): symlink node_modules to worktrees for TypeScript support (#1148) by @Andy in d7c7ce8e
|
||||
- fix(terminal): wait for PTY exit on Windows before recreating terminal (#1184) by @Andy in d5d56975
|
||||
- fix(runners): use resolve_model_id() for model resolution instead of hardcoded fallbacks (ACS-294) (#1170) by @StillKnotKnown in 5199fdbf
|
||||
- fix(frontend): support Windows shell commands in Claude CLI invocation (ACS-261) (#1152) by @StillKnotKnown in 3a1966bd
|
||||
- feat(terminal): add keyboard shortcut to toggle expand/collapse (#1180) by @Andy in 1edfe333
|
||||
- fix(kanban): remove error column and add backend JSON repair (#1143) by @Andy in 51f67c5d
|
||||
- fix(ci): add gate jobs and consolidate linting workflow (#1182) by @Andy in 4b43f074
|
||||
- fix(ci): standardize workflow naming and remove redundant workflows (#1178) by @Andy in 4a3391b2
|
||||
- fix(terminal): enable scrolling in worktree dropdown when many items exist (#1175) by @Andy in 5525f36d
|
||||
- fix: windows (#1056) by @Alex in d6234f52
|
||||
- fix(backend): reduce ultrathink value from 65536 to 60000 for Opus 4.5 compatibility (#1173) by @StillKnotKnown in 30638c2f
|
||||
- feat(backend): add Linux secret-service support for OAuth token storage (ACS-293) (#1168) by @StillKnotKnown in a6934a8e
|
||||
- fix(terminal): prevent aggressive renaming on Claude invocation (#1147) by @Andy in 10bceac9
|
||||
- fix(pr-review): resolve verdict message contradiction and blocked status limbo (#1151) by @Andy in 8b269fea
|
||||
- feat(pr-review): add fast-path detection for merge commits without finding overlap (#1145) by @Andy in 32811142
|
||||
- fix(frontend): resolve agent profile before falling back to defaults (ACS-255) (#1068) by @StillKnotKnown in 33014682
|
||||
- fix(terminal): add scroll area to worktree dropdown to prevent overflow (#1146) by @Andy in 200bb3bc
|
||||
- fix(frontend): add windowsVerbatimArguments for Windows .cmd validation (ACS-252) (#1075) by @StillKnotKnown in 658f26cb
|
||||
- fix(backend): improve gh CLI detection for PR creation (ACS-247) (#1071) by @StillKnotKnown in 2eef82bf
|
||||
- fix(terminal): filter stale worktree metadata and auto-cleanup (#1038) by @Andy in 16bc37ce
|
||||
- Fix Delete Worktree Status Regression (#1076) by @Andy in 97f98ed7
|
||||
- 117-sidebar-update-banner (#1078) by @Andy in 4fd25b01
|
||||
- fix(ci): add beta manifest renaming and validation (#1002) (#1080) by @Andy in c6c6525b
|
||||
- fix: update all model versions to Claude 4.5 and connect insights to frontend settings (#1082) by @Andy in 58f4f30b
|
||||
- fix: file drag-and-drop to terminals and task modals + branch status refresh (#1092) by @Andy in b5c0e631
|
||||
- fix(github-issues): add pagination and infinite scroll for issues tab (#1042) by @Andy in f1674923
|
||||
- fix(ci): enable automatic release workflow triggering (#1043) by @Andy in 2ff9ccab
|
||||
- fix(backend): isolate PYTHONPATH to prevent pollution of external projects (ACS-251) (#1065) by @StillKnotKnown in 18d9b6cf
|
||||
- add time sensitive AI review logic (#1137) by @Andy in 5fb7574b
|
||||
- fix(pr-review): use list instead of tuple for line_range to fix SDK structured output (#1140) by @Andy in 45060ca3
|
||||
- feat(github-review): wait for CI checks before starting AI PR review (#1131) by @Andy in a55e4f68
|
||||
- fix(frontend): pass CLAUDE_CLI_PATH to Python backend subprocess (ACS-230) (#1081) by @StillKnotKnown in 5e91c3a7
|
||||
- fix(runners): correct roadmap import path in roadmap_runner.py (ACS-264) (#1091) by @StillKnotKnown in 767dd5c3
|
||||
- fix(pr-review): properly capture structured output from SDK ResultMessage (#1133) by @Andy in f28d2298
|
||||
- fix(github-review): refresh CI status before returning cached verdict (#1083) by @Andy in c3bdd4f8
|
||||
- fix(agent): ensure Python env is ready before spawning tasks (ACS-254) (#1061) by @StillKnotKnown in 7dc54f23
|
||||
- fix(windows): prevent pywintypes import errors before dependency validation (ACS-253) (#1057) by @StillKnotKnown in 71a9fc84
|
||||
- fix(docs): update README download links to v2.7.4 by @Test User in 67b39e52
|
||||
- fix readme for 2.7.4 by @Test User in a0800646
|
||||
- changelog 2.7.4 by @AndyMik90 in 1b5aecdd
|
||||
- 2.7.4 release by @AndyMik90 in 72797ac0
|
||||
- fix(frontend): validate Windows claude.cmd reliably in GUI (#1023) by @Umaru in 1ae3359b
|
||||
- fix(auth): await profile manager initialization before auth check (#1010) by @StillKnotKnown in c8374bc1
|
||||
- Add file/screenshot upload to QA feedback interface (#1018) by @Andy in 88277f84
|
||||
- feat(terminal): add task worktrees section and remove terminal limit (#1033) by @Andy in 17118b07
|
||||
- fix(terminal): enhance terminal recreation logic with retry mechanism (#1013) by @Andy in df1b8a3f
|
||||
- fix(terminal): improve worktree name input UX (#1012) by @Andy in 54e9f228
|
||||
- Make worktree isolation prominent in UI (#1020) by @Andy in 4dbb7ee4
|
||||
- feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions (#1016) by @Andy in d48e5f68
|
||||
- Fix Duplicate Kanban Task Creation on Rapid Button Clicks (#1021) by @Andy in 2d1d3ef1
|
||||
- feat(sentry): embed Sentry DSN at build time for packaged apps (#1025) by @Andy in aed28c5f
|
||||
- fix(github): resolve circular import issues in context_gatherer and services (#1026) by @Andy in 0307a4a9
|
||||
- hotfix/sentry-backend-build by @AndyMik90 in e7b38d49
|
||||
- chore: bump version to 2.7.4 by @AndyMik90 in 432e985b
|
||||
- fix(github-prs): prevent preloading of PRs currently under review (#1006) by @Andy in 1babcc86
|
||||
- fix(ui): display actual base branch name instead of hardcoded main (#969) by @Andy in 5d07d5f1
|
||||
- ci(release): move VirusTotal scan to separate post-release workflow (#980) by @Andy in 553d1e8d
|
||||
- fix: improve Claude CLI detection and add installation selector (#1004) by @Andy in e07a0dbd
|
||||
- fix(backend): add Sentry integration and fix broken pipe errors (#991) by @Andy in aa9fbe9d
|
||||
- fix(app-update): persist downloaded update state for Install button visibility (#992) by @Andy in 6f059bb5
|
||||
- fix(terminal): detect Claude exit and reset label when user closes Claude (#990) by @Andy in 14982e66
|
||||
- fix(merge): include files with content changes even when semantic analysis is empty (#986) by @Andy in 4736b6b6
|
||||
- fix(frontend): sync worktree config to renderer on terminal restoration (#982) by @Andy in 68fe0860
|
||||
- feat(frontend): add searchable branch combobox to worktree creation dialog (#979) by @Andy in 2a2dc3b8
|
||||
- fix(security): inherit security profiles in worktrees and validate shell -c commands (#971) by @Andy in 750ea8d1
|
||||
- feat(frontend): add Claude Code version rollback feature (#983) by @Andy in 8d21978f
|
||||
- fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles (#900) by @Michael Ludlow in e7427321
|
||||
- fix(terminal): add collision detection for terminal drag and drop reordering (#985) by @Andy in 1701160b
|
||||
- fix(worktree): handle "already up to date" case correctly (ACS-226) (#961) by @StillKnotKnown in 74ed4320
|
||||
- ci: add Azure auth test workflow by @AndyMik90 in d12eb523
|
||||
|
||||
## Thanks to all contributors
|
||||
|
||||
@AndyMik90, @Andy, @Adam Slaker, @TamerineSky, @StillKnotKnown, @Test User, @youngmrz, @VDT-91, @kaigler, @Alexander Penzin, @Antti, @Alex, @Michael Ludlow, @Umaru
|
||||
|
||||
## 2.7.4 - Terminal & Workflow Enhancements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -56,9 +56,10 @@ cd apps/backend && uv venv && uv pip install -r requirements.txt
|
||||
# Frontend (from apps/frontend/)
|
||||
cd apps/frontend && npm install
|
||||
|
||||
# Set up OAuth token
|
||||
claude setup-token
|
||||
# Add to apps/backend/.env: CLAUDE_CODE_OAUTH_TOKEN=your-token
|
||||
# Authenticate (token auto-saved to Keychain)
|
||||
claude
|
||||
# Then type: /login
|
||||
# Press Enter to open browser and complete OAuth
|
||||
```
|
||||
|
||||
### Creating and Running Specs
|
||||
@@ -401,6 +402,139 @@ const { t } = useTranslation(['errors']);
|
||||
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
|
||||
3. Never use hardcoded strings in JSX/TSX files
|
||||
|
||||
### Cross-Platform Development
|
||||
|
||||
**CRITICAL: This project supports Windows, macOS, and Linux. Platform-specific bugs are the #1 source of breakage.**
|
||||
|
||||
#### The Problem
|
||||
|
||||
When developers on macOS fix something using Mac-specific assumptions, it breaks on Windows. When Windows developers fix something, it breaks on macOS. This happens because:
|
||||
|
||||
1. **CI only tested on Linux** - Platform-specific bugs weren't caught until after merge
|
||||
2. **Scattered platform checks** - `process.platform === 'win32'` checks were spread across 50+ files
|
||||
3. **Hardcoded paths** - Direct paths like `C:\Program Files` or `/opt/homebrew/bin` throughout code
|
||||
|
||||
#### The Solution
|
||||
|
||||
**1. Centralized Platform Abstraction**
|
||||
|
||||
All platform-specific code now lives in dedicated modules:
|
||||
|
||||
- **Frontend:** `apps/frontend/src/main/platform/`
|
||||
- **Backend:** `apps/backend/core/platform/`
|
||||
|
||||
**Import from these modules instead of checking `process.platform` directly:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Direct platform check
|
||||
if (process.platform === 'win32') {
|
||||
// Windows logic
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Use abstraction
|
||||
import { isWindows, getPathDelimiter } from './platform';
|
||||
|
||||
if (isWindows()) {
|
||||
// Windows logic
|
||||
}
|
||||
```
|
||||
|
||||
**2. Multi-Platform CI**
|
||||
|
||||
CI now tests on **all three platforms** (Windows, macOS, Linux). A PR cannot merge unless all platforms pass:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
```
|
||||
|
||||
**3. Platform Module API**
|
||||
|
||||
The platform module provides:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `isWindows()` / `isMacOS()` / `isLinux()` | OS detection |
|
||||
| `getPathDelimiter()` | Get `;` (Windows) or `:` (Unix) |
|
||||
| `getExecutableExtension()` | Get `.exe` (Windows) or `` (Unix) |
|
||||
| `findExecutable(name)` | Find executables across platforms |
|
||||
| `getBinaryDirectories()` | Get platform-specific bin paths |
|
||||
| `requiresShell(command)` | Check if .cmd/.bat needs shell on Windows |
|
||||
|
||||
**4. Path Handling Best Practices**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Hardcoded Windows path
|
||||
const claudePath = 'C:\\Program Files\\Claude\\claude.exe';
|
||||
|
||||
// ❌ WRONG - Hardcoded macOS path
|
||||
const brewPath = '/opt/homebrew/bin/python3';
|
||||
|
||||
// ❌ WRONG - Manual path joining
|
||||
const fullPath = dir + '/subdir/file.txt';
|
||||
|
||||
// ✅ CORRECT - Use platform abstraction
|
||||
import { findExecutable, joinPaths } from './platform';
|
||||
|
||||
const claudePath = await findExecutable('claude');
|
||||
const fullPath = joinPaths(dir, 'subdir', 'file.txt');
|
||||
```
|
||||
|
||||
**5. Testing Platform-Specific Code**
|
||||
|
||||
```typescript
|
||||
// Mock process.platform for testing
|
||||
import { isWindows } from './platform';
|
||||
|
||||
// In tests, use jest.mock or similar
|
||||
jest.mock('./platform', () => ({
|
||||
isWindows: () => true // Simulate Windows
|
||||
}));
|
||||
```
|
||||
|
||||
**6. When You Need Platform-Specific Code**
|
||||
|
||||
If you must write platform-specific code:
|
||||
|
||||
1. **Add it to the platform module** - Not scattered in your feature code
|
||||
2. **Write tests for all platforms** - Mock `process.platform` to test each case
|
||||
3. **Use feature detection** - Check for file/path existence, not just OS name
|
||||
4. **Document why** - Explain the platform difference in comments
|
||||
|
||||
**7. Submitting Platform-Specific Fixes**
|
||||
|
||||
When fixing a platform-specific bug:
|
||||
|
||||
1. Ensure your fix doesn't break other platforms
|
||||
2. Test locally if you have access to other OSs
|
||||
3. Rely on CI to catch issues you can't test
|
||||
4. Consider adding a test that mocks other platforms
|
||||
|
||||
**Example: Adding a New Tool Detection**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Add to platform/paths.ts
|
||||
export function getMyToolPaths(): string[] {
|
||||
if (isWindows()) {
|
||||
return [
|
||||
joinPaths('C:', 'Program Files', 'MyTool', 'tool.exe'),
|
||||
// ... more Windows paths
|
||||
];
|
||||
}
|
||||
return [
|
||||
joinPaths('/usr', 'local', 'bin', 'mytool'),
|
||||
// ... more Unix paths
|
||||
];
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Use in your code
|
||||
import { findExecutable, getMyToolPaths } from './platform';
|
||||
|
||||
const toolPath = await findExecutable('mytool', getMyToolPaths());
|
||||
```
|
||||
|
||||
### End-to-End Testing (Electron App)
|
||||
|
||||
**IMPORTANT: When bug fixing or implementing new features in the frontend, AI agents can perform automated E2E testing using the Electron MCP server.**
|
||||
|
||||
+126
-2
@@ -16,6 +16,7 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
- [Testing](#testing)
|
||||
- [Continuous Integration](#continuous-integration)
|
||||
- [Git Workflow](#git-workflow)
|
||||
- [Working with Forks](#working-with-forks)
|
||||
- [Branch Overview](#branch-overview)
|
||||
- [Main Branches](#main-branches)
|
||||
- [Supporting Branches](#supporting-branches)
|
||||
@@ -154,7 +155,7 @@ The project consists of two main components:
|
||||
|
||||
### Python Backend
|
||||
|
||||
The recommended way is to use `npm run install:backend`, but you can also set up manually:
|
||||
The recommended way is to use `npm run install:backend` (or `npm run install:all` from the root), which automatically installs both runtime and test dependencies. You can also set up manually:
|
||||
|
||||
```bash
|
||||
# Navigate to the backend directory
|
||||
@@ -357,6 +358,64 @@ export default function(props) {
|
||||
- End files with a newline
|
||||
- Keep line length under 100 characters when practical
|
||||
|
||||
### File Encoding (Python)
|
||||
|
||||
**Always specify `encoding="utf-8"` for text file operations** to ensure Windows compatibility.
|
||||
|
||||
Windows Python defaults to `cp1252` encoding instead of UTF-8, causing errors with:
|
||||
- Emoji (🚀, ✅, ❌)
|
||||
- International characters (ñ, é, 中文, العربية)
|
||||
- Special symbols (™, ©, ®)
|
||||
|
||||
**DO:**
|
||||
|
||||
```python
|
||||
# Reading files
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Writing files
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Path methods
|
||||
from pathlib import Path
|
||||
content = Path(file).read_text(encoding="utf-8")
|
||||
Path(file).write_text(content, encoding="utf-8")
|
||||
|
||||
# JSON files - reading
|
||||
import json
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# JSON files - writing
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
```
|
||||
|
||||
**DON'T:**
|
||||
|
||||
```python
|
||||
# Wrong - platform-dependent encoding
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Wrong - Path methods without encoding
|
||||
content = Path(file).read_text()
|
||||
|
||||
# Wrong - encoding on json.dump (not open!)
|
||||
json.dump(data, f, encoding="utf-8") # ERROR
|
||||
```
|
||||
|
||||
**Binary files - NO encoding:**
|
||||
|
||||
```python
|
||||
with open(path, "rb") as f: # Correct
|
||||
data = f.read()
|
||||
```
|
||||
|
||||
Our pre-commit hooks automatically check for missing encoding parameters. See [PR #782](https://github.com/AndyMik90/Auto-Claude/pull/782) for the comprehensive encoding fix and [guides/windows-development.md](guides/windows-development.md) for Windows-specific development guidance.
|
||||
|
||||
## Testing
|
||||
|
||||
### Python Tests
|
||||
@@ -429,7 +488,6 @@ All pull requests and pushes to `main` trigger automated CI checks via GitHub Ac
|
||||
|----------|---------|----------------|
|
||||
| **CI** | Push to `main`, PRs | Python tests (3.11 & 3.12), Frontend tests |
|
||||
| **Lint** | Push to `main`, PRs | Ruff (Python), ESLint + TypeScript (Frontend) |
|
||||
| **Test on Tag** | Version tags (`v*`) | Full test suite before release |
|
||||
|
||||
### PR Requirements
|
||||
|
||||
@@ -460,6 +518,72 @@ npm run typecheck
|
||||
|
||||
We use a **Git Flow** branching strategy to manage releases and parallel development.
|
||||
|
||||
### Working with Forks
|
||||
|
||||
When contributing to Auto Claude, you'll typically fork the repository first. Proper fork configuration is essential to avoid sync issues.
|
||||
|
||||
#### Initial Fork Setup
|
||||
|
||||
```bash
|
||||
# 1. Fork on GitHub (click the Fork button on the repo page)
|
||||
|
||||
# 2. Clone YOUR fork (not the original repo)
|
||||
git clone https://github.com/YOUR-USERNAME/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# 3. Verify your remotes point to YOUR fork
|
||||
git remote -v
|
||||
# Should show:
|
||||
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (fetch)
|
||||
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (push)
|
||||
|
||||
# 4. Add upstream remote to sync with the original repo
|
||||
git remote add upstream https://github.com/AndyMik90/Auto-Claude.git
|
||||
```
|
||||
|
||||
#### Keeping Your Fork Updated
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Sync your develop branch with upstream
|
||||
git checkout develop
|
||||
git merge upstream/develop
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
#### Converting a Fork to Standalone
|
||||
|
||||
> ⚠️ **Common Issue:** After making a fork standalone (e.g., disconnecting from the original repo on GitHub), your local git configuration may still reference the original forked repository, causing push/pull issues.
|
||||
|
||||
If you convert your fork to a standalone repository:
|
||||
|
||||
```bash
|
||||
# 1. Update origin to point to your standalone repo
|
||||
git remote set-url origin https://github.com/YOUR-USERNAME/Your-Standalone-Repo.git
|
||||
|
||||
# 2. Remove the upstream remote (no longer applicable)
|
||||
git remote remove upstream
|
||||
|
||||
# 3. Verify your configuration
|
||||
git remote -v
|
||||
# Should only show your standalone repo as origin
|
||||
|
||||
# 4. Update your default branch tracking if needed
|
||||
git branch --set-upstream-to=origin/main main
|
||||
git branch --set-upstream-to=origin/develop develop
|
||||
```
|
||||
|
||||
#### Troubleshooting Fork Issues
|
||||
|
||||
| Problem | Cause | Solution |
|
||||
|---------|-------|----------|
|
||||
| `Permission denied` on push | Origin points to upstream repo | `git remote set-url origin <your-fork-url>` |
|
||||
| `Repository not found` | Fork was deleted or made standalone | Update remote URL to current repo location |
|
||||
| Can't push to develop | Local branch tracks wrong remote | `git branch --set-upstream-to=origin/develop` |
|
||||
| Commits show wrong author | Git config not set | `git config user.email "[email protected]"` |
|
||||
|
||||
### Branch Overview
|
||||
|
||||
```
|
||||
|
||||
@@ -186,7 +186,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
|----------|---------|---------|
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
|
||||
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
|
||||
| `update-readme` (in release.yml) | After release | Updates README with new version |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -269,9 +269,9 @@ GRAPHITI_ENABLED=true
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
|
||||
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
|
||||
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
@@ -368,5 +368,5 @@ GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
# Token Encryption Investigation
|
||||
|
||||
## Issue Summary
|
||||
|
||||
Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use.
|
||||
|
||||
**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..."
|
||||
|
||||
## Token Storage Format
|
||||
|
||||
### Encrypted Token Format
|
||||
|
||||
Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`:
|
||||
|
||||
```text
|
||||
enc:djEwtxMGISt3tQ...
|
||||
```
|
||||
|
||||
This format is used when tokens are stored in:
|
||||
- **macOS**: Keychain (service: "Claude Code-credentials")
|
||||
- **Linux**: Secret Service API (DBus, via secretstorage library)
|
||||
- **Windows**: Credential Manager / .credentials.json files
|
||||
|
||||
### Decrypted Token Format
|
||||
|
||||
Valid Claude OAuth tokens have the format:
|
||||
```text
|
||||
sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
## Current Token Flow (BROKEN)
|
||||
|
||||
1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain
|
||||
2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from:
|
||||
- Environment variable `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- OR system keychain via `get_token_from_keychain()`
|
||||
3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact
|
||||
4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK
|
||||
5. **API Call Fails**: SDK sends encrypted token to API → 401 error
|
||||
|
||||
### Proof of Broken Flow
|
||||
|
||||
Test in `apps/backend`:
|
||||
```python
|
||||
import os
|
||||
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123'
|
||||
|
||||
from core.auth import get_auth_token
|
||||
token = get_auth_token()
|
||||
print(f"Token: {token}") # Output: "enc:test123"
|
||||
print(f"Encrypted: {token.startswith('enc:')}") # Output: True
|
||||
```
|
||||
|
||||
## How Standalone Claude Code CLI Handles Tokens
|
||||
|
||||
### Current Understanding
|
||||
|
||||
1. **Token Detection**: CLI checks if token starts with `enc:` prefix
|
||||
2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access
|
||||
3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls
|
||||
|
||||
### Missing Documentation
|
||||
|
||||
Web search for "Claude Code CLI encrypted token enc: prefix decryption" found:
|
||||
- Token storage formats (JSON with accessToken, refreshToken, expiresAt)
|
||||
- Security issues (tokens exposed in debug logs before v2.1.0)
|
||||
- Keychain access patterns for macOS/Linux/Windows
|
||||
|
||||
**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens
|
||||
|
||||
Sources:
|
||||
- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/)
|
||||
- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam)
|
||||
- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/)
|
||||
|
||||
## Decryption Approach Options
|
||||
|
||||
### Option 1: Claude Agent SDK Built-in Decryption
|
||||
|
||||
**Status**: NEEDS VERIFICATION
|
||||
|
||||
The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if:
|
||||
- Token is passed to SDK still encrypted
|
||||
- SDK detects `enc:` prefix
|
||||
- SDK has access to system keyring for decryption
|
||||
|
||||
**Action Required**: Check if SDK has decryption capabilities by examining:
|
||||
- SDK source code or documentation
|
||||
- Whether SDK expects encrypted vs decrypted tokens
|
||||
- If SDK requires specific environment variables for decryption
|
||||
|
||||
### Option 2: Python Backend Decryption (Recommended)
|
||||
|
||||
**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK
|
||||
|
||||
**Implementation Pattern**:
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
"""Get authentication token (decrypted if necessary)."""
|
||||
token = _retrieve_token_from_sources() # From env or keychain
|
||||
|
||||
if token and token.startswith("enc:"):
|
||||
# Decrypt the token
|
||||
token = decrypt_token(token)
|
||||
|
||||
return token
|
||||
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""
|
||||
Decrypt Claude Code encrypted token.
|
||||
|
||||
Args:
|
||||
encrypted_token: Token with 'enc:' prefix
|
||||
|
||||
Returns:
|
||||
Decrypted token in format 'sk-ant-oat01-...'
|
||||
"""
|
||||
# Remove 'enc:' prefix
|
||||
encrypted_data = encrypted_token[4:]
|
||||
|
||||
# TODO: Implement decryption logic
|
||||
# Questions to answer:
|
||||
# 1. What encryption algorithm does Claude Code use?
|
||||
# 2. Where is the decryption key stored?
|
||||
# 3. Is the decryption key platform-specific (per-user)?
|
||||
# 4. Can we reuse Claude Code's decryption mechanism?
|
||||
|
||||
raise NotImplementedError("Token decryption not yet implemented")
|
||||
```
|
||||
|
||||
### Option 3: Call Claude Code CLI for Decryption
|
||||
|
||||
**Approach**: Use the Claude Code CLI binary to decrypt tokens
|
||||
|
||||
```python
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""Decrypt token by invoking Claude Code CLI."""
|
||||
# Find claude binary
|
||||
claude_path = shutil.which("claude") or "~/.local/bin/claude"
|
||||
|
||||
# Use CLI command to get decrypted token
|
||||
# (if such a command exists - needs research)
|
||||
result = subprocess.run(
|
||||
[claude_path, "auth", "decrypt", encrypted_token],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
**Issues**:
|
||||
- Requires Claude Code CLI to be installed
|
||||
- No documented CLI command for token decryption
|
||||
- Adds external dependency
|
||||
|
||||
## Required Investigation Steps
|
||||
|
||||
### 1. Verify SDK Decryption Capabilities
|
||||
|
||||
**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically
|
||||
|
||||
**Method**:
|
||||
```bash
|
||||
# In environment with SDK installed
|
||||
python3 << 'EOF'
|
||||
import os
|
||||
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token
|
||||
|
||||
from claude_agent_sdk import Client
|
||||
# Try creating client - does it decrypt internally?
|
||||
client = Client()
|
||||
# Check if authentication works
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Reverse Engineer Claude Code CLI Decryption
|
||||
|
||||
**Task**: Understand how Claude CLI decrypts tokens
|
||||
|
||||
**Method**:
|
||||
- Examine Claude CLI binary (if possible)
|
||||
- Trace system calls when CLI runs (strace on Linux, dtruss on macOS)
|
||||
- Check if CLI accesses specific keychain entries for decryption keys
|
||||
- Look for encryption/decryption libraries used by CLI
|
||||
|
||||
### 3. Find Decryption Key Storage
|
||||
|
||||
**Task**: Locate where decryption keys are stored
|
||||
|
||||
**Hypothesis**: Decryption key stored in:
|
||||
- macOS: Keychain (separate entry from encrypted token)
|
||||
- Linux: Secret Service API
|
||||
- Windows: Credential Manager
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
# macOS: List all keychain entries
|
||||
security find-generic-password -a "$(whoami)" | grep -i claude
|
||||
|
||||
# Linux: Use secretstorage to list all items
|
||||
python3 -c "import secretstorage; ..."
|
||||
```
|
||||
|
||||
## Recommended Decryption Approach for Python Backend
|
||||
|
||||
Based on investigation so far, the recommended approach is:
|
||||
|
||||
1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()`
|
||||
2. **Decrypt before use**: Implement `decrypt_token()` function
|
||||
3. **Platform-specific decryption**: Use appropriate keyring library:
|
||||
- macOS: Use `subprocess` with `/usr/bin/security` to access decryption key
|
||||
- Linux: Use `secretstorage` library to access Secret Service API
|
||||
- Windows: Access Credential Manager or credentials.json
|
||||
4. **Backward compatibility**: Support both encrypted and plaintext tokens
|
||||
5. **Error handling**: Provide clear error messages if decryption fails
|
||||
|
||||
## Complete Token Flow Trace (Frontend → Backend)
|
||||
|
||||
### 1. Token Retrieval (Frontend)
|
||||
|
||||
**File**: `apps/frontend/src/main/services/profile-service.ts`
|
||||
|
||||
The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on:
|
||||
- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- OR the Python backend retrieving it from the keychain
|
||||
|
||||
**Key Code**:
|
||||
```typescript
|
||||
// Line 223: Returns empty object in OAuth mode, allowing
|
||||
// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain
|
||||
```
|
||||
|
||||
### 2. Environment Variable Passing (Frontend → PTY)
|
||||
|
||||
**File**: `apps/frontend/src/main/terminal/pty-manager.ts`
|
||||
|
||||
The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`:
|
||||
|
||||
**Key Code** (Lines 149-152):
|
||||
```typescript
|
||||
// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens
|
||||
// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys
|
||||
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
|
||||
```
|
||||
|
||||
**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it.
|
||||
|
||||
### 3. Token Retrieval (Backend)
|
||||
|
||||
**File**: `apps/backend/core/auth.py`
|
||||
|
||||
#### 3.1. get_auth_token()
|
||||
|
||||
This function retrieves the token from multiple sources:
|
||||
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token # ❌ Returns immediately without checking for enc: prefix
|
||||
|
||||
# Fallback to system credential store
|
||||
return get_token_from_keychain() # ❌ Also returns without decryption
|
||||
```
|
||||
|
||||
**Issue**: Returns token as-is with `enc:` prefix intact.
|
||||
|
||||
#### 3.2. require_auth_token()
|
||||
|
||||
This function calls `get_auth_token()` and raises an error if no token is found:
|
||||
|
||||
```python
|
||||
def require_auth_token() -> str:
|
||||
token = get_auth_token() # ❌ Gets encrypted token
|
||||
if not token:
|
||||
raise ValueError("No OAuth token found...")
|
||||
return token # ❌ Returns encrypted token
|
||||
```
|
||||
|
||||
**Issue**: No decryption step between retrieval and return.
|
||||
|
||||
#### 3.3. ensure_claude_code_oauth_token()
|
||||
|
||||
This function ensures the environment variable is set:
|
||||
|
||||
```python
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
return
|
||||
|
||||
token = get_auth_token() # ❌ Gets encrypted token
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Propagates encrypted token to environment variable.
|
||||
|
||||
### 4. Token Usage in SDK Client Creation (Backend)
|
||||
|
||||
#### 4.1. Full Client Creation
|
||||
|
||||
**File**: `apps/backend/core/client.py` (see `create_client()` function)
|
||||
|
||||
```python
|
||||
def create_client(...):
|
||||
oauth_token = require_auth_token() # ❌ Gets encrypted token
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token.
|
||||
|
||||
#### 4.2. Simple Client Creation
|
||||
|
||||
**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function)
|
||||
|
||||
```python
|
||||
def create_simple_client(...):
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token() # ❌ Gets encrypted token
|
||||
import os
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Same problem - encrypted token passed to SDK.
|
||||
|
||||
#### 4.3. Other Usages
|
||||
|
||||
**Files**:
|
||||
- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations
|
||||
- `apps/backend/runners/insights_runner.py` - Insights analysis
|
||||
- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations
|
||||
- `apps/backend/integrations/linear/updater.py` - Linear integration
|
||||
- `apps/backend/commit_message.py` - Commit message generation
|
||||
- `apps/backend/analysis/insight_extractor.py` - Code insights
|
||||
- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution
|
||||
|
||||
**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error.
|
||||
|
||||
### 5. Where Decryption Should Be Inserted
|
||||
|
||||
Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic:
|
||||
|
||||
**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()`
|
||||
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
# ✅ INSERT DECRYPTION HERE
|
||||
if token.startswith("enc:"):
|
||||
token = decrypt_token(token)
|
||||
return token
|
||||
|
||||
# Fallback to system credential store
|
||||
token = get_token_from_keychain()
|
||||
# ✅ ALSO DECRYPT KEYCHAIN TOKENS
|
||||
if token and token.startswith("enc:"):
|
||||
token = decrypt_token(token)
|
||||
return token
|
||||
```
|
||||
|
||||
**Benefits of this approach**:
|
||||
1. Single location for decryption logic
|
||||
2. All downstream functions automatically get decrypted tokens
|
||||
3. Backward compatible (plaintext tokens pass through unchanged)
|
||||
4. Consistent behavior across all token sources (env vars and keychain)
|
||||
|
||||
**Alternative insertion points** (NOT recommended):
|
||||
- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage
|
||||
- `create_client()` - Would need duplication in `create_simple_client()` and all other clients
|
||||
- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED)
|
||||
2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED)
|
||||
3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED)
|
||||
4. ⏳ Verify if Claude Agent SDK handles decryption internally
|
||||
5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism
|
||||
6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py`
|
||||
7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()`
|
||||
8. ⏳ Test with real encrypted tokens on macOS and Linux
|
||||
9. ⏳ Add comprehensive error handling for decryption failures
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What encryption algorithm does Claude Code use for `enc:` tokens?**
|
||||
- Possible: AES-256, ChaCha20, or similar
|
||||
- Key derivation method?
|
||||
|
||||
2. **Where is the decryption key stored?**
|
||||
- Same keychain entry as encrypted token?
|
||||
- Separate keychain entry?
|
||||
- Derived from system/user credentials?
|
||||
|
||||
3. **Does Claude Agent SDK expect encrypted or decrypted tokens?**
|
||||
- If it expects decrypted: we must decrypt before passing
|
||||
- If it handles encryption: we may be missing SDK configuration
|
||||
|
||||
4. **Is there a Claude Code CLI command to decrypt tokens?**
|
||||
- `claude auth decrypt <token>`?
|
||||
- `claude auth get-token`?
|
||||
- No documented command found in research
|
||||
|
||||
5. **Can we reuse Claude Code's decryption mechanism?**
|
||||
- Import decryption functions from CLI?
|
||||
- Call CLI as subprocess?
|
||||
- Implement decryption ourselves?
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223)
|
||||
- Current auth implementation: `apps/backend/core/auth.py`
|
||||
- SDK client initialization: `apps/backend/core/client.py`
|
||||
- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux)
|
||||
@@ -17,12 +17,14 @@ python -m pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set your Claude API token in `.env`:
|
||||
```
|
||||
CLAUDE_CODE_OAUTH_TOKEN=your-token-here
|
||||
Authenticate with Claude Code (token auto-saved to Keychain):
|
||||
```bash
|
||||
claude
|
||||
# Type: /login
|
||||
# Press Enter to open browser
|
||||
```
|
||||
|
||||
Get your token by running: `claude setup-token`
|
||||
Token is auto-detected from macOS Keychain / Windows Credential Manager.
|
||||
|
||||
### 3. Run
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.4"
|
||||
__version__ = "2.7.5"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -226,7 +226,7 @@ async def run_autonomous_agent(
|
||||
print(" PAUSED BY HUMAN")
|
||||
print("=" * 70)
|
||||
|
||||
pause_content = pause_file.read_text().strip()
|
||||
pause_content = pause_file.read_text(encoding="utf-8").strip()
|
||||
if pause_content:
|
||||
print(f"\nMessage: {pause_content}")
|
||||
|
||||
@@ -319,7 +319,9 @@ async def run_autonomous_agent(
|
||||
task_logger.set_session(iteration)
|
||||
else:
|
||||
# Switch to coding phase after planning
|
||||
just_transitioned_from_planning = False
|
||||
if is_planning_phase:
|
||||
just_transitioned_from_planning = True
|
||||
is_planning_phase = False
|
||||
current_log_phase = LogPhase.CODING
|
||||
emit_phase(ExecutionPhase.CODING, "Starting implementation")
|
||||
@@ -338,8 +340,35 @@ async def run_autonomous_agent(
|
||||
print_status("Phase transition synced to main project", "success")
|
||||
|
||||
if not next_subtask:
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
# FIX for Issue #495: Race condition after planning phase
|
||||
# The implementation_plan.json may not be fully flushed to disk yet,
|
||||
# or there may be a brief delay before subtasks become available.
|
||||
# Retry with exponential backoff before giving up.
|
||||
if just_transitioned_from_planning:
|
||||
print_status(
|
||||
"Waiting for implementation plan to be ready...", "progress"
|
||||
)
|
||||
for retry_attempt in range(3):
|
||||
delay = (retry_attempt + 1) * 2 # 2s, 4s, 6s
|
||||
await asyncio.sleep(delay)
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
if next_subtask:
|
||||
# Update subtask_id and phase_name after successful retry
|
||||
subtask_id = next_subtask.get("id")
|
||||
phase_name = next_subtask.get("phase_name")
|
||||
print_status(
|
||||
f"Found subtask {subtask_id} after {delay}s delay",
|
||||
"success",
|
||||
)
|
||||
break
|
||||
print_status(
|
||||
f"Retry {retry_attempt + 1}/3: No subtask found yet...",
|
||||
"warning",
|
||||
)
|
||||
|
||||
if not next_subtask:
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
|
||||
# Get attempt count for recovery context
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
|
||||
@@ -247,7 +247,9 @@ AGENT_CONFIGS = {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
# Note: Default to "none" because insight_extractor uses Haiku which doesn't support thinking
|
||||
# If using Sonnet/Opus models, override max_thinking_tokens in create_simple_client()
|
||||
"thinking_default": "none",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
|
||||
@@ -171,7 +171,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file) as f:
|
||||
with open(codebase_map_file, encoding="utf-8") as f:
|
||||
codebase_map = json.load(f)
|
||||
else:
|
||||
codebase_map = {
|
||||
@@ -187,7 +187,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
codebase_map["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(codebase_map_file, "w") as f:
|
||||
with open(codebase_map_file, "w", encoding="utf-8") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
@@ -246,7 +246,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
entry += f"\n\n_Context: {context}_"
|
||||
entry += "\n"
|
||||
|
||||
with open(gotchas_file, "a") as f:
|
||||
with open(gotchas_file, "a", encoding="utf-8") as f:
|
||||
if not gotchas_file.exists() or gotchas_file.stat().st_size == 0:
|
||||
f.write(
|
||||
"# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n"
|
||||
@@ -303,7 +303,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
if codebase_map_file.exists():
|
||||
try:
|
||||
with open(codebase_map_file) as f:
|
||||
with open(codebase_map_file, encoding="utf-8") as f:
|
||||
codebase_map = json.load(f)
|
||||
|
||||
discoveries = codebase_map.get("discovered_files", {})
|
||||
@@ -319,7 +319,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
if gotchas_file.exists():
|
||||
try:
|
||||
content = gotchas_file.read_text()
|
||||
content = gotchas_file.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
result_parts.append("\n## Gotchas")
|
||||
# Take last 1000 chars to avoid too much context
|
||||
@@ -333,7 +333,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
patterns_file = memory_dir / "patterns.md"
|
||||
if patterns_file.exists():
|
||||
try:
|
||||
content = patterns_file.read_text()
|
||||
content = patterns_file.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
result_parts.append("\n## Patterns")
|
||||
result_parts.append(
|
||||
|
||||
@@ -57,7 +57,7 @@ def create_progress_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
stats = {
|
||||
|
||||
@@ -6,10 +6,14 @@ Tools for managing QA status and sign-off in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from spec.validate_pkg.auto_fix import auto_fix_plan
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
@@ -19,6 +23,53 @@ except ImportError:
|
||||
tool = None
|
||||
|
||||
|
||||
def _apply_qa_update(
|
||||
plan: dict[str, Any],
|
||||
status: str,
|
||||
issues: list[Any],
|
||||
tests_passed: dict[str, Any],
|
||||
) -> int:
|
||||
"""
|
||||
Apply QA update to the plan and return the new QA session number.
|
||||
|
||||
Args:
|
||||
plan: The implementation plan dict
|
||||
status: QA status (pending, in_review, approved, rejected, fixes_applied)
|
||||
issues: List of issues found
|
||||
tests_passed: Dict of test results
|
||||
|
||||
Returns:
|
||||
The new QA session number
|
||||
"""
|
||||
# Get current QA session number
|
||||
current_qa = plan.get("qa_signoff", {})
|
||||
qa_session = current_qa.get("qa_session", 0)
|
||||
if status in ["in_review", "rejected"]:
|
||||
qa_session += 1
|
||||
|
||||
plan["qa_signoff"] = {
|
||||
"status": status,
|
||||
"qa_session": qa_session,
|
||||
"issues_found": issues,
|
||||
"tests_passed": tests_passed,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# Update plan status to match QA result
|
||||
# This ensures the UI shows the correct column after QA
|
||||
if status == "approved":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
elif status == "rejected":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return qa_session
|
||||
|
||||
|
||||
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create QA management tools.
|
||||
@@ -89,37 +140,13 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
except json.JSONDecodeError:
|
||||
tests_passed = {}
|
||||
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Get current QA session number
|
||||
current_qa = plan.get("qa_signoff", {})
|
||||
qa_session = current_qa.get("qa_session", 0)
|
||||
if status in ["in_review", "rejected"]:
|
||||
qa_session += 1
|
||||
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
|
||||
|
||||
plan["qa_signoff"] = {
|
||||
"status": status,
|
||||
"qa_session": qa_session,
|
||||
"issues_found": issues,
|
||||
"tests_passed": tests_passed,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# Update plan status to match QA result
|
||||
# This ensures the UI shows the correct column after QA
|
||||
if status == "approved":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
elif status == "rejected":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(plan_file, "w") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
# Use atomic write to prevent file corruption
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
@@ -130,6 +157,47 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
]
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt to auto-fix the plan and retry
|
||||
if auto_fix_plan(spec_dir):
|
||||
# Retry after fix
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Updated QA status to '{status}' (session {qa_session}) (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logging.warning(
|
||||
f"QA update retry failed after auto-fix: {retry_err} (original error: {e})"
|
||||
)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: QA update failed after auto-fix: {retry_err} (original JSON error: {e})",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
|
||||
|
||||
@@ -6,10 +6,14 @@ Tools for managing subtask status in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from spec.validate_pkg.auto_fix import auto_fix_plan
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
@@ -19,6 +23,43 @@ except ImportError:
|
||||
tool = None
|
||||
|
||||
|
||||
def _update_subtask_in_plan(
|
||||
plan: dict[str, Any],
|
||||
subtask_id: str,
|
||||
status: str,
|
||||
notes: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Update a subtask in the plan.
|
||||
|
||||
Args:
|
||||
plan: The implementation plan dict
|
||||
subtask_id: ID of the subtask to update
|
||||
status: New status (pending, in_progress, completed, failed)
|
||||
notes: Optional notes to add
|
||||
|
||||
Returns:
|
||||
True if subtask was found and updated, False otherwise
|
||||
"""
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
subtask["status"] = status
|
||||
if notes:
|
||||
subtask["notes"] = notes
|
||||
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
|
||||
if subtask_found:
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return subtask_found
|
||||
|
||||
|
||||
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create subtask management tools.
|
||||
@@ -72,22 +113,10 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Find and update the subtask
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
subtask["status"] = status
|
||||
if notes:
|
||||
subtask["notes"] = notes
|
||||
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
subtask_found = _update_subtask_in_plan(plan, subtask_id, status, notes)
|
||||
|
||||
if not subtask_found:
|
||||
return {
|
||||
@@ -99,11 +128,8 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
]
|
||||
}
|
||||
|
||||
# Update plan metadata
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(plan_file, "w") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
# Use atomic write to prevent file corruption
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
@@ -115,6 +141,49 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt to auto-fix the plan and retry
|
||||
if auto_fix_plan(spec_dir):
|
||||
# Retry after fix
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
subtask_found = _update_subtask_in_plan(
|
||||
plan, subtask_id, status, notes
|
||||
)
|
||||
|
||||
if subtask_found:
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}' (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logging.warning(
|
||||
f"Subtask update retry failed after auto-fix: {retry_err}"
|
||||
)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask update failed after auto-fix: {retry_err}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
|
||||
@@ -48,9 +48,9 @@ def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w") as f:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Project index saved to: {output_file}")
|
||||
|
||||
@@ -87,7 +87,7 @@ def analyze_service(
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w") as f:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Service analysis saved to: {output_file}")
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ class BaseAnalyzer:
|
||||
def _read_file(self, path: str) -> str:
|
||||
"""Read a file relative to the analyzer's path."""
|
||||
try:
|
||||
return (self.path / path).read_text()
|
||||
return (self.path / path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class AuthDetector(BaseAnalyzer):
|
||||
|
||||
for py_file in all_py_files:
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
# Find custom decorators
|
||||
if (
|
||||
"@require" in content
|
||||
|
||||
@@ -52,7 +52,7 @@ class JobsDetector(BaseAnalyzer):
|
||||
tasks = []
|
||||
for task_file in celery_files:
|
||||
try:
|
||||
content = task_file.read_text()
|
||||
content = task_file.read_text(encoding="utf-8")
|
||||
# Find @celery.task or @shared_task decorators
|
||||
task_pattern = r"@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)"
|
||||
task_matches = re.findall(task_pattern, content)
|
||||
|
||||
@@ -77,7 +77,7 @@ class MonitoringDetector(BaseAnalyzer):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
# Look for actual Prometheus imports or usage patterns
|
||||
prometheus_patterns = [
|
||||
"from prometheus_client import",
|
||||
|
||||
@@ -52,7 +52,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in py_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -119,7 +119,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -168,7 +168,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
return models
|
||||
|
||||
try:
|
||||
content = schema_file.read_text()
|
||||
content = schema_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return models
|
||||
|
||||
@@ -216,7 +216,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in ts_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -265,7 +265,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in schema_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -295,7 +295,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
|
||||
@@ -287,6 +287,6 @@ class ProjectAnalyzer:
|
||||
|
||||
def _read_file(self, path: str) -> str:
|
||||
try:
|
||||
return (self.project_dir / path).read_text()
|
||||
return (self.project_dir / path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
@@ -66,7 +66,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -130,7 +130,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -179,7 +179,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in url_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -218,7 +218,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
files_to_check = js_files + ts_files
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -283,7 +283,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
|
||||
|
||||
try:
|
||||
content = route_file.read_text()
|
||||
content = route_file.read_text(encoding="utf-8")
|
||||
# Detect exported methods: export async function GET(request)
|
||||
methods = re.findall(
|
||||
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)",
|
||||
@@ -348,7 +348,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in go_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -384,7 +384,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in rust_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = wf_file.read_text()
|
||||
content = wf_file.read_text(encoding="utf-8")
|
||||
workflow_data = self._parse_yaml(content)
|
||||
|
||||
if not workflow_data:
|
||||
@@ -242,7 +242,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text()
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
@@ -312,7 +312,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text()
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
@@ -370,7 +370,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = jenkinsfile.read_text()
|
||||
content = jenkinsfile.read_text(encoding="utf-8")
|
||||
|
||||
# Extract sh commands using regex
|
||||
sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]')
|
||||
|
||||
@@ -33,7 +33,9 @@ except ImportError:
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
# Default model for insight extraction (fast and cheap)
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
|
||||
# Note: Using Haiku 4.5 for fast, cheap extraction. Haiku does not support
|
||||
# extended thinking, so thinking_default is set to "none" in models.py
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
# Maximum diff size to send to the LLM (avoid context limits)
|
||||
MAX_DIFF_CHARS = 15000
|
||||
@@ -235,7 +237,7 @@ def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Search through phases for the subtask
|
||||
@@ -278,7 +280,7 @@ def _build_extraction_prompt(inputs: dict) -> str:
|
||||
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
|
||||
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text()
|
||||
base_prompt = prompt_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
# Fallback if prompt file missing
|
||||
base_prompt = """Extract structured insights from this coding session.
|
||||
|
||||
@@ -302,7 +302,7 @@ class TestDiscovery:
|
||||
try:
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
pkg = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
@@ -398,7 +398,7 @@ class TestDiscovery:
|
||||
# Check pyproject.toml
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
content = pyproject.read_text(encoding="utf-8")
|
||||
|
||||
# Check for pytest
|
||||
if "pytest" in content:
|
||||
@@ -418,7 +418,7 @@ class TestDiscovery:
|
||||
# Check requirements.txt
|
||||
requirements = project_dir / "requirements.txt"
|
||||
if requirements.exists():
|
||||
content = requirements.read_text().lower()
|
||||
content = requirements.read_text(encoding="utf-8").lower()
|
||||
if "pytest" in content and not any(
|
||||
f.name == "pytest" for f in result.frameworks
|
||||
):
|
||||
@@ -496,7 +496,7 @@ class TestDiscovery:
|
||||
if not gemfile.exists():
|
||||
return
|
||||
|
||||
content = gemfile.read_text().lower()
|
||||
content = gemfile.read_text(encoding="utf-8").lower()
|
||||
|
||||
if "rspec" in content or (project_dir / ".rspec").exists():
|
||||
result.frameworks.append(
|
||||
|
||||
@@ -31,7 +31,7 @@ def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(batch_path) as f:
|
||||
with open(batch_path, encoding="utf-8") as f:
|
||||
batch_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print_status(f"Invalid JSON in batch file: {e}", "error")
|
||||
@@ -81,7 +81,7 @@ def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
|
||||
}
|
||||
|
||||
req_file = spec_dir / "requirements.json"
|
||||
with open(req_file, "w") as f:
|
||||
with open(req_file, "w", encoding="utf-8") as f:
|
||||
json.dump(requirements, f, indent=2, default=str)
|
||||
|
||||
created_specs.append(
|
||||
@@ -145,7 +145,7 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
|
||||
if req_file.exists():
|
||||
try:
|
||||
with open(req_file) as f:
|
||||
with open(req_file, encoding="utf-8") as f:
|
||||
req = json.load(f)
|
||||
title = req.get("task_description", title)
|
||||
except json.JSONDecodeError:
|
||||
|
||||
@@ -421,7 +421,7 @@ def _handle_build_interrupt(
|
||||
if human_input:
|
||||
# Save to HUMAN_INPUT.md
|
||||
input_file = spec_dir / "HUMAN_INPUT.md"
|
||||
input_file.write_text(human_input)
|
||||
input_file.write_text(human_input, encoding="utf-8")
|
||||
|
||||
content = [
|
||||
success(f"{icon(Icons.SUCCESS)} INSTRUCTIONS SAVED"),
|
||||
|
||||
@@ -121,7 +121,7 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_str).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
followup_task = file_path.read_text().strip()
|
||||
followup_task = file_path.read_text(encoding="utf-8").strip()
|
||||
if followup_task:
|
||||
print_status(
|
||||
f"Loaded {len(followup_task)} characters from file",
|
||||
@@ -195,7 +195,7 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
|
||||
|
||||
# Save to FOLLOWUP_REQUEST.md
|
||||
request_file = spec_dir / "FOLLOWUP_REQUEST.md"
|
||||
request_file.write_text(followup_task)
|
||||
request_file.write_text(followup_task, encoding="utf-8")
|
||||
|
||||
# Show confirmation
|
||||
content = [
|
||||
@@ -285,7 +285,7 @@ def handle_followup_command(
|
||||
# Check for prior follow-ups (for sequential follow-up context)
|
||||
prior_followup_count = 0
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan_data = json.load(f)
|
||||
phases = plan_data.get("phases", [])
|
||||
# Count phases that look like follow-up phases (name contains "Follow" or high phase number)
|
||||
|
||||
@@ -149,7 +149,7 @@ def read_from_file() -> str | None:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_input).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
content = file_path.read_text().strip()
|
||||
content = file_path.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
print_status(
|
||||
f"Loaded {len(content)} characters from file",
|
||||
|
||||
@@ -74,12 +74,12 @@ Examples:
|
||||
python auto-claude/run.py --spec 001 --qa-status # Check QA validation status
|
||||
|
||||
Prerequisites:
|
||||
1. Create a spec first: claude /spec
|
||||
2. Run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN
|
||||
1. Authenticate: Run 'claude' and type '/login'
|
||||
2. Create a spec first: claude /spec
|
||||
|
||||
Environment Variables:
|
||||
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (required)
|
||||
Get it by running: claude setup-token
|
||||
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (auto-detected from Keychain)
|
||||
Or authenticate via: claude → /login
|
||||
AUTO_BUILD_MODEL Override default model (optional)
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -56,7 +56,9 @@ def import_dotenv():
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
from graphiti_config import get_graphiti_status
|
||||
# NOTE: graphiti_config is imported lazily in validate_environment() to avoid
|
||||
# triggering graphiti_core -> real_ladybug -> pywintypes import chain before
|
||||
# platform dependency validation can run. See ACS-253.
|
||||
from linear_integration import LinearManager
|
||||
from linear_updater import is_linear_enabled
|
||||
from spec.pipeline import get_specs_dir
|
||||
@@ -205,6 +207,9 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
print("Linear integration: DISABLED (set LINEAR_API_KEY to enable)")
|
||||
|
||||
# Check Graphiti integration (optional but show status)
|
||||
# Lazy import to avoid triggering pywintypes import before validation (ACS-253)
|
||||
from graphiti_config import get_graphiti_status
|
||||
|
||||
graphiti_status = get_graphiti_status()
|
||||
if graphiti_status["available"]:
|
||||
print("Graphiti memory: ENABLED")
|
||||
|
||||
@@ -182,7 +182,7 @@ def _detect_worktree_base_branch(
|
||||
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text())
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if config.get("base_branch"):
|
||||
debug(
|
||||
MODULE,
|
||||
|
||||
@@ -37,8 +37,12 @@ class ContextBuilder:
|
||||
"""Load project index from file or create new one (.auto-claude is the installed instance)."""
|
||||
index_file = self.project_dir / ".auto-claude" / "project_index.json"
|
||||
if index_file.exists():
|
||||
with open(index_file) as f:
|
||||
return json.load(f)
|
||||
try:
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Corrupted or legacy-encoded file, regenerate
|
||||
pass
|
||||
|
||||
# Try to create one
|
||||
from analyzer import analyze_project
|
||||
@@ -230,7 +234,9 @@ class ContextBuilder:
|
||||
if context_file.exists():
|
||||
return {
|
||||
"source": "SERVICE_CONTEXT.md",
|
||||
"content": context_file.read_text()[:2000], # First 2000 chars
|
||||
"content": context_file.read_text(encoding="utf-8")[
|
||||
:2000
|
||||
], # First 2000 chars
|
||||
}
|
||||
|
||||
# Generate basic context from service info
|
||||
|
||||
@@ -72,7 +72,7 @@ def build_task_context(
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w") as f:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
print(f"Task context saved to: {output_file}")
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class PatternDiscoverer:
|
||||
for match in reference_files[:max_files]:
|
||||
try:
|
||||
file_path = self.project_dir / match.path
|
||||
content = file_path.read_text(errors="ignore")
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
# Look for common patterns
|
||||
for keyword in keywords:
|
||||
|
||||
@@ -41,7 +41,7 @@ class CodeSearcher:
|
||||
|
||||
for file_path in self._iter_code_files(service_path):
|
||||
try:
|
||||
content = file_path.read_text(errors="ignore")
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
content_lower = content.lower()
|
||||
|
||||
# Score this file
|
||||
|
||||
@@ -41,7 +41,7 @@ def save_context(context: TaskContext, output_file: Path) -> None:
|
||||
output_file: Path to output JSON file
|
||||
"""
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w") as f:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(serialize_context(context), f, indent=2)
|
||||
|
||||
|
||||
@@ -55,5 +55,5 @@ def load_context(input_file: Path) -> dict:
|
||||
Returns:
|
||||
Context dictionary
|
||||
"""
|
||||
with open(input_file) as f:
|
||||
with open(input_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
+697
-43
@@ -7,9 +7,29 @@ for custom API endpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.platform import (
|
||||
is_linux,
|
||||
is_macos,
|
||||
is_windows,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional import for Linux secret-service support
|
||||
# secretstorage provides access to the Freedesktop.org Secret Service API via DBus
|
||||
if TYPE_CHECKING:
|
||||
import secretstorage
|
||||
else:
|
||||
try:
|
||||
import secretstorage # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
secretstorage = None # type: ignore[assignment]
|
||||
|
||||
# Priority order for auth token resolution
|
||||
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
|
||||
@@ -38,9 +58,294 @@ SDK_ENV_VARS = [
|
||||
"API_TIMEOUT_MS",
|
||||
# Windows-specific: Git Bash path for Claude Code CLI
|
||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
||||
# Claude CLI path override (allows frontend to pass detected CLI path to SDK)
|
||||
"CLAUDE_CLI_PATH",
|
||||
# Profile's custom config directory (for multi-profile token storage)
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
]
|
||||
|
||||
|
||||
def is_encrypted_token(token: str | None) -> bool:
|
||||
"""
|
||||
Check if a token is encrypted (has "enc:" prefix).
|
||||
|
||||
Args:
|
||||
token: Token string to check (can be None)
|
||||
|
||||
Returns:
|
||||
True if token starts with "enc:", False otherwise
|
||||
"""
|
||||
return bool(token and token.startswith("enc:"))
|
||||
|
||||
|
||||
def validate_token_not_encrypted(token: str) -> None:
|
||||
"""
|
||||
Validate that a token is not in encrypted format.
|
||||
|
||||
This function should be called before passing a token to the Claude Agent SDK
|
||||
to ensure proper error messages when decryption has failed.
|
||||
|
||||
Args:
|
||||
token: Token string to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If token is in encrypted format (enc:...)
|
||||
"""
|
||||
if is_encrypted_token(token):
|
||||
raise ValueError(
|
||||
"Authentication token is in encrypted format and cannot be used.\n\n"
|
||||
"The token decryption process failed or was not attempted.\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
|
||||
"Note: Encrypted tokens require the Claude Code CLI to be installed\n"
|
||||
"and properly configured with system keychain access."
|
||||
)
|
||||
|
||||
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""
|
||||
Decrypt Claude Code encrypted token.
|
||||
|
||||
NOTE: This implementation currently relies on the system keychain (macOS Keychain,
|
||||
Linux Secret Service, Windows Credential Manager) to provide already-decrypted tokens.
|
||||
Encrypted tokens in the CLAUDE_CODE_OAUTH_TOKEN environment variable are NOT supported
|
||||
and will fail with NotImplementedError.
|
||||
|
||||
For encrypted token support, users should:
|
||||
1. Run: claude setup-token (stores decrypted token in system keychain)
|
||||
2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file
|
||||
|
||||
Claude Code CLI stores OAuth tokens in encrypted format with "enc:" prefix.
|
||||
This function attempts to decrypt the token using platform-specific methods.
|
||||
|
||||
Cross-platform token decryption approaches:
|
||||
- macOS: Token stored in Keychain with encryption key
|
||||
- Linux: Token stored in Secret Service API with encryption key
|
||||
- Windows: Token stored in Credential Manager or .credentials.json
|
||||
|
||||
Args:
|
||||
encrypted_token: Token with 'enc:' prefix from Claude Code CLI
|
||||
|
||||
Returns:
|
||||
Decrypted token in format 'sk-ant-oat01-...'
|
||||
|
||||
Raises:
|
||||
ValueError: If token format is invalid or decryption fails
|
||||
"""
|
||||
# Validate encrypted token format
|
||||
if not isinstance(encrypted_token, str):
|
||||
raise ValueError(
|
||||
f"Invalid token type. Expected string, got: {type(encrypted_token).__name__}"
|
||||
)
|
||||
|
||||
if not encrypted_token.startswith("enc:"):
|
||||
raise ValueError(
|
||||
"Invalid encrypted token format. Token must start with 'enc:' prefix."
|
||||
)
|
||||
|
||||
# Remove 'enc:' prefix to get encrypted data
|
||||
encrypted_data = encrypted_token[4:]
|
||||
|
||||
if not encrypted_data:
|
||||
raise ValueError("Empty encrypted token data after 'enc:' prefix")
|
||||
|
||||
# Basic validation of encrypted data format
|
||||
# Encrypted data should be a reasonable length (at least 10 chars)
|
||||
if len(encrypted_data) < 10:
|
||||
raise ValueError(
|
||||
"Encrypted token data is too short. The token may be corrupted."
|
||||
)
|
||||
|
||||
# Check for obviously invalid characters that suggest corruption
|
||||
# Accepts both standard base64 (+/) and URL-safe base64 (-_) to be permissive
|
||||
if not all(c.isalnum() or c in "+-_/=" for c in encrypted_data):
|
||||
raise ValueError(
|
||||
"Encrypted token contains invalid characters. "
|
||||
"Expected base64-encoded data. The token may be corrupted."
|
||||
)
|
||||
|
||||
# Attempt platform-specific decryption
|
||||
try:
|
||||
if is_macos():
|
||||
return _decrypt_token_macos(encrypted_data)
|
||||
elif is_linux():
|
||||
return _decrypt_token_linux(encrypted_data)
|
||||
elif is_windows():
|
||||
return _decrypt_token_windows(encrypted_data)
|
||||
else:
|
||||
raise ValueError("Unsupported platform for token decryption")
|
||||
|
||||
except NotImplementedError as e:
|
||||
# Decryption not implemented - log warning and provide guidance
|
||||
logger.warning(
|
||||
"Token decryption failed: %s. Users must use plaintext tokens.", str(e)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Encrypted token decryption is not yet implemented: {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token (without 'enc:' prefix)\n"
|
||||
" 2. Or re-authenticate with: claude setup-token"
|
||||
)
|
||||
except ValueError:
|
||||
# Re-raise ValueError as-is (already has good error message)
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
# File-related errors (missing credentials file, missing binary)
|
||||
raise ValueError(
|
||||
f"Failed to decrypt token - required file not found: {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Permission errors (can't access keychain, credential manager, etc.)
|
||||
raise ValueError(
|
||||
f"Failed to decrypt token - permission denied: {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Grant keychain/credential manager access to this application\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout during decryption process
|
||||
raise ValueError(
|
||||
"Failed to decrypt token - operation timed out.\n\n"
|
||||
"This may indicate a problem with system keychain access.\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
||||
)
|
||||
except Exception as e:
|
||||
# Catch-all for other errors - provide helpful error message
|
||||
error_type = type(e).__name__
|
||||
raise ValueError(
|
||||
f"Failed to decrypt token ({error_type}): {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
|
||||
"Note: Encrypted tokens (enc:...) require the Claude Code CLI to be installed\n"
|
||||
"and properly configured with system keychain access."
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_token_macos(encrypted_data: str) -> str:
|
||||
"""
|
||||
Decrypt token on macOS using Keychain.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
||||
|
||||
Returns:
|
||||
Decrypted token
|
||||
|
||||
Raises:
|
||||
ValueError: If decryption fails or Claude CLI not available
|
||||
"""
|
||||
# Verify Claude CLI is installed (required for future decryption implementation)
|
||||
if not shutil.which("claude"):
|
||||
raise ValueError(
|
||||
"Claude Code CLI not found. Please install it from https://code.claude.com"
|
||||
)
|
||||
|
||||
# The Claude Code CLI handles token decryption internally when it runs
|
||||
# We can trigger this by running a simple command that requires authentication
|
||||
# and capturing the decrypted token from the environment it sets up
|
||||
#
|
||||
# However, there's no direct CLI command to decrypt tokens.
|
||||
# The SDK should handle this automatically when it receives encrypted tokens.
|
||||
raise NotImplementedError(
|
||||
"Encrypted tokens in environment variables are not supported. "
|
||||
"Please use one of these options:\n"
|
||||
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
||||
"Note: This requires Claude Agent SDK >= 0.1.19"
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_token_linux(encrypted_data: str) -> str:
|
||||
"""
|
||||
Decrypt token on Linux using Secret Service API.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
||||
|
||||
Returns:
|
||||
Decrypted token
|
||||
|
||||
Raises:
|
||||
ValueError: If decryption fails or dependencies not available
|
||||
"""
|
||||
# Linux token decryption requires secretstorage library
|
||||
if secretstorage is None:
|
||||
raise ValueError(
|
||||
"secretstorage library not found. Install it with: pip install secretstorage"
|
||||
)
|
||||
|
||||
# Similar to macOS, the actual decryption mechanism isn't publicly documented
|
||||
# The Claude Agent SDK should handle this automatically
|
||||
raise NotImplementedError(
|
||||
"Encrypted tokens in environment variables are not supported. "
|
||||
"Please use one of these options:\n"
|
||||
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
||||
"Note: This requires Claude Agent SDK >= 0.1.19"
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_token_windows(encrypted_data: str) -> str:
|
||||
"""
|
||||
Decrypt token on Windows using Credential Manager.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
||||
|
||||
Returns:
|
||||
Decrypted token
|
||||
|
||||
Raises:
|
||||
ValueError: If decryption fails
|
||||
"""
|
||||
# Windows token decryption from Credential Manager or .credentials.json
|
||||
# The Claude Agent SDK should handle this automatically
|
||||
raise NotImplementedError(
|
||||
"Encrypted tokens in environment variables are not supported. "
|
||||
"Please use one of these options:\n"
|
||||
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
||||
"Note: This requires Claude Agent SDK >= 0.1.19"
|
||||
)
|
||||
|
||||
|
||||
def _try_decrypt_token(token: str | None) -> str | None:
|
||||
"""
|
||||
Attempt to decrypt an encrypted token, returning original if decryption fails.
|
||||
|
||||
This helper centralizes the decrypt-or-return-as-is logic used when resolving
|
||||
tokens from various sources (env vars, config dir, keychain).
|
||||
|
||||
Args:
|
||||
token: Token string (may be encrypted with "enc:" prefix, plaintext, or None)
|
||||
|
||||
Returns:
|
||||
- Decrypted token if successfully decrypted
|
||||
- Original token if decryption fails (allows client validation to report error)
|
||||
- Original token if not encrypted
|
||||
- None if token is None
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if is_encrypted_token(token):
|
||||
try:
|
||||
return decrypt_token(token)
|
||||
except ValueError:
|
||||
# Decryption failed - return encrypted token so client validation
|
||||
# (validate_token_not_encrypted) can provide specific error message.
|
||||
return token
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def get_token_from_keychain() -> str | None:
|
||||
"""
|
||||
Get authentication token from system credential store.
|
||||
@@ -48,20 +353,18 @@ def get_token_from_keychain() -> str | None:
|
||||
Reads Claude Code credentials from:
|
||||
- macOS: Keychain
|
||||
- Windows: Credential Manager
|
||||
- Linux: Not yet supported (use env var)
|
||||
- Linux: Secret Service API (via dbus/secretstorage)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
if is_macos():
|
||||
return _get_token_from_macos_keychain()
|
||||
elif system == "Windows":
|
||||
elif is_windows():
|
||||
return _get_token_from_windows_credential_files()
|
||||
else:
|
||||
# Linux: secret-service not yet implemented
|
||||
return None
|
||||
# Linux: use secret-service API via DBus
|
||||
return _get_token_from_linux_secret_service()
|
||||
|
||||
|
||||
def _get_token_from_macos_keychain() -> str | None:
|
||||
@@ -131,59 +434,216 @@ def _get_token_from_windows_credential_files() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or system credential store.
|
||||
def _get_token_from_linux_secret_service() -> str | None:
|
||||
"""Get token from Linux Secret Service API via DBus.
|
||||
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager)
|
||||
Claude Code on Linux stores credentials in the Secret Service API
|
||||
using the 'org.freedesktop.secrets' collection. This implementation
|
||||
uses the secretstorage library which communicates via DBus.
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
The credential is stored with:
|
||||
- Label: "Claude Code-credentials"
|
||||
- Attributes: {application: "claude-code"}
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# First check environment variables
|
||||
if secretstorage is None:
|
||||
# secretstorage not installed, fall back to env var
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get the default collection (typically "login" keyring)
|
||||
# secretstorage handles DBus communication internally
|
||||
try:
|
||||
collection = secretstorage.get_default_collection(None)
|
||||
except (
|
||||
AttributeError,
|
||||
secretstorage.exceptions.SecretServiceNotAvailableException,
|
||||
):
|
||||
# DBus not available or secret-service not running
|
||||
return None
|
||||
|
||||
if collection.is_locked():
|
||||
# Try to unlock the collection (may prompt user for password)
|
||||
try:
|
||||
collection.unlock()
|
||||
except secretstorage.exceptions.SecretStorageException:
|
||||
# User cancelled or unlock failed
|
||||
return None
|
||||
|
||||
# Search for items with our application attribute
|
||||
items = collection.search_items({"application": "claude-code"})
|
||||
|
||||
for item in items:
|
||||
# Check if this is the Claude Code credentials item
|
||||
label = item.get_label()
|
||||
# Use exact match for "Claude Code-credentials" to avoid false positives
|
||||
if label == "Claude Code-credentials":
|
||||
# Get the secret (stored as JSON string)
|
||||
secret = item.get_secret()
|
||||
if not secret:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Explicitly decode bytes to string if needed
|
||||
if isinstance(secret, bytes):
|
||||
secret = secret.decode("utf-8")
|
||||
data = json.loads(secret)
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
|
||||
if token and token.startswith("sk-ant-oat01-"):
|
||||
return token
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
except (
|
||||
secretstorage.exceptions.SecretStorageException,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
):
|
||||
# Any error with secret-service, fall back to env var
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_config_dir(config_dir: str) -> str | None:
|
||||
"""
|
||||
Read token from a custom config directory's credentials file.
|
||||
|
||||
Claude Code stores credentials in .credentials.json within the config directory.
|
||||
This function reads from a profile's custom configDir instead of the default location.
|
||||
|
||||
Args:
|
||||
config_dir: Path to the config directory (e.g., ~/.auto-claude/profiles/work)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# Expand ~ if present
|
||||
expanded_dir = os.path.expanduser(config_dir)
|
||||
|
||||
# Claude stores credentials in these files within the config dir
|
||||
cred_files = [
|
||||
os.path.join(expanded_dir, ".credentials.json"),
|
||||
os.path.join(expanded_dir, "credentials.json"),
|
||||
]
|
||||
|
||||
for cred_path in cred_files:
|
||||
if os.path.exists(cred_path):
|
||||
try:
|
||||
with open(cred_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Try both credential structures
|
||||
oauth_data = data.get("claudeAiOauth") or data.get("oauthAccount") or {}
|
||||
token = oauth_data.get("accessToken")
|
||||
|
||||
# Accept both plaintext tokens (sk-ant-oat01-) and encrypted tokens (enc:)
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
except (json.JSONDecodeError, KeyError, Exception) as e:
|
||||
logger.debug(f"Failed to read {cred_path}: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or credential store.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, reads credentials from this directory.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
|
||||
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. Custom config directory (config_dir param or CLAUDE_CONFIG_DIR env var)
|
||||
4. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
|
||||
If the token has an "enc:" prefix (encrypted format), it will be automatically
|
||||
decrypted before being returned.
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# First check environment variables (highest priority)
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Fallback to system credential store
|
||||
return get_token_from_keychain()
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# If a custom config directory is specified, read from there
|
||||
if effective_config_dir:
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Fallback to system credential store (default locations)
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
|
||||
|
||||
def get_auth_token_source() -> str | None:
|
||||
"""Get the name of the source that provided the auth token."""
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
"""
|
||||
Get the name of the source that provided the auth token.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, checks this directory for credentials.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var.
|
||||
"""
|
||||
# Check environment variables first
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
|
||||
# Check if token came from custom config directory (profile's configDir)
|
||||
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
if effective_config_dir and _get_token_from_config_dir(effective_config_dir):
|
||||
return "CLAUDE_CONFIG_DIR"
|
||||
|
||||
# Check if token came from system credential store
|
||||
if get_token_from_keychain():
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
if is_macos():
|
||||
return "macOS Keychain"
|
||||
elif system == "Windows":
|
||||
elif is_windows():
|
||||
return "Windows Credential Files"
|
||||
else:
|
||||
return "System Credential Store"
|
||||
return "Linux Secret Service"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def require_auth_token() -> str:
|
||||
def require_auth_token(config_dir: str | None = None) -> str:
|
||||
"""
|
||||
Get authentication token or raise ValueError.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, reads credentials from this directory.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
|
||||
|
||||
Raises:
|
||||
ValueError: If no auth token is found in any supported source
|
||||
"""
|
||||
token = get_auth_token()
|
||||
token = get_auth_token(config_dir)
|
||||
if not token:
|
||||
error_msg = (
|
||||
"No OAuth token found.\n\n"
|
||||
@@ -191,27 +651,33 @@ def require_auth_token() -> str:
|
||||
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
|
||||
)
|
||||
# Provide platform-specific guidance
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
if is_macos():
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token will be saved to macOS Keychain automatically\n\n"
|
||||
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser\n\n"
|
||||
"The token will be saved to macOS Keychain automatically."
|
||||
)
|
||||
elif system == "Windows":
|
||||
elif is_windows():
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token should be saved to Windows Credential Manager\n\n"
|
||||
"If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n"
|
||||
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser\n\n"
|
||||
"The token will be saved to Windows Credential Manager."
|
||||
)
|
||||
else:
|
||||
# Linux
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN in your .env file"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser\n\n"
|
||||
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
return token
|
||||
@@ -228,7 +694,7 @@ def _find_git_bash_path() -> str | None:
|
||||
Returns:
|
||||
Full path to bash.exe if found, None otherwise
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
if not is_windows():
|
||||
return None
|
||||
|
||||
# If already set in environment, use that
|
||||
@@ -316,11 +782,21 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
|
||||
# On Windows, auto-detect git-bash path if not already set
|
||||
# Claude Code CLI requires bash.exe to run on Windows
|
||||
if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
||||
if is_windows() and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
||||
bash_path = _find_git_bash_path()
|
||||
if bash_path:
|
||||
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
|
||||
|
||||
# Explicitly unset PYTHONPATH in SDK subprocess environment to prevent
|
||||
# pollution of agent subprocess environments. This fixes ACS-251 where
|
||||
# external projects with different Python versions would fail due to
|
||||
# inheriting Auto-Claude's PYTHONPATH (which points to Python 3.12 packages).
|
||||
#
|
||||
# The SDK merges os.environ with the env dict we provide, so setting
|
||||
# PYTHONPATH to an empty string here overrides any inherited value.
|
||||
# The empty string ensures Python doesn't add any extra paths to sys.path.
|
||||
env["PYTHONPATH"] = ""
|
||||
|
||||
return env
|
||||
|
||||
|
||||
@@ -337,3 +813,181 @@ def ensure_claude_code_oauth_token() -> None:
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
|
||||
|
||||
|
||||
def trigger_login() -> bool:
|
||||
"""
|
||||
Trigger Claude Code OAuth login flow.
|
||||
|
||||
Opens the Claude Code CLI and sends /login command to initiate
|
||||
browser-based OAuth authentication. The token is automatically
|
||||
saved to the system credential store (macOS Keychain, Windows
|
||||
Credential Manager).
|
||||
|
||||
Returns:
|
||||
True if login was successful, False otherwise
|
||||
"""
|
||||
if is_macos():
|
||||
return _trigger_login_macos()
|
||||
elif is_windows():
|
||||
return _trigger_login_windows()
|
||||
else:
|
||||
# Linux: fall back to manual instructions
|
||||
print("\nTo authenticate, run 'claude' and type '/login'")
|
||||
return False
|
||||
|
||||
|
||||
def _trigger_login_macos() -> bool:
|
||||
"""Trigger login on macOS using expect."""
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
# Check if expect is available
|
||||
if not shutil.which("expect"):
|
||||
print("\nTo authenticate, run 'claude' and type '/login'")
|
||||
return False
|
||||
|
||||
# Create expect script
|
||||
expect_script = """#!/usr/bin/expect -f
|
||||
set timeout 120
|
||||
spawn claude
|
||||
expect {
|
||||
-re ".*" {
|
||||
send "/login\\r"
|
||||
expect {
|
||||
"Press Enter" {
|
||||
send "\\r"
|
||||
}
|
||||
-re ".*login.*" {
|
||||
send "\\r"
|
||||
}
|
||||
timeout {
|
||||
send "\\r"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Keep running until user completes login or exits
|
||||
interact
|
||||
"""
|
||||
|
||||
# Use TemporaryDirectory context manager for automatic cleanup
|
||||
# This prevents information leakage about authentication activity
|
||||
# Directory created with mode 0o700 (owner read/write/execute only)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Ensure directory has owner-only permissions
|
||||
os.chmod(temp_dir, 0o700)
|
||||
|
||||
# Write expect script to temp file in our private directory
|
||||
script_path = os.path.join(temp_dir, "login.exp")
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
f.write(expect_script)
|
||||
|
||||
# Set script permissions to owner-only (0o700)
|
||||
os.chmod(script_path, 0o700)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("CLAUDE CODE LOGIN")
|
||||
print("=" * 60)
|
||||
print("\nOpening Claude Code for authentication...")
|
||||
print("A browser window will open for OAuth login.")
|
||||
print("After completing login in the browser, press Ctrl+C to exit.\n")
|
||||
|
||||
# Run expect script
|
||||
subprocess.run(
|
||||
["expect", script_path],
|
||||
timeout=300, # 5 minute timeout
|
||||
)
|
||||
|
||||
# Verify token was saved
|
||||
token = get_token_from_keychain()
|
||||
if token:
|
||||
print("\n✓ Login successful! Token saved to macOS Keychain.")
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
"\n✗ Login may not have completed. Try running 'claude' and type '/login'"
|
||||
)
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("\nLogin timed out. Try running 'claude' manually and type '/login'")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
# User pressed Ctrl+C - check if login completed
|
||||
token = get_token_from_keychain()
|
||||
if token:
|
||||
print("\n✓ Login successful! Token saved to macOS Keychain.")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"\nLogin failed: {e}")
|
||||
print("Try running 'claude' manually and type '/login'")
|
||||
return False
|
||||
|
||||
|
||||
def _trigger_login_windows() -> bool:
|
||||
"""Trigger login on Windows."""
|
||||
# Windows doesn't have expect by default, so we use a simpler approach
|
||||
# that just launches claude and tells the user what to type
|
||||
print("\n" + "=" * 60)
|
||||
print("CLAUDE CODE LOGIN")
|
||||
print("=" * 60)
|
||||
print("\nLaunching Claude Code...")
|
||||
print("Please type '/login' and press Enter.")
|
||||
print("A browser window will open for OAuth login.\n")
|
||||
|
||||
try:
|
||||
# Launch claude interactively
|
||||
subprocess.run(["claude"], timeout=300)
|
||||
|
||||
# Verify token was saved
|
||||
token = _get_token_from_windows_credential_files()
|
||||
if token:
|
||||
print("\n✓ Login successful!")
|
||||
return True
|
||||
else:
|
||||
print("\n✗ Login may not have completed.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nLogin failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def ensure_authenticated() -> str:
|
||||
"""
|
||||
Ensure the user is authenticated, prompting for login if needed.
|
||||
|
||||
Checks for existing token and triggers login flow if not found.
|
||||
|
||||
Returns:
|
||||
The authentication token
|
||||
|
||||
Raises:
|
||||
ValueError: If authentication fails after login attempt
|
||||
"""
|
||||
# First check if already authenticated
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
return token
|
||||
|
||||
# No token found - trigger login
|
||||
print("\nNo OAuth token found. Starting login flow...")
|
||||
|
||||
if trigger_login():
|
||||
# Re-check for token after login
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Login failed or was cancelled
|
||||
raise ValueError(
|
||||
"Authentication required.\n\n"
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser"
|
||||
)
|
||||
|
||||
+25
-301
@@ -16,14 +16,16 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.platform import (
|
||||
is_windows,
|
||||
validate_cli_path,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
@@ -123,290 +125,6 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None:
|
||||
logger.debug(f"Invalidated project index cache for {project_dir}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Claude CLI Path Detection
|
||||
# =============================================================================
|
||||
# Cross-platform detection of Claude Code CLI binary.
|
||||
# This mirrors the frontend's cli-tool-manager.ts logic to ensure consistency.
|
||||
|
||||
_CLAUDE_CLI_CACHE: dict[str, str | None] = {}
|
||||
_CLI_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _get_claude_detection_paths() -> dict[str, list[str]]:
|
||||
"""
|
||||
Get all candidate paths for Claude CLI detection.
|
||||
|
||||
Returns platform-specific paths where Claude CLI might be installed.
|
||||
|
||||
IMPORTANT: This function mirrors the frontend's getClaudeDetectionPaths()
|
||||
in apps/frontend/src/main/cli-tool-manager.ts. Both implementations MUST
|
||||
be kept in sync to ensure consistent detection behavior across the
|
||||
Python backend and Electron frontend.
|
||||
|
||||
When adding new detection paths, update BOTH:
|
||||
1. This function (_get_claude_detection_paths in client.py)
|
||||
2. getClaudeDetectionPaths() in cli-tool-manager.ts
|
||||
|
||||
Returns:
|
||||
Dict with 'homebrew', 'platform', and 'nvm' path lists
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/claude", # Apple Silicon
|
||||
"/usr/local/bin/claude", # Intel Mac
|
||||
]
|
||||
|
||||
if is_windows:
|
||||
platform_paths = [
|
||||
str(home_dir / "AppData" / "Local" / "Programs" / "claude" / "claude.exe"),
|
||||
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
|
||||
str(home_dir / ".local" / "bin" / "claude.exe"),
|
||||
"C:\\Program Files\\Claude\\claude.exe",
|
||||
"C:\\Program Files (x86)\\Claude\\claude.exe",
|
||||
]
|
||||
else:
|
||||
platform_paths = [
|
||||
str(home_dir / ".local" / "bin" / "claude"),
|
||||
str(home_dir / "bin" / "claude"),
|
||||
]
|
||||
|
||||
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
|
||||
|
||||
return {
|
||||
"homebrew": homebrew_paths,
|
||||
"platform": platform_paths,
|
||||
"nvm_versions_dir": nvm_versions_dir,
|
||||
}
|
||||
|
||||
|
||||
def _is_secure_path(path_str: str) -> bool:
|
||||
"""
|
||||
Validate that a path doesn't contain dangerous characters.
|
||||
|
||||
Prevents command injection attacks by rejecting paths with shell metacharacters,
|
||||
directory traversal patterns, or environment variable expansion.
|
||||
|
||||
Args:
|
||||
path_str: Path to validate
|
||||
|
||||
Returns:
|
||||
True if the path is safe, False otherwise
|
||||
"""
|
||||
import re
|
||||
|
||||
dangerous_patterns = [
|
||||
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
|
||||
r"%[^%]+%", # Windows environment variable expansion
|
||||
r"\.\./", # Unix directory traversal
|
||||
r"\.\.\\", # Windows directory traversal
|
||||
r"[\r\n]", # Newlines (command injection)
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, path_str):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate that a Claude CLI path is executable and returns a version.
|
||||
|
||||
Includes security validation to prevent command injection attacks.
|
||||
|
||||
Args:
|
||||
cli_path: Path to the Claude CLI executable
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, version_string or None)
|
||||
|
||||
Note:
|
||||
Cross-references with frontend's validateClaudeCliAsync() in
|
||||
apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts
|
||||
Both should be kept in sync for consistent behavior.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Security validation: reject paths with shell metacharacters or directory traversal
|
||||
if not _is_secure_path(cli_path):
|
||||
logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}")
|
||||
return False, None
|
||||
|
||||
try:
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
# Augment PATH with the CLI directory for proper resolution
|
||||
env = os.environ.copy()
|
||||
cli_dir = os.path.dirname(cli_path)
|
||||
if cli_dir:
|
||||
env["PATH"] = cli_dir + os.pathsep + env.get("PATH", "")
|
||||
|
||||
# For Windows .cmd/.bat files, use cmd.exe with proper quoting
|
||||
# /d = disable AutoRun registry commands
|
||||
# /s = strip first and last quotes, preserving inner quotes
|
||||
# /c = run command then terminate
|
||||
if is_windows and cli_path.lower().endswith((".cmd", ".bat")):
|
||||
# Get cmd.exe path from environment or use default
|
||||
cmd_exe = os.environ.get("ComSpec") or os.path.join(
|
||||
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
|
||||
)
|
||||
# Use double-quoted command line for paths with spaces
|
||||
cmd_line = f'""{cli_path}" --version"'
|
||||
result = subprocess.run(
|
||||
[cmd_exe, "/d", "/s", "/c", cmd_line],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[cli_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Extract version from output (e.g., "claude-code version 1.0.0")
|
||||
output = result.stdout.strip()
|
||||
match = re.search(r"(\d+\.\d+\.\d+)", output)
|
||||
version = match.group(1) if match else output.split("\n")[0]
|
||||
return True, version
|
||||
|
||||
return False, None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.debug(f"Claude CLI validation failed for {cli_path}: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
def find_claude_cli() -> str | None:
|
||||
"""
|
||||
Find the Claude Code CLI binary path.
|
||||
|
||||
Uses cross-platform detection with the following priority:
|
||||
1. CLAUDE_CLI_PATH environment variable (user override)
|
||||
2. shutil.which() - system PATH lookup
|
||||
3. Homebrew paths (macOS)
|
||||
4. NVM paths (Unix - checks Node.js version manager)
|
||||
5. Platform-specific standard locations
|
||||
|
||||
Returns:
|
||||
Path to Claude CLI if found and valid, None otherwise
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = "claude_cli"
|
||||
with _CLI_CACHE_LOCK:
|
||||
if cache_key in _CLAUDE_CLI_CACHE:
|
||||
cached = _CLAUDE_CLI_CACHE[cache_key]
|
||||
logger.debug(f"Using cached Claude CLI path: {cached}")
|
||||
return cached
|
||||
|
||||
is_windows = platform.system() == "Windows"
|
||||
paths = _get_claude_detection_paths()
|
||||
|
||||
# 1. Check environment variable override
|
||||
env_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_path:
|
||||
if Path(env_path).exists():
|
||||
valid, version = _validate_claude_cli(env_path)
|
||||
if valid:
|
||||
logger.info(f"Using CLAUDE_CLI_PATH: {env_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = env_path
|
||||
return env_path
|
||||
logger.warning(f"CLAUDE_CLI_PATH is set but invalid: {env_path}")
|
||||
|
||||
# 2. Try shutil.which() - most reliable cross-platform PATH lookup
|
||||
which_path = shutil.which("claude")
|
||||
if which_path:
|
||||
valid, version = _validate_claude_cli(which_path)
|
||||
if valid:
|
||||
logger.info(f"Found Claude CLI in PATH: {which_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = which_path
|
||||
return which_path
|
||||
|
||||
# 3. Homebrew paths (macOS)
|
||||
if platform.system() == "Darwin":
|
||||
for hb_path in paths["homebrew"]:
|
||||
if Path(hb_path).exists():
|
||||
valid, version = _validate_claude_cli(hb_path)
|
||||
if valid:
|
||||
logger.info(f"Found Claude CLI (Homebrew): {hb_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = hb_path
|
||||
return hb_path
|
||||
|
||||
# 4. NVM paths (Unix only) - check Node.js version manager installations
|
||||
if not is_windows:
|
||||
nvm_dir = Path(paths["nvm_versions_dir"])
|
||||
if nvm_dir.exists():
|
||||
try:
|
||||
# Get all version directories and sort by version (newest first)
|
||||
version_dirs = []
|
||||
for entry in nvm_dir.iterdir():
|
||||
if entry.is_dir() and entry.name.startswith("v"):
|
||||
# Parse version: v20.0.0 -> (20, 0, 0)
|
||||
try:
|
||||
parts = entry.name[1:].split(".")
|
||||
if len(parts) == 3:
|
||||
version_dirs.append(
|
||||
(tuple(int(p) for p in parts), entry.name)
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Sort by version descending (newest first)
|
||||
version_dirs.sort(reverse=True)
|
||||
|
||||
for _, version_name in version_dirs:
|
||||
nvm_claude = nvm_dir / version_name / "bin" / "claude"
|
||||
if nvm_claude.exists():
|
||||
valid, version = _validate_claude_cli(str(nvm_claude))
|
||||
if valid:
|
||||
logger.info(
|
||||
f"Found Claude CLI (NVM): {nvm_claude} (v{version})"
|
||||
)
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = str(nvm_claude)
|
||||
return str(nvm_claude)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error scanning NVM directory: {e}")
|
||||
|
||||
# 5. Platform-specific standard locations
|
||||
for plat_path in paths["platform"]:
|
||||
if Path(plat_path).exists():
|
||||
valid, version = _validate_claude_cli(plat_path)
|
||||
if valid:
|
||||
logger.info(f"Found Claude CLI: {plat_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = plat_path
|
||||
return plat_path
|
||||
|
||||
# Not found
|
||||
logger.warning(
|
||||
"Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
|
||||
)
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
def clear_claude_cli_cache() -> None:
|
||||
"""Clear the Claude CLI path cache, forcing re-detection on next call."""
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE.clear()
|
||||
logger.debug("Claude CLI cache cleared")
|
||||
|
||||
|
||||
from agents.tools_pkg import (
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
@@ -420,7 +138,11 @@ from agents.tools_pkg import (
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from core.auth import (
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
from linear_updater import is_linear_enabled
|
||||
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
|
||||
from security import bash_security_hook
|
||||
@@ -768,7 +490,14 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
# Get OAuth token - Claude CLI handles token lifecycle internally
|
||||
oauth_token = require_auth_token()
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
@@ -778,7 +507,7 @@ def create_client(
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
elif platform.system() == "Windows":
|
||||
elif is_windows():
|
||||
logger.warning("Git Bash path not detected on Windows!")
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
@@ -928,7 +657,7 @@ def create_client(
|
||||
|
||||
# Write settings to a file in the project directory
|
||||
settings_file = project_dir / ".claude_settings.json"
|
||||
with open(settings_file, "w") as f:
|
||||
with open(settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
print(f"Security settings: {settings_file}")
|
||||
@@ -1066,14 +795,6 @@ def create_client(
|
||||
print(" - CLAUDE.md: disabled by project settings")
|
||||
print()
|
||||
|
||||
# Find Claude CLI path for SDK
|
||||
# This ensures the SDK can find the Claude Code binary even if it's not in PATH
|
||||
cli_path = find_claude_cli()
|
||||
if cli_path:
|
||||
print(f" - Claude CLI: {cli_path}")
|
||||
else:
|
||||
print(" - Claude CLI: using SDK default detection")
|
||||
|
||||
# Build options dict, conditionally including output_format
|
||||
options_kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -1098,9 +819,12 @@ def create_client(
|
||||
"enable_file_checkpointing": True,
|
||||
}
|
||||
|
||||
# Add CLI path if found (helps SDK find Claude Code in non-standard locations)
|
||||
if cli_path:
|
||||
options_kwargs["cli_path"] = cli_path
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
# The SDK bundles its own CLI, but users can override if needed
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_cli_path and validate_cli_path(env_cli_path):
|
||||
options_kwargs["cli_path"] = env_cli_path
|
||||
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
|
||||
|
||||
# Add structured output format if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
|
||||
@@ -110,7 +110,7 @@ def _write_log(message: str, to_file: bool = True) -> None:
|
||||
import re
|
||||
|
||||
clean_message = re.sub(r"\033\[[0-9;]*m", "", message)
|
||||
with open(log_file, "a") as f:
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(clean_message + "\n")
|
||||
except Exception:
|
||||
pass # Silently fail file logging
|
||||
|
||||
@@ -8,6 +8,8 @@ Validates platform-specific dependencies are installed before running agents.
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.platform import is_linux, is_windows
|
||||
|
||||
|
||||
def validate_platform_dependencies() -> None:
|
||||
"""
|
||||
@@ -17,34 +19,116 @@ def validate_platform_dependencies() -> None:
|
||||
SystemExit: If required platform-specific dependencies are missing,
|
||||
with helpful installation instructions.
|
||||
"""
|
||||
# Check Windows-specific dependencies
|
||||
if sys.platform == "win32" and sys.version_info >= (3, 12):
|
||||
# Check Windows-specific dependencies (all Python versions per ACS-306)
|
||||
# pywin32 is required on all Python versions on Windows - MCP library unconditionally imports win32api
|
||||
if is_windows():
|
||||
try:
|
||||
import pywintypes # noqa: F401
|
||||
except ImportError:
|
||||
_exit_with_pywin32_error()
|
||||
|
||||
# Check Linux-specific dependencies (ACS-310)
|
||||
# Note: secretstorage is optional for app functionality (falls back to .env),
|
||||
# but we validate it to ensure proper OAuth token storage via keyring
|
||||
if is_linux():
|
||||
try:
|
||||
import secretstorage # noqa: F401
|
||||
except ImportError:
|
||||
_warn_missing_secretstorage()
|
||||
|
||||
|
||||
def _exit_with_pywin32_error() -> None:
|
||||
"""Exit with helpful error message for missing pywin32."""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
# This works for venv and poetry environments
|
||||
venv_activate = Path(sys.prefix) / "Scripts" / "activate"
|
||||
# Check for common Windows activation scripts (activate, activate.bat, Activate.ps1)
|
||||
scripts_dir = Path(sys.prefix) / "Scripts"
|
||||
activation_candidates = [
|
||||
scripts_dir / "activate",
|
||||
scripts_dir / "activate.bat",
|
||||
scripts_dir / "Activate.ps1",
|
||||
]
|
||||
venv_activate = next((p for p in activation_candidates if p.exists()), None)
|
||||
|
||||
# Build activation step only if activate script exists
|
||||
activation_step = ""
|
||||
if venv_activate:
|
||||
activation_step = (
|
||||
"To fix this:\n"
|
||||
"1. Activate your virtual environment:\n"
|
||||
f" {venv_activate}\n"
|
||||
"\n"
|
||||
"2. Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
)
|
||||
else:
|
||||
# For system Python or environments without activate script
|
||||
activation_step = (
|
||||
"To fix this:\n"
|
||||
"Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
)
|
||||
|
||||
sys.exit(
|
||||
"Error: Required Windows dependency 'pywin32' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n"
|
||||
"Auto Claude requires pywin32 on Windows for:\n"
|
||||
" - MCP library (win32api, win32con, win32job modules)\n"
|
||||
" - LadybugDB/Graphiti memory integration\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. Activate your virtual environment:\n"
|
||||
f" {venv_activate}\n"
|
||||
f"{activation_step}"
|
||||
"\n"
|
||||
"2. Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
def _warn_missing_secretstorage() -> None:
|
||||
"""Emit warning message for missing secretstorage.
|
||||
|
||||
Note: This is a warning, not a hard error - the app will fall back to .env
|
||||
file storage for OAuth tokens. We warn users to ensure they understand the
|
||||
security implications.
|
||||
"""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
venv_activate = Path(sys.prefix) / "bin" / "activate"
|
||||
# Only include activation instruction if venv script actually exists
|
||||
activation_prefix = (
|
||||
f"1. Activate your virtual environment:\n source {venv_activate}\n\n"
|
||||
if venv_activate.exists()
|
||||
else ""
|
||||
)
|
||||
# Adjust step number based on whether activation step is included
|
||||
install_step = (
|
||||
"2. Install secretstorage:\n"
|
||||
if activation_prefix
|
||||
else "Install secretstorage:\n"
|
||||
)
|
||||
|
||||
sys.stderr.write(
|
||||
"Warning: Linux dependency 'secretstorage' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude can use secretstorage for secure OAuth token storage via\n"
|
||||
"the system keyring (gnome-keyring, kwallet, etc.). Without it, tokens\n"
|
||||
"will be stored in plaintext in your .env file.\n"
|
||||
"\n"
|
||||
"To enable keyring integration:\n"
|
||||
f"{activation_prefix}"
|
||||
f"{install_step}"
|
||||
" pip install 'secretstorage>=3.3.3'\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
"Note: The app will continue to work, but OAuth tokens will be stored\n"
|
||||
"in your .env file instead of the system keyring.\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
# Continue execution - this is a warning, not a blocking error
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitHub CLI Executable Finder
|
||||
============================
|
||||
|
||||
Utility to find the gh (GitHub CLI) executable, with platform-specific fallbacks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_cached_gh_path: str | None = None
|
||||
|
||||
|
||||
def invalidate_gh_cache() -> None:
|
||||
"""Invalidate the cached gh executable path.
|
||||
|
||||
Useful when gh may have been uninstalled, updated, or when
|
||||
GITHUB_CLI_PATH environment variable has changed.
|
||||
"""
|
||||
global _cached_gh_path
|
||||
_cached_gh_path = None
|
||||
|
||||
|
||||
def _verify_gh_executable(path: str) -> bool:
|
||||
"""Verify that a path is a valid gh executable by checking version.
|
||||
|
||||
Args:
|
||||
path: Path to the potential gh executable
|
||||
|
||||
Returns:
|
||||
True if the path points to a valid gh executable, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _run_where_command() -> str | None:
|
||||
"""Run Windows 'where gh' command to find gh executable.
|
||||
|
||||
Returns:
|
||||
First path found, or None if command failed
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where gh",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if (
|
||||
found_path
|
||||
and os.path.isfile(found_path)
|
||||
and _verify_gh_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_gh_executable() -> str | None:
|
||||
"""Find the gh executable, with platform-specific fallbacks.
|
||||
|
||||
Returns the path to gh executable, or None if not found.
|
||||
|
||||
Priority order:
|
||||
1. GITHUB_CLI_PATH env var (user-configured path from frontend)
|
||||
2. shutil.which (if gh is in PATH)
|
||||
3. Homebrew paths on macOS
|
||||
4. Windows Program Files paths
|
||||
5. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find. Use invalidate_gh_cache()
|
||||
to force re-detection (e.g., after gh installation/uninstallation).
|
||||
"""
|
||||
global _cached_gh_path
|
||||
|
||||
# Return cached result if available AND still exists
|
||||
if _cached_gh_path is not None and os.path.isfile(_cached_gh_path):
|
||||
return _cached_gh_path
|
||||
|
||||
_cached_gh_path = _find_gh_executable()
|
||||
return _cached_gh_path
|
||||
|
||||
|
||||
def _find_gh_executable() -> str | None:
|
||||
"""Internal function to find gh executable."""
|
||||
# 1. Check GITHUB_CLI_PATH env var (set by Electron frontend)
|
||||
env_path = os.environ.get("GITHUB_CLI_PATH")
|
||||
if env_path and os.path.isfile(env_path) and _verify_gh_executable(env_path):
|
||||
return env_path
|
||||
|
||||
# 2. Try shutil.which (works if gh is in PATH)
|
||||
gh_path = shutil.which("gh")
|
||||
if gh_path and _verify_gh_executable(gh_path):
|
||||
return gh_path
|
||||
|
||||
# 3. macOS-specific: check Homebrew paths
|
||||
if os.name != "nt": # Unix-like systems (macOS, Linux)
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/gh", # Apple Silicon
|
||||
"/usr/local/bin/gh", # Intel Mac
|
||||
"/home/linuxbrew/.linuxbrew/bin/gh", # Linux Homebrew
|
||||
]
|
||||
for path in homebrew_paths:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 4. Windows-specific: check Program Files paths
|
||||
if os.name == "nt":
|
||||
windows_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\GitHub CLI\gh.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\GitHub CLI\gh.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"),
|
||||
]
|
||||
for path in windows_paths:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_gh(
|
||||
args: list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a gh command with proper executable finding.
|
||||
|
||||
Args:
|
||||
args: gh command arguments (without 'gh' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
gh = get_gh_executable()
|
||||
if not gh:
|
||||
return subprocess.CompletedProcess(
|
||||
args=["gh"] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitHub CLI (gh) not found. Install from https://cli.github.com/",
|
||||
)
|
||||
try:
|
||||
return subprocess.run(
|
||||
[gh] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[gh] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[gh] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitHub CLI (gh) executable not found. Install from https://cli.github.com/",
|
||||
)
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Executable Finder
|
||||
======================
|
||||
Git Executable Finder and Isolation
|
||||
====================================
|
||||
|
||||
Utility to find the git executable, with Windows-specific fallbacks.
|
||||
Also provides environment isolation to prevent pre-commit hooks and
|
||||
other git configurations from affecting worktree operations.
|
||||
|
||||
Separated into its own module to avoid circular imports.
|
||||
"""
|
||||
|
||||
@@ -12,9 +15,53 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Git environment variables that can interfere with worktree operations
|
||||
# when set by pre-commit hooks or other git configurations.
|
||||
# These must be cleared to prevent cross-worktree contamination.
|
||||
GIT_ENV_VARS_TO_CLEAR = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
# Identity variables that could be set by hooks
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_AUTHOR_DATE",
|
||||
"GIT_COMMITTER_NAME",
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
"GIT_COMMITTER_DATE",
|
||||
]
|
||||
|
||||
_cached_git_path: str | None = None
|
||||
|
||||
|
||||
def get_isolated_git_env(base_env: dict | None = None) -> dict:
|
||||
"""
|
||||
Create an isolated environment for git operations.
|
||||
|
||||
Clears git environment variables that may be set by pre-commit hooks
|
||||
or other git configurations, preventing cross-worktree contamination
|
||||
and ensuring git operations target the intended repository.
|
||||
|
||||
Args:
|
||||
base_env: Base environment dict to copy from. If None, uses os.environ.
|
||||
|
||||
Returns:
|
||||
Environment dict safe for git subprocess operations.
|
||||
"""
|
||||
env = dict(base_env) if base_env is not None else os.environ.copy()
|
||||
|
||||
for key in GIT_ENV_VARS_TO_CLEAR:
|
||||
env.pop(key, None)
|
||||
|
||||
# Disable user's pre-commit hooks during Auto-Claude managed git operations
|
||||
# to prevent double-hook execution and potential conflicts
|
||||
env["HUSKY"] = "0"
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def get_git_executable() -> str:
|
||||
"""Find the git executable, with Windows-specific fallbacks.
|
||||
|
||||
@@ -102,19 +149,27 @@ def run_git(
|
||||
cwd: Path | str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
env: dict | None = None,
|
||||
isolate_env: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command with proper executable finding.
|
||||
"""Run a git command with proper executable finding and environment isolation.
|
||||
|
||||
Args:
|
||||
args: Git command arguments (without 'git' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
env: Custom environment dict. If None and isolate_env=True, uses isolated env.
|
||||
isolate_env: If True (default), clears git env vars to prevent hook interference.
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
|
||||
if env is None and isolate_env:
|
||||
env = get_isolated_git_env()
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
[git] + args,
|
||||
@@ -125,6 +180,7 @@ def run_git(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
Platform Abstraction Layer
|
||||
|
||||
Centralized platform-specific operations for the Python backend.
|
||||
All code that checks sys.platform or handles OS differences should use this module.
|
||||
|
||||
Design principles:
|
||||
- Single source of truth for platform detection
|
||||
- Feature detection over platform detection when possible
|
||||
- Clear, intention-revealing names
|
||||
- Immutable configurations where possible
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
# Type Definitions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OS(Enum):
|
||||
"""Supported operating systems."""
|
||||
|
||||
WINDOWS = "Windows"
|
||||
MACOS = "Darwin"
|
||||
LINUX = "Linux"
|
||||
|
||||
|
||||
class ShellType(Enum):
|
||||
"""Available shell types."""
|
||||
|
||||
POWERSHELL = "powershell"
|
||||
CMD = "cmd"
|
||||
BASH = "bash"
|
||||
ZSH = "zsh"
|
||||
FISH = "fish"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Detection
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_current_os() -> OS:
|
||||
"""Get the current operating system.
|
||||
|
||||
Returns the OS enum for the current platform. For unsupported Unix-like
|
||||
systems (e.g., FreeBSD, SunOS), defaults to Linux for compatibility.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
return OS.WINDOWS
|
||||
elif system == "Darwin":
|
||||
return OS.MACOS
|
||||
# Default to Linux for other Unix-like systems (FreeBSD, SunOS, etc.)
|
||||
return OS.LINUX
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
"""Check if running on Windows."""
|
||||
return platform.system() == "Windows"
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
"""Check if running on macOS."""
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Check if running on Linux."""
|
||||
return platform.system() == "Linux"
|
||||
|
||||
|
||||
def is_unix() -> bool:
|
||||
"""Check if running on a Unix-like system (macOS or Linux)."""
|
||||
return not is_windows()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Path Configuration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_path_delimiter() -> str:
|
||||
"""Get the PATH separator for environment variables."""
|
||||
return ";" if is_windows() else ":"
|
||||
|
||||
|
||||
def get_executable_extension() -> str:
|
||||
"""Get the default file extension for executables."""
|
||||
return ".exe" if is_windows() else ""
|
||||
|
||||
|
||||
def with_executable_extension(base_name: str) -> str:
|
||||
"""Add executable extension to a base name if needed."""
|
||||
if not base_name:
|
||||
return base_name
|
||||
|
||||
# Check if already has extension
|
||||
if os.path.splitext(base_name)[1]:
|
||||
return base_name
|
||||
|
||||
exe_ext = get_executable_extension()
|
||||
return f"{base_name}{exe_ext}" if exe_ext else base_name
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Binary Directories
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_binary_directories() -> dict[str, list[str]]:
|
||||
"""
|
||||
Get common binary directories for the current platform.
|
||||
|
||||
Returns:
|
||||
Dict with 'user' and 'system' keys containing lists of directories.
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
|
||||
if is_windows():
|
||||
return {
|
||||
"user": [
|
||||
str(home_dir / "AppData" / "Local" / "Programs"),
|
||||
str(home_dir / "AppData" / "Roaming" / "npm"),
|
||||
str(home_dir / ".local" / "bin"),
|
||||
],
|
||||
"system": [
|
||||
os.environ.get("ProgramFiles", "C:\\Program Files"),
|
||||
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
|
||||
os.path.join(os.environ.get("SystemRoot", "C:\\Windows"), "System32"),
|
||||
],
|
||||
}
|
||||
|
||||
if is_macos():
|
||||
return {
|
||||
"user": [
|
||||
str(home_dir / ".local" / "bin"),
|
||||
str(home_dir / "bin"),
|
||||
],
|
||||
"system": [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
],
|
||||
}
|
||||
|
||||
# Linux
|
||||
return {
|
||||
"user": [
|
||||
str(home_dir / ".local" / "bin"),
|
||||
str(home_dir / "bin"),
|
||||
],
|
||||
"system": [
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/snap/bin",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_homebrew_path() -> str | None:
|
||||
"""
|
||||
Get Homebrew binary directory (macOS only).
|
||||
|
||||
Returns:
|
||||
Homebrew bin path or None if not on macOS.
|
||||
"""
|
||||
if not is_macos():
|
||||
return None
|
||||
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin", # Apple Silicon
|
||||
"/usr/local/bin", # Intel
|
||||
]
|
||||
|
||||
for brew_path in homebrew_paths:
|
||||
if os.path.exists(brew_path):
|
||||
return brew_path
|
||||
|
||||
return homebrew_paths[0] # Default to Apple Silicon
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tool Detection
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def find_executable(name: str, additional_paths: list[str] | None = None) -> str | None:
|
||||
"""
|
||||
Find an executable in standard locations.
|
||||
|
||||
Searches:
|
||||
1. System PATH
|
||||
2. Platform-specific binary directories
|
||||
3. Additional custom paths
|
||||
|
||||
Args:
|
||||
name: Name of the executable (without extension)
|
||||
additional_paths: Optional list of additional paths to search
|
||||
|
||||
Returns:
|
||||
Full path to executable if found, None otherwise
|
||||
"""
|
||||
# First check system PATH
|
||||
in_path = shutil.which(name)
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
# Check with extension on Windows
|
||||
if is_windows():
|
||||
for ext in [".exe", ".cmd", ".bat"]:
|
||||
in_path = shutil.which(f"{name}{ext}")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
# Search in platform-specific directories
|
||||
bins = get_binary_directories()
|
||||
search_dirs = bins["user"] + bins["system"]
|
||||
|
||||
if additional_paths:
|
||||
search_dirs.extend(additional_paths)
|
||||
|
||||
for directory in search_dirs:
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
|
||||
# Try without extension
|
||||
exe_path = os.path.join(directory, with_executable_extension(name))
|
||||
if os.path.isfile(exe_path):
|
||||
return exe_path
|
||||
|
||||
# Try common extensions on Windows
|
||||
if is_windows():
|
||||
for ext in [".exe", ".cmd", ".bat"]:
|
||||
exe_path = os.path.join(directory, f"{name}{ext}")
|
||||
if os.path.isfile(exe_path):
|
||||
return exe_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_claude_detection_paths() -> list[str]:
|
||||
"""
|
||||
Get platform-specific paths for Claude CLI detection.
|
||||
|
||||
Returns:
|
||||
List of possible Claude CLI executable paths.
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
paths = []
|
||||
|
||||
if is_windows():
|
||||
paths.extend(
|
||||
[
|
||||
str(
|
||||
home_dir
|
||||
/ "AppData"
|
||||
/ "Local"
|
||||
/ "Programs"
|
||||
/ "claude"
|
||||
/ "claude.exe"
|
||||
),
|
||||
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
|
||||
str(home_dir / ".local" / "bin" / "claude.exe"),
|
||||
r"C:\Program Files\Claude\claude.exe",
|
||||
r"C:\Program Files (x86)\Claude\claude.exe",
|
||||
]
|
||||
)
|
||||
else:
|
||||
paths.extend(
|
||||
[
|
||||
str(home_dir / ".local" / "bin" / "claude"),
|
||||
str(home_dir / "bin" / "claude"),
|
||||
]
|
||||
)
|
||||
|
||||
# Add Homebrew path on macOS
|
||||
if is_macos():
|
||||
brew_path = get_homebrew_path()
|
||||
if brew_path:
|
||||
paths.append(os.path.join(brew_path, "claude"))
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def get_claude_detection_paths_structured() -> dict[str, list[str] | str]:
|
||||
"""
|
||||
Get platform-specific paths for Claude CLI detection in structured format.
|
||||
|
||||
Returns a dict with categorized paths for different detection strategies:
|
||||
- 'homebrew': Homebrew installation paths (macOS)
|
||||
- 'platform': Platform-specific standard installation locations
|
||||
- 'nvm_versions_dir': NVM versions directory path for scanning Node installations
|
||||
|
||||
This structured format allows callers to implement custom detection logic
|
||||
for each category (e.g., iterating NVM version directories).
|
||||
|
||||
Returns:
|
||||
Dict with 'homebrew', 'platform', and 'nvm_versions_dir' keys
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/claude", # Apple Silicon
|
||||
"/usr/local/bin/claude", # Intel Mac
|
||||
]
|
||||
|
||||
if is_windows():
|
||||
platform_paths = [
|
||||
str(home_dir / "AppData/Local/Programs/claude/claude.exe"),
|
||||
str(home_dir / "AppData/Roaming/npm/claude.cmd"),
|
||||
str(home_dir / ".local/bin/claude.exe"),
|
||||
r"C:\Program Files\Claude\claude.exe",
|
||||
r"C:\Program Files (x86)\Claude\claude.exe",
|
||||
]
|
||||
else:
|
||||
platform_paths = [
|
||||
str(home_dir / ".local" / "bin" / "claude"),
|
||||
str(home_dir / "bin" / "claude"),
|
||||
]
|
||||
|
||||
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
|
||||
|
||||
return {
|
||||
"homebrew": homebrew_paths,
|
||||
"platform": platform_paths,
|
||||
"nvm_versions_dir": nvm_versions_dir,
|
||||
}
|
||||
|
||||
|
||||
def get_python_commands() -> list[list[str]]:
|
||||
"""
|
||||
Get platform-specific Python command variations as argument sequences.
|
||||
|
||||
Returns command arguments as sequences so callers can pass each entry
|
||||
directly to subprocess.run(cmd) or use cmd[0] with shutil.which().
|
||||
|
||||
Returns:
|
||||
List of command argument lists to try, in order of preference.
|
||||
Each inner list contains the executable and any required arguments.
|
||||
|
||||
Example:
|
||||
for cmd in get_python_commands():
|
||||
if shutil.which(cmd[0]):
|
||||
subprocess.run(cmd + ["--version"])
|
||||
break
|
||||
"""
|
||||
if is_windows():
|
||||
return [["py", "-3"], ["python"], ["python3"], ["py"]]
|
||||
return [["python3"], ["python"]]
|
||||
|
||||
|
||||
def validate_cli_path(cli_path: str) -> bool:
|
||||
"""
|
||||
Validate that a CLI path is secure and executable.
|
||||
|
||||
Prevents command injection attacks by rejecting paths with shell metacharacters,
|
||||
directory traversal patterns, or environment variable expansion.
|
||||
|
||||
Args:
|
||||
cli_path: Path to validate
|
||||
|
||||
Returns:
|
||||
True if path is secure, False otherwise
|
||||
"""
|
||||
if not cli_path:
|
||||
return False
|
||||
|
||||
# Security validation: reject paths with shell metacharacters or other dangerous patterns
|
||||
dangerous_patterns = [
|
||||
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
|
||||
r"%[^%]+%", # Windows environment variable expansion
|
||||
r"\.\./", # Unix directory traversal
|
||||
r"\.\.\\", # Windows directory traversal
|
||||
r"[\r\n]", # Newlines (command injection)
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, cli_path):
|
||||
return False
|
||||
|
||||
# On Windows, validate executable name additionally
|
||||
if is_windows():
|
||||
# Extract just the executable name
|
||||
exe_name = os.path.basename(cli_path)
|
||||
name_without_ext = os.path.splitext(exe_name)[0]
|
||||
|
||||
# Allow only alphanumeric, dots, hyphens, underscores in the name
|
||||
if not name_without_ext or not all(
|
||||
c.isalnum() or c in "._-" for c in name_without_ext
|
||||
):
|
||||
return False
|
||||
|
||||
# Check if path exists (if absolute)
|
||||
if os.path.isabs(cli_path):
|
||||
return os.path.isfile(cli_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shell Execution
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def requires_shell(command: str) -> bool:
|
||||
"""
|
||||
Check if a command requires shell execution on Windows.
|
||||
|
||||
Windows needs shell execution for .cmd and .bat files.
|
||||
|
||||
Args:
|
||||
command: Command string to check
|
||||
|
||||
Returns:
|
||||
True if shell execution is required
|
||||
"""
|
||||
if not is_windows():
|
||||
return False
|
||||
|
||||
_, ext = os.path.splitext(command)
|
||||
return ext.lower() in {".cmd", ".bat", ".ps1"}
|
||||
|
||||
|
||||
def get_comspec_path() -> str:
|
||||
"""
|
||||
Get the path to cmd.exe on Windows.
|
||||
|
||||
Returns:
|
||||
Path to cmd.exe or default location.
|
||||
"""
|
||||
if is_windows():
|
||||
return os.environ.get(
|
||||
"ComSpec",
|
||||
os.path.join(
|
||||
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
|
||||
),
|
||||
)
|
||||
return "/bin/sh"
|
||||
|
||||
|
||||
def build_windows_command(cli_path: str, args: list[str]) -> list[str]:
|
||||
"""
|
||||
Build a command array for Windows execution.
|
||||
|
||||
Handles .cmd/.bat files that require shell execution.
|
||||
|
||||
Args:
|
||||
cli_path: Path to the CLI executable
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Command array suitable for subprocess.run
|
||||
"""
|
||||
if is_windows() and cli_path.lower().endswith((".cmd", ".bat")):
|
||||
# Use cmd.exe to execute .cmd/.bat files
|
||||
cmd_exe = get_comspec_path()
|
||||
# Properly escape arguments for Windows command line
|
||||
escaped_args = subprocess.list2cmdline(args)
|
||||
return [cmd_exe, "/d", "/s", "/c", f'"{cli_path}" {escaped_args}']
|
||||
|
||||
return [cli_path] + args
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Environment Variables
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_env_var(name: str, default: str | None = None) -> str | None:
|
||||
"""
|
||||
Get environment variable value with case-insensitive support on Windows.
|
||||
|
||||
Args:
|
||||
name: Environment variable name
|
||||
default: Default value if not found
|
||||
|
||||
Returns:
|
||||
Environment variable value or default
|
||||
"""
|
||||
if is_windows():
|
||||
# Case-insensitive lookup on Windows
|
||||
for key, value in os.environ.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return default
|
||||
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Description
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_platform_description() -> str:
|
||||
"""
|
||||
Get a human-readable platform description.
|
||||
|
||||
Returns:
|
||||
String like "Windows (AMD64)" or "macOS (arm64)"
|
||||
"""
|
||||
os_name = {OS.WINDOWS: "Windows", OS.MACOS: "macOS", OS.LINUX: "Linux"}.get(
|
||||
get_current_os(), platform.system()
|
||||
)
|
||||
|
||||
arch = platform.machine()
|
||||
return f"{os_name} ({arch})"
|
||||
@@ -43,7 +43,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
|
||||
return 0, 0
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
total = 0
|
||||
@@ -56,7 +56,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
|
||||
completed += 1
|
||||
|
||||
return completed, total
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return 0, 0
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
|
||||
return result
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
@@ -94,7 +94,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
|
||||
result["pending"] += 1
|
||||
|
||||
return result
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return result
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
|
||||
# Phase summary
|
||||
try:
|
||||
with open(spec_dir / "implementation_plan.json") as f:
|
||||
with open(spec_dir / "implementation_plan.json", encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
print("\nPhases:")
|
||||
@@ -230,8 +230,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
|
||||
)
|
||||
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Ignore corrupted/unreadable progress files
|
||||
else:
|
||||
print()
|
||||
print_status("No implementation subtasks yet - planner needs to run", "pending")
|
||||
@@ -302,7 +302,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
summary = {
|
||||
@@ -355,7 +355,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
|
||||
|
||||
return summary
|
||||
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {
|
||||
"workflow_type": None,
|
||||
"total_phases": 0,
|
||||
@@ -376,7 +376,7 @@ def get_current_phase(spec_dir: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
@@ -396,7 +396,7 @@ def get_current_phase(spec_dir: Path) -> dict | None:
|
||||
|
||||
return None
|
||||
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -470,7 +470,7 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
|
||||
return None
|
||||
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+23
-12
@@ -53,7 +53,7 @@ def _get_version() -> str:
|
||||
if package_json.exists():
|
||||
import json
|
||||
|
||||
with open(package_json) as f:
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("version", "0.0.0")
|
||||
except Exception as e:
|
||||
@@ -257,17 +257,28 @@ def init_sentry(
|
||||
event_level=logging.ERROR, # Send ERROR and above as events
|
||||
)
|
||||
|
||||
# Initialize Sentry
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
environment=environment,
|
||||
release=f"auto-claude@{version}",
|
||||
traces_sample_rate=traces_sample_rate,
|
||||
before_send=_before_send,
|
||||
integrations=[logging_integration],
|
||||
# Don't send PII
|
||||
send_default_pii=False,
|
||||
)
|
||||
# Initialize Sentry with exception handling for malformed DSN
|
||||
try:
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
environment=environment,
|
||||
release=f"auto-claude@{version}",
|
||||
traces_sample_rate=traces_sample_rate,
|
||||
before_send=_before_send,
|
||||
integrations=[logging_integration],
|
||||
# Don't send PII
|
||||
send_default_pii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle malformed DSN (e.g., missing public key) gracefully
|
||||
# This prevents crashes when SENTRY_DSN is misconfigured
|
||||
logger.warning(
|
||||
f"[Sentry] Failed to initialize - invalid DSN configuration: {e}"
|
||||
)
|
||||
logger.debug(
|
||||
"[Sentry] DSN should be in format: https://[email protected]/PROJECT_ID"
|
||||
)
|
||||
return False
|
||||
|
||||
# Set component tag
|
||||
sentry_sdk.set_tag("component", component)
|
||||
|
||||
@@ -21,14 +21,21 @@ Example usage:
|
||||
client = create_simple_client(agent_type="insights", cwd=project_dir)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from core.client import find_claude_cli
|
||||
from core.auth import (
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
from core.platform import validate_cli_path
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_simple_client(
|
||||
agent_type: str = "merge_resolver",
|
||||
@@ -67,6 +74,12 @@ def create_simple_client(
|
||||
"""
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token()
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
import os
|
||||
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
@@ -85,10 +98,8 @@ def create_simple_client(
|
||||
thinking_level = get_default_thinking_level(agent_type)
|
||||
max_thinking_tokens = get_thinking_budget(thinking_level)
|
||||
|
||||
# Find Claude CLI path (handles non-standard installations)
|
||||
cli_path = find_claude_cli()
|
||||
|
||||
# Build options dict
|
||||
# Note: SDK bundles its own CLI, so no cli_path detection needed
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": system_prompt,
|
||||
@@ -96,11 +107,16 @@ def create_simple_client(
|
||||
"max_turns": max_turns,
|
||||
"cwd": str(cwd.resolve()) if cwd else None,
|
||||
"env": sdk_env,
|
||||
"max_thinking_tokens": max_thinking_tokens,
|
||||
}
|
||||
|
||||
# Add CLI path if found
|
||||
if cli_path:
|
||||
options_kwargs["cli_path"] = cli_path
|
||||
# Only add max_thinking_tokens if not None (Haiku doesn't support extended thinking)
|
||||
if max_thinking_tokens is not None:
|
||||
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
|
||||
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_cli_path and validate_cli_path(env_cli_path):
|
||||
options_kwargs["cli_path"] = env_cli_path
|
||||
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
|
||||
@@ -17,7 +17,6 @@ This module has been refactored for better maintainability:
|
||||
Public API is exported via workspace/__init__.py for backward compatibility.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Import git command helper for centralized logging and allowlist compliance
|
||||
@@ -188,11 +187,9 @@ def merge_existing_build(
|
||||
# Detect current branch - this is where user wants changes merged
|
||||
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
|
||||
# and wants to merge the spec changes into it, then PR to main
|
||||
current_branch_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
current_branch_result = run_git(
|
||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
current_branch = (
|
||||
current_branch_result.stdout.strip()
|
||||
@@ -569,11 +566,9 @@ def _try_smart_merge_inner(
|
||||
base_branch = git_conflicts.get("base_branch", "main")
|
||||
|
||||
# Get merge-base for diff
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, spec_branch],
|
||||
merge_base_result = run_git(
|
||||
["merge-base", base_branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
merge_base = (
|
||||
merge_base_result.stdout.strip()
|
||||
@@ -655,22 +650,19 @@ def _try_smart_merge_inner(
|
||||
|
||||
# Stage all files in a single git add call for efficiency
|
||||
if files_to_stage:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "add"] + files_to_stage,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
add_result = run_git(
|
||||
["add"] + files_to_stage,
|
||||
cwd=project_dir,
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE, f"Failed to stage files for direct copy: {e.stderr}"
|
||||
MODULE,
|
||||
f"Failed to stage files for direct copy: {add_result.stderr}",
|
||||
)
|
||||
# Return failure - files were written but not staged
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to stage files: {e.stderr}",
|
||||
"error": f"Failed to stage files: {add_result.stderr}",
|
||||
"resolved_files": [],
|
||||
}
|
||||
|
||||
@@ -1137,11 +1129,9 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
|
||||
# Get merge-base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, spec_branch],
|
||||
merge_base_result = run_git(
|
||||
["merge-base", base_branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
merge_base = (
|
||||
merge_base_result.stdout.strip() if merge_base_result.returncode == 0 else None
|
||||
@@ -1194,11 +1184,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
debug(MODULE, f"Copied new binary file: {file_path}")
|
||||
else:
|
||||
@@ -1207,11 +1193,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
@@ -1351,9 +1333,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(merged_content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
)
|
||||
run_git(["add", file_path], cwd=project_dir)
|
||||
resolved_files.append(file_path)
|
||||
# Show appropriate message based on merge type
|
||||
if file_path in auto_merged_simple:
|
||||
@@ -1372,11 +1352,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / file_path
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
subprocess.run(
|
||||
["git", "add", file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", file_path], cwd=project_dir)
|
||||
resolved_files.append(file_path)
|
||||
print(success(f" ✓ {file_path} (deleted)"))
|
||||
except Exception as e:
|
||||
@@ -1418,11 +1394,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / result.file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(result.merged_content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", result.file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", result.file_path], cwd=project_dir)
|
||||
resolved_files.append(result.file_path)
|
||||
|
||||
if result.was_auto_merged:
|
||||
@@ -1537,11 +1509,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / result.file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(result.merged_content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", result.file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", result.file_path], cwd=project_dir)
|
||||
resolved_files.append(result.file_path)
|
||||
|
||||
if result.was_auto_merged:
|
||||
@@ -1570,11 +1538,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
target_path = project_dir / target_file_path
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
else:
|
||||
# Modified without path change - simple copy
|
||||
# Check if binary file to use correct read/write method
|
||||
@@ -1587,11 +1551,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
@@ -1604,11 +1564,7 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
run_git(["add", target_file_path], cwd=project_dir)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
|
||||
@@ -93,7 +93,7 @@ class MergeLock:
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()))
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -101,7 +101,7 @@ class MergeLock:
|
||||
# Lock file exists - check if process is still running
|
||||
if self.lock_file.exists():
|
||||
try:
|
||||
pid = int(self.lock_file.read_text().strip())
|
||||
pid = int(self.lock_file.read_text(encoding="utf-8").strip())
|
||||
# Import locally to avoid circular dependency
|
||||
import os as _os
|
||||
|
||||
@@ -183,7 +183,7 @@ class SpecNumberLock:
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()))
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -191,7 +191,7 @@ class SpecNumberLock:
|
||||
# Lock file exists - check if process is still running
|
||||
if self.lock_file.exists():
|
||||
try:
|
||||
pid = int(self.lock_file.read_text().strip())
|
||||
pid = int(self.lock_file.read_text(encoding="utf-8").strip())
|
||||
import os as _os
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,9 @@ Functions for setting up and initializing workspaces.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -181,6 +183,106 @@ def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[s
|
||||
return copied
|
||||
|
||||
|
||||
def symlink_node_modules_to_worktree(
|
||||
project_dir: Path, worktree_path: Path
|
||||
) -> list[str]:
|
||||
"""
|
||||
Symlink node_modules directories from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to dependencies for TypeScript checks
|
||||
and other tooling without requiring a separate npm install.
|
||||
|
||||
Works with npm workspace hoisting where dependencies are hoisted to root
|
||||
and workspace-specific dependencies remain in nested node_modules.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of symlinked paths (relative to worktree)
|
||||
"""
|
||||
symlinked = []
|
||||
|
||||
# Node modules locations to symlink for TypeScript and tooling support.
|
||||
# These are the standard locations for this monorepo structure.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Hardcoded paths are intentional for simplicity and reliability
|
||||
# - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
# and potential failure points without significant benefit
|
||||
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
#
|
||||
# To add new workspace locations:
|
||||
# 1. Add (source_rel, target_rel) tuple below
|
||||
# 2. Update the parallel TypeScript implementation in
|
||||
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
|
||||
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
node_modules_locations = [
|
||||
("node_modules", "node_modules"),
|
||||
("apps/frontend/node_modules", "apps/frontend/node_modules"),
|
||||
]
|
||||
|
||||
for source_rel, target_rel in node_modules_locations:
|
||||
source_path = project_dir / source_rel
|
||||
target_path = worktree_path / target_rel
|
||||
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping {source_rel} - source does not exist")
|
||||
continue
|
||||
|
||||
# Skip if target already exists (don't overwrite existing node_modules)
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping {target_rel} - target already exists")
|
||||
continue
|
||||
|
||||
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
|
||||
if target_path.is_symlink():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping {target_rel} - symlink already exists (possibly broken)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
# Junctions require absolute paths
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(target_rel)
|
||||
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
|
||||
except OSError as e:
|
||||
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
|
||||
# Log warning but don't fail - worktree is still usable, just without
|
||||
# TypeScript checking
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
|
||||
)
|
||||
# Warn user - pre-commit hooks may fail without dependencies
|
||||
print_status(
|
||||
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
|
||||
"warning",
|
||||
)
|
||||
|
||||
return symlinked
|
||||
|
||||
|
||||
def copy_spec_to_worktree(
|
||||
source_spec_dir: Path,
|
||||
worktree_path: Path,
|
||||
@@ -268,6 +370,14 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Symlink node_modules to worktree for TypeScript and tooling support
|
||||
# This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
symlinked_modules = symlink_node_modules_to_worktree(
|
||||
project_dir, worktree_info.path
|
||||
)
|
||||
if symlinked_modules:
|
||||
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
# the worktree uses the same security rules as the main project.
|
||||
@@ -302,10 +412,10 @@ def setup_workspace(
|
||||
if PROFILE_FILENAME in security_files_copied:
|
||||
profile_path = worktree_info.path / PROFILE_FILENAME
|
||||
try:
|
||||
with open(profile_path) as f:
|
||||
with open(profile_path, encoding="utf-8") as f:
|
||||
profile_data = json.load(f)
|
||||
profile_data["inherited_from"] = str(project_dir.resolve())
|
||||
with open(profile_path, "w") as f:
|
||||
with open(profile_path, "w", encoding="utf-8") as f:
|
||||
json.dump(profile_data, f, indent=2)
|
||||
debug(
|
||||
MODULE, f"Marked security profile as inherited from {project_dir}"
|
||||
@@ -364,7 +474,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
|
||||
|
||||
# Handle worktrees (where .git is a file, not directory)
|
||||
if git_dir.is_file():
|
||||
content = git_dir.read_text().strip()
|
||||
content = git_dir.read_text(encoding="utf-8").strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
else:
|
||||
@@ -374,7 +484,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
|
||||
|
||||
# Check if hook already installed
|
||||
if hook_path.exists():
|
||||
if "FileTimelineTracker" in hook_path.read_text():
|
||||
if "FileTimelineTracker" in hook_path.read_text(encoding="utf-8"):
|
||||
debug(MODULE, "FileTimelineTracker hook already installed")
|
||||
return
|
||||
|
||||
@@ -412,7 +522,7 @@ def initialize_timeline_tracking(
|
||||
if source_spec_dir:
|
||||
plan_path = source_spec_dir / "implementation_plan.json"
|
||||
if plan_path.exists():
|
||||
with open(plan_path) as f:
|
||||
with open(plan_path, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
task_title = plan.get("title", spec_name)
|
||||
task_intent = plan.get("description", "")
|
||||
@@ -423,6 +533,7 @@ def initialize_timeline_tracking(
|
||||
files_to_modify.extend(subtask.get("files", []))
|
||||
|
||||
# Get the current branch point commit
|
||||
# Note: run_git() already handles capture_output and encoding internally
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
|
||||
@@ -26,7 +26,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, TypeVar
|
||||
|
||||
from core.git_executable import get_git_executable, run_git
|
||||
from core.gh_executable import get_gh_executable, invalidate_gh_cache
|
||||
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
|
||||
from debug import debug_warning
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -883,6 +884,7 @@ class WorktreeManager:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GIT_PUSH_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -959,9 +961,17 @@ class WorktreeManager:
|
||||
# Get PR body from spec.md if available
|
||||
pr_body = self._extract_spec_summary(spec_name)
|
||||
|
||||
# Find gh executable before attempting PR creation
|
||||
gh_executable = get_gh_executable()
|
||||
if not gh_executable:
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error="GitHub CLI (gh) not found. Install from https://cli.github.com/",
|
||||
)
|
||||
|
||||
# Build gh pr create command
|
||||
gh_args = [
|
||||
"gh",
|
||||
gh_executable,
|
||||
"pr",
|
||||
"create",
|
||||
"--base",
|
||||
@@ -993,6 +1003,7 @@ class WorktreeManager:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_CLI_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
# Check for "already exists" case (success, no retry needed)
|
||||
@@ -1063,7 +1074,8 @@ class WorktreeManager:
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
# gh CLI not installed
|
||||
# Cached gh path became invalid - clear cache so next call re-discovers
|
||||
invalidate_gh_cache()
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error="gh CLI not found. Install from https://cli.github.com/",
|
||||
@@ -1121,15 +1133,30 @@ class WorktreeManager:
|
||||
if not info:
|
||||
return None
|
||||
|
||||
gh_executable = get_gh_executable()
|
||||
if not gh_executable:
|
||||
# gh CLI not found - return None and let caller handle it
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "pr", "view", info.branch, "--json", "url", "--jq", ".url"],
|
||||
[
|
||||
gh_executable,
|
||||
"pr",
|
||||
"view",
|
||||
info.branch,
|
||||
"--json",
|
||||
"url",
|
||||
"--jq",
|
||||
".url",
|
||||
],
|
||||
cwd=info.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_QUERY_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
@@ -1141,6 +1168,8 @@ class WorktreeManager:
|
||||
# Silently ignore errors when fetching existing PR URL - this is a best-effort
|
||||
# lookup that may fail due to network issues, missing gh CLI, or auth problems.
|
||||
# Returning None allows the caller to handle missing URLs gracefully.
|
||||
if isinstance(e, FileNotFoundError):
|
||||
invalidate_gh_cache()
|
||||
debug_warning("worktree", f"Could not get existing PR URL: {e}")
|
||||
|
||||
return None
|
||||
|
||||
@@ -51,7 +51,7 @@ class ProjectAnalyzer:
|
||||
project_index_path = self.project_dir / ".auto-claude" / "project_index.json"
|
||||
if project_index_path.exists():
|
||||
try:
|
||||
with open(project_index_path) as f:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
# Extract tech stack from services
|
||||
for service_name, service_info in index.get("services", {}).items():
|
||||
@@ -70,7 +70,7 @@ class ProjectAnalyzer:
|
||||
)
|
||||
if roadmap_path.exists():
|
||||
try:
|
||||
with open(roadmap_path) as f:
|
||||
with open(roadmap_path, encoding="utf-8") as f:
|
||||
roadmap = json.load(f)
|
||||
# Extract planned features
|
||||
for feature in roadmap.get("features", []):
|
||||
@@ -87,7 +87,7 @@ class ProjectAnalyzer:
|
||||
)
|
||||
if discovery_path.exists() and not context["target_audience"]:
|
||||
try:
|
||||
with open(discovery_path) as f:
|
||||
with open(discovery_path, encoding="utf-8") as f:
|
||||
discovery = json.load(f)
|
||||
audience = discovery.get("target_audience", {})
|
||||
context["target_audience"] = audience.get("primary_persona")
|
||||
@@ -109,7 +109,7 @@ class ProjectAnalyzer:
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.exists():
|
||||
# Extract title from spec
|
||||
content = spec_file.read_text()
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
for line in lines:
|
||||
if line.startswith("# "):
|
||||
|
||||
@@ -39,7 +39,7 @@ class IdeationFormatter:
|
||||
existing_session = None
|
||||
if append and ideation_file.exists():
|
||||
try:
|
||||
with open(ideation_file) as f:
|
||||
with open(ideation_file, encoding="utf-8") as f:
|
||||
existing_session = json.load(f)
|
||||
existing_ideas = existing_session.get("ideas", [])
|
||||
print_status(
|
||||
@@ -56,7 +56,7 @@ class IdeationFormatter:
|
||||
type_file = self.output_dir / f"{ideation_type}_ideas.json"
|
||||
if type_file.exists():
|
||||
try:
|
||||
with open(type_file) as f:
|
||||
with open(type_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
ideas = data.get(ideation_type, [])
|
||||
new_ideas.extend(ideas)
|
||||
@@ -123,7 +123,7 @@ class IdeationFormatter:
|
||||
ideation_session["summary"]["by_status"].get(idea_status, 0) + 1
|
||||
)
|
||||
|
||||
with open(ideation_file, "w") as f:
|
||||
with open(ideation_file, "w", encoding="utf-8") as f:
|
||||
json.dump(ideation_session, f, indent=2)
|
||||
|
||||
action = "Updated" if append else "Created"
|
||||
@@ -139,7 +139,7 @@ class IdeationFormatter:
|
||||
context_data = {}
|
||||
if context_file.exists():
|
||||
try:
|
||||
with open(context_file) as f:
|
||||
with open(context_file, encoding="utf-8") as f:
|
||||
context_data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -80,7 +80,7 @@ class IdeationGenerator:
|
||||
return False, f"Prompt not found: {prompt_path}"
|
||||
|
||||
# Load prompt
|
||||
prompt = prompt_path.read_text()
|
||||
prompt = prompt_path.read_text(encoding="utf-8")
|
||||
|
||||
# Add context
|
||||
prompt += f"\n\n---\n\n**Output Directory**: {self.output_dir}\n"
|
||||
|
||||
@@ -85,7 +85,7 @@ class PhaseExecutor:
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
print_status("Graphiti not enabled, skipping graph hints", "info")
|
||||
with open(hints_file, "w") as f:
|
||||
with open(hints_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"enabled": False,
|
||||
@@ -131,7 +131,7 @@ class PhaseExecutor:
|
||||
total_hints += len(result)
|
||||
|
||||
# Save hints
|
||||
with open(hints_file, "w") as f:
|
||||
with open(hints_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"enabled": True,
|
||||
@@ -178,11 +178,11 @@ class PhaseExecutor:
|
||||
graph_hints = {}
|
||||
if hints_file.exists():
|
||||
try:
|
||||
with open(hints_file) as f:
|
||||
with open(hints_file, encoding="utf-8") as f:
|
||||
hints_data = json.load(f)
|
||||
graph_hints = hints_data.get("hints_by_type", {})
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Use empty hints if file is corrupted/unreadable
|
||||
|
||||
# Write context file
|
||||
context_data = {
|
||||
@@ -200,7 +200,7 @@ class PhaseExecutor:
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
with open(context_file, "w") as f:
|
||||
with open(context_file, "w", encoding="utf-8") as f:
|
||||
json.dump(context_data, f, indent=2)
|
||||
|
||||
print_status("Created ideation_context.json", "success")
|
||||
@@ -252,7 +252,7 @@ class PhaseExecutor:
|
||||
if output_file.exists() and not self.refresh:
|
||||
# Load and validate existing ideas - only skip if we have valid ideas
|
||||
try:
|
||||
with open(output_file) as f:
|
||||
with open(output_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
count = len(data.get(ideation_type, []))
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class IdeaPrioritizer:
|
||||
}
|
||||
|
||||
try:
|
||||
content = output_file.read_text()
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
data = json.loads(content)
|
||||
debug_verbose(
|
||||
"ideation_prioritizer",
|
||||
@@ -102,7 +102,7 @@ class IdeaPrioritizer:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid JSON: {e}",
|
||||
"current_content": output_file.read_text()
|
||||
"current_content": output_file.read_text(encoding="utf-8")
|
||||
if output_file.exists()
|
||||
else "",
|
||||
"count": 0,
|
||||
|
||||
@@ -232,7 +232,7 @@ class IdeationOrchestrator:
|
||||
"""Print summary of ideation generation results."""
|
||||
ideation_file = self.output_dir / "ideation.json"
|
||||
if ideation_file.exists():
|
||||
with open(ideation_file) as f:
|
||||
with open(ideation_file, encoding="utf-8") as f:
|
||||
ideation = json.load(f)
|
||||
|
||||
ideas = ideation.get("ideas", [])
|
||||
|
||||
+106
-10
@@ -4,8 +4,15 @@ Auto Claude project initialization utilities.
|
||||
Handles first-time setup of .auto-claude directory and ensures proper gitignore configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import get_git_executable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All entries that should be added to .gitignore for auto-claude projects
|
||||
AUTO_CLAUDE_GITIGNORE_ENTRIES = [
|
||||
".auto-claude/",
|
||||
@@ -50,7 +57,7 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
|
||||
# Check if .gitignore exists and if entry is already present
|
||||
if gitignore_path.exists():
|
||||
content = gitignore_path.read_text()
|
||||
content = gitignore_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
if _entry_exists_in_gitignore(lines, entry):
|
||||
@@ -65,18 +72,94 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
content += "\n# Auto Claude data directory\n"
|
||||
content += entry + "\n"
|
||||
|
||||
gitignore_path.write_text(content)
|
||||
gitignore_path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
else:
|
||||
# Create new .gitignore with the entry
|
||||
content = "# Auto Claude data directory\n"
|
||||
content += entry + "\n"
|
||||
|
||||
gitignore_path.write_text(content)
|
||||
gitignore_path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
|
||||
def _is_git_repo(project_dir: Path) -> bool:
|
||||
"""Check if the directory is a git repository."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[get_git_executable(), "rev-parse", "--is-inside-work-tree"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, Exception) as e:
|
||||
logger.debug("Git repo check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _commit_gitignore(project_dir: Path) -> bool:
|
||||
"""
|
||||
Commit .gitignore changes with a standard message.
|
||||
|
||||
FIX (#1087): Auto-commit .gitignore changes to prevent merge failures.
|
||||
Without this, merging tasks fails with "local changes would be overwritten".
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
|
||||
Returns:
|
||||
True if commit succeeded, False otherwise
|
||||
"""
|
||||
if not _is_git_repo(project_dir):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Use LC_ALL=C to ensure English git output for reliable parsing
|
||||
git_env = {**os.environ, "LC_ALL": "C"}
|
||||
|
||||
# Stage .gitignore
|
||||
result = subprocess.run(
|
||||
[get_git_executable(), "add", ".gitignore"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=git_env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
# Commit with standard message - explicitly specify .gitignore to avoid
|
||||
# committing other staged files the user may have
|
||||
result = subprocess.run(
|
||||
[
|
||||
get_git_executable(),
|
||||
"commit",
|
||||
".gitignore",
|
||||
"-m",
|
||||
"chore: add auto-claude entries to .gitignore",
|
||||
],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=git_env,
|
||||
)
|
||||
# Return True even if commit "fails" due to nothing to commit
|
||||
# Check both stdout and stderr as message location varies by git version
|
||||
combined_output = result.stdout + result.stderr
|
||||
return result.returncode == 0 or "nothing to commit" in combined_output
|
||||
|
||||
except (subprocess.TimeoutExpired, Exception) as e:
|
||||
logger.debug("Git commit failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_all_gitignore_entries(
|
||||
project_dir: Path, auto_commit: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Ensure all auto-claude related entries exist in the project's .gitignore file.
|
||||
|
||||
@@ -84,6 +167,7 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
auto_commit: If True, automatically commit the .gitignore changes
|
||||
|
||||
Returns:
|
||||
List of entries that were added (empty if all already existed)
|
||||
@@ -93,7 +177,7 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
|
||||
|
||||
# Read existing content or start fresh
|
||||
if gitignore_path.exists():
|
||||
content = gitignore_path.read_text()
|
||||
content = gitignore_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
else:
|
||||
content = ""
|
||||
@@ -119,7 +203,17 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
|
||||
content += entry + "\n"
|
||||
added_entries.append(entry)
|
||||
|
||||
gitignore_path.write_text(content)
|
||||
gitignore_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# Auto-commit if requested and entries were added
|
||||
if auto_commit and added_entries:
|
||||
if not _commit_gitignore(project_dir):
|
||||
logger.warning(
|
||||
"Failed to auto-commit .gitignore changes in %s. "
|
||||
"Manual commit may be required to avoid merge conflicts.",
|
||||
project_dir,
|
||||
)
|
||||
|
||||
return added_entries
|
||||
|
||||
|
||||
@@ -143,16 +237,17 @@ def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
|
||||
auto_claude_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Ensure all auto-claude entries are in .gitignore (only on first creation)
|
||||
# FIX (#1087): Auto-commit the changes to prevent merge failures
|
||||
gitignore_updated = False
|
||||
if dir_created:
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
added = ensure_all_gitignore_entries(project_dir, auto_commit=True)
|
||||
gitignore_updated = len(added) > 0
|
||||
else:
|
||||
# Even if dir exists, check gitignore on first run
|
||||
# Use a marker file to track if we've already checked
|
||||
marker = auto_claude_dir / ".gitignore_checked"
|
||||
if not marker.exists():
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
added = ensure_all_gitignore_entries(project_dir, auto_commit=True)
|
||||
gitignore_updated = len(added) > 0
|
||||
marker.touch()
|
||||
|
||||
@@ -185,6 +280,7 @@ def repair_gitignore(project_dir: Path) -> list[str]:
|
||||
or when gitignore entries were manually removed.
|
||||
|
||||
Also resets the .gitignore_checked marker to allow future updates.
|
||||
Changes are automatically committed if the project is a git repository.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
@@ -200,8 +296,8 @@ def repair_gitignore(project_dir: Path) -> list[str]:
|
||||
if marker.exists():
|
||||
marker.unlink()
|
||||
|
||||
# Add all missing entries
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
# Add all missing entries and auto-commit
|
||||
added = ensure_all_gitignore_entries(project_dir, auto_commit=True)
|
||||
|
||||
# Re-create the marker
|
||||
if auto_claude_dir.exists():
|
||||
|
||||
@@ -146,7 +146,7 @@ class GraphitiConfig:
|
||||
# OpenRouter settings (multi-provider aggregator)
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
openrouter_llm_model: str = "anthropic/claude-3.5-sonnet"
|
||||
openrouter_llm_model: str = "anthropic/claude-sonnet-4"
|
||||
openrouter_embedding_model: str = "openai/text-embedding-3-small"
|
||||
|
||||
# Ollama settings (local)
|
||||
@@ -210,7 +210,7 @@ class GraphitiConfig:
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
openrouter_llm_model = os.environ.get(
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-3.5-sonnet"
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
|
||||
)
|
||||
openrouter_embedding_model = os.environ.get(
|
||||
"OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small"
|
||||
@@ -507,7 +507,7 @@ class GraphitiState:
|
||||
def save(self, spec_dir: Path) -> None:
|
||||
"""Save state to the spec directory."""
|
||||
marker_file = spec_dir / GRAPHITI_STATE_MARKER
|
||||
with open(marker_file, "w") as f:
|
||||
with open(marker_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
@@ -518,9 +518,9 @@ class GraphitiState:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(marker_file) as f:
|
||||
with open(marker_file, encoding="utf-8") as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
def record_error(self, error_msg: str) -> None:
|
||||
|
||||
@@ -35,7 +35,7 @@ def create_openrouter_llm_client(config: "GraphitiConfig") -> Any:
|
||||
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
|
||||
>>> config = GraphitiConfig(
|
||||
... openrouter_api_key="sk-or-...",
|
||||
... openrouter_llm_model="anthropic/claude-3.5-sonnet"
|
||||
... openrouter_llm_model="anthropic/claude-sonnet-4"
|
||||
... )
|
||||
>>> client = create_openrouter_llm_client(config)
|
||||
"""
|
||||
|
||||
@@ -126,7 +126,7 @@ class LinearProjectState:
|
||||
def save(self, spec_dir: Path) -> None:
|
||||
"""Save state to the spec directory."""
|
||||
marker_file = spec_dir / LINEAR_PROJECT_MARKER
|
||||
with open(marker_file, "w") as f:
|
||||
with open(marker_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
@@ -137,9 +137,9 @@ class LinearProjectState:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(marker_file) as f:
|
||||
with open(marker_file, encoding="utf-8") as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -158,9 +158,9 @@ class LinearManager:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
def get_subtasks_for_sync(self) -> list[dict]:
|
||||
|
||||
@@ -81,7 +81,7 @@ class LinearTaskState:
|
||||
def save(self, spec_dir: Path) -> None:
|
||||
"""Save state to the spec directory."""
|
||||
state_file = spec_dir / LINEAR_TASK_FILE
|
||||
with open(state_file, "w") as f:
|
||||
with open(state_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
@@ -92,9 +92,9 @@ class LinearTaskState:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
|
||||
# Load existing map or create new
|
||||
if map_file.exists():
|
||||
try:
|
||||
with open(map_file) as f:
|
||||
with open(map_file, encoding="utf-8") as f:
|
||||
codebase_map = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
codebase_map = {}
|
||||
else:
|
||||
codebase_map = {}
|
||||
@@ -58,7 +58,7 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
|
||||
)
|
||||
|
||||
# Write back
|
||||
with open(map_file, "w") as f:
|
||||
with open(map_file, "w", encoding="utf-8") as f:
|
||||
json.dump(codebase_map, f, indent=2, sort_keys=True)
|
||||
|
||||
# Also save to Graphiti if enabled
|
||||
@@ -90,12 +90,12 @@ def load_codebase_map(spec_dir: Path) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(map_file) as f:
|
||||
with open(map_file, encoding="utf-8") as f:
|
||||
codebase_map = json.load(f)
|
||||
|
||||
# Remove metadata before returning
|
||||
codebase_map.pop("_metadata", None)
|
||||
return codebase_map
|
||||
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
@@ -36,7 +36,7 @@ def append_gotcha(spec_dir: Path, gotcha: str) -> None:
|
||||
# Load existing gotchas
|
||||
existing_gotchas = set()
|
||||
if gotchas_file.exists():
|
||||
content = gotchas_file.read_text()
|
||||
content = gotchas_file.read_text(encoding="utf-8")
|
||||
# Extract bullet points
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
@@ -47,7 +47,7 @@ def append_gotcha(spec_dir: Path, gotcha: str) -> None:
|
||||
gotcha_stripped = gotcha.strip()
|
||||
if gotcha_stripped and gotcha_stripped not in existing_gotchas:
|
||||
# Append to file
|
||||
with open(gotchas_file, "a") as f:
|
||||
with open(gotchas_file, "a", encoding="utf-8") as f:
|
||||
if gotchas_file.stat().st_size == 0:
|
||||
# First entry - add header
|
||||
f.write("# Gotchas and Pitfalls\n\n")
|
||||
@@ -80,7 +80,7 @@ def load_gotchas(spec_dir: Path) -> list[str]:
|
||||
if not gotchas_file.exists():
|
||||
return []
|
||||
|
||||
content = gotchas_file.read_text()
|
||||
content = gotchas_file.read_text(encoding="utf-8")
|
||||
gotchas = []
|
||||
|
||||
for line in content.split("\n"):
|
||||
@@ -112,7 +112,7 @@ def append_pattern(spec_dir: Path, pattern: str) -> None:
|
||||
# Load existing patterns
|
||||
existing_patterns = set()
|
||||
if patterns_file.exists():
|
||||
content = patterns_file.read_text()
|
||||
content = patterns_file.read_text(encoding="utf-8")
|
||||
# Extract bullet points
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
@@ -123,7 +123,7 @@ def append_pattern(spec_dir: Path, pattern: str) -> None:
|
||||
pattern_stripped = pattern.strip()
|
||||
if pattern_stripped and pattern_stripped not in existing_patterns:
|
||||
# Append to file
|
||||
with open(patterns_file, "a") as f:
|
||||
with open(patterns_file, "a", encoding="utf-8") as f:
|
||||
if patterns_file.stat().st_size == 0:
|
||||
# First entry - add header
|
||||
f.write("# Code Patterns\n\n")
|
||||
@@ -156,7 +156,7 @@ def load_patterns(spec_dir: Path) -> list[str]:
|
||||
if not patterns_file.exists():
|
||||
return []
|
||||
|
||||
content = patterns_file.read_text()
|
||||
content = patterns_file.read_text(encoding="utf-8")
|
||||
patterns = []
|
||||
|
||||
for line in content.split("\n"):
|
||||
|
||||
@@ -76,7 +76,7 @@ def save_session_insights(
|
||||
}
|
||||
|
||||
# Write to file (always use file-based storage)
|
||||
with open(session_file, "w") as f:
|
||||
with open(session_file, "w", encoding="utf-8") as f:
|
||||
json.dump(session_data, f, indent=2)
|
||||
|
||||
# Also save to Graphiti if enabled (non-blocking, errors logged but not raised)
|
||||
@@ -110,9 +110,9 @@ def load_all_insights(spec_dir: Path) -> list[dict[str, Any]]:
|
||||
insights = []
|
||||
for session_file in session_files:
|
||||
try:
|
||||
with open(session_file) as f:
|
||||
with open(session_file, encoding="utf-8") as f:
|
||||
insights.append(json.load(f))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Skip corrupted files
|
||||
continue
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class EvolutionStorage:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(self.evolution_file) as f:
|
||||
with open(self.evolution_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
evolutions = {}
|
||||
@@ -88,7 +88,7 @@ class EvolutionStorage:
|
||||
for file_path, evolution in evolutions.items()
|
||||
}
|
||||
|
||||
with open(self.evolution_file, "w") as f:
|
||||
with open(self.evolution_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
logger.debug(f"Saved evolution data for {len(evolutions)} files")
|
||||
|
||||
@@ -83,10 +83,16 @@ def apply_single_task_changes(
|
||||
# Addition - need to determine where to add
|
||||
if change.change_type == ChangeType.ADD_IMPORT:
|
||||
# Add import at top
|
||||
# Content is already normalized to LF, so only check for \n
|
||||
has_trailing_newline = content.endswith("\n")
|
||||
lines = content.splitlines()
|
||||
import_end = find_import_end(lines, file_path)
|
||||
lines.insert(import_end, change.content_after)
|
||||
# Strip trailing newline from content_after to prevent double newlines
|
||||
# (content_after may include newline from diff generation)
|
||||
lines.insert(import_end, change.content_after.rstrip("\n\r"))
|
||||
content = line_ending.join(lines)
|
||||
if has_trailing_newline:
|
||||
content += line_ending
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
# Add function at end (before exports)
|
||||
content += f"{line_ending}{line_ending}{change.content_after}"
|
||||
@@ -149,13 +155,21 @@ def combine_non_conflicting_changes(
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
# Content is already normalized to LF, so only check for \n
|
||||
has_trailing_newline = content.endswith("\n")
|
||||
lines = content.splitlines()
|
||||
import_end = find_import_end(lines, file_path)
|
||||
for imp in imports:
|
||||
if imp.content_after and imp.content_after not in content:
|
||||
lines.insert(import_end, imp.content_after)
|
||||
# Strip trailing newline from content_after to prevent double newlines
|
||||
import_content = (
|
||||
imp.content_after.rstrip("\n\r") if imp.content_after else ""
|
||||
)
|
||||
if import_content and import_content not in content:
|
||||
lines.insert(import_end, import_content)
|
||||
import_end += 1
|
||||
content = line_ending.join(lines)
|
||||
if has_trailing_newline:
|
||||
content += line_ending
|
||||
|
||||
# Apply modifications
|
||||
for mod in modifications:
|
||||
|
||||
@@ -75,7 +75,7 @@ def install_hook(project_path: Path) -> bool:
|
||||
# Handle worktrees (where .git is a file, not directory)
|
||||
if git_dir.is_file():
|
||||
# Read the gitdir from the file
|
||||
content = git_dir.read_text().strip()
|
||||
content = git_dir.read_text(encoding="utf-8").strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
else:
|
||||
@@ -93,7 +93,7 @@ def install_hook(project_path: Path) -> bool:
|
||||
|
||||
# Check if hook already exists
|
||||
if hook_path.exists():
|
||||
existing = hook_path.read_text()
|
||||
existing = hook_path.read_text(encoding="utf-8")
|
||||
if "FileTimelineTracker" in existing:
|
||||
print(f"Hook already installed at {hook_path}")
|
||||
return True
|
||||
@@ -104,13 +104,13 @@ def install_hook(project_path: Path) -> bool:
|
||||
print(f"Backed up existing hook to {backup_path}")
|
||||
|
||||
# Append our hook to existing
|
||||
with open(hook_path, "a") as f:
|
||||
with open(hook_path, "a", encoding="utf-8") as f:
|
||||
f.write("\n\n# FileTimelineTracker integration\n")
|
||||
f.write(HOOK_SCRIPT.split("#!/bin/bash", 1)[1]) # Skip shebang
|
||||
print(f"Appended FileTimelineTracker hook to {hook_path}")
|
||||
else:
|
||||
# Write new hook
|
||||
hook_path.write_text(HOOK_SCRIPT)
|
||||
hook_path.write_text(HOOK_SCRIPT, encoding="utf-8")
|
||||
print(f"Created new hook at {hook_path}")
|
||||
|
||||
# Make executable
|
||||
@@ -127,7 +127,7 @@ def uninstall_hook(project_path: Path) -> bool:
|
||||
git_dir = project_path / ".git"
|
||||
|
||||
if git_dir.is_file():
|
||||
content = git_dir.read_text().strip()
|
||||
content = git_dir.read_text(encoding="utf-8").strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
|
||||
@@ -137,7 +137,7 @@ def uninstall_hook(project_path: Path) -> bool:
|
||||
print("No hook to uninstall")
|
||||
return True
|
||||
|
||||
content = hook_path.read_text()
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
if "FileTimelineTracker" not in content:
|
||||
print("Hook does not contain FileTimelineTracker integration")
|
||||
return True
|
||||
|
||||
@@ -108,5 +108,5 @@ class MergeReport:
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Save report to JSON file."""
|
||||
with open(path, "w") as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@@ -17,6 +17,8 @@ import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import get_isolated_git_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import debug utilities
|
||||
@@ -62,6 +64,7 @@ class TimelineGitHelper:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -86,6 +89,7 @@ class TimelineGitHelper:
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
@@ -117,6 +121,7 @@ class TimelineGitHelper:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
return [f for f in result.stdout.strip().split("\n") if f]
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -133,33 +138,34 @@ class TimelineGitHelper:
|
||||
Dictionary with keys: message, author, diff_summary
|
||||
"""
|
||||
info = {}
|
||||
env = get_isolated_git_env()
|
||||
try:
|
||||
# Get commit message
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["message"] = result.stdout.strip()
|
||||
|
||||
# Get author
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%an", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["author"] = result.stdout.strip()
|
||||
|
||||
# Get diff stat
|
||||
result = subprocess.run(
|
||||
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info["diff_summary"] = (
|
||||
@@ -226,6 +232,7 @@ class TimelineGitHelper:
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -259,6 +266,7 @@ class TimelineGitHelper:
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -284,24 +292,23 @@ class TimelineGitHelper:
|
||||
Returns:
|
||||
The detected target branch name, defaults to 'main' if detection fails
|
||||
"""
|
||||
# Try to get the upstream tracking branch
|
||||
env = get_isolated_git_env()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
upstream = result.stdout.strip()
|
||||
# Extract branch name from origin/branch format
|
||||
if "/" in upstream:
|
||||
return upstream.split("/", 1)[1]
|
||||
return upstream
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try common branch names and find which one has a valid merge-base
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -309,13 +316,13 @@ class TimelineGitHelper:
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Default to main
|
||||
return "main"
|
||||
|
||||
def count_commits_between(self, from_commit: str, to_commit: str) -> int:
|
||||
@@ -335,6 +342,7 @@ class TimelineGitHelper:
|
||||
cwd=self.project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
|
||||
@@ -71,13 +71,13 @@ class TimelinePersistence:
|
||||
return timelines
|
||||
|
||||
try:
|
||||
with open(index_path) as f:
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
|
||||
for file_path in index.get("files", []):
|
||||
timeline_file = self._get_timeline_file_path(file_path)
|
||||
if timeline_file.exists():
|
||||
with open(timeline_file) as f:
|
||||
with open(timeline_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
timelines[file_path] = FileTimeline.from_dict(data)
|
||||
|
||||
@@ -101,7 +101,7 @@ class TimelinePersistence:
|
||||
timeline_file = self._get_timeline_file_path(file_path)
|
||||
timeline_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(timeline_file, "w") as f:
|
||||
with open(timeline_file, "w", encoding="utf-8") as f:
|
||||
json.dump(timeline.to_dict(), f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
@@ -119,7 +119,7 @@ class TimelinePersistence:
|
||||
"files": file_paths,
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
}
|
||||
with open(index_path, "w") as f:
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
def _get_timeline_file_path(self, file_path: str) -> Path:
|
||||
|
||||
@@ -25,7 +25,7 @@ THINKING_BUDGET_MAP: dict[str, int | None] = {
|
||||
"low": 1024,
|
||||
"medium": 4096, # Moderate analysis
|
||||
"high": 16384, # Deep thinking for QA review
|
||||
"ultrathink": 65536, # Maximum reasoning depth
|
||||
"ultrathink": 63999, # Maximum reasoning depth (API requires max_tokens >= budget + 1, so 63999 + 1 = 64000 limit)
|
||||
}
|
||||
|
||||
# Spec runner phase-specific thinking levels
|
||||
@@ -164,7 +164,7 @@ def load_task_metadata(spec_dir: Path) -> TaskMetadataConfig | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path) as f:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
@@ -41,21 +41,29 @@ class ContextLoader:
|
||||
"""Load all context files from spec directory."""
|
||||
# Read spec.md
|
||||
spec_file = self.spec_dir / "spec.md"
|
||||
spec_content = spec_file.read_text() if spec_file.exists() else ""
|
||||
spec_content = (
|
||||
spec_file.read_text(encoding="utf-8") if spec_file.exists() else ""
|
||||
)
|
||||
|
||||
# Read project_index.json
|
||||
index_file = self.spec_dir / "project_index.json"
|
||||
project_index = {}
|
||||
if index_file.exists():
|
||||
with open(index_file) as f:
|
||||
project_index = json.load(f)
|
||||
try:
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
project_index = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Use empty dict on error
|
||||
|
||||
# Read context.json
|
||||
context_file = self.spec_dir / "context.json"
|
||||
task_context = {}
|
||||
if context_file.exists():
|
||||
with open(context_file) as f:
|
||||
task_context = json.load(f)
|
||||
try:
|
||||
with open(context_file, encoding="utf-8") as f:
|
||||
task_context = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Use empty dict on error
|
||||
|
||||
# Determine services involved
|
||||
services = task_context.get("scoped_services", [])
|
||||
@@ -89,7 +97,7 @@ class ContextLoader:
|
||||
requirements_file = self.spec_dir / "requirements.json"
|
||||
if requirements_file.exists():
|
||||
try:
|
||||
with open(requirements_file) as f:
|
||||
with open(requirements_file, encoding="utf-8") as f:
|
||||
requirements = json.load(f)
|
||||
declared_type = _normalize_workflow_type(
|
||||
requirements.get("workflow_type", "")
|
||||
@@ -103,7 +111,7 @@ class ContextLoader:
|
||||
assessment_file = self.spec_dir / "complexity_assessment.json"
|
||||
if assessment_file.exists():
|
||||
try:
|
||||
with open(assessment_file) as f:
|
||||
with open(assessment_file, encoding="utf-8") as f:
|
||||
assessment = json.load(f)
|
||||
declared_type = _normalize_workflow_type(
|
||||
assessment.get("workflow_type", "")
|
||||
|
||||
@@ -52,7 +52,7 @@ def main():
|
||||
print(f"Error: No implementation_plan.json found in {spec_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(plan_file) as f:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Find first pending subtask
|
||||
|
||||
@@ -33,7 +33,7 @@ class MemoryLoader:
|
||||
return []
|
||||
|
||||
gotchas = []
|
||||
content = self.gotchas_file.read_text()
|
||||
content = self.gotchas_file.read_text(encoding="utf-8")
|
||||
|
||||
# Parse markdown list items
|
||||
for line in content.split("\n"):
|
||||
@@ -56,7 +56,7 @@ class MemoryLoader:
|
||||
return []
|
||||
|
||||
patterns = []
|
||||
content = self.patterns_file.read_text()
|
||||
content = self.patterns_file.read_text(encoding="utf-8")
|
||||
|
||||
# Parse markdown sections
|
||||
current_pattern = None
|
||||
@@ -89,8 +89,8 @@ class MemoryLoader:
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.history_file) as f:
|
||||
with open(self.history_file, encoding="utf-8") as f:
|
||||
history = json.load(f)
|
||||
return history.get("attempts", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
@@ -69,7 +69,7 @@ class ProjectAnalyzer:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(profile_path) as f:
|
||||
with open(profile_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return SecurityProfile.from_dict(data)
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
@@ -80,7 +80,7 @@ class ProjectAnalyzer:
|
||||
profile_path = self.get_profile_path()
|
||||
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(profile_path, "w") as f:
|
||||
with open(profile_path, "w", encoding="utf-8") as f:
|
||||
json.dump(profile.to_dict(), f, indent=2)
|
||||
|
||||
def compute_project_hash(self) -> str:
|
||||
|
||||
@@ -38,7 +38,7 @@ class ConfigParser:
|
||||
def read_json(self, filename: str) -> dict | None:
|
||||
"""Read a JSON file from project root."""
|
||||
try:
|
||||
with open(self.project_dir / filename) as f:
|
||||
with open(self.project_dir / filename, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
@@ -59,7 +59,7 @@ class ConfigParser:
|
||||
def read_text(self, filename: str) -> str | None:
|
||||
"""Read a text file from project root."""
|
||||
try:
|
||||
with open(self.project_dir / filename) as f:
|
||||
with open(self.project_dir / filename, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except (OSError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
@@ -248,7 +248,7 @@ class StackDetector:
|
||||
"**/*.yaml"
|
||||
) + self.parser.glob_files("**/*.yml"):
|
||||
try:
|
||||
with open(yaml_file) as f:
|
||||
with open(yaml_file, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if "apiVersion:" in content and "kind:" in content:
|
||||
self.stack.infrastructure.append("kubernetes")
|
||||
|
||||
@@ -942,13 +942,13 @@ if insights["discoveries"]["patterns_found"]:
|
||||
# Load existing patterns
|
||||
existing_patterns = set()
|
||||
if patterns_file.exists():
|
||||
content = patterns_file.read_text()
|
||||
content = patterns_file.read_text(encoding="utf-8")
|
||||
for line in content.split("\n"):
|
||||
if line.strip().startswith("- "):
|
||||
existing_patterns.add(line.strip()[2:])
|
||||
|
||||
# Add new patterns
|
||||
with open(patterns_file, "a") as f:
|
||||
with open(patterns_file, "a", encoding="utf-8") as f:
|
||||
if patterns_file.stat().st_size == 0:
|
||||
f.write("# Code Patterns\n\n")
|
||||
f.write("Established patterns to follow in this codebase:\n\n")
|
||||
@@ -965,13 +965,13 @@ if insights["discoveries"]["gotchas_encountered"]:
|
||||
# Load existing gotchas
|
||||
existing_gotchas = set()
|
||||
if gotchas_file.exists():
|
||||
content = gotchas_file.read_text()
|
||||
content = gotchas_file.read_text(encoding="utf-8")
|
||||
for line in content.split("\n"):
|
||||
if line.strip().startswith("- "):
|
||||
existing_gotchas.add(line.strip()[2:])
|
||||
|
||||
# Add new gotchas
|
||||
with open(gotchas_file, "a") as f:
|
||||
with open(gotchas_file, "a", encoding="utf-8") as f:
|
||||
if gotchas_file.stat().st_size == 0:
|
||||
f.write("# Gotchas and Pitfalls\n\n")
|
||||
f.write("Things to watch out for in this codebase:\n\n")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user