mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
225 Commits
v0.4.0
...
feature/to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3559e0104c | ||
|
|
d104a24446 | ||
|
|
a26ef4347a | ||
|
|
e9dba8c9e5 | ||
|
|
2b02db8ae3 | ||
|
|
1ad3b1739b | ||
|
|
17a2053e79 | ||
|
|
46c3dd252f | ||
|
|
96b0e74794 | ||
|
|
18a2ed2a44 | ||
|
|
7d6ed0cb37 | ||
|
|
396100686c | ||
|
|
35ecb0dd2d | ||
|
|
8d6dae7495 | ||
|
|
340e76c3ed | ||
|
|
275037c73d | ||
|
|
a14ef30c69 | ||
|
|
18fa0f3066 | ||
|
|
3e015591d3 | ||
|
|
81d631ea72 | ||
|
|
2a1ab218ec | ||
|
|
6c31f725ff | ||
|
|
f0bea76141 | ||
|
|
46933a2a81 | ||
|
|
dccb5faa4b | ||
|
|
15ae1fe147 | ||
|
|
6f82f64195 | ||
|
|
1656b4fb7a | ||
|
|
419e954230 | ||
|
|
1a83c9b256 | ||
|
|
a0efa5d351 | ||
|
|
afcda98dc4 | ||
|
|
e508f9c1d1 | ||
|
|
8c2c54b0a4 | ||
|
|
65edddbc36 | ||
|
|
0dfe5a91e7 | ||
|
|
2f75c0bec5 | ||
|
|
516f26edae | ||
|
|
dd8862ce21 | ||
|
|
0c2192d039 | ||
|
|
a85390b289 | ||
|
|
c4a90d7f29 | ||
|
|
8794156f28 | ||
|
|
7603c827f6 | ||
|
|
7ad70a3923 | ||
|
|
95c6a69610 | ||
|
|
157dd71efa | ||
|
|
06ed965a3b | ||
|
|
ea7e273fb4 | ||
|
|
dcf05e4f1c | ||
|
|
50fb7ece4f | ||
|
|
f9db4fffa7 | ||
|
|
1cb6daaa07 | ||
|
|
adf9307796 | ||
|
|
7fdc2b2fab | ||
|
|
0d8043f1f2 | ||
|
|
2c079623a8 | ||
|
|
899c45fc1b | ||
|
|
c763f2a545 | ||
|
|
45dd1d45a1 | ||
|
|
a860b3cf45 | ||
|
|
9fcdd899b2 | ||
|
|
bacafd129f | ||
|
|
20d7fb1949 | ||
|
|
a192eaa20f | ||
|
|
99fe6f6497 | ||
|
|
35c6beca37 | ||
|
|
f7cb92fa9d | ||
|
|
b403d0d570 | ||
|
|
c80ae3367a | ||
|
|
c7312af3c8 | ||
|
|
f3dbc996d4 | ||
|
|
0549b8085a | ||
|
|
760f254f78 | ||
|
|
91bff6c572 | ||
|
|
019ac56ceb | ||
|
|
ecf34b178e | ||
|
|
3cd6e8c13b | ||
|
|
ca8341bf39 | ||
|
|
160bd8bfc7 | ||
|
|
0d1138dfcf | ||
|
|
b112747073 | ||
|
|
e1c3b7528f | ||
|
|
e78bfc80ec | ||
|
|
3eac848d4f | ||
|
|
76cb72812f | ||
|
|
bfc8f9bc26 | ||
|
|
8f2e06bc32 | ||
|
|
2c9f77356f | ||
|
|
ea1b10fea6 | ||
|
|
e6a63ccae1 | ||
|
|
784188cf52 | ||
|
|
1d945f4f75 | ||
|
|
df50cccb7b | ||
|
|
b0a9c89157 | ||
|
|
45eaf91cb3 | ||
|
|
c20b224f30 | ||
|
|
96b941b008 | ||
|
|
ad4da23743 | ||
|
|
5136c32b68 | ||
|
|
cffdec91f1 | ||
|
|
d9c87f8116 | ||
|
|
9954feafd8 | ||
|
|
acfb8d2255 | ||
|
|
fe6106e807 | ||
|
|
2af43d7c2d | ||
|
|
a7a6ff2e6c | ||
|
|
8f598d7ce3 | ||
|
|
7f8092264a | ||
|
|
e0471fef09 | ||
|
|
043edde63b | ||
|
|
4b9a211c49 | ||
|
|
b59bbd93ba | ||
|
|
2a782392bc | ||
|
|
fe56ba133e | ||
|
|
40a3046a3b | ||
|
|
1aa8b5b56b | ||
|
|
266e0c54b9 | ||
|
|
31550ab4e7 | ||
|
|
cce8d1569a | ||
|
|
ad051eb8f0 | ||
|
|
01098545cf | ||
|
|
d58bd782ef | ||
|
|
c11cb6a6cd | ||
|
|
64549f824c | ||
|
|
83fab5321e | ||
|
|
bb47f22d6c | ||
|
|
4996a63bcc | ||
|
|
f302234b0e | ||
|
|
58d6ae02a5 | ||
|
|
f9ec7222f2 | ||
|
|
360b7ebe08 | ||
|
|
ebc99d06eb | ||
|
|
f2600821d6 | ||
|
|
6e08126875 | ||
|
|
176eeca096 | ||
|
|
f6a9ae6335 | ||
|
|
30db67f89c | ||
|
|
da90bafde8 | ||
|
|
04d263b1ed | ||
|
|
e8e79d8446 | ||
|
|
8c24381759 | ||
|
|
8482cdab87 | ||
|
|
064a395c4c | ||
|
|
d103d0aa45 | ||
|
|
9509c8ea00 | ||
|
|
26b73fdaa9 | ||
|
|
a3c9c9cee5 | ||
|
|
0d088962a0 | ||
|
|
2ce4e02ada | ||
|
|
fad3ed1aae | ||
|
|
81444d5603 | ||
|
|
e8b65dbd0b | ||
|
|
f86bb3eab8 | ||
|
|
54a102f029 | ||
|
|
2ee4ec65b4 | ||
|
|
166679cd36 | ||
|
|
b95c54a539 | ||
|
|
e71be53459 | ||
|
|
8c5759d74e | ||
|
|
bd1c4e0690 | ||
|
|
9a428eefe0 | ||
|
|
8774d28bc4 | ||
|
|
9eb9c070cd | ||
|
|
7110a690e1 | ||
|
|
1194e7d51e | ||
|
|
1641f9da5e | ||
|
|
ff4887773e | ||
|
|
15a580ece9 | ||
|
|
b37d258698 | ||
|
|
e0e7bb9190 | ||
|
|
7131c70186 | ||
|
|
be98a59023 | ||
|
|
7e517101a0 | ||
|
|
92f60cceb5 | ||
|
|
b1dcb8a9d7 | ||
|
|
ec6ec7d569 | ||
|
|
31bb069e75 | ||
|
|
363be54303 | ||
|
|
ca0f6661d3 | ||
|
|
cd803cd9bc | ||
|
|
cbdc88c5d0 | ||
|
|
44b548c5c8 | ||
|
|
cc2ac3542d | ||
|
|
25044d40b9 | ||
|
|
676169b189 | ||
|
|
c8c05efb8d | ||
|
|
23cef5fd82 | ||
|
|
c0b0b30541 | ||
|
|
7eeba5f17c | ||
|
|
fcb2457e17 | ||
|
|
02e378905e | ||
|
|
87c0ab6daa | ||
|
|
60f9da9208 | ||
|
|
ff318d6ef5 | ||
|
|
93d9e08de1 | ||
|
|
7edca6b823 | ||
|
|
73eebe7c9e | ||
|
|
049f9a9e37 | ||
|
|
658cbb8bd6 | ||
|
|
19f1c32805 | ||
|
|
ece8ff8cbc | ||
|
|
a3a648aef1 | ||
|
|
3bc2b74d30 | ||
|
|
9d17cd7d9c | ||
|
|
091c6b2737 | ||
|
|
2880314931 | ||
|
|
0102719067 | ||
|
|
123b471b68 | ||
|
|
b66d228460 | ||
|
|
770d67d8c4 | ||
|
|
d42857ec26 | ||
|
|
54b977ee1b | ||
|
|
e8999ba908 | ||
|
|
96c4383b29 | ||
|
|
93d1d2c41a | ||
|
|
b075af5bc9 | ||
|
|
07ca7fccb8 | ||
|
|
797643ffdc | ||
|
|
7d4052be95 | ||
|
|
1036719f2a | ||
|
|
1ab520eda3 | ||
|
|
658f7d816e | ||
|
|
a5c61b0546 | ||
|
|
480589510e |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dir \"C:\\Users\\Ben\\Desktop\\appdev\\git\\automaker\\apps\\app\\public\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
66
.github/actions/setup-project/action.yml
vendored
Normal file
66
.github/actions/setup-project/action.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: "Setup Project"
|
||||
description: "Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules"
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node.js version to use"
|
||||
required: false
|
||||
default: "22"
|
||||
check-lockfile:
|
||||
description: "Run lockfile lint check for SSH URLs"
|
||||
required: false
|
||||
default: "false"
|
||||
rebuild-node-pty-path:
|
||||
description: "Working directory for node-pty rebuild (empty = root)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
shell: bash
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
|
||||
run: npm install --ignore-scripts
|
||||
|
||||
- name: Install Linux native bindings
|
||||
shell: bash
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force --ignore-scripts \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Rebuild native modules (root)
|
||||
if: inputs.rebuild-node-pty-path == ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules for Electron
|
||||
run: npm rebuild node-pty
|
||||
|
||||
- name: Rebuild native modules (workspace)
|
||||
if: inputs.rebuild-node-pty-path != ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules needed for server
|
||||
run: npm rebuild node-pty
|
||||
working-directory: ${{ inputs.rebuild-node-pty-path }}
|
||||
38
.github/workflows/e2e-tests.yml
vendored
38
.github/workflows/e2e-tests.yml
vendored
@@ -18,29 +18,15 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
check-lockfile: "true"
|
||||
rebuild-node-pty-path: "apps/server"
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: apps/app
|
||||
working-directory: apps/ui
|
||||
|
||||
- name: Build server
|
||||
run: npm run build --workspace=apps/server
|
||||
@@ -66,20 +52,20 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
# Playwright automatically starts the Next.js frontend via webServer config
|
||||
# (see apps/app/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/app
|
||||
# Playwright automatically starts the Vite frontend via webServer config
|
||||
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/ui
|
||||
env:
|
||||
CI: true
|
||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
|
||||
NEXT_PUBLIC_SKIP_SETUP: "true"
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/app/playwright-report/
|
||||
path: apps/ui/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
@@ -87,5 +73,5 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: apps/app/test-results/
|
||||
path: apps/ui/test-results/
|
||||
retention-days: 7
|
||||
|
||||
25
.github/workflows/pr-check.yml
vendored
25
.github/workflows/pr-check.yml
vendored
@@ -17,25 +17,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
check-lockfile: "true"
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Run build:electron
|
||||
run: npm run build:electron
|
||||
- name: Run build:electron (dir only - faster CI)
|
||||
run: npm run build:electron:dir
|
||||
|
||||
175
.github/workflows/release.yml
vendored
175
.github/workflows/release.yml
vendored
@@ -1,175 +0,0 @@
|
||||
name: Build and Release Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*" # Triggers on version tags like v1.0.0
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g., v1.0.0)"
|
||||
required: true
|
||||
default: "v0.1.0"
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
name: macOS
|
||||
artifact-name: macos-builds
|
||||
- os: windows-latest
|
||||
name: Windows
|
||||
artifact-name: windows-builds
|
||||
- os: ubuntu-latest
|
||||
name: Linux
|
||||
artifact-name: linux-builds
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Only needed on Linux - macOS and Windows get their bindings automatically
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Extract and set version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
# Update the app's package.json version
|
||||
cd apps/app
|
||||
npm version $VERSION --no-git-tag-version
|
||||
cd ../..
|
||||
echo "Updated apps/app/package.json to version $VERSION"
|
||||
|
||||
- name: Build Electron App (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --mac --x64 --arm64
|
||||
|
||||
- name: Build Electron App (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --win --x64
|
||||
|
||||
- name: Build Electron App (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --linux --x64
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
files: |
|
||||
apps/app/dist/*.exe
|
||||
apps/app/dist/*.dmg
|
||||
apps/app/dist/*.AppImage
|
||||
apps/app/dist/*.zip
|
||||
apps/app/dist/*.deb
|
||||
apps/app/dist/*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload macOS artifacts for R2
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Windows artifacts for R2
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Linux artifacts for R2
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.AppImage
|
||||
retention-days: 1
|
||||
|
||||
upload-to-r2:
|
||||
needs: build-and-release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Install AWS SDK
|
||||
run: npm install @aws-sdk/client-s3
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
|
||||
- name: Upload to R2 and update releases.json
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node .github/scripts/upload-to-r2.js
|
||||
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
@@ -17,25 +17,11 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
check-lockfile: "true"
|
||||
rebuild-node-pty-path: "apps/server"
|
||||
|
||||
- name: Run server tests with coverage
|
||||
run: npm run test:server:coverage
|
||||
|
||||
67
.gitignore
vendored
67
.gitignore
vendored
@@ -6,10 +6,75 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.turbo/
|
||||
|
||||
# Automaker
|
||||
.automaker/images/
|
||||
.automaker/
|
||||
/.automaker/*
|
||||
/.automaker/
|
||||
|
||||
/logs
|
||||
.worktrees/
|
||||
|
||||
/logs
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# IDE/Editor configs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Editor backup/temp files
|
||||
*~
|
||||
*.bak
|
||||
*.backup
|
||||
*.orig
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Local settings (user-specific)
|
||||
*.local.json
|
||||
|
||||
# Application state/backup
|
||||
backup.json
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Environment files (keep .example)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
71
README.md
71
README.md
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="apps/ui/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||
</p>
|
||||
|
||||
> **[!TIP]**
|
||||
>
|
||||
> **Learn more about Agentic Coding!**
|
||||
@@ -10,8 +14,39 @@
|
||||
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||
- [The Workflow](#the-workflow)
|
||||
- [Powered by Claude Code](#powered-by-claude-code)
|
||||
- [Why This Matters](#why-this-matters)
|
||||
- [Security Disclaimer](#security-disclaimer)
|
||||
- [Community & Support](#community--support)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Development Mode](#development-mode)
|
||||
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
|
||||
- [Web Browser Mode](#web-browser-mode)
|
||||
- [Building for Production](#building-for-production)
|
||||
- [Running Production Build](#running-production-build)
|
||||
- [Testing](#testing)
|
||||
- [Linting](#linting)
|
||||
- [Authentication Options](#authentication-options)
|
||||
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Learn More](#learn-more)
|
||||
- [License](#license)
|
||||
|
||||
</details>
|
||||
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
||||
|
||||

|
||||
|
||||
## What Makes Automaker Different?
|
||||
|
||||
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
|
||||
@@ -48,6 +83,23 @@ The future of software development is **agentic coding**—where developers beco
|
||||
|
||||
---
|
||||
|
||||
## Community & Support
|
||||
|
||||
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||
|
||||
In the Discord, you can:
|
||||
|
||||
- 💬 Discuss agentic coding patterns and best practices
|
||||
- 🧠 Share ideas for AI-driven development workflows
|
||||
- 🛠️ Get help setting up or extending Automaker
|
||||
- 🚀 Show off projects built with AI agents
|
||||
- 🤝 Collaborate with other developers and contributors
|
||||
|
||||
👉 **Join the Discord:**
|
||||
https://discord.gg/jjem7aEDKU
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -60,7 +112,7 @@ The future of software development is **agentic coding**—where developers beco
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
||||
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
|
||||
# 2. Install dependencies
|
||||
@@ -144,21 +196,17 @@ npm run lint
|
||||
|
||||
Automaker supports multiple authentication methods (in order of priority):
|
||||
|
||||
| Method | Environment Variable | Description |
|
||||
| -------------------- | ------------------------- | --------------------------------------------------------- |
|
||||
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
|
||||
| OAuth Token (stored) | — | Stored in app credentials file |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key |
|
||||
|
||||
**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription.
|
||||
| Method | Environment Variable | Description |
|
||||
| ---------------- | -------------------- | ------------------------------- |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
|
||||
### Persistent Setup (Optional)
|
||||
|
||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_HERE"
|
||||
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
Then restart your terminal or run `source ~/.bashrc`.
|
||||
@@ -205,19 +253,16 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE
|
||||
**Summary of Terms:**
|
||||
|
||||
- **Allowed:**
|
||||
|
||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||
|
||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||
|
||||
- **No Resale:** You cannot resell Automaker itself.
|
||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||
|
||||
- **Liability:**
|
||||
|
||||
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||
|
||||
- **Contributing:**
|
||||
|
||||
BIN
apps/.DS_Store
vendored
BIN
apps/.DS_Store
vendored
Binary file not shown.
@@ -1,123 +0,0 @@
|
||||
# Automaker
|
||||
|
||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
||||
|
||||
---
|
||||
|
||||
> **[!CAUTION]**
|
||||
>
|
||||
> ## Security Disclaimer
|
||||
>
|
||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||
>
|
||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Step 1:** Clone this repository:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
```
|
||||
|
||||
**Step 2:** Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Windows notes (in-app Claude auth)
|
||||
|
||||
- Node.js 22.x
|
||||
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
|
||||
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
|
||||
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
|
||||
|
||||
**Step 3:** Run the Claude Code setup token command:
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
|
||||
|
||||
**Step 4:** Export the Claude Code OAuth token in your shell:
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
|
||||
```
|
||||
|
||||
**Step 5:** Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev:electron
|
||||
```
|
||||
|
||||
This will start both the Next.js development server and the Electron application.
|
||||
|
||||
### Auth smoke test (Windows)
|
||||
|
||||
1. Ensure dependencies are installed (prebuilt pty is included).
|
||||
2. Run `npm run dev:electron` and open the Setup modal.
|
||||
3. Click Start on Claude auth; watch the embedded terminal stream logs.
|
||||
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
|
||||
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
|
||||
|
||||
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
|
||||
|
||||
```bash
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
||||
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [Next.js](https://nextjs.org) - React framework
|
||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](../LICENSE) for details.
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Simplified Electron preload script
|
||||
*
|
||||
* Only exposes native features (dialogs, shell) and server URL.
|
||||
* All other operations go through HTTP API.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Expose minimal API for native features
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Platform info
|
||||
platform: process.platform,
|
||||
isElectron: true,
|
||||
|
||||
// Connection check
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
|
||||
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
|
||||
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
|
||||
|
||||
// Shell operations
|
||||
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
|
||||
|
||||
// App info
|
||||
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
|
||||
});
|
||||
|
||||
console.log("[Preload] Electron API exposed (simplified mode)");
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Electron files use CommonJS
|
||||
"electron/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
env: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
...(reuseServer
|
||||
? {}
|
||||
: {
|
||||
webServer: {
|
||||
command: `npx next dev -p ${port}`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_SKIP_SETUP: "true",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 10000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `npx next dev -p ${port}`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
@@ -1,97 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface AnthropicResponse {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
model?: string;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send a simple test prompt to the Anthropic API
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": effectiveApiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as AnthropicResponse;
|
||||
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnthropicResponse;
|
||||
|
||||
// Check if we got a valid response
|
||||
if (data.content && data.content.length > 0) {
|
||||
const textContent = data.content.find((block) => block.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${textContent.text}"`,
|
||||
model: data.model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Claude responded.",
|
||||
model: data.model,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Claude API test error:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Claude API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface GeminiContent {
|
||||
parts: Array<{
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
}>;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface GeminiRequest {
|
||||
contents: GeminiContent[];
|
||||
generationConfig?: {
|
||||
maxOutputTokens?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
content: {
|
||||
parts: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
role: string;
|
||||
};
|
||||
finishReason: string;
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
}>;
|
||||
promptFeedback?: {
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey, imageData, mimeType, prompt } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build the request body
|
||||
const requestBody: GeminiRequest = {
|
||||
contents: [
|
||||
{
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: 150,
|
||||
temperature: 0.4,
|
||||
},
|
||||
};
|
||||
|
||||
// Add image if provided
|
||||
if (imageData && mimeType) {
|
||||
requestBody.contents[0].parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: imageData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add text prompt
|
||||
const textPrompt = prompt || (imageData
|
||||
? "Describe what you see in this image briefly."
|
||||
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
|
||||
|
||||
requestBody.contents[0].parts.push({
|
||||
text: textPrompt,
|
||||
});
|
||||
|
||||
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
|
||||
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
|
||||
|
||||
const response = await fetch(geminiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data: GeminiResponse = await response.json();
|
||||
|
||||
// Check for API errors
|
||||
if (data.error) {
|
||||
const errorMessage = data.error.message || "Unknown Gemini API error";
|
||||
const statusCode = data.error.code || 500;
|
||||
|
||||
if (statusCode === 400 && errorMessage.includes("API key")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Google API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for valid response
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract response text
|
||||
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
|
||||
const responseText = data.candidates[0].content.parts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle blocked responses
|
||||
if (data.promptFeedback?.safetyRatings) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded (response may have been filtered).",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded.",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Gemini API test error:", error);
|
||||
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Network error. Unable to reach Gemini API." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Gemini API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,26 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
export const metadata: Metadata = {
|
||||
title: "Automaker - Autonomous AI Development Studio",
|
||||
description: "Build software autonomously with intelligent orchestration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { WelcomeView } from "@/components/views/welcome-view";
|
||||
import { BoardView } from "@/components/views/board-view";
|
||||
import { SpecView } from "@/components/views/spec-view";
|
||||
import { AgentView } from "@/components/views/agent-view";
|
||||
import { SettingsView } from "@/components/views/settings-view";
|
||||
import { InterviewView } from "@/components/views/interview-view";
|
||||
import { ContextView } from "@/components/views/context-view";
|
||||
import { ProfilesView } from "@/components/views/profiles-view";
|
||||
import { SetupView } from "@/components/views/setup-view";
|
||||
import { RunningAgentsView } from "@/components/views/running-agents-view";
|
||||
import { TerminalView } from "@/components/views/terminal-view";
|
||||
import { WikiView } from "@/components/views/wiki-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
setGlobalFileBrowser,
|
||||
} from "@/contexts/file-browser-context";
|
||||
|
||||
function HomeContent() {
|
||||
const {
|
||||
currentView,
|
||||
setCurrentView,
|
||||
setIpcConnected,
|
||||
theme,
|
||||
currentProject,
|
||||
previewTheme,
|
||||
getEffectiveTheme,
|
||||
} = useAppStore();
|
||||
const { isFirstRun, setupComplete } = useSetupStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||
// Don't trigger when typing in inputs
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement) {
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
if (
|
||||
tagName === "input" ||
|
||||
tagName === "textarea" ||
|
||||
tagName === "select"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
||||
return;
|
||||
}
|
||||
const role = activeElement.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't trigger with modifier keys
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for "\" key (backslash)
|
||||
if (event.key === "\\") {
|
||||
event.preventDefault();
|
||||
setStreamerPanelOpen((prev) => !prev);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Register the "\" shortcut for streamer panel
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleStreamerPanelShortcut);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleStreamerPanelShortcut);
|
||||
};
|
||||
}, [handleStreamerPanelShortcut]);
|
||||
|
||||
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
|
||||
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Initialize global file browser for HttpApiClient
|
||||
useEffect(() => {
|
||||
setGlobalFileBrowser(openFileBrowser);
|
||||
}, [openFileBrowser]);
|
||||
|
||||
// Check if this is first run and redirect to setup if needed
|
||||
useEffect(() => {
|
||||
console.log("[Setup Flow] Checking setup state:", {
|
||||
isMounted,
|
||||
isFirstRun,
|
||||
setupComplete,
|
||||
currentView,
|
||||
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
|
||||
});
|
||||
|
||||
if (isMounted && isFirstRun && !setupComplete) {
|
||||
console.log(
|
||||
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
|
||||
);
|
||||
setCurrentView("setup");
|
||||
} else if (isMounted && setupComplete) {
|
||||
console.log("[Setup Flow] Setup already complete, showing normal view");
|
||||
}
|
||||
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
|
||||
|
||||
// Test IPC connection on mount
|
||||
useEffect(() => {
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ping();
|
||||
setIpcConnected(result === "pong");
|
||||
} catch (error) {
|
||||
console.error("IPC connection failed:", error);
|
||||
setIpcConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
testConnection();
|
||||
}, [setIpcConnected]);
|
||||
|
||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove(
|
||||
"dark",
|
||||
"retro",
|
||||
"light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"monokai",
|
||||
"tokyonight",
|
||||
"solarized",
|
||||
"gruvbox",
|
||||
"catppuccin",
|
||||
"onedark",
|
||||
"synthwave",
|
||||
"red"
|
||||
);
|
||||
|
||||
if (effectiveTheme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else if (effectiveTheme === "retro") {
|
||||
root.classList.add("retro");
|
||||
} else if (effectiveTheme === "dracula") {
|
||||
root.classList.add("dracula");
|
||||
} else if (effectiveTheme === "nord") {
|
||||
root.classList.add("nord");
|
||||
} else if (effectiveTheme === "monokai") {
|
||||
root.classList.add("monokai");
|
||||
} else if (effectiveTheme === "tokyonight") {
|
||||
root.classList.add("tokyonight");
|
||||
} else if (effectiveTheme === "solarized") {
|
||||
root.classList.add("solarized");
|
||||
} else if (effectiveTheme === "gruvbox") {
|
||||
root.classList.add("gruvbox");
|
||||
} else if (effectiveTheme === "catppuccin") {
|
||||
root.classList.add("catppuccin");
|
||||
} else if (effectiveTheme === "onedark") {
|
||||
root.classList.add("onedark");
|
||||
} else if (effectiveTheme === "synthwave") {
|
||||
root.classList.add("synthwave");
|
||||
} else if (effectiveTheme === "red") {
|
||||
root.classList.add("red");
|
||||
} else if (effectiveTheme === "light") {
|
||||
root.classList.add("light");
|
||||
} else if (effectiveTheme === "system") {
|
||||
// System theme
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (isDark) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.add("light");
|
||||
}
|
||||
}
|
||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case "welcome":
|
||||
return <WelcomeView />;
|
||||
case "setup":
|
||||
return <SetupView />;
|
||||
case "board":
|
||||
return <BoardView />;
|
||||
case "spec":
|
||||
return <SpecView />;
|
||||
case "agent":
|
||||
return <AgentView />;
|
||||
case "settings":
|
||||
return <SettingsView />;
|
||||
case "interview":
|
||||
return <InterviewView />;
|
||||
case "context":
|
||||
return <ContextView />;
|
||||
case "profiles":
|
||||
return <ProfilesView />;
|
||||
case "running-agents":
|
||||
return <RunningAgentsView />;
|
||||
case "terminal":
|
||||
return <TerminalView />;
|
||||
case "wiki":
|
||||
return <WikiView />;
|
||||
default:
|
||||
return <WelcomeView />;
|
||||
}
|
||||
};
|
||||
|
||||
// Setup view is full-screen without sidebar
|
||||
if (currentView === "setup") {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<SetupView />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<Sidebar />
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
|
||||
>
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
||||
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<FileBrowserProvider>
|
||||
<HomeContent />
|
||||
</FileBrowserProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function CategoryAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder = "Select or type a category...",
|
||||
className,
|
||||
disabled = false,
|
||||
"data-testid": testId,
|
||||
}: CategoryAutocompleteProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{value
|
||||
? suggestions.find((s) => s === value) ?? value
|
||||
: placeholder}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search category..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestions.map((suggestion) => (
|
||||
<CommandItem
|
||||
key={suggestion}
|
||||
value={suggestion}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{suggestion}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === suggestion ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,88 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface CoursePromoBadgeProps {
|
||||
sidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function CoursePromoBadge({ sidebarOpen = true }: CoursePromoBadgeProps) {
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collapsed state - show only icon with tooltip
|
||||
if (!sidebarOpen) {
|
||||
return (
|
||||
<div className="p-2 pb-0 flex justify-center">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://agenticjumpstart.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group cursor-pointer flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-all border border-primary/30"
|
||||
data-testid="course-promo-badge-collapsed"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2">
|
||||
<span>Become a 10x Dev</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="p-0.5 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - show full badge
|
||||
return (
|
||||
<div className="p-2 pb-0">
|
||||
<a
|
||||
href="https://agenticjumpstart.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group cursor-pointer flex items-center justify-between w-full px-2 lg:px-3 py-2.5 bg-primary/10 text-primary rounded-lg font-medium text-sm hover:bg-primary/20 transition-all border border-primary/30"
|
||||
data-testid="course-promo-badge"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
<span className="hidden lg:block">Become a 10x Dev</span>
|
||||
</div>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="hidden lg:block p-1 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
Info,
|
||||
FileOutput,
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
parseLogOutput,
|
||||
getLogTypeColors,
|
||||
type LogEntry,
|
||||
type LogEntryType,
|
||||
} from "@/lib/log-parser";
|
||||
|
||||
interface LogViewerProps {
|
||||
output: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getLogIcon = (type: LogEntryType) => {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
case "tool_call":
|
||||
return <Wrench className="w-4 h-4" />;
|
||||
case "tool_result":
|
||||
return <FileOutput className="w-4 h-4" />;
|
||||
case "phase":
|
||||
return <Zap className="w-4 h-4" />;
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="w-4 h-4" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
case "thinking":
|
||||
return <Brain className="w-4 h-4" />;
|
||||
case "debug":
|
||||
return <Bug className="w-4 h-4" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface LogEntryItemProps {
|
||||
entry: LogEntry;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
const colors = getLogTypeColors(entry.type);
|
||||
const hasContent = entry.content.length > 100;
|
||||
|
||||
// Format content - detect and highlight JSON
|
||||
const formattedContent = useMemo(() => {
|
||||
const content = entry.content;
|
||||
|
||||
// Try to find and format JSON blocks
|
||||
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
||||
let lastIndex = 0;
|
||||
const parts: { type: "text" | "json"; content: string }[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = jsonRegex.exec(content)) !== null) {
|
||||
// Add text before JSON
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to parse and format JSON
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
parts.push({
|
||||
type: "json",
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
});
|
||||
} catch {
|
||||
// Not valid JSON, treat as text
|
||||
parts.push({ type: "text", content: match[1] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push({ type: "text", content: content.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
||||
}, [entry.content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-l-4 transition-all duration-200",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
"hover:brightness-110"
|
||||
)}
|
||||
data-testid={`log-entry-${entry.type}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left"
|
||||
data-testid={`log-entry-toggle-${entry.id}`}
|
||||
>
|
||||
{hasContent ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={cn("flex-shrink-0", colors.icon)}>
|
||||
{getLogIcon(entry.type)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
||||
colors.badge
|
||||
)}
|
||||
data-testid="log-entry-badge"
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
||||
{!isExpanded &&
|
||||
entry.content.slice(0, 80) +
|
||||
(entry.content.length > 80 ? "..." : "")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(isExpanded || !hasContent) && (
|
||||
<div
|
||||
className="px-4 pb-3 pt-1"
|
||||
data-testid={`log-entry-content-${entry.id}`}
|
||||
>
|
||||
<div className="font-mono text-xs space-y-1">
|
||||
{formattedContent.map((part, index) => (
|
||||
<div key={index}>
|
||||
{part.type === "json" ? (
|
||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
||||
{part.content}
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{part.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogViewer({ output, className }: LogViewerProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const entries = useMemo(() => parseLogOutput(output), [output]);
|
||||
|
||||
const toggleEntry = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedIds(new Set(entries.map((e) => e.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedIds(new Set());
|
||||
};
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
|
||||
{output && output.trim() && (
|
||||
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Count entries by type
|
||||
const typeCounts = entries.reduce((acc, entry) => {
|
||||
acc[entry.type] = (acc[entry.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{/* Header with controls */}
|
||||
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(typeCounts).map(([type, count]) => {
|
||||
const colors = getLogTypeColors(type as LogEntryType);
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
colors.badge
|
||||
)}
|
||||
data-testid={`log-type-count-${type}`}
|
||||
>
|
||||
{type}: {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-expand-all"
|
||||
>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-collapse-all"
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div className="space-y-2" data-testid="log-entries-container">
|
||||
{entries.map((entry) => (
|
||||
<LogEntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isExpanded={expandedIds.has(entry.id)}
|
||||
onToggle={() => toggleEntry(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
||||
"text-foreground/70 hover:text-foreground hover:bg-accent",
|
||||
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Key,
|
||||
Palette,
|
||||
Terminal,
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Settings2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
|
||||
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
|
||||
import { SettingsHeader } from "./settings-view/components/settings-header";
|
||||
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
|
||||
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
|
||||
import type {
|
||||
Project as SettingsProject,
|
||||
Theme,
|
||||
} from "./settings-view/shared/types";
|
||||
import type { Project as ElectronProject } from "@/lib/electron";
|
||||
|
||||
// Navigation items for the side panel
|
||||
const NAV_ITEMS = [
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
{ id: "claude", label: "Claude", icon: Terminal },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
||||
];
|
||||
|
||||
export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
useWorktrees,
|
||||
setUseWorktrees,
|
||||
showProfilesOnly,
|
||||
setShowProfilesOnly,
|
||||
muteDoneSound,
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (
|
||||
project: ElectronProject | null
|
||||
): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
||||
|
||||
// Handler to set theme - always updates global theme (user's preference),
|
||||
// and also sets per-project theme if a project is selected
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
// Always update global theme so user's preference persists across all projects
|
||||
setTheme(newTheme);
|
||||
// Also set per-project theme if a project is selected
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
}
|
||||
};
|
||||
|
||||
// Use CLI status hook
|
||||
const {
|
||||
claudeCliStatus,
|
||||
isCheckingClaudeCli,
|
||||
handleRefreshClaudeCli,
|
||||
} = useCliStatus();
|
||||
|
||||
// Use scroll tracking hook
|
||||
const { activeSection, scrollToSection, scrollContainerRef } =
|
||||
useScrollTracking({
|
||||
items: NAV_ITEMS,
|
||||
filterFn: (item) => item.id !== "danger" || !!currentProject,
|
||||
initialSection: "api-keys",
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="settings-view"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sticky Side Navigation */}
|
||||
<SettingsNavigation
|
||||
navItems={NAV_ITEMS}
|
||||
activeSection={activeSection}
|
||||
currentProject={currentProject}
|
||||
onNavigate={scrollToSection}
|
||||
/>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
||||
{/* API Keys Section */}
|
||||
<ApiKeysSection />
|
||||
|
||||
{/* Claude CLI Status Section */}
|
||||
{claudeCliStatus && (
|
||||
<ClaudeCliStatus
|
||||
status={claudeCliStatus}
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Appearance Section */}
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
currentProject={settingsProject}
|
||||
onThemeChange={handleSetTheme}
|
||||
/>
|
||||
|
||||
|
||||
{/* Keyboard Shortcuts Section */}
|
||||
<KeyboardShortcutsSection
|
||||
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
||||
/>
|
||||
|
||||
{/* Audio Section */}
|
||||
<div
|
||||
id="audio"
|
||||
className="rounded-2xl border border-border/50 bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl shadow-sm shadow-black/5 overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Volume2 className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Audio
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure audio and notification settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Mute Done Sound Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="mute-done-sound"
|
||||
checked={muteDoneSound}
|
||||
onCheckedChange={(checked) =>
|
||||
setMuteDoneSound(checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="mute-done-sound-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="mute-done-sound"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<VolumeX className="w-4 h-4 text-brand-500" />
|
||||
Mute notification sound when agents complete
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, disables the "ding" sound that
|
||||
plays when an agent completes a feature. The feature
|
||||
will still move to the completed column, but without
|
||||
audio notification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Defaults Section */}
|
||||
<FeatureDefaultsSection
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
useWorktrees={useWorktrees}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
/>
|
||||
|
||||
{/* Danger Zone Section - Only show when a project is selected */}
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog
|
||||
open={showKeyboardMapDialog}
|
||||
onOpenChange={setShowKeyboardMapDialog}
|
||||
/>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FeatureDefaultsSectionProps {
|
||||
showProfilesOnly: boolean;
|
||||
defaultSkipTests: boolean;
|
||||
useWorktrees: boolean;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
showProfilesOnly,
|
||||
defaultSkipTests,
|
||||
useWorktrees,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onUseWorktreesChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
return (
|
||||
<div
|
||||
id="defaults"
|
||||
className={cn(
|
||||
"rounded-2xl overflow-hidden scroll-mt-6",
|
||||
"border border-border/50",
|
||||
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
|
||||
"shadow-sm shadow-black/5"
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<FlaskConical className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Feature Defaults
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure default settings for new features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Profiles Only Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="show-profiles-only"
|
||||
checked={showProfilesOnly}
|
||||
onCheckedChange={(checked) =>
|
||||
onShowProfilesOnlyChange(checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="show-profiles-only-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="show-profiles-only"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-brand-500" />
|
||||
Show profiles only by default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, the Add Feature dialog will show only AI profiles
|
||||
and hide advanced model tweaking options. This creates a cleaner, less
|
||||
overwhelming UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Automated Testing Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="default-skip-tests"
|
||||
checked={!defaultSkipTests}
|
||||
onCheckedChange={(checked) =>
|
||||
onDefaultSkipTestsChange(checked !== true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="default-skip-tests-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="default-skip-tests"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<TestTube className="w-4 h-4 text-brand-500" />
|
||||
Enable automated testing by default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, new features will use TDD with automated tests. When disabled, features will
|
||||
require manual verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Worktree Isolation Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="use-worktrees"
|
||||
checked={useWorktrees}
|
||||
onCheckedChange={(checked) =>
|
||||
onUseWorktreesChange(checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-worktrees"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Enable Git Worktree Isolation
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-500 border border-amber-500/20 font-medium">
|
||||
experimental
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Creates isolated git branches for each feature. When disabled,
|
||||
agents work directly in the main project directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export all setup dialog components for easier imports
|
||||
export { SetupTokenModal } from "./setup-token-modal";
|
||||
@@ -1,262 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOAuthAuthentication } from "../hooks";
|
||||
|
||||
interface SetupTokenModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTokenObtained: (token: string) => void;
|
||||
}
|
||||
|
||||
export function SetupTokenModal({
|
||||
open,
|
||||
onClose,
|
||||
onTokenObtained,
|
||||
}: SetupTokenModalProps) {
|
||||
// Use the OAuth authentication hook
|
||||
const { authState, output, token, error, startAuth, reset } =
|
||||
useOAuthAuthentication({ cliType: "claude" });
|
||||
|
||||
const [manualToken, setManualToken] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset();
|
||||
setManualToken("");
|
||||
}
|
||||
}, [open, reset]);
|
||||
|
||||
const handleUseToken = useCallback(() => {
|
||||
const tokenToUse = token || manualToken;
|
||||
if (tokenToUse.trim()) {
|
||||
onTokenObtained(tokenToUse.trim());
|
||||
onClose();
|
||||
}
|
||||
}, [token, manualToken, onTokenObtained, onClose]);
|
||||
|
||||
const copyCommand = useCallback(() => {
|
||||
navigator.clipboard.writeText("claude setup-token");
|
||||
toast.success("Command copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
reset();
|
||||
setManualToken("");
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-2xl bg-card border-border"
|
||||
data-testid="setup-token-modal"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
Claude Subscription Authentication
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{authState === "idle" &&
|
||||
"Click Start to begin the authentication process."}
|
||||
{authState === "running" &&
|
||||
"Complete the sign-in in your browser..."}
|
||||
{authState === "success" &&
|
||||
"Authentication successful! Your token has been captured."}
|
||||
{authState === "error" &&
|
||||
"Authentication failed. Please try again or enter the token manually."}
|
||||
{authState === "manual" &&
|
||||
"Copy the token from your terminal and paste it below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto border border-border mt-3"
|
||||
>
|
||||
{output.map((line, index) => (
|
||||
<div key={index} className="text-zinc-300 whitespace-pre-wrap">
|
||||
{line.startsWith("Error") || line.startsWith("⚠") ? (
|
||||
<span className="text-yellow-400">{line}</span>
|
||||
) : line.startsWith("✓") ? (
|
||||
<span className="text-green-400">{line}</span>
|
||||
) : (
|
||||
line
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{output.length === 0 && (
|
||||
<div className="text-zinc-500 italic">Waiting to start...</div>
|
||||
)}
|
||||
{authState === "running" && (
|
||||
<div className="flex items-center gap-2 text-brand-400 mt-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Waiting for authentication...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Token Input (for fallback) */}
|
||||
{(authState === "manual" || authState === "error") && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Run this command in your terminal:</span>
|
||||
<code className="bg-muted px-2 py-1 rounded font-mono text-foreground">
|
||||
claude setup-token
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyCommand}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-token" className="text-foreground">
|
||||
Paste your token:
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-token"
|
||||
type="password"
|
||||
placeholder="Paste token here..."
|
||||
value={manualToken}
|
||||
onChange={(e) => setManualToken(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="manual-token-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{authState === "success" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Token captured successfully!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click "Use Token" to save and continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && authState === "error" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-6 h-6 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Error</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-5 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{authState === "idle" && (
|
||||
<Button
|
||||
onClick={startAuth}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="start-auth-button"
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Start Authentication
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "running" && (
|
||||
<Button disabled className="bg-brand-500">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Authenticating...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "success" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="use-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "manual" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
disabled={!manualToken.trim()}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50"
|
||||
data-testid="use-manual-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "error" && (
|
||||
<>
|
||||
{manualToken.trim() && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
>
|
||||
Use Manual Token
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
||||
|
||||
interface UseOAuthAuthenticationOptions {
|
||||
cliType: "claude";
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useOAuthAuthentication({
|
||||
cliType,
|
||||
enabled = true,
|
||||
}: UseOAuthAuthenticationOptions) {
|
||||
const [authState, setAuthState] = useState<AuthState>("idle");
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Reset state when disabled
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setAuthState("idle");
|
||||
setOutput([]);
|
||||
setToken("");
|
||||
setError(null);
|
||||
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const startAuth = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup) {
|
||||
setError("Setup API not available");
|
||||
setAuthState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthState("running");
|
||||
setOutput([
|
||||
"Starting authentication...",
|
||||
`Running ${cliType} CLI in an embedded terminal so you don't need to copy/paste.`,
|
||||
"When your browser opens, complete sign-in and return here.",
|
||||
"",
|
||||
]);
|
||||
setError(null);
|
||||
setToken("");
|
||||
|
||||
// Subscribe to progress events
|
||||
if (api.setup.onAuthProgress) {
|
||||
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
|
||||
if (progress.cli === cliType && progress.data) {
|
||||
// Split by newlines and add each line
|
||||
const normalized = progress.data.replace(/\r/g, "\n");
|
||||
const lines = normalized
|
||||
.split("\n")
|
||||
.map((line: string) => line.trimEnd())
|
||||
.filter((line: string) => line.length > 0);
|
||||
if (lines.length > 0) {
|
||||
setOutput((prev) => [...prev, ...lines]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the auth API
|
||||
const result = await api.setup.authClaude();
|
||||
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
setError("Authentication API not available");
|
||||
setAuthState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for token (only available for Claude)
|
||||
const resultToken =
|
||||
cliType === "claude" && "token" in result ? result.token : undefined;
|
||||
const resultTerminalOpened =
|
||||
cliType === "claude" && "terminalOpened" in result
|
||||
? result.terminalOpened
|
||||
: false;
|
||||
|
||||
if (result.success && resultToken && typeof resultToken === "string") {
|
||||
setToken(resultToken);
|
||||
setAuthState("success");
|
||||
setOutput((prev) => [
|
||||
...prev,
|
||||
"",
|
||||
"✓ Authentication successful!",
|
||||
"✓ Token captured automatically.",
|
||||
]);
|
||||
} else if (result.requiresManualAuth) {
|
||||
// Terminal was opened - user needs to copy token manually
|
||||
setAuthState("manual");
|
||||
// Don't add extra messages if terminalOpened - the progress messages already explain
|
||||
if (!resultTerminalOpened) {
|
||||
const extraMessages = [
|
||||
"",
|
||||
"⚠ Could not capture token automatically.",
|
||||
];
|
||||
if (result.error) {
|
||||
extraMessages.push(result.error);
|
||||
}
|
||||
setOutput((prev) => [
|
||||
...prev,
|
||||
...extraMessages,
|
||||
"Please copy the token from above and paste it below.",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Authentication failed");
|
||||
setAuthState("error");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err !== null && "error" in err
|
||||
? String((err as { error: unknown }).error)
|
||||
: "Authentication failed";
|
||||
|
||||
// Check if we should fall back to manual mode
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"requiresManualAuth" in err &&
|
||||
(err as { requiresManualAuth: boolean }).requiresManualAuth
|
||||
) {
|
||||
setAuthState("manual");
|
||||
setOutput((prev) => [
|
||||
...prev,
|
||||
"",
|
||||
"⚠ " + errorMessage,
|
||||
"Please copy the token manually and paste it below.",
|
||||
]);
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
setAuthState("error");
|
||||
}
|
||||
}
|
||||
}, [cliType]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAuthState("idle");
|
||||
setOutput([]);
|
||||
setToken("");
|
||||
setError(null);
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { authState, output, token, error, startAuth, reset };
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Key,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SetupTokenModal } from "../dialogs";
|
||||
import { StatusBadge, TerminalOutput } from "../components";
|
||||
import {
|
||||
useCliStatus,
|
||||
useCliInstallation,
|
||||
useTokenSave,
|
||||
} from "../hooks";
|
||||
|
||||
interface ClaudeSetupStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
// Claude Setup Step - 2 Authentication Options:
|
||||
// 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token
|
||||
// 2. API Key (Pay-per-use): User provides their Anthropic API key directly
|
||||
export function ClaudeSetupStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: ClaudeSetupStepProps) {
|
||||
const {
|
||||
claudeCliStatus,
|
||||
claudeAuthStatus,
|
||||
setClaudeCliStatus,
|
||||
setClaudeAuthStatus,
|
||||
setClaudeInstallProgress,
|
||||
} = useSetupStore();
|
||||
const { setApiKeys, apiKeys } = useAppStore();
|
||||
|
||||
const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(null);
|
||||
const [oauthToken, setOAuthToken] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
|
||||
// Memoize API functions to prevent infinite loops
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getClaudeStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installClaude() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const getStoreState = useCallback(
|
||||
() => useSetupStore.getState().claudeCliStatus,
|
||||
[]
|
||||
);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "claude",
|
||||
statusApi,
|
||||
setCliStatus: setClaudeCliStatus,
|
||||
setAuthStatus: setClaudeAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "claude",
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
getStoreState,
|
||||
});
|
||||
|
||||
const { isSaving: isSavingOAuth, saveToken: saveOAuthToken } = useTokenSave({
|
||||
provider: "anthropic_oauth_token",
|
||||
onSuccess: () => {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "oauth_token",
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid: true,
|
||||
});
|
||||
setAuthMethod(null);
|
||||
checkStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||
provider: "anthropic",
|
||||
onSuccess: () => {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "api_key",
|
||||
hasCredentialsFile: false,
|
||||
apiKeyValid: true,
|
||||
});
|
||||
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
||||
setAuthMethod(null);
|
||||
checkStatus();
|
||||
},
|
||||
});
|
||||
|
||||
// Sync install progress to store
|
||||
useEffect(() => {
|
||||
setClaudeInstallProgress({
|
||||
isInstalling,
|
||||
output: installProgress.output,
|
||||
});
|
||||
}, [isInstalling, installProgress, setClaudeInstallProgress]);
|
||||
|
||||
// Check status on mount
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success("Command copied to clipboard");
|
||||
};
|
||||
|
||||
// Handle token obtained from the OAuth modal
|
||||
const handleTokenFromModal = useCallback(
|
||||
async (token: string) => {
|
||||
setOAuthToken(token);
|
||||
setShowTokenModal(false);
|
||||
await saveOAuthToken(token);
|
||||
},
|
||||
[saveOAuthToken]
|
||||
);
|
||||
|
||||
const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic;
|
||||
|
||||
const getAuthMethodLabel = () => {
|
||||
if (!isAuthenticated) return null;
|
||||
if (
|
||||
claudeAuthStatus?.method === "oauth_token_env" ||
|
||||
claudeAuthStatus?.method === "oauth_token"
|
||||
)
|
||||
return "Subscription Token";
|
||||
if (
|
||||
apiKeys.anthropic ||
|
||||
claudeAuthStatus?.method === "api_key" ||
|
||||
claudeAuthStatus?.method === "api_key_env"
|
||||
)
|
||||
return "API Key";
|
||||
return "Authenticated";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-8 h-8 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Claude Setup
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure Claude for code generation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Status</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">CLI Installation</span>
|
||||
{isChecking ? (
|
||||
<StatusBadge status="checking" label="Checking..." />
|
||||
) : claudeCliStatus?.installed ? (
|
||||
<StatusBadge status="installed" label="Installed" />
|
||||
) : (
|
||||
<StatusBadge status="not_installed" label="Not Installed" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claudeCliStatus?.version && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Version</span>
|
||||
<span className="text-sm font-mono text-foreground">
|
||||
{claudeCliStatus.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">Authentication</span>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status="authenticated" label="Authenticated" />
|
||||
{getAuthMethodLabel() && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({getAuthMethodLabel()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status="not_authenticated"
|
||||
label="Not Authenticated"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Installation Section */}
|
||||
{!claudeCliStatus?.installed && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Install Claude CLI
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Required for subscription-based authentication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
macOS / Linux
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(
|
||||
"curl -fsSL https://claude.ai/install.sh | bash"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
irm https://claude.ai/install.ps1 | iex
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand("irm https://claude.ai/install.ps1 | iex")
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && (
|
||||
<TerminalOutput lines={installProgress.output} />
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={install}
|
||||
disabled={isInstalling}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="install-claude-button"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Auto Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Authentication Section */}
|
||||
{!isAuthenticated && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>Choose your authentication method</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Option 1: Subscription Token */}
|
||||
{authMethod === "token" ? (
|
||||
<div className="p-4 rounded-lg bg-brand-500/5 border border-brand-500/20 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
Subscription Token
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Use your Claude subscription (no API charges)
|
||||
</p>
|
||||
|
||||
{claudeCliStatus?.installed ? (
|
||||
<>
|
||||
{/* Primary: Automated OAuth setup */}
|
||||
<Button
|
||||
onClick={() => setShowTokenModal(true)}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white mb-4"
|
||||
data-testid="setup-oauth-button"
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Setup with OAuth
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-brand-500/5 px-2 text-muted-foreground">
|
||||
or paste manually
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback: Manual token entry */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-sm">
|
||||
Paste token from{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||
claude setup-token
|
||||
</code>
|
||||
:
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste token here..."
|
||||
value={oauthToken}
|
||||
onChange={(e) => setOAuthToken(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="oauth-token-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAuthMethod(null)}
|
||||
className="border-border"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveOAuthToken(oauthToken)}
|
||||
disabled={isSavingOAuth || !oauthToken.trim()}
|
||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="save-oauth-token-button"
|
||||
>
|
||||
{isSavingOAuth ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Save Token"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-3 rounded bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600">
|
||||
<AlertCircle className="w-4 h-4 inline mr-1" />
|
||||
Install Claude CLI first to use subscription
|
||||
authentication
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : authMethod === "api_key" ? (
|
||||
/* Option 2: API Key */
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="w-5 h-5 text-green-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">API Key</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Pay-per-use with your Anthropic API key
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="anthropic-key"
|
||||
className="text-foreground"
|
||||
>
|
||||
Anthropic API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-key"
|
||||
type="password"
|
||||
placeholder="sk-ant-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="anthropic-api-key-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAuthMethod(null)}
|
||||
className="border-border"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveApiKeyToken(apiKey)}
|
||||
disabled={isSavingApiKey || !apiKey.trim()}
|
||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="save-anthropic-key-button"
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Save API Key"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Auth Method Selection */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setAuthMethod("token")}
|
||||
className="p-4 rounded-lg border border-border hover:border-brand-500/50 bg-card hover:bg-brand-500/5 transition-all text-left"
|
||||
data-testid="select-subscription-auth"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-brand-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Subscription
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use your Claude subscription
|
||||
</p>
|
||||
<p className="text-xs text-brand-500 mt-2">
|
||||
No API charges
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setAuthMethod("api_key")}
|
||||
className="p-4 rounded-lg border border-border hover:border-green-500/50 bg-card hover:bg-green-500/5 transition-all text-left"
|
||||
data-testid="select-api-key-auth"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="w-6 h-6 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">API Key</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use Anthropic API key
|
||||
</p>
|
||||
<p className="text-xs text-green-500 mt-2">Pay-per-use</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{isAuthenticated && (
|
||||
<Card className="bg-green-500/5 border-green-500/20">
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Claude is ready to use!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You
|
||||
can proceed to the next step
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="claude-next-button"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Setup Modal */}
|
||||
<SetupTokenModal
|
||||
open={showTokenModal}
|
||||
onClose={() => setShowTokenModal(false)}
|
||||
onTokenObtained={handleTokenFromModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
interface CompleteStepProps {
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
const { claudeCliStatus, claudeAuthStatus } =
|
||||
useSetupStore();
|
||||
const { apiKeys } = useAppStore();
|
||||
|
||||
const claudeReady =
|
||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||
apiKeys.anthropic;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Setup Complete!
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Your development environment is configured. You're ready to start
|
||||
building with AI-powered assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card
|
||||
className={`bg-card/50 border ${
|
||||
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||
}`}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{claudeReady ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">Claude</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{claudeReady ? "Ready to use" : "Configure later in settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Your credentials are secure
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
API keys are stored locally and never sent to our servers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onFinish}
|
||||
data-testid="setup-finish-button"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Start Building
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,434 +0,0 @@
|
||||
/**
|
||||
* Log Parser Utility
|
||||
* Parses agent output into structured sections for display
|
||||
*/
|
||||
|
||||
export type LogEntryType =
|
||||
| "prompt"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "phase"
|
||||
| "error"
|
||||
| "success"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "warning"
|
||||
| "thinking";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
type: LogEntryType;
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
collapsed?: boolean;
|
||||
metadata?: {
|
||||
toolName?: string;
|
||||
phase?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic ID based on content and position
|
||||
* This ensures the same log entry always gets the same ID,
|
||||
* preserving expanded/collapsed state when new logs stream in
|
||||
*
|
||||
* Uses only the first 200 characters of content to ensure stability
|
||||
* even when entries are merged (which appends content at the end)
|
||||
*/
|
||||
const generateDeterministicId = (content: string, lineIndex: number): string => {
|
||||
// Use first 200 chars to ensure stability when entries are merged
|
||||
const stableContent = content.slice(0, 200);
|
||||
// Simple hash function for the content
|
||||
let hash = 0;
|
||||
const str = stableContent + '|' + lineIndex.toString();
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return 'log_' + Math.abs(hash).toString(36);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the type of log entry based on content patterns
|
||||
*/
|
||||
function detectEntryType(content: string): LogEntryType {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Tool calls
|
||||
if (trimmed.startsWith("🔧 Tool:") || trimmed.match(/^Tool:\s*/)) {
|
||||
return "tool_call";
|
||||
}
|
||||
|
||||
// Tool results / Input
|
||||
if (trimmed.startsWith("Input:") || trimmed.startsWith("Result:") || trimmed.startsWith("Output:")) {
|
||||
return "tool_result";
|
||||
}
|
||||
|
||||
// Phase changes
|
||||
if (
|
||||
trimmed.startsWith("📋") ||
|
||||
trimmed.startsWith("⚡") ||
|
||||
trimmed.startsWith("✅") ||
|
||||
trimmed.match(/^(Planning|Action|Verification)/i) ||
|
||||
trimmed.match(/\[Phase:\s*([^\]]+)\]/) ||
|
||||
trimmed.match(/Phase:\s*\w+/i)
|
||||
) {
|
||||
return "phase";
|
||||
}
|
||||
|
||||
// Feature creation events
|
||||
if (
|
||||
trimmed.match(/\[Feature Creation\]/i) ||
|
||||
trimmed.match(/Feature Creation/i) ||
|
||||
trimmed.match(/Creating feature/i)
|
||||
) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
// Errors
|
||||
if (trimmed.startsWith("❌") || trimmed.toLowerCase().includes("error:")) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Success messages
|
||||
if (
|
||||
trimmed.startsWith("✅") ||
|
||||
trimmed.toLowerCase().includes("success") ||
|
||||
trimmed.toLowerCase().includes("completed")
|
||||
) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (trimmed.startsWith("⚠️") || trimmed.toLowerCase().includes("warning:")) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
// Thinking/Preparation info
|
||||
if (
|
||||
trimmed.toLowerCase().includes("ultrathink") ||
|
||||
trimmed.toLowerCase().includes("thinking level") ||
|
||||
trimmed.toLowerCase().includes("estimated cost") ||
|
||||
trimmed.toLowerCase().includes("estimated time") ||
|
||||
trimmed.toLowerCase().includes("budget tokens") ||
|
||||
trimmed.match(/thinking.*preparation/i)
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
// Debug info (JSON, stack traces, etc.)
|
||||
if (
|
||||
trimmed.startsWith("{") ||
|
||||
trimmed.startsWith("[") ||
|
||||
trimmed.includes("at ") ||
|
||||
trimmed.match(/^\s*\d+\s*\|/)
|
||||
) {
|
||||
return "debug";
|
||||
}
|
||||
|
||||
// Default to info
|
||||
return "info";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a tool call entry
|
||||
*/
|
||||
function extractToolName(content: string): string | undefined {
|
||||
const match = content.match(/🔧\s*Tool:\s*(\S+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts phase name from a phase entry
|
||||
*/
|
||||
function extractPhase(content: string): string | undefined {
|
||||
if (content.includes("📋")) return "planning";
|
||||
if (content.includes("⚡")) return "action";
|
||||
if (content.includes("✅")) return "verification";
|
||||
|
||||
// Extract from [Phase: ...] format
|
||||
const phaseMatch = content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||
if (phaseMatch) {
|
||||
return phaseMatch[1].toLowerCase();
|
||||
}
|
||||
|
||||
const match = content.match(/^(Planning|Action|Verification)/i);
|
||||
return match?.[1]?.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a title for a log entry
|
||||
*/
|
||||
function generateTitle(type: LogEntryType, content: string): string {
|
||||
switch (type) {
|
||||
case "tool_call": {
|
||||
const toolName = extractToolName(content);
|
||||
return toolName ? `Tool Call: ${toolName}` : "Tool Call";
|
||||
}
|
||||
case "tool_result":
|
||||
return "Tool Input/Result";
|
||||
case "phase": {
|
||||
const phase = extractPhase(content);
|
||||
if (phase) {
|
||||
// Capitalize first letter of each word
|
||||
const formatted = phase.split(/\s+/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(" ");
|
||||
return `Phase: ${formatted}`;
|
||||
}
|
||||
return "Phase Change";
|
||||
}
|
||||
case "error":
|
||||
return "Error";
|
||||
case "success":
|
||||
return "Success";
|
||||
case "warning":
|
||||
return "Warning";
|
||||
case "thinking":
|
||||
return "Thinking Level";
|
||||
case "debug":
|
||||
return "Debug Info";
|
||||
case "prompt":
|
||||
return "Prompt";
|
||||
default:
|
||||
return "Info";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw log output into structured entries
|
||||
*/
|
||||
export function parseLogOutput(rawOutput: string): LogEntry[] {
|
||||
if (!rawOutput || !rawOutput.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
const lines = rawOutput.split("\n");
|
||||
|
||||
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
|
||||
let currentContent: string[] = [];
|
||||
let entryStartLine = 0; // Track the starting line for deterministic ID generation
|
||||
|
||||
const finalizeEntry = () => {
|
||||
if (currentEntry && currentContent.length > 0) {
|
||||
currentEntry.content = currentContent.join("\n").trim();
|
||||
if (currentEntry.content) {
|
||||
// Generate deterministic ID based on content and position
|
||||
const entryWithId: LogEntry = {
|
||||
...currentEntry as Omit<LogEntry, 'id'>,
|
||||
id: generateDeterministicId(currentEntry.content, entryStartLine),
|
||||
};
|
||||
entries.push(entryWithId);
|
||||
}
|
||||
}
|
||||
currentContent = [];
|
||||
};
|
||||
|
||||
let lineIndex = 0;
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines at the beginning
|
||||
if (!trimmedLine && !currentEntry) {
|
||||
lineIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect if this line starts a new entry
|
||||
const lineType = detectEntryType(trimmedLine);
|
||||
const isNewEntry =
|
||||
trimmedLine.startsWith("🔧") ||
|
||||
trimmedLine.startsWith("📋") ||
|
||||
trimmedLine.startsWith("⚡") ||
|
||||
trimmedLine.startsWith("✅") ||
|
||||
trimmedLine.startsWith("❌") ||
|
||||
trimmedLine.startsWith("⚠️") ||
|
||||
trimmedLine.startsWith("🧠") ||
|
||||
trimmedLine.match(/\[Phase:\s*([^\]]+)\]/) ||
|
||||
trimmedLine.match(/\[Feature Creation\]/i) ||
|
||||
trimmedLine.match(/\[Tool\]/i) ||
|
||||
trimmedLine.match(/\[Agent\]/i) ||
|
||||
trimmedLine.match(/\[Complete\]/i) ||
|
||||
trimmedLine.match(/\[ERROR\]/i) ||
|
||||
trimmedLine.match(/\[Status\]/i) ||
|
||||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
|
||||
trimmedLine.toLowerCase().includes("thinking level") ||
|
||||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
|
||||
|
||||
if (isNewEntry) {
|
||||
// Finalize previous entry
|
||||
finalizeEntry();
|
||||
|
||||
// Track starting line for deterministic ID
|
||||
entryStartLine = lineIndex;
|
||||
|
||||
// Start new entry (ID will be generated when finalizing)
|
||||
currentEntry = {
|
||||
type: lineType,
|
||||
title: generateTitle(lineType, trimmedLine),
|
||||
content: "",
|
||||
metadata: {
|
||||
toolName: extractToolName(trimmedLine),
|
||||
phase: extractPhase(trimmedLine),
|
||||
},
|
||||
};
|
||||
currentContent.push(trimmedLine);
|
||||
} else if (currentEntry) {
|
||||
// Continue current entry
|
||||
currentContent.push(line);
|
||||
} else {
|
||||
// Track starting line for deterministic ID
|
||||
entryStartLine = lineIndex;
|
||||
|
||||
// No current entry, create a default info entry
|
||||
currentEntry = {
|
||||
type: "info",
|
||||
title: "Info",
|
||||
content: "",
|
||||
};
|
||||
currentContent.push(line);
|
||||
}
|
||||
lineIndex++;
|
||||
}
|
||||
|
||||
// Finalize last entry
|
||||
finalizeEntry();
|
||||
|
||||
// Merge consecutive entries of the same type if they're both debug or info
|
||||
const mergedEntries = mergeConsecutiveEntries(entries);
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges consecutive entries of the same type for cleaner display
|
||||
*/
|
||||
function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
||||
if (entries.length <= 1) return entries;
|
||||
|
||||
const merged: LogEntry[] = [];
|
||||
let current: LogEntry | null = null;
|
||||
let mergeIndex = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
current &&
|
||||
(current.type === "debug" || current.type === "info") &&
|
||||
current.type === entry.type
|
||||
) {
|
||||
// Merge into current - regenerate ID based on merged content
|
||||
current.content += "\n\n" + entry.content;
|
||||
current.id = generateDeterministicId(current.content, mergeIndex);
|
||||
} else {
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
current = { ...entry };
|
||||
mergeIndex = merged.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the color classes for a log entry type
|
||||
*/
|
||||
export function getLogTypeColors(type: LogEntryType): {
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
badge: string;
|
||||
} {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return {
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-l-blue-500",
|
||||
text: "text-blue-300",
|
||||
icon: "text-blue-400",
|
||||
badge: "bg-blue-500/20 text-blue-300",
|
||||
};
|
||||
case "tool_call":
|
||||
return {
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-l-amber-500",
|
||||
text: "text-amber-300",
|
||||
icon: "text-amber-400",
|
||||
badge: "bg-amber-500/20 text-amber-300",
|
||||
};
|
||||
case "tool_result":
|
||||
return {
|
||||
bg: "bg-slate-500/10",
|
||||
border: "border-l-slate-400",
|
||||
text: "text-slate-300",
|
||||
icon: "text-slate-400",
|
||||
badge: "bg-slate-500/20 text-slate-300",
|
||||
};
|
||||
case "phase":
|
||||
return {
|
||||
bg: "bg-cyan-500/10",
|
||||
border: "border-l-cyan-500",
|
||||
text: "text-cyan-300",
|
||||
icon: "text-cyan-400",
|
||||
badge: "bg-cyan-500/20 text-cyan-300",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-l-red-500",
|
||||
text: "text-red-300",
|
||||
icon: "text-red-400",
|
||||
badge: "bg-red-500/20 text-red-300",
|
||||
};
|
||||
case "success":
|
||||
return {
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-l-emerald-500",
|
||||
text: "text-emerald-300",
|
||||
icon: "text-emerald-400",
|
||||
badge: "bg-emerald-500/20 text-emerald-300",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
bg: "bg-orange-500/10",
|
||||
border: "border-l-orange-500",
|
||||
text: "text-orange-300",
|
||||
icon: "text-orange-400",
|
||||
badge: "bg-orange-500/20 text-orange-300",
|
||||
};
|
||||
case "thinking":
|
||||
return {
|
||||
bg: "bg-indigo-500/10",
|
||||
border: "border-l-indigo-500",
|
||||
text: "text-indigo-300",
|
||||
icon: "text-indigo-400",
|
||||
badge: "bg-indigo-500/20 text-indigo-300",
|
||||
};
|
||||
case "debug":
|
||||
return {
|
||||
bg: "bg-primary/10",
|
||||
border: "border-l-primary",
|
||||
text: "text-primary",
|
||||
icon: "text-primary",
|
||||
badge: "bg-primary/20 text-primary",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-zinc-500/10",
|
||||
border: "border-l-zinc-500",
|
||||
text: "text-zinc-300",
|
||||
icon: "text-zinc-400",
|
||||
badge: "bg-zinc-500/20 text-zinc-300",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { AgentModel } from "@/store/app-store"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current model supports extended thinking controls
|
||||
*/
|
||||
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||
// All Claude models support thinking
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model
|
||||
*/
|
||||
export function getModelDisplayName(model: AgentModel | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
haiku: "Claude Haiku",
|
||||
sonnet: "Claude Sonnet",
|
||||
opus: "Claude Opus",
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Perform a drag and drop operation that works with @dnd-kit
|
||||
* This uses explicit mouse movements with pointer events
|
||||
*/
|
||||
export async function dragAndDropWithDndKit(
|
||||
page: Page,
|
||||
sourceLocator: Locator,
|
||||
targetLocator: Locator
|
||||
): Promise<void> {
|
||||
const sourceBox = await sourceLocator.boundingBox();
|
||||
const targetBox = await targetLocator.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error("Could not find source or target element bounds");
|
||||
}
|
||||
|
||||
// Start drag from the center of the source element
|
||||
const startX = sourceBox.x + sourceBox.width / 2;
|
||||
const startY = sourceBox.y + sourceBox.height / 2;
|
||||
|
||||
// End drag at the center of the target element
|
||||
const endX = targetBox.x + targetBox.width / 2;
|
||||
const endY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
// Perform the drag and drop with pointer events
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(150); // Give dnd-kit time to recognize the drag
|
||||
await page.mouse.move(endX, endY, { steps: 15 });
|
||||
await page.waitForTimeout(100); // Allow time for drop detection
|
||||
await page.mouse.up();
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Simulate drag and drop of a file onto an element
|
||||
*/
|
||||
export async function simulateFileDrop(
|
||||
page: Page,
|
||||
targetSelector: string,
|
||||
fileName: string,
|
||||
fileContent: string,
|
||||
mimeType: string = "text/plain"
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ selector, content, name, mime }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
const file = new File([content], name, { type: mime });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
// Dispatch drag events
|
||||
target.dispatchEvent(
|
||||
new DragEvent("dragover", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
},
|
||||
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get a kanban card by feature ID
|
||||
*/
|
||||
export async function getKanbanCard(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a kanban column by its ID
|
||||
*/
|
||||
export async function getKanbanColumn(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the width of a kanban column
|
||||
*/
|
||||
export async function getKanbanColumnWidth(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<number> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const box = await column.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a kanban column has CSS columns (masonry) layout
|
||||
*/
|
||||
export async function hasKanbanColumnMasonryLayout(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<boolean> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const contentDiv = column.locator("> div").nth(1); // Second child is the content area
|
||||
|
||||
const columnCount = await contentDiv.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.columnCount;
|
||||
});
|
||||
|
||||
return columnCount === "2";
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a kanban card from one column to another
|
||||
*/
|
||||
export async function dragKanbanCard(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
targetColumnId: string
|
||||
): Promise<void> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
const targetColumn = page.locator(
|
||||
`[data-testid="kanban-column-${targetColumnId}"]`
|
||||
);
|
||||
|
||||
// Perform drag and drop
|
||||
await dragHandle.dragTo(targetColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the view output button on a kanban card
|
||||
*/
|
||||
export async function clickViewOutput(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
// Try the running version first, then the in-progress version
|
||||
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
|
||||
const inProgressBtn = page.locator(
|
||||
`[data-testid="view-output-inprogress-${featureId}"]`
|
||||
);
|
||||
|
||||
if (await runningBtn.isVisible()) {
|
||||
await runningBtn.click();
|
||||
} else if (await inProgressBtn.isVisible()) {
|
||||
await inProgressBtn.click();
|
||||
} else {
|
||||
throw new Error(`View output button not found for feature ${featureId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the drag handle is visible for a specific feature card
|
||||
*/
|
||||
export async function isDragHandleVisibleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
return await dragHandle.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the drag handle element for a specific feature card
|
||||
*/
|
||||
export async function getDragHandleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -18,24 +18,24 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"express": "^5.2.1",
|
||||
"morgan": "^1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"tsx": "^4.19.4",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.15"
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createAgentRoutes } from "./routes/agent/index.js";
|
||||
import { createSessionsRoutes } from "./routes/sessions/index.js";
|
||||
import { createFeaturesRoutes } from "./routes/features/index.js";
|
||||
import { createAutoModeRoutes } from "./routes/auto-mode/index.js";
|
||||
import { createEnhancePromptRoutes } from "./routes/enhance-prompt/index.js";
|
||||
import { createWorktreeRoutes } from "./routes/worktree/index.js";
|
||||
import { createGitRoutes } from "./routes/git/index.js";
|
||||
import { createSetupRoutes } from "./routes/setup/index.js";
|
||||
@@ -50,30 +51,21 @@ const DATA_DIR = process.env.DATA_DIR || "./data";
|
||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
|
||||
|
||||
// Check for required environment variables
|
||||
// Claude Agent SDK supports EITHER OAuth token (subscription) OR API key (pay-per-use)
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
if (!hasAnthropicKey && !hasOAuthToken) {
|
||||
if (!hasAnthropicKey) {
|
||||
console.warn(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ WARNING: No Claude authentication configured ║
|
||||
║ ║
|
||||
║ The Claude Agent SDK requires authentication to function. ║
|
||||
║ ║
|
||||
║ Option 1 - Subscription (OAuth Token): ║
|
||||
║ export CLAUDE_CODE_OAUTH_TOKEN="your-oauth-token" ║
|
||||
║ ║
|
||||
║ Option 2 - Pay-per-use (API Key): ║
|
||||
║ Set your Anthropic API key: ║
|
||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||
║ ║
|
||||
║ Or use the setup wizard in Settings to configure authentication. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else if (hasOAuthToken) {
|
||||
console.log(
|
||||
"[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)"
|
||||
);
|
||||
} else {
|
||||
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
|
||||
}
|
||||
@@ -134,6 +126,7 @@ app.use("/api/agent", createAgentRoutes(agentService, events));
|
||||
app.use("/api/sessions", createSessionsRoutes(agentService));
|
||||
app.use("/api/features", createFeaturesRoutes(featureLoader));
|
||||
app.use("/api/auto-mode", createAutoModeRoutes(autoModeService));
|
||||
app.use("/api/enhance-prompt", createEnhancePromptRoutes());
|
||||
app.use("/api/worktree", createWorktreeRoutes());
|
||||
app.use("/api/git", createGitRoutes());
|
||||
app.use("/api/setup", createSetupRoutes());
|
||||
@@ -197,6 +190,18 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
// Track WebSocket connections per session
|
||||
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
|
||||
// Track last resize dimensions per session to deduplicate resize messages
|
||||
const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map();
|
||||
// Track last resize timestamp to rate-limit resize operations (prevents resize storm)
|
||||
const lastResizeTime: Map<string, number> = new Map();
|
||||
const RESIZE_MIN_INTERVAL_MS = 100; // Minimum 100ms between resize operations
|
||||
|
||||
// Clean up resize tracking when sessions actually exit (not just when connections close)
|
||||
terminalService.onExit((sessionId) => {
|
||||
lastResizeDimensions.delete(sessionId);
|
||||
lastResizeTime.delete(sessionId);
|
||||
terminalConnections.delete(sessionId);
|
||||
});
|
||||
|
||||
// Terminal WebSocket connection handler
|
||||
terminalWss.on(
|
||||
@@ -248,7 +253,29 @@ terminalWss.on(
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
|
||||
// Subscribe to terminal data
|
||||
// Send initial connection success FIRST
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
})
|
||||
);
|
||||
|
||||
// Send scrollback buffer BEFORE subscribing to prevent race condition
|
||||
// Also clear pending output buffer to prevent duplicates from throttled flush
|
||||
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
||||
if (scrollback && scrollback.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: scrollback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// NOW subscribe to terminal data (after scrollback is sent)
|
||||
const unsubscribeData = terminalService.onData((sid, data) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
@@ -275,9 +302,33 @@ terminalWss.on(
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal
|
||||
// Resize terminal with deduplication and rate limiting
|
||||
if (msg.cols && msg.rows) {
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows);
|
||||
const now = Date.now();
|
||||
const lastTime = lastResizeTime.get(sessionId) || 0;
|
||||
const lastDimensions = lastResizeDimensions.get(sessionId);
|
||||
|
||||
// Skip if resized too recently (prevents resize storm during splits)
|
||||
if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if dimensions are different from last resize
|
||||
if (
|
||||
!lastDimensions ||
|
||||
lastDimensions.cols !== msg.cols ||
|
||||
lastDimensions.rows !== msg.rows
|
||||
) {
|
||||
// Only suppress output on subsequent resizes, not the first one
|
||||
// The first resize happens on terminal open and we don't want to drop the initial prompt
|
||||
const isFirstResize = !lastDimensions;
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
|
||||
lastResizeDimensions.set(sessionId, {
|
||||
cols: msg.cols,
|
||||
rows: msg.rows,
|
||||
});
|
||||
lastResizeTime.set(sessionId, now);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -307,6 +358,10 @@ terminalWss.on(
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
terminalConnections.delete(sessionId);
|
||||
// DON'T delete lastResizeDimensions/lastResizeTime here!
|
||||
// The session still exists, and reconnecting clients need to know
|
||||
// this isn't the "first resize" to prevent duplicate prompts.
|
||||
// These get cleaned up when the session actually exits.
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -316,27 +371,6 @@ terminalWss.on(
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
|
||||
// Send initial connection success
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
})
|
||||
);
|
||||
|
||||
// Send scrollback buffer to replay previous output
|
||||
const scrollback = terminalService.getScrollback(sessionId);
|
||||
if (scrollback && scrollback.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: scrollback,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,235 @@
|
||||
* This format must be included in all prompts that generate, modify, or regenerate
|
||||
* app specifications to ensure consistency across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TypeScript interface for structured spec output
|
||||
*/
|
||||
export interface SpecOutput {
|
||||
project_name: string;
|
||||
overview: string;
|
||||
technology_stack: string[];
|
||||
core_capabilities: string[];
|
||||
implemented_features: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
file_locations?: string[];
|
||||
}>;
|
||||
additional_requirements?: string[];
|
||||
development_guidelines?: string[];
|
||||
implementation_roadmap?: Array<{
|
||||
phase: string;
|
||||
status: "completed" | "in_progress" | "pending";
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for structured spec output
|
||||
* Used with Claude's structured output feature for reliable parsing
|
||||
*/
|
||||
export const specOutputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_name: {
|
||||
type: "string",
|
||||
description: "The name of the project",
|
||||
},
|
||||
overview: {
|
||||
type: "string",
|
||||
description:
|
||||
"A comprehensive description of what the project does, its purpose, and key goals",
|
||||
},
|
||||
technology_stack: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"List of all technologies, frameworks, libraries, and tools used",
|
||||
},
|
||||
core_capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of main features and capabilities the project provides",
|
||||
},
|
||||
implemented_features: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Name of the implemented feature",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Description of what the feature does",
|
||||
},
|
||||
file_locations: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "File paths where this feature is implemented",
|
||||
},
|
||||
},
|
||||
required: ["name", "description"],
|
||||
},
|
||||
description: "Features that have been implemented based on code analysis",
|
||||
},
|
||||
additional_requirements: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Any additional requirements or constraints",
|
||||
},
|
||||
development_guidelines: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Development standards and practices",
|
||||
},
|
||||
implementation_roadmap: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
phase: {
|
||||
type: "string",
|
||||
description: "Name of the implementation phase",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["completed", "in_progress", "pending"],
|
||||
description: "Current status of this phase",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Description of what this phase involves",
|
||||
},
|
||||
},
|
||||
required: ["phase", "status", "description"],
|
||||
},
|
||||
description: "Phases or roadmap items for implementation",
|
||||
},
|
||||
},
|
||||
required: [
|
||||
"project_name",
|
||||
"overview",
|
||||
"technology_stack",
|
||||
"core_capabilities",
|
||||
"implemented_features",
|
||||
],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert structured spec output to XML format
|
||||
*/
|
||||
export function specToXml(spec: SpecOutput): string {
|
||||
const indent = " ";
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
${indent}<project_name>${escapeXml(spec.project_name)}</project_name>
|
||||
|
||||
${indent}<overview>
|
||||
${indent}${indent}${escapeXml(spec.overview)}
|
||||
${indent}</overview>
|
||||
|
||||
${indent}<technology_stack>
|
||||
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join("\n")}
|
||||
${indent}</technology_stack>
|
||||
|
||||
${indent}<core_capabilities>
|
||||
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join("\n")}
|
||||
${indent}</core_capabilities>
|
||||
|
||||
${indent}<implemented_features>
|
||||
${spec.implemented_features
|
||||
.map(
|
||||
(f) => `${indent}${indent}<feature>
|
||||
${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
|
||||
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
|
||||
f.file_locations && f.file_locations.length > 0
|
||||
? `\n${indent}${indent}${indent}<file_locations>
|
||||
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join("\n")}
|
||||
${indent}${indent}${indent}</file_locations>`
|
||||
: ""
|
||||
}
|
||||
${indent}${indent}</feature>`
|
||||
)
|
||||
.join("\n")}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Optional sections
|
||||
if (spec.additional_requirements && spec.additional_requirements.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<additional_requirements>
|
||||
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join("\n")}
|
||||
${indent}</additional_requirements>`;
|
||||
}
|
||||
|
||||
if (spec.development_guidelines && spec.development_guidelines.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<development_guidelines>
|
||||
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join("\n")}
|
||||
${indent}</development_guidelines>`;
|
||||
}
|
||||
|
||||
if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<implementation_roadmap>
|
||||
${spec.implementation_roadmap
|
||||
.map(
|
||||
(r) => `${indent}${indent}<phase>
|
||||
${indent}${indent}${indent}<name>${escapeXml(r.phase)}</name>
|
||||
${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
|
||||
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
|
||||
${indent}${indent}</phase>`
|
||||
)
|
||||
.join("\n")}
|
||||
${indent}</implementation_roadmap>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</project_specification>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompt instruction for structured output (simpler than XML instructions)
|
||||
*/
|
||||
export function getStructuredSpecPromptInstruction(): string {
|
||||
return `
|
||||
Analyze the project and provide a comprehensive specification with:
|
||||
|
||||
1. **project_name**: The name of the project
|
||||
2. **overview**: A comprehensive description of what the project does, its purpose, and key goals
|
||||
3. **technology_stack**: List all technologies, frameworks, libraries, and tools used
|
||||
4. **core_capabilities**: List the main features and capabilities the project provides
|
||||
5. **implemented_features**: For each implemented feature, provide:
|
||||
- name: Feature name
|
||||
- description: What it does
|
||||
- file_locations: Key files where it's implemented (optional)
|
||||
6. **additional_requirements**: Any system requirements, dependencies, or constraints (optional)
|
||||
7. **development_guidelines**: Development standards and best practices (optional)
|
||||
8. **implementation_roadmap**: Project phases with status (completed/in_progress/pending) (optional)
|
||||
|
||||
Be thorough in your analysis. The output will be automatically formatted as structured JSON.
|
||||
`;
|
||||
}
|
||||
export const APP_SPEC_XML_FORMAT = `
|
||||
The app_spec.txt file MUST follow this exact XML format:
|
||||
|
||||
@@ -63,10 +292,11 @@ export function getAppSpecFormatInstruction(): string {
|
||||
${APP_SPEC_XML_FORMAT}
|
||||
|
||||
CRITICAL FORMATTING REQUIREMENTS:
|
||||
- Do NOT use the Write, Edit, or Bash tools to create files - just OUTPUT the XML in your response
|
||||
- Your ENTIRE response MUST be valid XML following the exact template structure above
|
||||
- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.)
|
||||
- Do NOT include any explanatory text, prefix, or suffix outside the XML tags
|
||||
- Do NOT include phrases like "Based on my analysis..." or "I'll create..." before the XML
|
||||
- Do NOT include phrases like "Based on my analysis...", "I'll create...", "Let me analyze..." before the XML
|
||||
- Do NOT include any text before <project_specification> or after </project_specification>
|
||||
- Your response must start IMMEDIATELY with <project_specification> with no preceding text
|
||||
- Your response must end IMMEDIATELY with </project_specification> with no following text
|
||||
|
||||
91
apps/server/src/lib/automaker-paths.ts
Normal file
91
apps/server/src/lib/automaker-paths.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Automaker Paths - Utilities for managing automaker data storage
|
||||
*
|
||||
* Stores project data inside the project directory at {projectPath}/.automaker/
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Get the automaker data directory for a project
|
||||
* This is stored inside the project at .automaker/
|
||||
*/
|
||||
export function getAutomakerDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".automaker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the features directory for a project
|
||||
*/
|
||||
export function getFeaturesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "features");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory for a specific feature
|
||||
*/
|
||||
export function getFeatureDir(projectPath: string, featureId: string): string {
|
||||
return path.join(getFeaturesDir(projectPath), featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the images directory for a feature
|
||||
*/
|
||||
export function getFeatureImagesDir(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): string {
|
||||
return path.join(getFeatureDir(projectPath, featureId), "images");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the board directory for a project (board backgrounds, etc.)
|
||||
*/
|
||||
export function getBoardDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "board");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the images directory for a project (general images)
|
||||
*/
|
||||
export function getImagesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "images");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context files directory for a project (user-added context files)
|
||||
*/
|
||||
export function getContextDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "context");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the worktrees metadata directory for a project
|
||||
*/
|
||||
export function getWorktreesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "worktrees");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app spec file path for a project
|
||||
*/
|
||||
export function getAppSpecPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch tracking file path for a project
|
||||
*/
|
||||
export function getBranchTrackingPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "active-branches.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the automaker directory structure exists for a project
|
||||
*/
|
||||
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
|
||||
const automakerDir = getAutomakerDir(projectPath);
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
return automakerDir;
|
||||
}
|
||||
221
apps/server/src/lib/dependency-resolver.ts
Normal file
221
apps/server/src/lib/dependency-resolver.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Dependency Resolution Utility (Server-side)
|
||||
*
|
||||
* Provides topological sorting and dependency analysis for features.
|
||||
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||
*/
|
||||
|
||||
import type { Feature } from "../services/feature-loader.js";
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||
* 2. Apply Kahn's algorithm for topological sort
|
||||
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||
* 4. Detect circular dependencies for features that can't be ordered
|
||||
*
|
||||
* @param features - Array of features to order
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
const blockedFeatures = new Map<string, string[]>();
|
||||
|
||||
// Initialize graph structures
|
||||
for (const feature of features) {
|
||||
inDegree.set(feature.id, 0);
|
||||
adjacencyList.set(feature.id, []);
|
||||
}
|
||||
|
||||
// Build dependency graph and detect missing/blocked dependencies
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!featureMap.has(depId)) {
|
||||
// Missing dependency - track it
|
||||
if (!missingDependencies.has(feature.id)) {
|
||||
missingDependencies.set(feature.id, []);
|
||||
}
|
||||
missingDependencies.get(feature.id)!.push(depId);
|
||||
} else {
|
||||
// Valid dependency - add edge to graph
|
||||
adjacencyList.get(depId)!.push(feature.id);
|
||||
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||
|
||||
// Check if dependency is incomplete (blocking)
|
||||
const depFeature = featureMap.get(depId)!;
|
||||
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||
if (!blockedFeatures.has(feature.id)) {
|
||||
blockedFeatures.set(feature.id, []);
|
||||
}
|
||||
blockedFeatures.get(feature.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with priority-aware selection
|
||||
const queue: Feature[] = [];
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort initial queue by priority
|
||||
queue.sort(sortByPriority);
|
||||
|
||||
// Process features in topological order
|
||||
while (queue.length > 0) {
|
||||
// Take highest priority feature from queue
|
||||
const current = queue.shift()!;
|
||||
orderedFeatures.push(current);
|
||||
|
||||
// Process features that depend on this one
|
||||
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||
const currentDegree = inDegree.get(dependentId);
|
||||
if (currentDegree === undefined) {
|
||||
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||
}
|
||||
const newDegree = currentDegree - 1;
|
||||
inDegree.set(dependentId, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(featureMap.get(dependentId)!);
|
||||
// Re-sort queue to maintain priority order
|
||||
queue.sort(sortByPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
// Add remaining features at end (part of cycles)
|
||||
orderedFeatures.push(...remaining);
|
||||
}
|
||||
|
||||
return {
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects circular dependencies using depth-first search
|
||||
*
|
||||
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const currentPath: string[] = [];
|
||||
|
||||
function dfs(featureId: string): boolean {
|
||||
visited.add(featureId);
|
||||
recursionStack.add(featureId);
|
||||
currentPath.push(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (feature) {
|
||||
for (const depId of feature.dependencies || []) {
|
||||
if (!visited.has(depId)) {
|
||||
if (dfs(depId)) return true;
|
||||
} else if (recursionStack.has(depId)) {
|
||||
// Found cycle - extract it
|
||||
const cycleStart = currentPath.indexOf(depId);
|
||||
cycles.push(currentPath.slice(cycleStart));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop();
|
||||
recursionStack.delete(featureId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (!visited.has(feature.id)) {
|
||||
dfs(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
456
apps/server/src/lib/enhancement-prompts.ts
Normal file
456
apps/server/src/lib/enhancement-prompts.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Enhancement Prompts Library - AI-powered text enhancement for task descriptions
|
||||
*
|
||||
* Provides prompt templates and utilities for enhancing user-written task descriptions:
|
||||
* - Improve: Transform vague requests into clear, actionable tasks
|
||||
* - Technical: Add implementation details and technical specifications
|
||||
* - Simplify: Make verbose descriptions concise and focused
|
||||
* - Acceptance: Add testable acceptance criteria
|
||||
*
|
||||
* Uses chain-of-thought prompting with few-shot examples for consistent results.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available enhancement modes for transforming task descriptions
|
||||
*/
|
||||
export type EnhancementMode = "improve" | "technical" | "simplify" | "acceptance";
|
||||
|
||||
/**
|
||||
* Example input/output pair for few-shot learning
|
||||
*/
|
||||
export interface EnhancementExample {
|
||||
input: string;
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for the "improve" enhancement mode.
|
||||
* Transforms vague or unclear requests into clear, actionable task descriptions.
|
||||
*/
|
||||
export const IMPROVE_SYSTEM_PROMPT = `You are an expert at transforming vague, unclear, or incomplete task descriptions into clear, actionable specifications.
|
||||
|
||||
Your task is to take a user's rough description and improve it by:
|
||||
|
||||
1. ANALYZE the input:
|
||||
- Identify the core intent behind the request
|
||||
- Note any ambiguities or missing details
|
||||
- Determine what success would look like
|
||||
|
||||
2. CLARIFY the scope:
|
||||
- Define clear boundaries for the task
|
||||
- Identify implicit requirements
|
||||
- Add relevant context that may be assumed
|
||||
|
||||
3. STRUCTURE the output:
|
||||
- Write a clear, actionable title
|
||||
- Provide a concise description of what needs to be done
|
||||
- Break down into specific sub-tasks if appropriate
|
||||
|
||||
4. ENHANCE with details:
|
||||
- Add specific, measurable outcomes where possible
|
||||
- Include edge cases to consider
|
||||
- Note any dependencies or prerequisites
|
||||
|
||||
Output ONLY the improved task description. Do not include explanations, markdown formatting, or meta-commentary about your changes.`;
|
||||
|
||||
/**
|
||||
* System prompt for the "technical" enhancement mode.
|
||||
* Adds implementation details and technical specifications.
|
||||
*/
|
||||
export const TECHNICAL_SYSTEM_PROMPT = `You are a senior software engineer skilled at adding technical depth to feature descriptions.
|
||||
|
||||
Your task is to enhance a task description with technical implementation details:
|
||||
|
||||
1. ANALYZE the requirement:
|
||||
- Understand the functional goal
|
||||
- Identify the technical domain (frontend, backend, database, etc.)
|
||||
- Consider the likely tech stack based on context
|
||||
|
||||
2. ADD technical specifications:
|
||||
- Suggest specific technologies, libraries, or patterns
|
||||
- Define API contracts or data structures if relevant
|
||||
- Note performance considerations
|
||||
- Identify security implications
|
||||
|
||||
3. OUTLINE implementation approach:
|
||||
- Break down into technical sub-tasks
|
||||
- Suggest file structure or component organization
|
||||
- Note integration points with existing systems
|
||||
|
||||
4. CONSIDER edge cases:
|
||||
- Error handling requirements
|
||||
- Loading and empty states
|
||||
- Boundary conditions
|
||||
|
||||
Output ONLY the enhanced technical description. Keep it concise but comprehensive. Do not include explanations about your reasoning.`;
|
||||
|
||||
/**
|
||||
* System prompt for the "simplify" enhancement mode.
|
||||
* Makes verbose descriptions concise and focused.
|
||||
*/
|
||||
export const SIMPLIFY_SYSTEM_PROMPT = `You are an expert editor who excels at making verbose text concise without losing meaning.
|
||||
|
||||
Your task is to simplify a task description while preserving essential information:
|
||||
|
||||
1. IDENTIFY the core message:
|
||||
- Extract the primary goal or requirement
|
||||
- Note truly essential details
|
||||
- Separate nice-to-have from must-have information
|
||||
|
||||
2. ELIMINATE redundancy:
|
||||
- Remove repeated information
|
||||
- Cut unnecessary qualifiers and hedging language
|
||||
- Remove filler words and phrases
|
||||
|
||||
3. CONSOLIDATE related points:
|
||||
- Merge overlapping requirements
|
||||
- Group related items together
|
||||
- Use concise language
|
||||
|
||||
4. PRESERVE critical details:
|
||||
- Keep specific technical requirements
|
||||
- Retain important constraints
|
||||
- Maintain actionable specifics
|
||||
|
||||
Output ONLY the simplified description. Aim for 30-50% reduction in length while keeping all essential information. Do not explain your changes.`;
|
||||
|
||||
/**
|
||||
* System prompt for the "acceptance" enhancement mode.
|
||||
* Adds testable acceptance criteria to task descriptions.
|
||||
*/
|
||||
export const ACCEPTANCE_SYSTEM_PROMPT = `You are a QA specialist skilled at defining testable acceptance criteria for software features.
|
||||
|
||||
Your task is to enhance a task description by adding clear acceptance criteria:
|
||||
|
||||
1. UNDERSTAND the feature:
|
||||
- Identify all user-facing behaviors
|
||||
- Note system state changes
|
||||
- Consider different user roles or scenarios
|
||||
|
||||
2. DEFINE acceptance criteria using Given-When-Then format:
|
||||
- Given: The initial context or preconditions
|
||||
- When: The action or trigger
|
||||
- Then: The expected outcome
|
||||
|
||||
3. COVER key scenarios:
|
||||
- Happy path (successful completion)
|
||||
- Edge cases (boundary conditions)
|
||||
- Error scenarios (what should NOT happen)
|
||||
- Performance requirements if relevant
|
||||
|
||||
4. MAKE criteria testable:
|
||||
- Use specific, measurable outcomes
|
||||
- Avoid vague terms like "quickly" or "easily"
|
||||
- Include specific values where applicable
|
||||
|
||||
Output the original description followed by a clear "Acceptance Criteria:" section with numbered, testable criteria. Do not include explanations about your process.`;
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "improve" enhancement mode
|
||||
*/
|
||||
export const IMPROVE_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "add dark mode",
|
||||
output: `Add Dark Mode Theme Support
|
||||
|
||||
Implement a dark mode option that allows users to switch between light and dark themes throughout the application.
|
||||
|
||||
Requirements:
|
||||
- Add a theme toggle switch in the settings area
|
||||
- Store user's theme preference persistently
|
||||
- Apply dark theme colors to all UI components
|
||||
- Respect system preference as default if user hasn't set a preference
|
||||
- Ensure smooth transition when switching themes`,
|
||||
},
|
||||
{
|
||||
input: "fix the login bug",
|
||||
output: `Fix Login Authentication Flow
|
||||
|
||||
Investigate and resolve issues preventing users from successfully logging in to the application.
|
||||
|
||||
Requirements:
|
||||
- Identify the specific failure point in the login process
|
||||
- Ensure proper credential validation
|
||||
- Handle error states with clear user feedback
|
||||
- Verify session token is correctly stored after successful login
|
||||
- Test with various credential combinations (valid, invalid, edge cases)`,
|
||||
},
|
||||
{
|
||||
input: "make it faster",
|
||||
output: `Improve Application Performance
|
||||
|
||||
Optimize application loading and response times to enhance user experience.
|
||||
|
||||
Requirements:
|
||||
- Profile current performance to identify bottlenecks
|
||||
- Implement code splitting and lazy loading for routes/components
|
||||
- Optimize database queries and add appropriate indexes
|
||||
- Add caching for frequently accessed data
|
||||
- Set performance budget targets (e.g., LCP < 2.5s, FID < 100ms)
|
||||
- Measure and document improvements`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "technical" enhancement mode
|
||||
*/
|
||||
export const TECHNICAL_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "Add user profile page",
|
||||
output: `Add User Profile Page
|
||||
|
||||
Create a dedicated profile page for viewing and editing user information.
|
||||
|
||||
Technical Implementation:
|
||||
- Frontend: React component at /profile route with form validation
|
||||
- API Endpoint: GET/PUT /api/users/:id for fetching and updating profile
|
||||
- Data Model: Extend User schema with profile fields (avatar, bio, preferences)
|
||||
- File Upload: Integrate cloud storage for avatar images with client-side resizing
|
||||
- State Management: Use optimistic updates for better UX
|
||||
- Validation: Server-side validation matching client-side rules
|
||||
|
||||
Components:
|
||||
- ProfileHeader (avatar, name, edit button)
|
||||
- ProfileForm (editable fields with validation)
|
||||
- AvatarUploader (drag-drop with preview)
|
||||
|
||||
Security: Ensure users can only edit their own profile (auth middleware)`,
|
||||
},
|
||||
{
|
||||
input: "Add search functionality",
|
||||
output: `Add Search Functionality
|
||||
|
||||
Implement full-text search across application content.
|
||||
|
||||
Technical Implementation:
|
||||
- Search Engine: Use Elasticsearch or PostgreSQL full-text search
|
||||
- API: GET /api/search?q={query}&type={type}&page={page}
|
||||
- Indexing: Create search index with relevant fields, update on content changes
|
||||
- Frontend: Debounced search input (300ms) with typeahead suggestions
|
||||
- Results: Paginated results with highlighted matching text
|
||||
|
||||
Architecture:
|
||||
- SearchService class handling query building and execution
|
||||
- SearchIndex worker for background indexing
|
||||
- SearchResults component with filtering and sorting
|
||||
|
||||
Performance:
|
||||
- Implement search result caching (Redis, 5-minute TTL)
|
||||
- Limit results per page (20 items)
|
||||
- Add query complexity limits to prevent abuse`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "simplify" enhancement mode
|
||||
*/
|
||||
export const SIMPLIFY_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: `We need to implement a feature that would allow our users to be able to export their data in various different formats. The formats we are thinking about supporting include CSV which is commonly used for spreadsheet applications, and also JSON which is good for developers, and possibly PDF for people who want a printable version. The user should be able to go somewhere in the UI and select which format they want and then download their data in that format. We should probably also show some kind of progress indicator while the export is happening since it might take a while for large datasets.`,
|
||||
output: `Add Data Export Feature
|
||||
|
||||
Allow users to export their data in CSV, JSON, or PDF format.
|
||||
|
||||
Requirements:
|
||||
- Add export button/menu in the UI
|
||||
- Support three formats: CSV (spreadsheets), JSON (developers), PDF (printing)
|
||||
- Show progress indicator during export
|
||||
- Handle large datasets gracefully`,
|
||||
},
|
||||
{
|
||||
input: `The current notification system is not working very well and users are complaining about it. Sometimes they don't receive notifications at all, and other times they receive too many notifications and feel overwhelmed. We also have had reports that the notifications are not appearing correctly on mobile devices. Additionally, some users have requested the ability to customize which types of notifications they receive. We should look into all of these issues and make the notification system work better overall.`,
|
||||
output: `Fix and Improve Notification System
|
||||
|
||||
Address reliability issues and add user controls for notifications.
|
||||
|
||||
Issues to fix:
|
||||
- Missing notifications (delivery reliability)
|
||||
- Notification overload (implement batching/digest)
|
||||
- Mobile display problems
|
||||
|
||||
Enhancements:
|
||||
- Add notification preferences (per-type controls)
|
||||
- Test across devices and platforms`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "acceptance" enhancement mode
|
||||
*/
|
||||
export const ACCEPTANCE_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "Add password reset functionality",
|
||||
output: `Add Password Reset Functionality
|
||||
|
||||
Allow users to reset their password via email when they forget it.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
1. Given a user is on the login page, when they click "Forgot Password", then they should see a password reset form requesting their email.
|
||||
|
||||
2. Given a user enters a valid registered email, when they submit the form, then they should receive a password reset email within 2 minutes.
|
||||
|
||||
3. Given a user clicks the reset link in their email, when the link is less than 1 hour old, then they should see a form to enter a new password.
|
||||
|
||||
4. Given a user enters a new valid password (min 8 chars, 1 number, 1 special char), when they submit, then their password should be updated and they should be redirected to login.
|
||||
|
||||
5. Given a user enters an unregistered email, when they submit the reset form, then they should see the same success message (security: don't reveal which emails exist).
|
||||
|
||||
6. Given a reset link older than 1 hour, when a user clicks it, then they should see an expiration message with option to request a new link.`,
|
||||
},
|
||||
{
|
||||
input: "Shopping cart checkout",
|
||||
output: `Shopping Cart Checkout
|
||||
|
||||
Implement the checkout flow for purchasing items in the shopping cart.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
1. Given a user has items in their cart, when they click "Checkout", then they should see an order summary with item details and total price.
|
||||
|
||||
2. Given a user is on the checkout page, when they enter valid shipping information, then the form should validate in real-time and show estimated delivery date.
|
||||
|
||||
3. Given valid shipping info is entered, when the user proceeds to payment, then they should see available payment methods (credit card, PayPal).
|
||||
|
||||
4. Given valid payment details are entered, when the user confirms the order, then the payment should be processed and order confirmation displayed within 5 seconds.
|
||||
|
||||
5. Given a successful order, when confirmation is shown, then the user should receive an email receipt and their cart should be emptied.
|
||||
|
||||
6. Given a payment failure, when the error occurs, then the user should see a clear error message and their cart should remain intact.
|
||||
|
||||
7. Given the user closes the browser during checkout, when they return, then their cart contents should still be available.`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Map of enhancement modes to their system prompts
|
||||
*/
|
||||
const SYSTEM_PROMPTS: Record<EnhancementMode, string> = {
|
||||
improve: IMPROVE_SYSTEM_PROMPT,
|
||||
technical: TECHNICAL_SYSTEM_PROMPT,
|
||||
simplify: SIMPLIFY_SYSTEM_PROMPT,
|
||||
acceptance: ACCEPTANCE_SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of enhancement modes to their few-shot examples
|
||||
*/
|
||||
const EXAMPLES: Record<EnhancementMode, EnhancementExample[]> = {
|
||||
improve: IMPROVE_EXAMPLES,
|
||||
technical: TECHNICAL_EXAMPLES,
|
||||
simplify: SIMPLIFY_EXAMPLES,
|
||||
acceptance: ACCEPTANCE_EXAMPLES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhancement prompt configuration returned by getEnhancementPrompt
|
||||
*/
|
||||
export interface EnhancementPromptConfig {
|
||||
/** System prompt for the enhancement mode */
|
||||
systemPrompt: string;
|
||||
/** Description of what this mode does */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions for each enhancement mode
|
||||
*/
|
||||
const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
|
||||
improve: "Transform vague requests into clear, actionable task descriptions",
|
||||
technical: "Add implementation details and technical specifications",
|
||||
simplify: "Make verbose descriptions concise and focused",
|
||||
acceptance: "Add testable acceptance criteria to task descriptions",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the enhancement prompt configuration for a given mode
|
||||
*
|
||||
* @param mode - The enhancement mode (falls back to 'improve' if invalid)
|
||||
* @returns The enhancement prompt configuration
|
||||
*/
|
||||
export function getEnhancementPrompt(mode: string): EnhancementPromptConfig {
|
||||
const normalizedMode = mode.toLowerCase() as EnhancementMode;
|
||||
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : "improve";
|
||||
|
||||
return {
|
||||
systemPrompt: SYSTEM_PROMPTS[validMode],
|
||||
description: MODE_DESCRIPTIONS[validMode],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system prompt for a specific enhancement mode
|
||||
*
|
||||
* @param mode - The enhancement mode to get the prompt for
|
||||
* @returns The system prompt string
|
||||
*/
|
||||
export function getSystemPrompt(mode: EnhancementMode): string {
|
||||
return SYSTEM_PROMPTS[mode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the few-shot examples for a specific enhancement mode
|
||||
*
|
||||
* @param mode - The enhancement mode to get examples for
|
||||
* @returns Array of input/output example pairs
|
||||
*/
|
||||
export function getExamples(mode: EnhancementMode): EnhancementExample[] {
|
||||
return EXAMPLES[mode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user prompt for enhancement with optional few-shot examples
|
||||
*
|
||||
* @param mode - The enhancement mode
|
||||
* @param text - The text to enhance
|
||||
* @param includeExamples - Whether to include few-shot examples (default: true)
|
||||
* @returns The formatted user prompt string
|
||||
*/
|
||||
export function buildUserPrompt(
|
||||
mode: EnhancementMode,
|
||||
text: string,
|
||||
includeExamples: boolean = true
|
||||
): string {
|
||||
const examples = includeExamples ? getExamples(mode) : [];
|
||||
|
||||
if (examples.length === 0) {
|
||||
return `Please enhance the following task description:\n\n${text}`;
|
||||
}
|
||||
|
||||
// Build few-shot examples section
|
||||
const examplesSection = examples
|
||||
.map(
|
||||
(example, index) =>
|
||||
`Example ${index + 1}:\nInput: ${example.input}\nOutput: ${example.output}`
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
return `Here are some examples of how to enhance task descriptions:
|
||||
|
||||
${examplesSection}
|
||||
|
||||
---
|
||||
|
||||
Now, please enhance the following task description:
|
||||
|
||||
${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode is a valid enhancement mode
|
||||
*
|
||||
* @param mode - The mode to check
|
||||
* @returns True if the mode is valid
|
||||
*/
|
||||
export function isValidEnhancementMode(mode: string): mode is EnhancementMode {
|
||||
return mode in SYSTEM_PROMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available enhancement modes
|
||||
*
|
||||
* @returns Array of available enhancement mode names
|
||||
*/
|
||||
export function getAvailableEnhancementModes(): EnhancementMode[] {
|
||||
return Object.keys(SYSTEM_PROMPTS) as EnhancementMode[];
|
||||
}
|
||||
@@ -21,6 +21,22 @@ export function isAbortError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a user-initiated cancellation
|
||||
*
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns True if the error is a user-initiated cancellation
|
||||
*/
|
||||
export function isCancellationError(errorMessage: string): boolean {
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes("cancelled") ||
|
||||
lowerMessage.includes("canceled") ||
|
||||
lowerMessage.includes("stopped") ||
|
||||
lowerMessage.includes("aborted")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication/API key error
|
||||
*
|
||||
@@ -39,7 +55,7 @@ export function isAuthenticationError(errorMessage: string): boolean {
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
@@ -49,6 +65,7 @@ export interface ErrorInfo {
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
isCancellation: boolean;
|
||||
originalError: unknown;
|
||||
}
|
||||
|
||||
@@ -62,12 +79,15 @@ export function classifyError(error: unknown): ErrorInfo {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
const isAbort = isAbortError(error);
|
||||
const isAuth = isAuthenticationError(message);
|
||||
const isCancellation = isCancellationError(message);
|
||||
|
||||
let type: ErrorType;
|
||||
if (isAuth) {
|
||||
type = "authentication";
|
||||
} else if (isAbort) {
|
||||
type = "abort";
|
||||
} else if (isCancellation) {
|
||||
type = "cancellation";
|
||||
} else if (error instanceof Error) {
|
||||
type = "execution";
|
||||
} else {
|
||||
@@ -79,6 +99,7 @@ export function classifyError(error: unknown): ErrorInfo {
|
||||
message,
|
||||
isAbort,
|
||||
isAuth,
|
||||
isCancellation,
|
||||
originalError: error,
|
||||
};
|
||||
}
|
||||
|
||||
67
apps/server/src/lib/fs-utils.ts
Normal file
67
apps/server/src/lib/fs-utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* File system utilities that handle symlinks safely
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Create a directory, handling symlinks safely to avoid ELOOP errors.
|
||||
* If the path already exists as a directory or symlink, returns success.
|
||||
*/
|
||||
export async function mkdirSafe(dirPath: string): Promise<void> {
|
||||
const resolvedPath = path.resolve(dirPath);
|
||||
|
||||
// Check if path already exists using lstat (doesn't follow symlinks)
|
||||
try {
|
||||
const stats = await fs.lstat(resolvedPath);
|
||||
// Path exists - if it's a directory or symlink, consider it success
|
||||
if (stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
// It's a file - can't create directory
|
||||
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
|
||||
} catch (error: any) {
|
||||
// ENOENT means path doesn't exist - we should create it
|
||||
if (error.code !== "ENOENT") {
|
||||
// Some other error (could be ELOOP in parent path)
|
||||
// If it's ELOOP, the path involves symlinks - don't try to create
|
||||
if (error.code === "ELOOP") {
|
||||
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Path doesn't exist, create it
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (error: any) {
|
||||
// Handle race conditions and symlink issues
|
||||
if (error.code === "EEXIST" || error.code === "ELOOP") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists, handling symlinks safely.
|
||||
* Returns true if the path exists as a file, directory, or symlink.
|
||||
*/
|
||||
export async function existsSafe(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(filePath);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
// ELOOP or other errors - path exists but is problematic
|
||||
if (error.code === "ELOOP") {
|
||||
return true; // Symlink exists, even if looping
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,4 @@ export function getLogLevel(): LogLevel {
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLogLevel = level;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export const TOOL_PRESETS = {
|
||||
*/
|
||||
export const MAX_TURNS = {
|
||||
/** Quick operations that shouldn't need many iterations */
|
||||
quick: 5,
|
||||
quick: 50,
|
||||
|
||||
/** Standard operations */
|
||||
standard: 20,
|
||||
standard: 100,
|
||||
|
||||
/** Long-running operations like full spec generation */
|
||||
extended: 50,
|
||||
extended: 250,
|
||||
|
||||
/** Very long operations that may require extensive exploration */
|
||||
maximum: 1000,
|
||||
@@ -143,6 +143,12 @@ export interface CreateSdkOptionsConfig {
|
||||
|
||||
/** Optional abort controller for cancellation */
|
||||
abortController?: AbortController;
|
||||
|
||||
/** Optional output format for structured outputs */
|
||||
outputFormat?: {
|
||||
type: "json_schema";
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,12 +164,17 @@ export function createSpecGenerationOptions(
|
||||
): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - spec generation only needs read-only tools
|
||||
// Using "acceptEdits" can cause Claude to write files to unexpected locations
|
||||
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
||||
permissionMode: "default",
|
||||
model: getModelForUseCase("spec", config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +191,8 @@ export function createFeatureGenerationOptions(
|
||||
): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - feature generation only needs read-only tools
|
||||
permissionMode: "default",
|
||||
model: getModelForUseCase("features", config.model),
|
||||
maxTurns: MAX_TURNS.quick,
|
||||
cwd: config.cwd,
|
||||
@@ -194,7 +207,7 @@ export function createFeatureGenerationOptions(
|
||||
*
|
||||
* Configuration:
|
||||
* - Uses read-only tools for analysis
|
||||
* - Quick turns for focused suggestions
|
||||
* - Standard turns to allow thorough codebase exploration and structured output generation
|
||||
* - Opus model by default for thorough analysis
|
||||
*/
|
||||
export function createSuggestionsOptions(
|
||||
@@ -203,11 +216,12 @@ export function createSuggestionsOptions(
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("suggestions", config.model),
|
||||
maxTurns: MAX_TURNS.quick,
|
||||
maxTurns: MAX_TURNS.extended,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -112,9 +112,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
// Claude SDK is always available since it's a dependency
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const hasApiKey = hasAnthropicKey || hasOAuthToken;
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
const status: InstallationStatus = {
|
||||
installed: true,
|
||||
|
||||
@@ -48,6 +48,29 @@ export interface ContentBlock {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage statistics from SDK execution
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
totalTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-model usage breakdown from SDK result
|
||||
*/
|
||||
export interface ModelUsageData {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message returned by a provider (matches Claude SDK streaming format)
|
||||
*/
|
||||
@@ -62,6 +85,15 @@ export interface ProviderMessage {
|
||||
result?: string;
|
||||
error?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
// Token usage fields (present in result messages)
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
total_cost_usd?: number;
|
||||
modelUsage?: Record<string, ModelUsageData>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,19 +35,9 @@ export function setRunningState(
|
||||
* Helper to log authentication status
|
||||
*/
|
||||
export function logAuthStatus(context: string): void {
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
logger.info(`${context} - Auth Status:`);
|
||||
logger.info(
|
||||
` CLAUDE_CODE_OAUTH_TOKEN: ${
|
||||
hasOAuthToken
|
||||
? "SET (" +
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) +
|
||||
"...)"
|
||||
: "NOT SET"
|
||||
}`
|
||||
);
|
||||
logger.info(
|
||||
` ANTHROPIC_API_KEY: ${
|
||||
hasApiKey
|
||||
@@ -56,7 +46,7 @@ export function logAuthStatus(context: string): void {
|
||||
}`
|
||||
);
|
||||
|
||||
if (!hasOAuthToken && !hasApiKey) {
|
||||
if (!hasApiKey) {
|
||||
logger.warn("⚠️ WARNING: No authentication configured! SDK will fail.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
|
||||
import { logAuthStatus } from "./common.js";
|
||||
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
|
||||
import { getAppSpecPath } from "../../lib/automaker-paths.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
@@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec(
|
||||
logger.debug("projectPath:", projectPath);
|
||||
logger.debug("maxFeatures:", featureCount);
|
||||
|
||||
// Read existing spec
|
||||
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
|
||||
// Read existing spec from .automaker directory
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
let spec: string;
|
||||
|
||||
logger.debug("Reading spec from:", specPath);
|
||||
@@ -56,17 +56,19 @@ ${spec}
|
||||
Generate a prioritized list of implementable features. For each feature provide:
|
||||
|
||||
1. **id**: A unique lowercase-hyphenated identifier
|
||||
2. **title**: Short descriptive title
|
||||
3. **description**: What this feature does (2-3 sentences)
|
||||
4. **priority**: 1 (high), 2 (medium), or 3 (low)
|
||||
5. **complexity**: "simple", "moderate", or "complex"
|
||||
6. **dependencies**: Array of feature IDs this depends on (can be empty)
|
||||
2. **category**: Functional category (e.g., "Core", "UI", "API", "Authentication", "Database")
|
||||
3. **title**: Short descriptive title
|
||||
4. **description**: What this feature does (2-3 sentences)
|
||||
5. **priority**: 1 (high), 2 (medium), or 3 (low)
|
||||
6. **complexity**: "simple", "moderate", or "complex"
|
||||
7. **dependencies**: Array of feature IDs this depends on (can be empty)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-id",
|
||||
"category": "Feature Category",
|
||||
"title": "Feature Title",
|
||||
"description": "What it does",
|
||||
"priority": 1,
|
||||
|
||||
@@ -6,11 +6,17 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { getAppSpecFormatInstruction } from "../../lib/app-spec-format.js";
|
||||
import {
|
||||
specOutputSchema,
|
||||
specToXml,
|
||||
getStructuredSpecPromptInstruction,
|
||||
type SpecOutput,
|
||||
} from "../../lib/app-spec-format.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
|
||||
import { logAuthStatus } from "./common.js";
|
||||
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
|
||||
import { ensureAutomakerDir, getAppSpecPath } from "../../lib/automaker-paths.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
@@ -37,7 +43,7 @@ export async function generateSpec(
|
||||
|
||||
if (analyzeProject !== false) {
|
||||
// Default to true - analyze the project
|
||||
analysisInstructions = `Based on this overview, analyze the project directory (if it exists) and create a comprehensive specification. Use the Read, Glob, and Grep tools to explore the codebase and understand:
|
||||
analysisInstructions = `Based on this overview, analyze the project directory (if it exists) using the Read, Glob, and Grep tools to understand:
|
||||
- Existing technologies and frameworks
|
||||
- Project structure and architecture
|
||||
- Current features and capabilities
|
||||
@@ -65,7 +71,7 @@ ${techStackDefaults}
|
||||
|
||||
${analysisInstructions}
|
||||
|
||||
${getAppSpecFormatInstruction()}`;
|
||||
${getStructuredSpecPromptInstruction()}`;
|
||||
|
||||
logger.info("========== PROMPT BEING SENT ==========");
|
||||
logger.info(`Prompt length: ${prompt.length} chars`);
|
||||
@@ -80,6 +86,10 @@ ${getAppSpecFormatInstruction()}`;
|
||||
const options = createSpecGenerationOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
outputFormat: {
|
||||
type: "json_schema",
|
||||
schema: specOutputSchema,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
|
||||
@@ -100,6 +110,7 @@ ${getAppSpecFormatInstruction()}`;
|
||||
|
||||
let responseText = "";
|
||||
let messageCount = 0;
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
logger.info("Starting to iterate over stream...");
|
||||
|
||||
@@ -113,75 +124,49 @@ ${getAppSpecFormatInstruction()}`;
|
||||
);
|
||||
|
||||
if (msg.type === "assistant") {
|
||||
// Log the full message structure to debug
|
||||
logger.info(`Assistant msg keys: ${Object.keys(msg).join(", ")}`);
|
||||
const msgAny = msg as any;
|
||||
if (msgAny.message) {
|
||||
logger.info(
|
||||
`msg.message keys: ${Object.keys(msgAny.message).join(", ")}`
|
||||
);
|
||||
if (msgAny.message.content) {
|
||||
logger.info(
|
||||
`msg.message.content length: ${msgAny.message.content.length}`
|
||||
);
|
||||
for (const block of msgAny.message.content) {
|
||||
if (msgAny.message?.content) {
|
||||
for (const block of msgAny.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Block keys: ${Object.keys(block).join(", ")}, type: ${
|
||||
block.type
|
||||
}`
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
logger.info(`Text preview: ${block.text.substring(0, 200)}...`);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
logger.info("Tool use:", block.name);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_tool",
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
logger.info("Tool use:", block.name);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_tool",
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn("msg.message.content is falsy");
|
||||
}
|
||||
} else {
|
||||
logger.warn("msg.message is falsy");
|
||||
// Log full message to see structure
|
||||
logger.info(
|
||||
`Full assistant msg: ${JSON.stringify(msg).substring(0, 1000)}`
|
||||
);
|
||||
}
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
logger.info("Received success result");
|
||||
logger.info(`Result value: "${(msg as any).result}"`);
|
||||
logger.info(
|
||||
`Current responseText length before result: ${responseText.length}`
|
||||
);
|
||||
// Only use result if it has content, otherwise keep accumulated text
|
||||
if ((msg as any).result && (msg as any).result.length > 0) {
|
||||
logger.info("Using result value as responseText");
|
||||
responseText = (msg as any).result;
|
||||
// Check for structured output - this is the reliable way to get spec data
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as SpecOutput;
|
||||
logger.info("✅ Received structured output");
|
||||
logger.debug("Structured output:", JSON.stringify(structuredOutput, null, 2));
|
||||
} else {
|
||||
logger.info("Result is empty, keeping accumulated responseText");
|
||||
logger.warn("⚠️ No structured output in result, will fall back to text parsing");
|
||||
}
|
||||
} else if (msg.type === "result") {
|
||||
// Handle all result types
|
||||
// Handle error result types
|
||||
const subtype = (msg as any).subtype;
|
||||
logger.info(`Result message: subtype=${subtype}`);
|
||||
if (subtype === "error_max_turns") {
|
||||
logger.error(
|
||||
"❌ Hit max turns limit! Claude used too many tool calls."
|
||||
);
|
||||
logger.info(`responseText so far: ${responseText.length} chars`);
|
||||
logger.error("❌ Hit max turns limit!");
|
||||
} else if (subtype === "error_max_structured_output_retries") {
|
||||
logger.error("❌ Failed to produce valid structured output after retries");
|
||||
throw new Error("Could not produce valid spec output");
|
||||
}
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
logger.error("❌ Received error message from stream:");
|
||||
@@ -201,23 +186,58 @@ ${getAppSpecFormatInstruction()}`;
|
||||
|
||||
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
|
||||
logger.info(`Response text length: ${responseText.length} chars`);
|
||||
logger.info("========== FINAL RESPONSE TEXT ==========");
|
||||
logger.info(responseText || "(empty)");
|
||||
logger.info("========== END RESPONSE TEXT ==========");
|
||||
|
||||
if (!responseText || responseText.trim().length === 0) {
|
||||
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
|
||||
// Determine XML content to save
|
||||
let xmlContent: string;
|
||||
|
||||
if (structuredOutput) {
|
||||
// Use structured output - convert JSON to XML
|
||||
logger.info("✅ Using structured output for XML generation");
|
||||
xmlContent = specToXml(structuredOutput);
|
||||
logger.info(`Generated XML from structured output: ${xmlContent.length} chars`);
|
||||
} else {
|
||||
// Fallback: Extract XML content from response text
|
||||
// Claude might include conversational text before/after
|
||||
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
||||
logger.warn("⚠️ No structured output, falling back to text parsing");
|
||||
logger.info("========== FINAL RESPONSE TEXT ==========");
|
||||
logger.info(responseText || "(empty)");
|
||||
logger.info("========== END RESPONSE TEXT ==========");
|
||||
|
||||
if (!responseText || responseText.trim().length === 0) {
|
||||
throw new Error("No response text and no structured output - cannot generate spec");
|
||||
}
|
||||
|
||||
const xmlStart = responseText.indexOf("<project_specification>");
|
||||
const xmlEnd = responseText.lastIndexOf("</project_specification>");
|
||||
|
||||
if (xmlStart !== -1 && xmlEnd !== -1) {
|
||||
// Extract just the XML content, discarding any conversational text before/after
|
||||
xmlContent = responseText.substring(xmlStart, xmlEnd + "</project_specification>".length);
|
||||
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
|
||||
} else {
|
||||
// No valid XML structure found in the response text
|
||||
// This happens when structured output was expected but not received, and the agent
|
||||
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
|
||||
// We should NOT save this conversational text as it's not a valid spec
|
||||
logger.error("❌ Response does not contain valid <project_specification> XML structure");
|
||||
logger.error("This typically happens when structured output failed and the agent produced conversational text instead of XML");
|
||||
throw new Error(
|
||||
"Failed to generate spec: No valid XML structure found in response. " +
|
||||
"The response contained conversational text but no <project_specification> tags. " +
|
||||
"Please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save spec
|
||||
const specDir = path.join(projectPath, ".automaker");
|
||||
const specPath = path.join(specDir, "app_spec.txt");
|
||||
// Save spec to .automaker directory
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
|
||||
logger.info("Saving spec to:", specPath);
|
||||
logger.info(`Content to save (${responseText.length} chars)`);
|
||||
logger.info(`Content to save (${xmlContent.length} chars)`);
|
||||
|
||||
await fs.mkdir(specDir, { recursive: true });
|
||||
await fs.writeFile(specPath, responseText);
|
||||
await fs.writeFile(specPath, xmlContent);
|
||||
|
||||
// Verify the file was written
|
||||
const savedContent = await fs.readFile(specPath, "utf-8");
|
||||
|
||||
@@ -21,3 +21,6 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { getFeaturesDir } from "../../lib/automaker-paths.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
@@ -41,7 +42,7 @@ export async function parseAndCreateFeatures(
|
||||
logger.info(`Parsed ${parsed.features?.length || 0} features`);
|
||||
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
|
||||
|
||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
await fs.mkdir(featuresDir, { recursive: true });
|
||||
|
||||
const createdFeatures: Array<{ id: string; title: string }> = [];
|
||||
@@ -53,6 +54,7 @@ export async function parseAndCreateFeatures(
|
||||
|
||||
const featureData = {
|
||||
id: feature.id,
|
||||
category: feature.category || "Uncategorized",
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
status: "backlog", // Features go to backlog - user must manually start them
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { Router } from "express";
|
||||
import type { AutoModeService } from "../../services/auto-mode-service.js";
|
||||
import { createStartHandler } from "./routes/start.js";
|
||||
import { createStopHandler } from "./routes/stop.js";
|
||||
import { createStopFeatureHandler } from "./routes/stop-feature.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
import { createRunFeatureHandler } from "./routes/run-feature.js";
|
||||
@@ -17,12 +15,11 @@ import { createContextExistsHandler } from "./routes/context-exists.js";
|
||||
import { createAnalyzeProjectHandler } from "./routes/analyze-project.js";
|
||||
import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js";
|
||||
import { createCommitFeatureHandler } from "./routes/commit-feature.js";
|
||||
import { createApprovePlanHandler } from "./routes/approve-plan.js";
|
||||
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/start", createStartHandler(autoModeService));
|
||||
router.post("/stop", createStopHandler(autoModeService));
|
||||
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
|
||||
router.post("/status", createStatusHandler(autoModeService));
|
||||
router.post("/run-feature", createRunFeatureHandler(autoModeService));
|
||||
@@ -35,6 +32,7 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
createFollowUpFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
|
||||
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
78
apps/server/src/routes/auto-mode/routes/approve-plan.ts
Normal file
78
apps/server/src/routes/auto-mode/routes/approve-plan.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* POST /approve-plan endpoint - Approve or reject a generated plan/spec
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
|
||||
featureId: string;
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
if (!featureId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "featureId is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof approved !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "approved must be a boolean",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
|
||||
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
|
||||
// This supports cases where the server restarted while waiting for approval
|
||||
|
||||
logger.info(
|
||||
`[AutoMode] Plan ${approved ? "approved" : "rejected"} for feature ${featureId}${
|
||||
editedPlan ? " (with edits)" : ""
|
||||
}${feedback ? ` - Feedback: ${feedback}` : ""}`
|
||||
);
|
||||
|
||||
// Resolve the pending approval (with recovery support)
|
||||
const result = await autoModeService.resolvePlanApproval(
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
approved,
|
||||
message: approved
|
||||
? "Plan approved - implementation will continue"
|
||||
: "Plan rejected - feature execution stopped",
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Approve plan failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import { getErrorMessage, logError } from "../common.js";
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
const { projectPath, featureId, worktreePath } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
worktreePath?: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
@@ -26,7 +27,8 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||
|
||||
const commitHash = await autoModeService.commitFeature(
|
||||
projectPath,
|
||||
featureId
|
||||
featureId,
|
||||
worktreePath
|
||||
);
|
||||
res.json({ success: true, commitHash });
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,12 +12,14 @@ const logger = createLogger("AutoMode");
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
imagePaths?: string[];
|
||||
};
|
||||
const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
imagePaths?: string[];
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !prompt) {
|
||||
res.status(400).json({
|
||||
@@ -28,13 +30,24 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
}
|
||||
|
||||
// Start follow-up in background
|
||||
// followUpFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths)
|
||||
.followUpFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
useWorktrees ?? true
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
`[AutoMode] Follow up feature ${featureId} error:`,
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when follow-up completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -19,18 +19,17 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start resume in background
|
||||
// Default to false - worktrees should only be used when explicitly enabled
|
||||
autoModeService
|
||||
.resumeFeature(projectPath, featureId, useWorktrees ?? true)
|
||||
.resumeFeature(projectPath, featureId, useWorktrees ?? false)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Resume feature ${featureId} error:`, error);
|
||||
});
|
||||
|
||||
@@ -19,20 +19,23 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start execution in background
|
||||
// executeFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? true, false)
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when execution completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* POST /start endpoint - Start auto mode loop
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Start auto loop failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop auto mode loop
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningCount = await autoModeService.stopAutoLoop();
|
||||
res.json({ success: true, runningFeatures: runningCount });
|
||||
} catch (error) {
|
||||
logError(error, "Stop auto loop failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,367 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger("Common");
|
||||
|
||||
// Max file size for generating synthetic diffs (1MB)
|
||||
const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024;
|
||||
|
||||
// Binary file extensions to skip
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
|
||||
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
||||
".db", ".sqlite", ".sqlite3",
|
||||
".pyc", ".pyo", ".class", ".o", ".obj",
|
||||
]);
|
||||
|
||||
// Status map for git status codes
|
||||
// Git porcelain format uses XY where X=staging area, Y=working tree
|
||||
const GIT_STATUS_MAP: Record<string, string> = {
|
||||
M: "Modified",
|
||||
A: "Added",
|
||||
D: "Deleted",
|
||||
R: "Renamed",
|
||||
C: "Copied",
|
||||
U: "Updated",
|
||||
"?": "Untracked",
|
||||
"!": "Ignored",
|
||||
" ": "Unmodified",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a readable status text from git status codes
|
||||
* Handles both single character and XY format status codes
|
||||
*/
|
||||
function getStatusText(indexStatus: string, workTreeStatus: string): string {
|
||||
// Untracked files
|
||||
if (indexStatus === "?" && workTreeStatus === "?") {
|
||||
return "Untracked";
|
||||
}
|
||||
|
||||
// Ignored files
|
||||
if (indexStatus === "!" && workTreeStatus === "!") {
|
||||
return "Ignored";
|
||||
}
|
||||
|
||||
// Prioritize staging area status, then working tree
|
||||
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
|
||||
|
||||
// Handle combined statuses
|
||||
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
|
||||
// Both staging and working tree have changes
|
||||
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
|
||||
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
|
||||
if (indexText === workText) {
|
||||
return indexText;
|
||||
}
|
||||
return `${indexText} (staged), ${workText} (unstaged)`;
|
||||
}
|
||||
|
||||
return GIT_STATUS_MAP[primaryStatus] || "Changed";
|
||||
}
|
||||
|
||||
/**
|
||||
* File status interface for git status results
|
||||
*/
|
||||
export interface FileStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is likely binary based on extension
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a git repository
|
||||
*/
|
||||
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `git status --porcelain` into FileStatus array
|
||||
* Git porcelain format: XY PATH where X=staging area status, Y=working tree status
|
||||
* For renamed files: XY ORIG_PATH -> NEW_PATH
|
||||
*/
|
||||
export function parseGitStatus(statusOutput: string): FileStatus[] {
|
||||
return statusOutput
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
// Git porcelain format uses two status characters: XY
|
||||
// X = status in staging area (index)
|
||||
// Y = status in working tree
|
||||
const indexStatus = line[0] || " ";
|
||||
const workTreeStatus = line[1] || " ";
|
||||
|
||||
// File path starts at position 3 (after "XY ")
|
||||
let filePath = line.slice(3);
|
||||
|
||||
// Handle renamed files (format: "R old_path -> new_path")
|
||||
if (indexStatus === "R" || workTreeStatus === "R") {
|
||||
const arrowIndex = filePath.indexOf(" -> ");
|
||||
if (arrowIndex !== -1) {
|
||||
filePath = filePath.slice(arrowIndex + 4); // Use new path
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the primary status character for backwards compatibility
|
||||
// Prioritize staging area status, then working tree
|
||||
let primaryStatus: string;
|
||||
if (indexStatus === "?" && workTreeStatus === "?") {
|
||||
primaryStatus = "?"; // Untracked
|
||||
} else if (indexStatus !== " " && indexStatus !== "?") {
|
||||
primaryStatus = indexStatus; // Staged change
|
||||
} else {
|
||||
primaryStatus = workTreeStatus; // Working tree change
|
||||
}
|
||||
|
||||
return {
|
||||
status: primaryStatus,
|
||||
path: filePath,
|
||||
statusText: getStatusText(indexStatus, workTreeStatus),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a synthetic unified diff for an untracked (new) file
|
||||
* This is needed because `git diff HEAD` doesn't include untracked files
|
||||
*/
|
||||
export async function generateSyntheticDiffForNewFile(
|
||||
basePath: string,
|
||||
relativePath: string
|
||||
): Promise<string> {
|
||||
const fullPath = path.join(basePath, relativePath);
|
||||
|
||||
try {
|
||||
// Check if it's a binary file
|
||||
if (isBinaryFile(relativePath)) {
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
Binary file ${relativePath} added
|
||||
`;
|
||||
}
|
||||
|
||||
// Get file stats to check size
|
||||
const stats = await fs.stat(fullPath);
|
||||
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
|
||||
const sizeKB = Math.round(stats.size / 1024);
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1 @@
|
||||
+[File too large to display: ${sizeKB}KB]
|
||||
`;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(fullPath, "utf-8");
|
||||
const hasTrailingNewline = content.endsWith("\n");
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Remove trailing empty line if the file ends with newline
|
||||
if (lines.length > 0 && lines.at(-1) === "") {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
// Generate diff format
|
||||
const lineCount = lines.length;
|
||||
const addedLines = lines.map(line => `+${line}`).join("\n");
|
||||
|
||||
let diff = `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1,${lineCount} @@
|
||||
${addedLines}`;
|
||||
|
||||
// Add "No newline at end of file" indicator if needed
|
||||
if (!hasTrailingNewline && content.length > 0) {
|
||||
diff += "\n\\ No newline at end of file";
|
||||
}
|
||||
|
||||
return diff + "\n";
|
||||
} catch (error) {
|
||||
// Log the error for debugging
|
||||
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
|
||||
// Return a placeholder diff
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1 @@
|
||||
+[Unable to read file content]
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate synthetic diffs for all untracked files and combine with existing diff
|
||||
*/
|
||||
export async function appendUntrackedFileDiffs(
|
||||
basePath: string,
|
||||
existingDiff: string,
|
||||
files: Array<{ status: string; path: string }>
|
||||
): Promise<string> {
|
||||
// Find untracked files (status "?")
|
||||
const untrackedFiles = files.filter(f => f.status === "?");
|
||||
|
||||
if (untrackedFiles.length === 0) {
|
||||
return existingDiff;
|
||||
}
|
||||
|
||||
// Generate synthetic diffs for each untracked file
|
||||
const syntheticDiffs = await Promise.all(
|
||||
untrackedFiles.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
|
||||
);
|
||||
|
||||
// Combine existing diff with synthetic diffs
|
||||
const combinedDiff = existingDiff + syntheticDiffs.join("");
|
||||
|
||||
return combinedDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a directory recursively (for non-git repositories)
|
||||
* Excludes hidden files/folders and common build artifacts
|
||||
*/
|
||||
export async function listAllFilesInDirectory(
|
||||
basePath: string,
|
||||
relativePath: string = ""
|
||||
): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const fullPath = path.join(basePath, relativePath);
|
||||
|
||||
// Directories to skip
|
||||
const skipDirs = new Set([
|
||||
"node_modules", ".git", ".automaker", "dist", "build",
|
||||
".next", ".nuxt", "__pycache__", ".cache", "coverage"
|
||||
]);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files/folders (except we want to allow some)
|
||||
if (entry.name.startsWith(".") && entry.name !== ".env") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!skipDirs.has(entry.name)) {
|
||||
const subFiles = await listAllFilesInDirectory(basePath, entryRelPath);
|
||||
files.push(...subFiles);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entryRelPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error to help diagnose file system issues
|
||||
logger.error(`Error reading directory ${fullPath}:`, error);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diffs for all files in a non-git directory
|
||||
* Treats all files as "new" files
|
||||
*/
|
||||
export async function generateDiffsForNonGitDirectory(
|
||||
basePath: string
|
||||
): Promise<{ diff: string; files: FileStatus[] }> {
|
||||
const allFiles = await listAllFilesInDirectory(basePath);
|
||||
|
||||
const files: FileStatus[] = allFiles.map(filePath => ({
|
||||
status: "?",
|
||||
path: filePath,
|
||||
statusText: "New",
|
||||
}));
|
||||
|
||||
// Generate synthetic diffs for all files
|
||||
const syntheticDiffs = await Promise.all(
|
||||
files.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
|
||||
);
|
||||
|
||||
return {
|
||||
diff: syntheticDiffs.join(""),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git repository diffs for a given path
|
||||
* Handles both git repos and non-git directories
|
||||
*/
|
||||
export async function getGitRepositoryDiffs(
|
||||
repoPath: string
|
||||
): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> {
|
||||
// Check if it's a git repository
|
||||
const isRepo = await isGitRepo(repoPath);
|
||||
|
||||
if (!isRepo) {
|
||||
// Not a git repo - list all files and treat them as new
|
||||
const result = await generateDiffsForNonGitDirectory(repoPath);
|
||||
return {
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.files.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get git diff and status
|
||||
const { stdout: diff } = await execAsync("git diff HEAD", {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
const files = parseGitStatus(status);
|
||||
|
||||
// Generate synthetic diffs for untracked (new) files
|
||||
const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files);
|
||||
|
||||
return {
|
||||
diff: combinedDiff,
|
||||
files,
|
||||
hasChanges: files.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message from error object
|
||||
*/
|
||||
|
||||
22
apps/server/src/routes/enhance-prompt/index.ts
Normal file
22
apps/server/src/routes/enhance-prompt/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Enhance prompt routes - HTTP API for AI-powered text enhancement
|
||||
*
|
||||
* Provides endpoints for enhancing user input text using Claude AI
|
||||
* with different enhancement modes (improve, expand, simplify, etc.)
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { createEnhanceHandler } from "./routes/enhance.js";
|
||||
|
||||
/**
|
||||
* Create the enhance-prompt router
|
||||
*
|
||||
* @returns Express router with enhance-prompt endpoints
|
||||
*/
|
||||
export function createEnhancePromptRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/", createEnhanceHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
195
apps/server/src/routes/enhance-prompt/routes/enhance.ts
Normal file
195
apps/server/src/routes/enhance-prompt/routes/enhance.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* POST /enhance-prompt endpoint - Enhance user input text
|
||||
*
|
||||
* Uses Claude AI to enhance text based on the specified enhancement mode.
|
||||
* Supports modes: improve, technical, simplify, acceptance
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import {
|
||||
getSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from "../../../lib/enhancement-prompts.js";
|
||||
import { resolveModelString, CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
|
||||
|
||||
const logger = createLogger("EnhancePrompt");
|
||||
|
||||
/**
|
||||
* Request body for the enhance endpoint
|
||||
*/
|
||||
interface EnhanceRequestBody {
|
||||
/** The original text to enhance */
|
||||
originalText: string;
|
||||
/** The enhancement mode to apply */
|
||||
enhancementMode: string;
|
||||
/** Optional model override */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success response from the enhance endpoint
|
||||
*/
|
||||
interface EnhanceSuccessResponse {
|
||||
success: true;
|
||||
enhancedText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from the enhance endpoint
|
||||
*/
|
||||
interface EnhanceErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from Claude SDK response messages
|
||||
*
|
||||
* @param stream - The async iterable from the query function
|
||||
* @returns The extracted text content
|
||||
*/
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
* @returns Express request handler for text enhancement
|
||||
*/
|
||||
export function createEnhanceHandler(): (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model } =
|
||||
req.body as EnhanceRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!originalText || typeof originalText !== "string") {
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "originalText is required and must be a string",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enhancementMode || typeof enhancementMode !== "string") {
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "enhancementMode is required and must be a string",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate text is not empty
|
||||
const trimmedText = originalText.trim();
|
||||
if (trimmedText.length === 0) {
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "originalText cannot be empty",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and normalize enhancement mode
|
||||
const normalizedMode = enhancementMode.toLowerCase();
|
||||
const validMode: EnhancementMode = isValidEnhancementMode(normalizedMode)
|
||||
? normalizedMode
|
||||
: "improve";
|
||||
|
||||
logger.info(
|
||||
`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`
|
||||
);
|
||||
|
||||
// Get the system prompt for this mode
|
||||
const systemPrompt = getSystemPrompt(validMode);
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
// This helps the model understand this is text transformation, not a coding task
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
|
||||
// Resolve the model - use the passed model, default to sonnet for quality
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
// Call Claude SDK with minimal configuration for text transformation
|
||||
// Key: no tools, just text completion
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: resolvedModel,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: "acceptEdits",
|
||||
},
|
||||
});
|
||||
|
||||
// Extract the enhanced text from the response
|
||||
const enhancedText = await extractTextFromStream(stream);
|
||||
|
||||
if (!enhancedText || enhancedText.trim().length === 0) {
|
||||
logger.warn("Received empty response from Claude");
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "Failed to generate enhanced text - empty response",
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Enhancement complete, output length: ${enhancedText.length} chars`
|
||||
);
|
||||
|
||||
const response: EnhanceSuccessResponse = {
|
||||
success: true,
|
||||
enhancedText: enhancedText.trim(),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
logger.error("Enhancement failed:", errorMessage);
|
||||
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { getBoardDir } from "../../../lib/automaker-paths.js";
|
||||
|
||||
export function createDeleteBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -20,10 +21,11 @@ export function createDeleteBoardBackgroundHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardDir = path.join(projectPath, ".automaker", "board");
|
||||
// Get board directory
|
||||
const boardDir = getBoardDir(projectPath);
|
||||
|
||||
try {
|
||||
// Try to remove all files in the board directory
|
||||
// Try to remove all background files in the board directory
|
||||
const files = await fs.readdir(boardDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith("background")) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* POST /mkdir endpoint - Create directory
|
||||
* Handles symlinks safely to avoid ELOOP errors
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
@@ -20,13 +21,46 @@ export function createMkdirHandler() {
|
||||
|
||||
const resolvedPath = path.resolve(dirPath);
|
||||
|
||||
// Check if path already exists using lstat (doesn't follow symlinks)
|
||||
try {
|
||||
const stats = await fs.lstat(resolvedPath);
|
||||
// Path exists - if it's a directory or symlink, consider it success
|
||||
if (stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
addAllowedPath(resolvedPath);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
// It's a file - can't create directory
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Path exists and is not a directory",
|
||||
});
|
||||
return;
|
||||
} catch (statError: any) {
|
||||
// ENOENT means path doesn't exist - we should create it
|
||||
if (statError.code !== "ENOENT") {
|
||||
// Some other error (could be ELOOP in parent path)
|
||||
throw statError;
|
||||
}
|
||||
}
|
||||
|
||||
// Path doesn't exist, create it
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
|
||||
// Add the new directory to allowed paths for tracking
|
||||
addAllowedPath(resolvedPath);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Handle ELOOP specifically
|
||||
if (error.code === "ELOOP") {
|
||||
logError(error, "Create directory failed - symlink loop detected");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Cannot create directory: symlink loop detected in path",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logError(error, "Create directory failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,23 @@ import fs from "fs/promises";
|
||||
import { validatePath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
// Optional files that are expected to not exist in new projects
|
||||
// Don't log ENOENT errors for these to reduce noise
|
||||
const OPTIONAL_FILES = ["categories.json", "app_spec.txt"];
|
||||
|
||||
function isOptionalFile(filePath: string): boolean {
|
||||
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export function createReadHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -22,7 +39,11 @@ export function createReadHandler() {
|
||||
|
||||
res.json({ success: true, content });
|
||||
} catch (error) {
|
||||
logError(error, "Read file failed");
|
||||
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
|
||||
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
|
||||
if (shouldLog) {
|
||||
logError(error, "Read file failed");
|
||||
}
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { addAllowedPath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { getBoardDir } from "../../../lib/automaker-paths.js";
|
||||
|
||||
export function createSaveBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -26,8 +27,8 @@ export function createSaveBoardBackgroundHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create .automaker/board directory if it doesn't exist
|
||||
const boardDir = path.join(projectPath, ".automaker", "board");
|
||||
// Get board directory
|
||||
const boardDir = getBoardDir(projectPath);
|
||||
await fs.mkdir(boardDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
@@ -42,12 +43,11 @@ export function createSaveBoardBackgroundHandler() {
|
||||
// Write file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Add project path to allowed paths if not already
|
||||
addAllowedPath(projectPath);
|
||||
// Add board directory to allowed paths
|
||||
addAllowedPath(boardDir);
|
||||
|
||||
// Return the relative path for storage
|
||||
const relativePath = `.automaker/board/${uniqueFilename}`;
|
||||
res.json({ success: true, path: relativePath });
|
||||
// Return the absolute path
|
||||
res.json({ success: true, path: filePath });
|
||||
} catch (error) {
|
||||
logError(error, "Save board background failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* POST /save-image endpoint - Save image to .automaker/images directory
|
||||
* POST /save-image endpoint - Save image to .automaker images directory
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
@@ -7,6 +7,7 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { addAllowedPath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { getImagesDir } from "../../../lib/automaker-paths.js";
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -26,8 +27,8 @@ export function createSaveImageHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create .automaker/images directory if it doesn't exist
|
||||
const imagesDir = path.join(projectPath, ".automaker", "images");
|
||||
// Get images directory
|
||||
const imagesDir = getImagesDir(projectPath);
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
@@ -44,9 +45,10 @@ export function createSaveImageHandler() {
|
||||
// Write file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Add project path to allowed paths if not already
|
||||
addAllowedPath(projectPath);
|
||||
// Add automaker directory to allowed paths
|
||||
addAllowedPath(imagesDir);
|
||||
|
||||
// Return the absolute path
|
||||
res.json({ success: true, path: filePath });
|
||||
} catch (error) {
|
||||
logError(error, "Save image failed");
|
||||
|
||||
@@ -7,6 +7,7 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { validatePath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { mkdirSafe } from "../../../lib/fs-utils.js";
|
||||
|
||||
export function createWriteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -23,8 +24,8 @@ export function createWriteHandler() {
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
||||
// Ensure parent directory exists (symlink-safe)
|
||||
await mkdirSafe(path.dirname(resolvedPath));
|
||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { getGitRepositoryDiffs } from "../../common.js";
|
||||
|
||||
export function createDiffsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -20,43 +17,15 @@ export function createDiffsHandler() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: diff } = await execAsync("git diff HEAD", {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const files = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const statusChar = line[0];
|
||||
const filePath = line.slice(3);
|
||||
const statusMap: Record<string, string> = {
|
||||
M: "Modified",
|
||||
A: "Added",
|
||||
D: "Deleted",
|
||||
R: "Renamed",
|
||||
C: "Copied",
|
||||
U: "Updated",
|
||||
"?": "Untracked",
|
||||
};
|
||||
return {
|
||||
status: statusChar,
|
||||
path: filePath,
|
||||
statusText: statusMap[statusChar] || "Unknown",
|
||||
};
|
||||
});
|
||||
|
||||
const result = await getGitRepositoryDiffs(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
diff,
|
||||
files,
|
||||
hasChanges: files.length > 0,
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.hasChanges,
|
||||
});
|
||||
} catch {
|
||||
} catch (innerError) {
|
||||
logError(innerError, "Git diff failed");
|
||||
res.json({ success: true, diff: "", files: [], hasChanges: false });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { generateSyntheticDiffForNewFile } from "../../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -25,16 +26,33 @@ export function createFileDiffHandler() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: diff } = await execAsync(
|
||||
`git diff HEAD -- "${filePath}"`,
|
||||
{
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}
|
||||
// First check if the file is untracked
|
||||
const { stdout: status } = await execAsync(
|
||||
`git status --porcelain -- "${filePath}"`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
const isUntracked = status.trim().startsWith("??");
|
||||
|
||||
let diff: string;
|
||||
if (isUntracked) {
|
||||
// Generate synthetic diff for untracked file
|
||||
diff = await generateSyntheticDiffForNewFile(projectPath, filePath);
|
||||
} else {
|
||||
// Use regular git diff for tracked files
|
||||
const result = await execAsync(
|
||||
`git diff HEAD -- "${filePath}"`,
|
||||
{
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
diff = result.stdout;
|
||||
}
|
||||
|
||||
res.json({ success: true, diff, filePath });
|
||||
} catch {
|
||||
} catch (innerError) {
|
||||
logError(innerError, "Git file diff failed");
|
||||
res.json({ success: true, diff: "", filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -15,9 +15,7 @@ export function createProvidersHandler() {
|
||||
const providers: Record<string, any> = {
|
||||
anthropic: {
|
||||
available: statuses.claude?.installed || false,
|
||||
hasApiKey:
|
||||
!!process.env.ANTHROPIC_API_KEY ||
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||
},
|
||||
google: {
|
||||
available: !!process.env.GOOGLE_API_KEY,
|
||||
|
||||
@@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: runningAgents.length,
|
||||
autoLoopRunning: status.autoLoopRunning,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get running agents failed");
|
||||
|
||||
@@ -17,12 +17,15 @@ export async function getClaudeStatus() {
|
||||
let cliPath = "";
|
||||
let method = "none";
|
||||
|
||||
// Try to find Claude CLI
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Try to find Claude CLI using platform-specific command
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
"which claude || where claude 2>/dev/null"
|
||||
);
|
||||
cliPath = stdout.trim();
|
||||
// Use 'where' on Windows, 'which' on Unix-like systems
|
||||
const findCommand = isWindows ? "where claude" : "which claude";
|
||||
const { stdout } = await execAsync(findCommand);
|
||||
// 'where' on Windows can return multiple paths - take the first one
|
||||
cliPath = stdout.trim().split(/\r?\n/)[0];
|
||||
installed = true;
|
||||
method = "path";
|
||||
|
||||
@@ -34,13 +37,26 @@ export async function getClaudeStatus() {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, try common locations
|
||||
const commonPaths = [
|
||||
path.join(os.homedir(), ".local", "bin", "claude"),
|
||||
path.join(os.homedir(), ".claude", "local", "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
path.join(os.homedir(), ".npm-global", "bin", "claude"),
|
||||
];
|
||||
// Not in PATH, try common locations based on platform
|
||||
const commonPaths = isWindows
|
||||
? (() => {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return [
|
||||
// Windows-specific paths
|
||||
path.join(os.homedir(), ".local", "bin", "claude.exe"),
|
||||
path.join(appData, "npm", "claude.cmd"),
|
||||
path.join(appData, "npm", "claude"),
|
||||
path.join(appData, ".npm-global", "bin", "claude.cmd"),
|
||||
path.join(appData, ".npm-global", "bin", "claude"),
|
||||
];
|
||||
})()
|
||||
: [
|
||||
// Unix (Linux/macOS) paths
|
||||
path.join(os.homedir(), ".local", "bin", "claude"),
|
||||
path.join(os.homedir(), ".claude", "local", "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
path.join(os.homedir(), ".npm-global", "bin", "claude"),
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
@@ -74,7 +90,6 @@ export async function getClaudeStatus() {
|
||||
hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"),
|
||||
hasStoredApiKey: !!getApiKey("anthropic"),
|
||||
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||
hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
// Additional fields for detailed status
|
||||
oauthTokenValid: false,
|
||||
apiKeyValid: false,
|
||||
@@ -125,34 +140,38 @@ export async function getClaudeStatus() {
|
||||
// Settings file doesn't exist
|
||||
}
|
||||
|
||||
// Check for credentials file (OAuth tokens from claude login) - legacy/alternative auth
|
||||
const credentialsPath = path.join(claudeDir, "credentials.json");
|
||||
try {
|
||||
const credentialsContent = await fs.readFile(credentialsPath, "utf-8");
|
||||
const credentials = JSON.parse(credentialsContent);
|
||||
auth.hasCredentialsFile = true;
|
||||
// Check for credentials file (OAuth tokens from claude login)
|
||||
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
|
||||
const credentialsPaths = [
|
||||
path.join(claudeDir, ".credentials.json"),
|
||||
path.join(claudeDir, "credentials.json"),
|
||||
];
|
||||
|
||||
// Check what type of token is in credentials
|
||||
if (credentials.oauth_token || credentials.access_token) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "oauth_token"; // Stored OAuth token from credentials file
|
||||
} else if (credentials.api_key) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "api_key"; // Stored API key in credentials file
|
||||
for (const credentialsPath of credentialsPaths) {
|
||||
try {
|
||||
const credentialsContent = await fs.readFile(credentialsPath, "utf-8");
|
||||
const credentials = JSON.parse(credentialsContent);
|
||||
auth.hasCredentialsFile = true;
|
||||
|
||||
// Check what type of token is in credentials
|
||||
if (credentials.oauth_token || credentials.access_token) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "oauth_token"; // Stored OAuth token from credentials file
|
||||
} else if (credentials.api_key) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "api_key"; // Stored API key in credentials file
|
||||
}
|
||||
break; // Found and processed credentials file
|
||||
} catch {
|
||||
// No credentials file at this path or invalid format
|
||||
}
|
||||
} catch {
|
||||
// No credentials file or invalid format
|
||||
}
|
||||
|
||||
// Environment variables override stored credentials (higher priority)
|
||||
if (auth.hasEnvOAuthToken) {
|
||||
auth.authenticated = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.method = "oauth_token_env"; // OAuth token from CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
} else if (auth.hasEnvApiKey) {
|
||||
if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var
|
||||
|
||||
@@ -7,8 +7,11 @@ import { createClaudeStatusHandler } from "./routes/claude-status.js";
|
||||
import { createInstallClaudeHandler } from "./routes/install-claude.js";
|
||||
import { createAuthClaudeHandler } from "./routes/auth-claude.js";
|
||||
import { createStoreApiKeyHandler } from "./routes/store-api-key.js";
|
||||
import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js";
|
||||
import { createApiKeysHandler } from "./routes/api-keys.js";
|
||||
import { createPlatformHandler } from "./routes/platform.js";
|
||||
import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js";
|
||||
import { createGhStatusHandler } from "./routes/gh-status.js";
|
||||
|
||||
export function createSetupRoutes(): Router {
|
||||
const router = Router();
|
||||
@@ -17,8 +20,11 @@ export function createSetupRoutes(): Router {
|
||||
router.post("/install-claude", createInstallClaudeHandler());
|
||||
router.post("/auth-claude", createAuthClaudeHandler());
|
||||
router.post("/store-api-key", createStoreApiKeyHandler());
|
||||
router.post("/delete-api-key", createDeleteApiKeyHandler());
|
||||
router.get("/api-keys", createApiKeysHandler());
|
||||
router.get("/platform", createPlatformHandler());
|
||||
router.post("/verify-claude-auth", createVerifyClaudeAuthHandler());
|
||||
router.get("/gh-status", createGhStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
106
apps/server/src/routes/setup/routes/delete-api-key.ts
Normal file
106
apps/server/src/routes/setup/routes/delete-api-key.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* POST /delete-api-key endpoint - Delete a stored API key
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const logger = createLogger("Setup");
|
||||
|
||||
// In-memory storage reference (imported from common.ts pattern)
|
||||
// We need to modify common.ts to export a deleteApiKey function
|
||||
import { setApiKey } from "../common.js";
|
||||
|
||||
/**
|
||||
* Remove an API key from the .env file
|
||||
*/
|
||||
async function removeApiKeyFromEnv(key: string): Promise<void> {
|
||||
const envPath = path.join(process.cwd(), ".env");
|
||||
|
||||
try {
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, "utf-8");
|
||||
} catch {
|
||||
// .env file doesn't exist, nothing to delete
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse existing env content and remove the key
|
||||
const lines = envContent.split("\n");
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
const newLines = lines.filter((line) => !keyRegex.test(line));
|
||||
|
||||
// Remove empty lines at the end
|
||||
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === "") {
|
||||
newLines.pop();
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join("\n") + (newLines.length > 0 ? "\n" : ""));
|
||||
logger.info(`[Setup] Removed ${key} from .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeleteApiKeyHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { provider } = req.body as { provider: string };
|
||||
|
||||
if (!provider) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Provider is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[Setup] Deleting API key for provider: ${provider}`);
|
||||
|
||||
// Map provider to env key name
|
||||
const envKeyMap: Record<string, string> = {
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
};
|
||||
|
||||
const envKey = envKeyMap[provider];
|
||||
if (!envKey) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unknown provider: ${provider}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear from in-memory storage
|
||||
setApiKey(provider, "");
|
||||
|
||||
// Remove from environment
|
||||
delete process.env[envKey];
|
||||
|
||||
// Remove from .env file
|
||||
await removeApiKeyFromEnv(envKey);
|
||||
|
||||
logger.info(`[Setup] Successfully deleted API key for ${provider}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `API key for ${provider} has been deleted`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[Setup] Delete API key error:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to delete API key",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
131
apps/server/src/routes/setup/routes/gh-status.ts
Normal file
131
apps/server/src/routes/setup/routes/gh-status.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* GET /gh-status endpoint - Get GitHub CLI status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
].filter(Boolean).join(":");
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
export interface GhStatus {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
version: string | null;
|
||||
path: string | null;
|
||||
user: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function getGhStatus(): Promise<GhStatus> {
|
||||
const status: GhStatus = {
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
version: null,
|
||||
path: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Check if gh CLI is installed
|
||||
try {
|
||||
const findCommand = isWindows ? "where gh" : "command -v gh";
|
||||
const { stdout } = await execAsync(findCommand, { env: execEnv });
|
||||
status.path = stdout.trim().split(/\r?\n/)[0];
|
||||
status.installed = true;
|
||||
} catch {
|
||||
// gh not in PATH, try common locations
|
||||
const commonPaths = isWindows
|
||||
? [
|
||||
path.join(process.env.LOCALAPPDATA || "", "Programs", "gh", "bin", "gh.exe"),
|
||||
path.join(process.env.ProgramFiles || "", "GitHub CLI", "gh.exe"),
|
||||
]
|
||||
: [
|
||||
"/opt/homebrew/bin/gh",
|
||||
"/usr/local/bin/gh",
|
||||
path.join(os.homedir(), ".local", "bin", "gh"),
|
||||
"/home/linuxbrew/.linuxbrew/bin/gh",
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!status.installed) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Get version
|
||||
try {
|
||||
const { stdout } = await execAsync("gh --version", { env: execEnv });
|
||||
// Extract version from output like "gh version 2.40.1 (2024-01-09)"
|
||||
const versionMatch = stdout.match(/gh version ([\d.]+)/);
|
||||
status.version = versionMatch ? versionMatch[1] : stdout.trim().split("\n")[0];
|
||||
} catch {
|
||||
// Version command failed
|
||||
}
|
||||
|
||||
// Check authentication status
|
||||
try {
|
||||
const { stdout } = await execAsync("gh auth status", { env: execEnv });
|
||||
// If this succeeds without error, we're authenticated
|
||||
status.authenticated = true;
|
||||
|
||||
// Try to extract username from output
|
||||
const userMatch = stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
|
||||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
|
||||
if (userMatch) {
|
||||
status.user = userMatch[1];
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Auth status returns non-zero if not authenticated
|
||||
const err = error as { stderr?: string };
|
||||
if (err.stderr?.includes("not logged in")) {
|
||||
status.authenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function createGhStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const status = await getGhStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get GitHub CLI status failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -31,16 +31,8 @@ export function createStoreApiKeyHandler() {
|
||||
setApiKey(provider, apiKey);
|
||||
|
||||
// Also set as environment variable and persist to .env
|
||||
// IMPORTANT: OAuth tokens and API keys must be stored separately
|
||||
// - OAuth tokens (subscription auth) -> CLAUDE_CODE_OAUTH_TOKEN
|
||||
// - API keys (pay-per-use) -> ANTHROPIC_API_KEY
|
||||
if (provider === "anthropic_oauth_token") {
|
||||
// OAuth token from claude setup-token (subscription-based auth)
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = apiKey;
|
||||
await persistApiKeyToEnv("CLAUDE_CODE_OAUTH_TOKEN", apiKey);
|
||||
logger.info("[Setup] Stored OAuth token as CLAUDE_CODE_OAUTH_TOKEN");
|
||||
} else if (provider === "anthropic") {
|
||||
// Direct API key (pay-per-use)
|
||||
if (provider === "anthropic" || provider === "anthropic_oauth_token") {
|
||||
// Both API key and OAuth token use ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
||||
logger.info("[Setup] Stored API key as ANTHROPIC_API_KEY");
|
||||
|
||||
330
apps/server/src/routes/setup/routes/verify-claude-auth.ts
Normal file
330
apps/server/src/routes/setup/routes/verify-claude-auth.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* POST /verify-claude-auth endpoint - Verify Claude authentication by running a test query
|
||||
* Supports verifying either CLI auth or API key auth independently
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { getApiKey } from "../common.js";
|
||||
|
||||
const logger = createLogger("Setup");
|
||||
|
||||
// Known error patterns that indicate auth failure
|
||||
const AUTH_ERROR_PATTERNS = [
|
||||
"OAuth token revoked",
|
||||
"Please run /login",
|
||||
"please run /login",
|
||||
"token revoked",
|
||||
"invalid_api_key",
|
||||
"authentication_error",
|
||||
"unauthorized",
|
||||
"not authenticated",
|
||||
"authentication failed",
|
||||
"invalid api key",
|
||||
"api key is invalid",
|
||||
];
|
||||
|
||||
// Patterns that indicate billing/credit issues - should FAIL verification
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
"credit balance is too low",
|
||||
"credit balance too low",
|
||||
"insufficient credits",
|
||||
"insufficient balance",
|
||||
"no credits",
|
||||
"out of credits",
|
||||
"billing",
|
||||
"payment required",
|
||||
"add credits",
|
||||
];
|
||||
|
||||
// Patterns that indicate rate/usage limits - should FAIL verification
|
||||
// Users need to wait or upgrade their plan
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"rate_limit",
|
||||
"resets", // Only valid if it's a temporary reset, not a billing issue
|
||||
"/upgrade",
|
||||
"extra-usage",
|
||||
];
|
||||
|
||||
function isRateLimitError(text: string): boolean {
|
||||
const lowerText = text.toLowerCase();
|
||||
// First check if it's a billing error - billing errors are NOT rate limits
|
||||
if (isBillingError(text)) {
|
||||
return false;
|
||||
}
|
||||
return RATE_LIMIT_PATTERNS.some((pattern) =>
|
||||
lowerText.includes(pattern.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function isBillingError(text: string): boolean {
|
||||
const lowerText = text.toLowerCase();
|
||||
return BILLING_ERROR_PATTERNS.some((pattern) =>
|
||||
lowerText.includes(pattern.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function containsAuthError(text: string): boolean {
|
||||
const lowerText = text.toLowerCase();
|
||||
return AUTH_ERROR_PATTERNS.some((pattern) =>
|
||||
lowerText.includes(pattern.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function createVerifyClaudeAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get the auth method from the request body
|
||||
const { authMethod } = req.body as { authMethod?: "cli" | "api_key" };
|
||||
|
||||
logger.info(
|
||||
`[Setup] Verifying Claude authentication using method: ${
|
||||
authMethod || "auto"
|
||||
}`
|
||||
);
|
||||
|
||||
// Create an AbortController with a 30-second timeout
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 30000);
|
||||
|
||||
let authenticated = false;
|
||||
let errorMessage = "";
|
||||
let receivedAnyContent = false;
|
||||
|
||||
// Save original env values
|
||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
try {
|
||||
// Configure environment based on auth method
|
||||
if (authMethod === "cli") {
|
||||
// For CLI verification, remove any API key so it uses CLI credentials only
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
logger.info(
|
||||
"[Setup] Cleared API key environment for CLI verification"
|
||||
);
|
||||
} else if (authMethod === "api_key") {
|
||||
// For API key verification, ensure we're using the stored API key
|
||||
const storedApiKey = getApiKey("anthropic");
|
||||
if (storedApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||
logger.info("[Setup] Using stored API key for verification");
|
||||
} else {
|
||||
// Check env var
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: "No API key configured. Please enter an API key first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run a minimal query to verify authentication
|
||||
const stream = query({
|
||||
prompt: "Reply with only the word 'ok'",
|
||||
options: {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
abortController,
|
||||
},
|
||||
});
|
||||
|
||||
// Collect all messages and check for errors
|
||||
const allMessages: string[] = [];
|
||||
|
||||
for await (const msg of stream) {
|
||||
const msgStr = JSON.stringify(msg);
|
||||
allMessages.push(msgStr);
|
||||
logger.info("[Setup] Stream message:", msgStr.substring(0, 500));
|
||||
|
||||
// Check for billing errors FIRST - these should fail verification
|
||||
if (isBillingError(msgStr)) {
|
||||
logger.error("[Setup] Found billing error in message");
|
||||
errorMessage =
|
||||
"Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com";
|
||||
authenticated = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if any part of the message contains auth errors
|
||||
if (containsAuthError(msgStr)) {
|
||||
logger.error("[Setup] Found auth error in message");
|
||||
if (authMethod === "cli") {
|
||||
errorMessage =
|
||||
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
|
||||
} else {
|
||||
errorMessage = "API key is invalid or has been revoked.";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check specifically for assistant messages with text content
|
||||
if (msg.type === "assistant" && (msg as any).message?.content) {
|
||||
const content = (msg as any).message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
const text = block.text;
|
||||
logger.info("[Setup] Assistant text:", text);
|
||||
|
||||
if (containsAuthError(text)) {
|
||||
if (authMethod === "cli") {
|
||||
errorMessage =
|
||||
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
|
||||
} else {
|
||||
errorMessage = "API key is invalid or has been revoked.";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Valid text response that's not an error
|
||||
if (text.toLowerCase().includes("ok") || text.length > 0) {
|
||||
receivedAnyContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for result messages
|
||||
if (msg.type === "result") {
|
||||
const resultStr = JSON.stringify(msg);
|
||||
|
||||
// First check for billing errors - these should FAIL verification
|
||||
if (isBillingError(resultStr)) {
|
||||
logger.error(
|
||||
"[Setup] Billing error detected - insufficient credits"
|
||||
);
|
||||
errorMessage =
|
||||
"Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com";
|
||||
authenticated = false;
|
||||
break;
|
||||
}
|
||||
// Check if it's a rate limit error - should FAIL verification
|
||||
else if (isRateLimitError(resultStr)) {
|
||||
logger.warn(
|
||||
"[Setup] Rate limit detected - treating as unverified"
|
||||
);
|
||||
errorMessage =
|
||||
"Rate limit reached. Please wait a while before trying again or upgrade your plan.";
|
||||
authenticated = false;
|
||||
break;
|
||||
} else if (containsAuthError(resultStr)) {
|
||||
if (authMethod === "cli") {
|
||||
errorMessage =
|
||||
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
|
||||
} else {
|
||||
errorMessage = "API key is invalid or has been revoked.";
|
||||
}
|
||||
} else {
|
||||
// Got a result without errors
|
||||
receivedAnyContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine authentication status
|
||||
if (errorMessage) {
|
||||
authenticated = false;
|
||||
} else if (receivedAnyContent) {
|
||||
authenticated = true;
|
||||
} else {
|
||||
// No content received - might be an issue
|
||||
logger.warn("[Setup] No content received from stream");
|
||||
logger.warn("[Setup] All messages:", allMessages.join("\n"));
|
||||
errorMessage =
|
||||
"No response received from Claude. Please check your authentication.";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
logger.error("[Setup] Claude auth verification exception:", errMessage);
|
||||
|
||||
// Check for billing errors FIRST - these always fail
|
||||
if (isBillingError(errMessage)) {
|
||||
authenticated = false;
|
||||
errorMessage =
|
||||
"Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com";
|
||||
}
|
||||
// Check for rate limit in exception - should FAIL verification
|
||||
else if (isRateLimitError(errMessage)) {
|
||||
authenticated = false;
|
||||
errorMessage =
|
||||
"Rate limit reached. Please wait a while before trying again or upgrade your plan.";
|
||||
logger.warn(
|
||||
"[Setup] Rate limit in exception - treating as unverified"
|
||||
);
|
||||
}
|
||||
// If we already determined auth was successful, keep it
|
||||
else if (authenticated) {
|
||||
logger.info("[Setup] Auth already confirmed, ignoring exception");
|
||||
}
|
||||
// Check for auth-related errors in exception
|
||||
else if (containsAuthError(errMessage)) {
|
||||
if (authMethod === "cli") {
|
||||
errorMessage =
|
||||
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
|
||||
} else {
|
||||
errorMessage = "API key is invalid or has been revoked.";
|
||||
}
|
||||
} else if (
|
||||
errMessage.includes("abort") ||
|
||||
errMessage.includes("timeout")
|
||||
) {
|
||||
errorMessage = "Verification timed out. Please try again.";
|
||||
} else if (
|
||||
errMessage.includes("exit") &&
|
||||
errMessage.includes("code 1")
|
||||
) {
|
||||
// Process exited with code 1 but we might have gotten rate limit info in the stream
|
||||
// Check if we received any content that indicated auth worked
|
||||
if (receivedAnyContent && !errorMessage) {
|
||||
authenticated = true;
|
||||
logger.info(
|
||||
"[Setup] Process exit 1 but content received - auth valid"
|
||||
);
|
||||
} else if (!errorMessage) {
|
||||
errorMessage = errMessage;
|
||||
}
|
||||
} else if (!errorMessage) {
|
||||
errorMessage = errMessage;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
// Restore original environment
|
||||
if (originalAnthropicKey !== undefined) {
|
||||
process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
|
||||
} else if (authMethod === "cli") {
|
||||
// If we cleared it and there was no original, keep it cleared
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[Setup] Verification result:", {
|
||||
authenticated,
|
||||
errorMessage,
|
||||
authMethod,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
error: errorMessage || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[Setup] Verify Claude auth endpoint error:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: error instanceof Error ? error.message : "Verification failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,39 @@ import { createSuggestionsOptions } from "../../lib/sdk-options.js";
|
||||
|
||||
const logger = createLogger("Suggestions");
|
||||
|
||||
/**
|
||||
* JSON Schema for suggestions output
|
||||
*/
|
||||
const suggestionsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
category: { type: "string" },
|
||||
description: { type: "string" },
|
||||
steps: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
priority: {
|
||||
type: "number",
|
||||
minimum: 1,
|
||||
maximum: 3,
|
||||
},
|
||||
reasoning: { type: "string" },
|
||||
},
|
||||
required: ["category", "description", "steps", "priority", "reasoning"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["suggestions"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateSuggestions(
|
||||
projectPath: string,
|
||||
suggestionType: string,
|
||||
@@ -36,19 +69,7 @@ For each suggestion, provide:
|
||||
4. Priority (1=high, 2=medium, 3=low)
|
||||
5. Brief reasoning for why this would help
|
||||
|
||||
Format your response as JSON:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "suggestion-123",
|
||||
"category": "Category",
|
||||
"description": "What to implement",
|
||||
"steps": ["Step 1", "Step 2"],
|
||||
"priority": 1,
|
||||
"reasoning": "Why this helps"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
The response will be automatically formatted as structured JSON.`;
|
||||
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_progress",
|
||||
@@ -58,16 +79,21 @@ Format your response as JSON:
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
outputFormat: {
|
||||
type: "json_schema",
|
||||
schema: suggestionsSchema,
|
||||
},
|
||||
});
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let responseText = "";
|
||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
responseText += block.text;
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_progress",
|
||||
content: block.text,
|
||||
@@ -81,18 +107,34 @@ Format your response as JSON:
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
// Check for structured output
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug("Received structured output:", structuredOutput);
|
||||
}
|
||||
} else if (msg.type === "result") {
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.subtype === "error_max_structured_output_retries") {
|
||||
logger.error("Failed to produce valid structured output after retries");
|
||||
throw new Error("Could not produce valid suggestions output");
|
||||
} else if (resultMsg.subtype === "error_max_turns") {
|
||||
logger.error("Hit max turns limit before completing suggestions generation");
|
||||
logger.warn(`Response text length: ${responseText.length} chars`);
|
||||
// Still try to parse what we have
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse suggestions from response
|
||||
// Use structured output if available, otherwise fall back to parsing text
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (structuredOutput && structuredOutput.suggestions) {
|
||||
// Use structured output directly
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_complete",
|
||||
suggestions: parsed.suggestions.map(
|
||||
suggestions: structuredOutput.suggestions.map(
|
||||
(s: Record<string, unknown>, i: number) => ({
|
||||
...s,
|
||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
||||
@@ -100,7 +142,23 @@ Format your response as JSON:
|
||||
),
|
||||
});
|
||||
} else {
|
||||
throw new Error("No valid JSON found in response");
|
||||
// Fallback: try to parse from text (for backwards compatibility)
|
||||
logger.warn("No structured output received, attempting to parse from text");
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_complete",
|
||||
suggestions: parsed.suggestions.map(
|
||||
(s: Record<string, unknown>, i: number) => ({
|
||||
...s,
|
||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
||||
})
|
||||
),
|
||||
});
|
||||
} else {
|
||||
throw new Error("No valid JSON found in response");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the parsing error for debugging
|
||||
|
||||
@@ -5,13 +5,29 @@
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
import { FeatureLoader } from "../../services/feature-loader.js";
|
||||
|
||||
const logger = createLogger("Worktree");
|
||||
const execAsync = promisify(exec);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
|
||||
"chore: automaker initial commit";
|
||||
|
||||
/**
|
||||
* Normalize path separators to forward slashes for cross-platform consistency.
|
||||
* This ensures paths from `path.join()` (backslashes on Windows) match paths
|
||||
* from git commands (which may use forward slashes).
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a git repo
|
||||
@@ -25,6 +41,69 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||
* These are expected in test environments with mock paths
|
||||
*/
|
||||
export function isENOENT(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a mock/test path that doesn't exist
|
||||
*/
|
||||
export function isMockPath(worktreePath: string): boolean {
|
||||
return worktreePath.startsWith("/mock/") || worktreePath.includes("/mock/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally log worktree errors - suppress ENOENT for mock paths
|
||||
* to reduce noise in test output
|
||||
*/
|
||||
export function logWorktreeError(
|
||||
error: unknown,
|
||||
message: string,
|
||||
worktreePath?: string
|
||||
): void {
|
||||
// Don't log ENOENT errors for mock paths (expected in tests)
|
||||
if (isENOENT(error) && worktreePath && isMockPath(worktreePath)) {
|
||||
return;
|
||||
}
|
||||
logError(error, message);
|
||||
}
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
/**
|
||||
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
|
||||
* Returns true if an empty commit was created, false if the repo already had commits.
|
||||
*/
|
||||
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync("git rev-parse --verify HEAD", { cwd: repoPath });
|
||||
return false;
|
||||
} catch {
|
||||
try {
|
||||
await execAsync(
|
||||
`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`,
|
||||
{ cwd: repoPath }
|
||||
);
|
||||
logger.info(
|
||||
`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const reason = getErrorMessageShared(error);
|
||||
throw new Error(
|
||||
`Failed to create initial git commit. Please commit manually and retry. ${reason}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,25 @@ import { createStatusHandler } from "./routes/status.js";
|
||||
import { createListHandler } from "./routes/list.js";
|
||||
import { createDiffsHandler } from "./routes/diffs.js";
|
||||
import { createFileDiffHandler } from "./routes/file-diff.js";
|
||||
import { createRevertHandler } from "./routes/revert.js";
|
||||
import { createMergeHandler } from "./routes/merge.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createCreatePRHandler } from "./routes/create-pr.js";
|
||||
import { createCommitHandler } from "./routes/commit.js";
|
||||
import { createPushHandler } from "./routes/push.js";
|
||||
import { createPullHandler } from "./routes/pull.js";
|
||||
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
|
||||
import { createListBranchesHandler } from "./routes/list-branches.js";
|
||||
import { createSwitchBranchHandler } from "./routes/switch-branch.js";
|
||||
import {
|
||||
createOpenInEditorHandler,
|
||||
createGetDefaultEditorHandler,
|
||||
} from "./routes/open-in-editor.js";
|
||||
import { createInitGitHandler } from "./routes/init-git.js";
|
||||
import { createMigrateHandler } from "./routes/migrate.js";
|
||||
import { createStartDevHandler } from "./routes/start-dev.js";
|
||||
import { createStopDevHandler } from "./routes/stop-dev.js";
|
||||
import { createListDevServersHandler } from "./routes/list-dev-servers.js";
|
||||
|
||||
export function createWorktreeRoutes(): Router {
|
||||
const router = Router();
|
||||
@@ -19,8 +36,23 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post("/list", createListHandler());
|
||||
router.post("/diffs", createDiffsHandler());
|
||||
router.post("/file-diff", createFileDiffHandler());
|
||||
router.post("/revert", createRevertHandler());
|
||||
router.post("/merge", createMergeHandler());
|
||||
router.post("/create", createCreateHandler());
|
||||
router.post("/delete", createDeleteHandler());
|
||||
router.post("/create-pr", createCreatePRHandler());
|
||||
router.post("/commit", createCommitHandler());
|
||||
router.post("/push", createPushHandler());
|
||||
router.post("/pull", createPullHandler());
|
||||
router.post("/checkout-branch", createCheckoutBranchHandler());
|
||||
router.post("/list-branches", createListBranchesHandler());
|
||||
router.post("/switch-branch", createSwitchBranchHandler());
|
||||
router.post("/open-in-editor", createOpenInEditorHandler());
|
||||
router.get("/default-editor", createGetDefaultEditorHandler());
|
||||
router.post("/init-git", createInitGitHandler());
|
||||
router.post("/migrate", createMigrateHandler());
|
||||
router.post("/start-dev", createStartDevHandler());
|
||||
router.post("/stop-dev", createStopDevHandler());
|
||||
router.post("/list-dev-servers", createListDevServersHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
123
apps/server/src/routes/worktree/routes/branch-tracking.ts
Normal file
123
apps/server/src/routes/worktree/routes/branch-tracking.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Branch tracking utilities
|
||||
*
|
||||
* Tracks active branches in .automaker so users
|
||||
* can switch between branches even after worktrees are removed.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import {
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir,
|
||||
} from "../../../lib/automaker-paths.js";
|
||||
|
||||
export interface TrackedBranch {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastActivatedAt?: string;
|
||||
}
|
||||
|
||||
interface BranchTrackingData {
|
||||
branches: TrackedBranch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tracked branches from file
|
||||
*/
|
||||
export async function getTrackedBranches(
|
||||
projectPath: string
|
||||
): Promise<TrackedBranch[]> {
|
||||
try {
|
||||
const filePath = getBranchTrackingPath(projectPath);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const data: BranchTrackingData = JSON.parse(content);
|
||||
return data.branches || [];
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
console.warn("[branch-tracking] Failed to read tracked branches:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tracked branches to file
|
||||
*/
|
||||
async function saveTrackedBranches(
|
||||
projectPath: string,
|
||||
branches: TrackedBranch[]
|
||||
): Promise<void> {
|
||||
const automakerDir = await ensureAutomakerDir(projectPath);
|
||||
const filePath = path.join(automakerDir, "active-branches.json");
|
||||
const data: BranchTrackingData = { branches };
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a branch to tracking
|
||||
*/
|
||||
export async function trackBranch(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
|
||||
// Check if already tracked
|
||||
const existing = branches.find((b) => b.name === branchName);
|
||||
if (existing) {
|
||||
return; // Already tracked
|
||||
}
|
||||
|
||||
branches.push({
|
||||
name: branchName,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await saveTrackedBranches(projectPath, branches);
|
||||
console.log(`[branch-tracking] Now tracking branch: ${branchName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a branch from tracking
|
||||
*/
|
||||
export async function untrackBranch(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
const filtered = branches.filter((b) => b.name !== branchName);
|
||||
|
||||
if (filtered.length !== branches.length) {
|
||||
await saveTrackedBranches(projectPath, filtered);
|
||||
console.log(`[branch-tracking] Stopped tracking branch: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activated timestamp for a branch
|
||||
*/
|
||||
export async function updateBranchActivation(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
const branch = branches.find((b) => b.name === branchName);
|
||||
|
||||
if (branch) {
|
||||
branch.lastActivatedAt = new Date().toISOString();
|
||||
await saveTrackedBranches(projectPath, branches);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch is tracked
|
||||
*/
|
||||
export async function isBranchTracked(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
return branches.some((b) => b.name === branchName);
|
||||
}
|
||||
86
apps/server/src/routes/worktree/routes/checkout-branch.ts
Normal file
86
apps/server/src/routes/worktree/routes/checkout-branch.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createCheckoutBranchHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, branchName } = req.body as {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "worktreePath required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branchName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "branchName required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name (basic validation)
|
||||
const invalidChars = /[\s~^:?*\[\\]/;
|
||||
if (invalidChars.test(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Branch name contains invalid characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch for reference
|
||||
const { stdout: currentBranchOutput } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
const currentBranch = currentBranchOutput.trim();
|
||||
|
||||
// Check if branch already exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Branch exists
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch '${branchName}' already exists`,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Branch doesn't exist, good to create
|
||||
}
|
||||
|
||||
// Create and checkout the new branch
|
||||
await execAsync(`git checkout -b ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch: currentBranch,
|
||||
newBranch: branchName,
|
||||
message: `Created and checked out branch '${branchName}'`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Checkout branch failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/worktree/routes/commit.ts
Normal file
79
apps/server/src/routes/worktree/routes/commit.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* POST /commit endpoint - Commit changes in a worktree
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createCommitHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, message } = req.body as {
|
||||
worktreePath: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || !message) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "worktreePath and message required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
if (!status.trim()) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
committed: false,
|
||||
message: "No changes to commit",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await execAsync("git add -A", { cwd: worktreePath });
|
||||
|
||||
// Create commit
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
// Get commit hash
|
||||
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const commitHash = hashOutput.trim().substring(0, 8);
|
||||
|
||||
// Get branch name
|
||||
const { stdout: branchOutput } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
committed: true,
|
||||
commitHash,
|
||||
branch: branchName,
|
||||
message,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Commit worktree failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
288
apps/server/src/routes/worktree/routes/create-pr.ts
Normal file
288
apps/server/src/routes/worktree/routes/create-pr.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* POST /create-pr endpoint - Commit changes and create a pull request from a worktree
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
// This is needed because Electron apps don't inherit the user's shell PATH
|
||||
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env["ProgramFiles(x86)"]) {
|
||||
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
||||
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
...additionalPaths.filter(Boolean),
|
||||
].filter(Boolean).join(pathSeparator);
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
export function createCreatePRHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
||||
worktreePath: string;
|
||||
commitMessage?: string;
|
||||
prTitle?: string;
|
||||
prBody?: string;
|
||||
baseBranch?: string;
|
||||
draft?: boolean;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "worktreePath required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
const hasChanges = status.trim().length > 0;
|
||||
|
||||
// If there are changes, commit them
|
||||
let commitHash: string | null = null;
|
||||
if (hasChanges) {
|
||||
const message = commitMessage || `Changes from ${branchName}`;
|
||||
|
||||
// Stage all changes
|
||||
await execAsync("git add -A", { cwd: worktreePath, env: execEnv });
|
||||
|
||||
// Create commit
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Get commit hash
|
||||
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
commitHash = hashOutput.trim().substring(0, 8);
|
||||
}
|
||||
|
||||
// Push the branch to remote
|
||||
let pushError: string | null = null;
|
||||
try {
|
||||
await execAsync(`git push -u origin ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// If push fails, try with --set-upstream
|
||||
try {
|
||||
await execAsync(`git push --set-upstream origin ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch (error2: unknown) {
|
||||
// Capture push error for reporting
|
||||
const err = error2 as { stderr?: string; message?: string };
|
||||
pushError = err.stderr || err.message || "Push failed";
|
||||
console.error("[CreatePR] Push failed:", pushError);
|
||||
}
|
||||
}
|
||||
|
||||
// If push failed, return error
|
||||
if (pushError) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Failed to push branch: ${pushError}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create PR using gh CLI or provide browser fallback
|
||||
const base = baseBranch || "main";
|
||||
const title = prTitle || branchName;
|
||||
const body = prBody || `Changes from branch ${branchName}`;
|
||||
const draftFlag = draft ? "--draft" : "";
|
||||
|
||||
let prUrl: string | null = null;
|
||||
let prError: string | null = null;
|
||||
let browserUrl: string | null = null;
|
||||
let ghCliAvailable = false;
|
||||
|
||||
// Check if gh CLI is available (cross-platform)
|
||||
try {
|
||||
const checkCommand = process.platform === "win32"
|
||||
? "where gh"
|
||||
: "command -v gh";
|
||||
await execAsync(checkCommand, { env: execEnv });
|
||||
ghCliAvailable = true;
|
||||
} catch {
|
||||
ghCliAvailable = false;
|
||||
}
|
||||
|
||||
// Get repository URL for browser fallback
|
||||
let repoUrl: string | null = null;
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
try {
|
||||
const { stdout: remotes } = await execAsync("git remote -v", {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Parse remotes to detect fork workflow and get repo URL
|
||||
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
|
||||
for (const line of lines) {
|
||||
// Try multiple patterns to match different remote URL formats
|
||||
// Pattern 1: git@github.com:owner/repo.git (fetch)
|
||||
// Pattern 2: https://github.com/owner/repo.git (fetch)
|
||||
// Pattern 3: https://github.com/owner/repo (fetch)
|
||||
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
|
||||
if (!match) {
|
||||
// Try SSH format: git@github.com:owner/repo.git
|
||||
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
||||
}
|
||||
if (!match) {
|
||||
// Try HTTPS format: https://github.com/owner/repo.git
|
||||
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
if (remoteName === "upstream") {
|
||||
upstreamRepo = `${owner}/${repo}`;
|
||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||
} else if (remoteName === "origin") {
|
||||
originOwner = owner;
|
||||
if (!repoUrl) {
|
||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Couldn't parse remotes - will try fallback
|
||||
}
|
||||
|
||||
// Fallback: Try to get repo URL from git config if remote parsing failed
|
||||
if (!repoUrl) {
|
||||
try {
|
||||
const { stdout: originUrl } = await execAsync("git config --get remote.origin.url", {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
const url = originUrl.trim();
|
||||
|
||||
// Parse URL to extract owner/repo
|
||||
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
||||
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const [, owner, repo] = match;
|
||||
originOwner = owner;
|
||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||
}
|
||||
} catch (error) {
|
||||
// Failed to get repo URL from config
|
||||
}
|
||||
}
|
||||
|
||||
// Construct browser URL for PR creation
|
||||
if (repoUrl) {
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
const encodedBody = encodeURIComponent(body);
|
||||
|
||||
if (upstreamRepo && originOwner) {
|
||||
// Fork workflow: PR to upstream from origin
|
||||
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
||||
} else {
|
||||
// Regular repo
|
||||
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ghCliAvailable) {
|
||||
try {
|
||||
// Build gh pr create command
|
||||
let prCmd = `gh pr create --base "${base}"`;
|
||||
|
||||
// If this is a fork (has upstream remote), specify the repo and head
|
||||
if (upstreamRepo && originOwner) {
|
||||
// For forks: --repo specifies where to create PR, --head specifies source
|
||||
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
|
||||
} else {
|
||||
// Not a fork, just specify the head branch
|
||||
prCmd += ` --head "${branchName}"`;
|
||||
}
|
||||
|
||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
||||
prCmd = prCmd.trim();
|
||||
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
prUrl = prOutput.trim();
|
||||
} catch (ghError: unknown) {
|
||||
// gh CLI failed
|
||||
const err = ghError as { stderr?: string; message?: string };
|
||||
prError = err.stderr || err.message || "PR creation failed";
|
||||
}
|
||||
} else {
|
||||
prError = "gh_cli_not_available";
|
||||
}
|
||||
|
||||
// Return result with browser fallback URL
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName,
|
||||
committed: hasChanges,
|
||||
commitHash,
|
||||
pushed: true,
|
||||
prUrl,
|
||||
prCreated: !!prUrl,
|
||||
prError: prError || undefined,
|
||||
browserUrl: browserUrl || undefined,
|
||||
ghCliAvailable,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Create PR failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
181
apps/server/src/routes/worktree/routes/create.ts
Normal file
181
apps/server/src/routes/worktree/routes/create.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* POST /create endpoint - Create a new git worktree
|
||||
*
|
||||
* This endpoint handles worktree creation with proper checks:
|
||||
* 1. First checks if git already has a worktree for the branch (anywhere)
|
||||
* 2. If found, returns the existing worktree (no error)
|
||||
* 3. Only creates a new worktree if none exists for the branch
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import { mkdir } from "fs/promises";
|
||||
import {
|
||||
isGitRepo,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
normalizePath,
|
||||
ensureInitialCommit,
|
||||
} from "../common.js";
|
||||
import { trackBranch } from "./branch-tracking.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Find an existing worktree for a given branch by checking git worktree list
|
||||
*/
|
||||
async function findExistingWorktreeForBranch(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<{ path: string; branch: string } | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const lines = stdout.split("\n");
|
||||
let currentPath: string | null = null;
|
||||
let currentBranch: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith("branch ")) {
|
||||
currentBranch = line.slice(7).replace("refs/heads/", "");
|
||||
} else if (line === "" && currentPath && currentBranch) {
|
||||
// End of a worktree entry
|
||||
if (currentBranch === branchName) {
|
||||
// Resolve to absolute path - git may return relative paths
|
||||
// Critical for cross-platform compatibility (Windows, macOS, Linux)
|
||||
const resolvedPath = path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
return { path: resolvedPath, branch: currentBranch };
|
||||
}
|
||||
currentPath = null;
|
||||
currentBranch = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the last entry (if file doesn't end with newline)
|
||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
||||
// Resolve to absolute path for cross-platform compatibility
|
||||
const resolvedPath = path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
return { path: resolvedPath, branch: currentBranch };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCreateHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, baseBranch } = req.body as {
|
||||
projectPath: string;
|
||||
branchName: string;
|
||||
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
|
||||
};
|
||||
|
||||
if (!projectPath || !branchName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and branchName required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isGitRepo(projectPath))) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Not a git repository",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
|
||||
await ensureInitialCommit(projectPath);
|
||||
|
||||
// First, check if git already has a worktree for this branch (anywhere)
|
||||
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
|
||||
if (existingWorktree) {
|
||||
// Worktree already exists, return it as success (not an error)
|
||||
// This handles manually created worktrees or worktrees from previous runs
|
||||
console.log(`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`);
|
||||
|
||||
// Track the branch so it persists in the UI
|
||||
await trackBranch(projectPath, branchName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
worktree: {
|
||||
path: normalizePath(existingWorktree.path),
|
||||
branch: branchName,
|
||||
isNew: false, // Not newly created
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize branch name for directory usage
|
||||
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const worktreesDir = path.join(projectPath, ".worktrees");
|
||||
const worktreePath = path.join(worktreesDir, sanitizedName);
|
||||
|
||||
// Create worktrees directory if it doesn't exist
|
||||
await mkdir(worktreesDir, { recursive: true });
|
||||
|
||||
// Check if branch exists
|
||||
let branchExists = false;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
branchExists = true;
|
||||
} catch {
|
||||
// Branch doesn't exist
|
||||
}
|
||||
|
||||
// Create worktree
|
||||
let createCmd: string;
|
||||
if (branchExists) {
|
||||
// Use existing branch
|
||||
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
|
||||
} else {
|
||||
// Create new branch from base or HEAD
|
||||
const base = baseBranch || "HEAD";
|
||||
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
|
||||
}
|
||||
|
||||
await execAsync(createCmd, { cwd: projectPath });
|
||||
|
||||
// Note: We intentionally do NOT symlink .automaker to worktrees
|
||||
// Features and config are always accessed from the main project path
|
||||
// This avoids symlink loop issues when activating worktrees
|
||||
|
||||
// Track the branch so it persists in the UI even after worktree is removed
|
||||
await trackBranch(projectPath, branchName);
|
||||
|
||||
// Resolve to absolute path for cross-platform compatibility
|
||||
// normalizePath converts to forward slashes for API consistency
|
||||
const absoluteWorktreePath = path.resolve(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
worktree: {
|
||||
path: normalizePath(absoluteWorktreePath),
|
||||
branch: branchName,
|
||||
isNew: !branchExists,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Create worktree failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/worktree/routes/delete.ts
Normal file
79
apps/server/src/routes/worktree/routes/delete.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* POST /delete endpoint - Delete a git worktree
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { isGitRepo, getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createDeleteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath, deleteBranch } = req.body as {
|
||||
projectPath: string;
|
||||
worktreePath: string;
|
||||
deleteBranch?: boolean; // Whether to also delete the branch
|
||||
};
|
||||
|
||||
if (!projectPath || !worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and worktreePath required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isGitRepo(projectPath))) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Not a git repository",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get branch name before removing worktree
|
||||
let branchName: string | null = null;
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
branchName = stdout.trim();
|
||||
} catch {
|
||||
// Could not get branch name
|
||||
}
|
||||
|
||||
// Remove the worktree
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
// Try with prune if remove fails
|
||||
await execAsync("git worktree prune", { cwd: projectPath });
|
||||
}
|
||||
|
||||
// Optionally delete the branch
|
||||
if (deleteBranch && branchName && branchName !== "main" && branchName !== "master") {
|
||||
try {
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Branch deletion failed, not critical
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted: {
|
||||
worktreePath,
|
||||
branch: deleteBranch ? branchName : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Delete worktree failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { getGitRepositoryDiffs } from "../../common.js";
|
||||
|
||||
export function createDiffsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -29,53 +26,37 @@ export function createDiffsHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
// Check if worktree exists
|
||||
await fs.access(worktreePath);
|
||||
const { stdout: diff } = await execAsync("git diff HEAD", {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
const files = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const statusChar = line[0];
|
||||
const filePath = line.slice(3);
|
||||
const statusMap: Record<string, string> = {
|
||||
M: "Modified",
|
||||
A: "Added",
|
||||
D: "Deleted",
|
||||
R: "Renamed",
|
||||
C: "Copied",
|
||||
U: "Updated",
|
||||
"?": "Untracked",
|
||||
};
|
||||
return {
|
||||
status: statusChar,
|
||||
path: filePath,
|
||||
statusText: statusMap[statusChar] || "Unknown",
|
||||
};
|
||||
});
|
||||
|
||||
// Get diffs from worktree
|
||||
const result = await getGitRepositoryDiffs(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
diff,
|
||||
files,
|
||||
hasChanges: files.length > 0,
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.hasChanges,
|
||||
});
|
||||
} catch {
|
||||
res.json({ success: true, diff: "", files: [], hasChanges: false });
|
||||
} catch (innerError) {
|
||||
// Worktree doesn't exist - fallback to main project path
|
||||
logError(innerError, "Worktree access failed, falling back to main project");
|
||||
|
||||
try {
|
||||
const result = await getGitRepositoryDiffs(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.hasChanges,
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
logError(fallbackError, "Fallback to main project also failed");
|
||||
res.json({ success: true, diff: "", files: [], hasChanges: false });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Get worktree diffs failed");
|
||||
|
||||
@@ -8,6 +8,7 @@ import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { generateSyntheticDiffForNewFile } from "../../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -28,25 +29,39 @@ export function createFileDiffHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout: diff } = await execAsync(
|
||||
`git diff HEAD -- "${filePath}"`,
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// First check if the file is untracked
|
||||
const { stdout: status } = await execAsync(
|
||||
`git status --porcelain -- "${filePath}"`,
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
|
||||
const isUntracked = status.trim().startsWith("??");
|
||||
|
||||
let diff: string;
|
||||
if (isUntracked) {
|
||||
// Generate synthetic diff for untracked file
|
||||
diff = await generateSyntheticDiffForNewFile(worktreePath, filePath);
|
||||
} else {
|
||||
// Use regular git diff for tracked files
|
||||
const result = await execAsync(
|
||||
`git diff HEAD -- "${filePath}"`,
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
diff = result.stdout;
|
||||
}
|
||||
|
||||
res.json({ success: true, diff, filePath });
|
||||
} catch {
|
||||
} catch (innerError) {
|
||||
logError(innerError, "Worktree file diff failed");
|
||||
res.json({ success: true, diff: "", filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import { getErrorMessage, logError, normalizePath } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -29,13 +29,8 @@ export function createInfoHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if worktree exists
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Check if worktree exists (git worktrees are stored in project directory)
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
@@ -43,7 +38,7 @@ export function createInfoHandler() {
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
worktreePath,
|
||||
worktreePath: normalizePath(worktreePath),
|
||||
branchName: stdout.trim(),
|
||||
});
|
||||
} catch {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user