mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
254 Commits
feature/to
...
new-ui-bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379976aba7 | ||
|
|
d50b15e639 | ||
|
|
172f1a7a3f | ||
|
|
5edb38691c | ||
|
|
f1f149c6c0 | ||
|
|
e0c5f55fe7 | ||
|
|
4958ee1dda | ||
|
|
3d00f40ea0 | ||
|
|
c9e0957dfe | ||
|
|
9d4f912c93 | ||
|
|
4898a1307e | ||
|
|
629b7e7433 | ||
|
|
190f18ecae | ||
|
|
e6eb5ad97e | ||
|
|
e95912f931 | ||
|
|
eb1875f558 | ||
|
|
c761ce8120 | ||
|
|
ee9cb4deec | ||
|
|
17ed2be918 | ||
|
|
5a5165818e | ||
|
|
9a7d21438b | ||
|
|
d4d4b8fb3d | ||
|
|
48955e9a71 | ||
|
|
870df88cd1 | ||
|
|
7618a75d85 | ||
|
|
51281095ea | ||
|
|
50a595a8da | ||
|
|
a398367f00 | ||
|
|
fe6faf9aae | ||
|
|
a1331ed514 | ||
|
|
38f2e0beea | ||
|
|
ef4035a462 | ||
|
|
cb07206dae | ||
|
|
cc0405cf27 | ||
|
|
4dd00a98e4 | ||
|
|
b3c321ce02 | ||
|
|
12a796bcbb | ||
|
|
ffcdbf7d75 | ||
|
|
e70c3b7722 | ||
|
|
524a9736b4 | ||
|
|
036a7d9d26 | ||
|
|
c4df2c141a | ||
|
|
c4a2f2c2a8 | ||
|
|
d08be3c7f9 | ||
|
|
7c75c24b5c | ||
|
|
a69611dcb2 | ||
|
|
2588ecaafa | ||
|
|
83af319be3 | ||
|
|
55d7120576 | ||
|
|
73e7a8558d | ||
|
|
a071097c0d | ||
|
|
0b8a79bc25 | ||
|
|
59cb48b7fa | ||
|
|
f45ba5a4f5 | ||
|
|
a0fd19fe17 | ||
|
|
b930091c42 | ||
|
|
4cff240520 | ||
|
|
e40881ed1d | ||
|
|
6a8b2067fd | ||
|
|
3f3f02905f | ||
|
|
edef4c7cee | ||
|
|
53c1a46409 | ||
|
|
0c508ce130 | ||
|
|
3c48b2ceb7 | ||
|
|
64bf02d59c | ||
|
|
a2030d5877 | ||
|
|
f17d062440 | ||
|
|
3a43033fa6 | ||
|
|
9586589453 | ||
|
|
a85dec6dbb | ||
|
|
632d3181f2 | ||
|
|
4e876de458 | ||
|
|
dea504a671 | ||
|
|
877fcb094e | ||
|
|
fc75923211 | ||
|
|
73cab38ba5 | ||
|
|
0cd3275e4a | ||
|
|
9702f142c4 | ||
|
|
2906fec500 | ||
|
|
5e2718f8b2 | ||
|
|
3b0a1a7eb2 | ||
|
|
35cda4eb8c | ||
|
|
fd39f96b4c | ||
|
|
398f7b8fdd | ||
|
|
e2718b37e3 | ||
|
|
95bcd9a7ec | ||
|
|
8d578558ff | ||
|
|
584f5a3426 | ||
|
|
17c69ea1ca | ||
|
|
4ce163691e | ||
|
|
042fc61542 | ||
|
|
7869ec046a | ||
|
|
9beefd1ac3 | ||
|
|
0c59add31f | ||
|
|
26236d3d5b | ||
|
|
43c93fe19a | ||
|
|
7b1b2fa463 | ||
|
|
20cf120b8a | ||
|
|
9ea80123fd | ||
|
|
ee9ccd03d6 | ||
|
|
af7a7ebacc | ||
|
|
7ddd9f8be1 | ||
|
|
a40bb6df24 | ||
|
|
aafd0b3991 | ||
|
|
b641884c37 | ||
|
|
7fac115a36 | ||
|
|
7e8995df24 | ||
|
|
e47b34288b | ||
|
|
6365cc137c | ||
|
|
41ea6f78eb | ||
|
|
9f97426859 | ||
|
|
6e341c1c15 | ||
|
|
b00568176c | ||
|
|
b18672f66d | ||
|
|
887fb93b3b | ||
|
|
d3005393df | ||
|
|
49f04cf403 | ||
|
|
ebaecca949 | ||
|
|
13095a4445 | ||
|
|
b80773b90d | ||
|
|
7dff6ea0ed | ||
|
|
3b39df4b12 | ||
|
|
ab65d46d08 | ||
|
|
077a63b03b | ||
|
|
5be85a45b1 | ||
|
|
6028889909 | ||
|
|
2b5479ae0d | ||
|
|
6a13c8e16e | ||
|
|
89acada310 | ||
|
|
3a2d8d118d | ||
|
|
1b8d23688e | ||
|
|
1209e923fc | ||
|
|
012d1c452b | ||
|
|
ab0487664a | ||
|
|
f504a00ce6 | ||
|
|
f2582c4453 | ||
|
|
820f43078b | ||
|
|
6533a15653 | ||
|
|
7416c8b428 | ||
|
|
8f5e782583 | ||
|
|
39b21830dc | ||
|
|
86cbb2f970 | ||
|
|
0e944e274a | ||
|
|
5e789c2817 | ||
|
|
6150926a75 | ||
|
|
0a2b4287ff | ||
|
|
18ccfa21e0 | ||
|
|
ebc7c9a7a0 | ||
|
|
5bd2b705dc | ||
|
|
2b1a7660b6 | ||
|
|
195b98e688 | ||
|
|
5aedb4fadf | ||
|
|
9cf12b9006 | ||
|
|
86d92e610b | ||
|
|
f2c40ab21a | ||
|
|
0ce6b6d4b1 | ||
|
|
55c49516c8 | ||
|
|
f3c9e828e2 | ||
|
|
3928539ade | ||
|
|
c1386caeb2 | ||
|
|
ade80484bb | ||
|
|
49a5a7448c | ||
|
|
873429db19 | ||
|
|
d6baf4583a | ||
|
|
0bcd52290b | ||
|
|
823e42e635 | ||
|
|
30f4315c17 | ||
|
|
f30240267f | ||
|
|
8cccf74ace | ||
|
|
9b798732b2 | ||
|
|
a7c19f15cd | ||
|
|
493c392422 | ||
|
|
67788bee0b | ||
|
|
0cef537a3d | ||
|
|
46994bea34 | ||
|
|
7ab65b22ec | ||
|
|
9bc245bd40 | ||
|
|
32e2315697 | ||
|
|
3a0a2e3019 | ||
|
|
8ff4b5912a | ||
|
|
7d0656bb14 | ||
|
|
e65c4aead2 | ||
|
|
f43a9288fb | ||
|
|
92e7945329 | ||
|
|
723274523d | ||
|
|
01d78be748 | ||
|
|
bcd87cc7c5 | ||
|
|
c9e7e4f1e0 | ||
|
|
532d03c231 | ||
|
|
f367db741a | ||
|
|
f4f7b4d25b | ||
|
|
63c581577f | ||
|
|
6190bd5f39 | ||
|
|
e29880254e | ||
|
|
ba7904c189 | ||
|
|
46210c5a26 | ||
|
|
ee40f2720a | ||
|
|
f1eba5ea56 | ||
|
|
c76ba691a4 | ||
|
|
ace736c7c2 | ||
|
|
1a78304ca2 | ||
|
|
0c6447a6f5 | ||
|
|
fb87c8bbb9 | ||
|
|
1a4e6ff17b | ||
|
|
3e7695dd2d | ||
|
|
8fcc6cb4db | ||
|
|
dcf19fbd45 | ||
|
|
80ab5ddad2 | ||
|
|
84832a130b | ||
|
|
fcb2e904eb | ||
|
|
36e007e647 | ||
|
|
36b4bd6c5e | ||
|
|
1b676717ea | ||
|
|
4afd360f66 | ||
|
|
dd610b7ed9 | ||
|
|
56ab21558d | ||
|
|
89c53acdcf | ||
|
|
a84f2e5942 | ||
|
|
6cb085f192 | ||
|
|
19fd23c39c | ||
|
|
cf7a737646 | ||
|
|
ff6a5a5565 | ||
|
|
3842eb1328 | ||
|
|
bb5f68c2f0 | ||
|
|
ec7c2892c2 | ||
|
|
5c01706806 | ||
|
|
6c25680115 | ||
|
|
3ca1daf44c | ||
|
|
80cf932ea4 | ||
|
|
abc55cf5e9 | ||
|
|
d4365de4b9 | ||
|
|
5f92af4c0a | ||
|
|
1fc40da052 | ||
|
|
5907cc0c04 | ||
|
|
57588bfc20 | ||
|
|
4afa73521d | ||
|
|
3a69f973d0 | ||
|
|
108d52ce9f | ||
|
|
dd58b70730 | ||
|
|
7ad7b63da2 | ||
|
|
060a789b45 | ||
|
|
bafddd627a | ||
|
|
6f4269aacd | ||
|
|
8b31039557 | ||
|
|
27b80b3e08 | ||
|
|
bdb65f5729 | ||
|
|
f4b95ea5bf | ||
|
|
b149607747 | ||
|
|
9bfcb91774 | ||
|
|
6a8f5c6d9c | ||
|
|
9110693c75 | ||
|
|
b8afb6c804 | ||
|
|
37ce09e07c | ||
|
|
334b82bfb4 |
@@ -21,4 +21,4 @@
|
||||
"mcp__puppeteer__puppeteer_evaluate"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
.github/actions/setup-project/action.yml
vendored
25
.github/actions/setup-project/action.yml
vendored
@@ -1,28 +1,28 @@
|
||||
name: "Setup Project"
|
||||
description: "Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules"
|
||||
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"
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: "22"
|
||||
default: '22'
|
||||
check-lockfile:
|
||||
description: "Run lockfile lint check for SSH URLs"
|
||||
description: 'Run lockfile lint check for SSH URLs'
|
||||
required: false
|
||||
default: "false"
|
||||
default: 'false'
|
||||
rebuild-node-pty-path:
|
||||
description: "Working directory for node-pty rebuild (empty = root)"
|
||||
description: 'Working directory for node-pty rebuild (empty = root)'
|
||||
required: false
|
||||
default: ""
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "npm"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
@@ -52,6 +52,11 @@ runs:
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Build shared packages
|
||||
shell: bash
|
||||
# Build shared packages (types, utils, platform, etc.) before apps can use them
|
||||
run: npm run build:packages
|
||||
|
||||
- name: Rebuild native modules (root)
|
||||
if: inputs.rebuild-node-pty-path == ''
|
||||
shell: bash
|
||||
|
||||
179
.github/scripts/upload-to-r2.js
vendored
179
.github/scripts/upload-to-r2.js
vendored
@@ -1,15 +1,11 @@
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
} = require("@aws-sdk/client-s3");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const { pipeline } = require('stream/promises');
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: "auto",
|
||||
region: 'auto',
|
||||
endpoint: process.env.R2_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
@@ -28,14 +24,14 @@ async function fetchExistingReleases() {
|
||||
const response = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: "releases.json",
|
||||
Key: 'releases.json',
|
||||
})
|
||||
);
|
||||
const body = await response.Body.transformToString();
|
||||
return JSON.parse(body);
|
||||
} catch (error) {
|
||||
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
|
||||
console.log("No existing releases.json found, creating new one");
|
||||
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
|
||||
console.log('No existing releases.json found, creating new one');
|
||||
return { latestVersion: null, releases: [] };
|
||||
}
|
||||
throw error;
|
||||
@@ -85,7 +81,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: "Redirect without location header",
|
||||
error: 'Redirect without location header',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -93,18 +89,16 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
return https
|
||||
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
|
||||
const redirectStatus = redirectResponse.statusCode;
|
||||
const contentType =
|
||||
redirectResponse.headers["content-type"] || "";
|
||||
const contentType = redirectResponse.headers['content-type'] || '';
|
||||
// Check if it's actually a file (zip/tar.gz) and not HTML
|
||||
const isFile =
|
||||
contentType.includes("application/zip") ||
|
||||
contentType.includes("application/gzip") ||
|
||||
contentType.includes("application/x-gzip") ||
|
||||
contentType.includes("application/x-tar") ||
|
||||
redirectUrl.includes(".zip") ||
|
||||
redirectUrl.includes(".tar.gz");
|
||||
const isGood =
|
||||
redirectStatus >= 200 && redirectStatus < 300 && isFile;
|
||||
contentType.includes('application/zip') ||
|
||||
contentType.includes('application/gzip') ||
|
||||
contentType.includes('application/x-gzip') ||
|
||||
contentType.includes('application/x-tar') ||
|
||||
redirectUrl.includes('.zip') ||
|
||||
redirectUrl.includes('.tar.gz');
|
||||
const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
|
||||
redirectResponse.destroy();
|
||||
resolve({
|
||||
accessible: isGood,
|
||||
@@ -113,38 +107,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
contentType,
|
||||
});
|
||||
})
|
||||
.on("error", (error) => {
|
||||
.on('error', (error) => {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.on("timeout", function () {
|
||||
.on('timeout', function () {
|
||||
this.destroy();
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: "Timeout following redirect",
|
||||
error: 'Timeout following redirect',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if status is good (200-299 range) and it's actually a file
|
||||
const contentType = response.headers["content-type"] || "";
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
const isFile =
|
||||
contentType.includes("application/zip") ||
|
||||
contentType.includes("application/gzip") ||
|
||||
contentType.includes("application/x-gzip") ||
|
||||
contentType.includes("application/x-tar") ||
|
||||
url.includes(".zip") ||
|
||||
url.includes(".tar.gz");
|
||||
contentType.includes('application/zip') ||
|
||||
contentType.includes('application/gzip') ||
|
||||
contentType.includes('application/x-gzip') ||
|
||||
contentType.includes('application/x-tar') ||
|
||||
url.includes('.zip') ||
|
||||
url.includes('.tar.gz');
|
||||
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
|
||||
response.destroy();
|
||||
resolve({ accessible: isGood, statusCode, contentType });
|
||||
});
|
||||
|
||||
request.on("error", (error) => {
|
||||
request.on('error', (error) => {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode: null,
|
||||
@@ -152,12 +146,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
});
|
||||
});
|
||||
|
||||
request.on("timeout", () => {
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode: null,
|
||||
error: "Request timeout",
|
||||
error: 'Request timeout',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -168,22 +162,14 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`✓ URL ${url} is accessible (status: ${result.statusCode})`
|
||||
);
|
||||
console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
|
||||
}
|
||||
return result.finalUrl || url; // Return the final URL (after redirects) if available
|
||||
} else {
|
||||
const errorMsg = result.error ? ` - ${result.error}` : "";
|
||||
const statusMsg = result.statusCode
|
||||
? ` (status: ${result.statusCode})`
|
||||
: "";
|
||||
const contentTypeMsg = result.contentType
|
||||
? ` [content-type: ${result.contentType}]`
|
||||
: "";
|
||||
console.log(
|
||||
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
|
||||
);
|
||||
const errorMsg = result.error ? ` - ${result.error}` : '';
|
||||
const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
|
||||
const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
|
||||
console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`✗ URL ${url} check failed: ${error.message}`);
|
||||
@@ -191,9 +177,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = initialDelay * Math.pow(2, attempt);
|
||||
console.log(
|
||||
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
|
||||
);
|
||||
console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
@@ -207,12 +191,7 @@ async function downloadFromGitHub(url, outputPath) {
|
||||
const statusCode = response.statusCode;
|
||||
|
||||
// Follow redirects (all redirect types)
|
||||
if (
|
||||
statusCode === 301 ||
|
||||
statusCode === 302 ||
|
||||
statusCode === 307 ||
|
||||
statusCode === 308
|
||||
) {
|
||||
if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
|
||||
const redirectUrl = response.headers.location;
|
||||
response.destroy();
|
||||
if (!redirectUrl) {
|
||||
@@ -220,39 +199,33 @@ async function downloadFromGitHub(url, outputPath) {
|
||||
return;
|
||||
}
|
||||
// Resolve relative redirects
|
||||
const finalRedirectUrl = redirectUrl.startsWith("http")
|
||||
const finalRedirectUrl = redirectUrl.startsWith('http')
|
||||
? redirectUrl
|
||||
: new URL(redirectUrl, url).href;
|
||||
console.log(` Following redirect: ${finalRedirectUrl}`);
|
||||
return downloadFromGitHub(finalRedirectUrl, outputPath)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
response.destroy();
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
|
||||
)
|
||||
);
|
||||
reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(outputPath);
|
||||
response.pipe(fileStream);
|
||||
fileStream.on("finish", () => {
|
||||
fileStream.on('finish', () => {
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
fileStream.on("error", (error) => {
|
||||
fileStream.on('error', (error) => {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
request.on("error", reject);
|
||||
request.on("timeout", () => {
|
||||
request.on('error', reject);
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
reject(new Error(`Request timeout for ${url}`));
|
||||
});
|
||||
@@ -260,8 +233,8 @@ async function downloadFromGitHub(url, outputPath) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifactsDir = "artifacts";
|
||||
const tempDir = path.join(artifactsDir, "temp");
|
||||
const artifactsDir = 'artifacts';
|
||||
const tempDir = path.join(artifactsDir, 'temp');
|
||||
|
||||
// Create temp directory for downloaded GitHub archives
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
@@ -292,40 +265,30 @@ async function main() {
|
||||
|
||||
// Find all artifacts
|
||||
const artifacts = {
|
||||
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
|
||||
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
|
||||
macosArm: findArtifacts(
|
||||
path.join(artifactsDir, "macos-builds"),
|
||||
/-arm64\.dmg$/
|
||||
),
|
||||
linux: findArtifacts(
|
||||
path.join(artifactsDir, "linux-builds"),
|
||||
/\.AppImage$/
|
||||
),
|
||||
windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
|
||||
macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
|
||||
macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
|
||||
linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
|
||||
sourceZip: [sourceZipPath],
|
||||
sourceTarGz: [sourceTarGzPath],
|
||||
};
|
||||
|
||||
console.log("Found artifacts:");
|
||||
console.log('Found artifacts:');
|
||||
for (const [platform, files] of Object.entries(artifacts)) {
|
||||
console.log(
|
||||
` ${platform}: ${
|
||||
files.length > 0
|
||||
? files.map((f) => path.basename(f)).join(", ")
|
||||
: "none"
|
||||
}`
|
||||
` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Upload each artifact to R2
|
||||
const assets = {};
|
||||
const contentTypes = {
|
||||
windows: "application/x-msdownload",
|
||||
macos: "application/x-apple-diskimage",
|
||||
macosArm: "application/x-apple-diskimage",
|
||||
linux: "application/x-executable",
|
||||
sourceZip: "application/zip",
|
||||
sourceTarGz: "application/gzip",
|
||||
windows: 'application/x-msdownload',
|
||||
macos: 'application/x-apple-diskimage',
|
||||
macosArm: 'application/x-apple-diskimage',
|
||||
linux: 'application/x-executable',
|
||||
sourceZip: 'application/zip',
|
||||
sourceTarGz: 'application/gzip',
|
||||
};
|
||||
|
||||
for (const [platform, files] of Object.entries(artifacts)) {
|
||||
@@ -345,11 +308,11 @@ async function main() {
|
||||
filename,
|
||||
size,
|
||||
arch:
|
||||
platform === "macosArm"
|
||||
? "arm64"
|
||||
: platform === "sourceZip" || platform === "sourceTarGz"
|
||||
? "source"
|
||||
: "x64",
|
||||
platform === 'macosArm'
|
||||
? 'arm64'
|
||||
: platform === 'sourceZip' || platform === 'sourceTarGz'
|
||||
? 'source'
|
||||
: 'x64',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,9 +327,7 @@ async function main() {
|
||||
};
|
||||
|
||||
// Remove existing entry for this version if re-running
|
||||
releasesData.releases = releasesData.releases.filter(
|
||||
(r) => r.version !== VERSION
|
||||
);
|
||||
releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
|
||||
|
||||
// Prepend new release
|
||||
releasesData.releases.unshift(newRelease);
|
||||
@@ -376,19 +337,19 @@ async function main() {
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: "releases.json",
|
||||
Key: 'releases.json',
|
||||
Body: JSON.stringify(releasesData, null, 2),
|
||||
ContentType: "application/json",
|
||||
CacheControl: "public, max-age=60",
|
||||
ContentType: 'application/json',
|
||||
CacheControl: 'public, max-age=60',
|
||||
})
|
||||
);
|
||||
|
||||
console.log("Successfully updated releases.json");
|
||||
console.log('Successfully updated releases.json');
|
||||
console.log(`Latest version: ${VERSION}`);
|
||||
console.log(`Total releases: ${releasesData.releases.length}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to upload to R2:", err);
|
||||
console.error('Failed to upload to R2:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
49
.github/workflows/claude.yml
vendored
Normal file
49
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
8
.github/workflows/e2e-tests.yml
vendored
8
.github/workflows/e2e-tests.yml
vendored
@@ -3,7 +3,7 @@ name: E2E Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: "true"
|
||||
rebuild-node-pty-path: "apps/server"
|
||||
check-lockfile: 'true'
|
||||
rebuild-node-pty-path: 'apps/server'
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
env:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: "true"
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
31
.github/workflows/format-check.yml
vendored
Normal file
31
.github/workflows/format-check.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Format Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
4
.github/workflows/pr-check.yml
vendored
4
.github/workflows/pr-check.yml
vendored
@@ -3,7 +3,7 @@ name: PR Build Check
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: "true"
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Run build:electron (dir only - faster CI)
|
||||
run: npm run build:electron:dir
|
||||
|
||||
111
.github/workflows/release.yml
vendored
Normal file
111
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
# Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3")
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: ${VERSION}"
|
||||
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
run: |
|
||||
node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Build Electron app (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:mac --workspace=apps/ui
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:win --workspace=apps/ui
|
||||
|
||||
- name: Build Electron app (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:linux --workspace=apps/ui
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-builds
|
||||
path: apps/ui/release/*.{dmg,zip}
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: apps/ui/release/*.{AppImage,deb}
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.release.draft == false
|
||||
|
||||
steps:
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-builds
|
||||
path: artifacts/macos-builds
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: artifacts/windows-builds
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: artifacts/linux-builds
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
artifacts/macos-builds/*
|
||||
artifacts/windows-builds/*
|
||||
artifacts/linux-builds/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
30
.github/workflows/security-audit.yml
vendored
Normal file
30
.github/workflows/security-audit.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
schedule:
|
||||
# Run weekly on Mondays at 9 AM UTC
|
||||
- cron: '0 9 * * 1'
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: false
|
||||
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@@ -3,7 +3,7 @@ name: Test Suite
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -20,8 +20,13 @@ jobs:
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: "true"
|
||||
rebuild-node-pty-path: "apps/server"
|
||||
check-lockfile: 'true'
|
||||
rebuild-node-pty-path: 'apps/server'
|
||||
|
||||
- name: Run package tests
|
||||
run: npm run test:packages
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Run server tests with coverage
|
||||
run: npm run test:server:coverage
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -78,3 +78,5 @@ blob-report/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
docker-compose.override.yml
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
39
.prettierignore
Normal file
39
.prettierignore
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.turbo/
|
||||
release/
|
||||
|
||||
# Automaker
|
||||
.automaker/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
coverage/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# IDE/Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Electron
|
||||
dist-electron/
|
||||
server-bundle/
|
||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -30,6 +30,26 @@ Before running Automaker, we strongly recommend reviewing the source code yourse
|
||||
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
|
||||
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
|
||||
|
||||
#### Running in Isolated Docker Container
|
||||
|
||||
For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
|
||||
|
||||
```bash
|
||||
# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
|
||||
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
|
||||
|
||||
# On Windows PowerShell, use instead:
|
||||
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
|
||||
|
||||
# 2. Build and run isolated container
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Access the UI at http://localhost:3007
|
||||
# API at http://localhost:3008/api/health
|
||||
```
|
||||
|
||||
The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
|
||||
|
||||
### 3. Limit Access
|
||||
|
||||
If you must run locally:
|
||||
|
||||
13
README.md
13
README.md
@@ -8,7 +8,7 @@
|
||||
>
|
||||
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
|
||||
>
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
|
||||
|
||||
# Automaker
|
||||
|
||||
@@ -61,7 +61,7 @@ Traditional development tools help you write code. Automaker helps you **orchest
|
||||
|
||||
### Powered by Claude Code
|
||||
|
||||
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
Automaker leverages the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
@@ -79,7 +79,7 @@ The future of software development is **agentic coding**—where developers beco
|
||||
>
|
||||
> **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)**
|
||||
> **[Read the full disclaimer](./DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
@@ -106,7 +106,7 @@ https://discord.gg/jjem7aEDKU
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -118,7 +118,10 @@ cd automaker
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Run Automaker (pick your mode)
|
||||
# 3. Build local shared packages
|
||||
npm run build:packages
|
||||
|
||||
# 4. Run Automaker (pick your mode)
|
||||
npm run dev
|
||||
# Then choose your run mode when prompted, or use specific commands below
|
||||
```
|
||||
|
||||
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/app/server-bundle/package.json
Normal file
15
apps/app/server-bundle/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@automaker/server-bundle",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,11 @@ ANTHROPIC_API_KEY=sk-ant-...
|
||||
# If set, all API requests must include X-API-Key header
|
||||
AUTOMAKER_API_KEY=
|
||||
|
||||
# Restrict file operations to these directories (comma-separated)
|
||||
# Important for security in multi-tenant environments
|
||||
ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
|
||||
# Root directory for projects and file operations
|
||||
# If set, users can only create/open projects and files within this directory
|
||||
# Recommended for sandboxed deployments (Docker, restricted environments)
|
||||
# Example: ALLOWED_ROOT_DIRECTORY=/projects
|
||||
ALLOWED_ROOT_DIRECTORY=
|
||||
|
||||
# CORS origin - which domains can access the API
|
||||
# Use "*" for development, set specific origin for production
|
||||
@@ -34,13 +36,6 @@ PORT=3008
|
||||
# Data directory for sessions and metadata
|
||||
DATA_DIR=./data
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Additional AI Providers
|
||||
# ============================================
|
||||
|
||||
# Google API key (for future Gemini support)
|
||||
GOOGLE_API_KEY=
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Terminal Access
|
||||
# ============================================
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
# Copy package files and scripts needed for postinstall
|
||||
COPY package*.json ./
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --workspace=apps/server
|
||||
@@ -22,6 +26,14 @@ RUN npm run build --workspace=apps/server
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install git, curl, and GitHub CLI (pinned version for reproducible builds)
|
||||
RUN apk add --no-cache git curl && \
|
||||
GH_VERSION="2.63.2" && \
|
||||
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
|
||||
tar -xzf gh.tar.gz && \
|
||||
mv "gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh && \
|
||||
rm -rf gh.tar.gz "gh_${GH_VERSION}_linux_amd64"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"name": "@automaker/server",
|
||||
"version": "0.1.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -19,6 +21,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/git-utils": "^1.0.0",
|
||||
"@automaker/model-resolver": "^1.0.0",
|
||||
"@automaker/platform": "^1.0.0",
|
||||
"@automaker/prompts": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
|
||||
@@ -6,49 +6,55 @@
|
||||
* In web mode, this server runs on a remote host.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import morgan from "morgan";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createServer } from "http";
|
||||
import dotenv from "dotenv";
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from "./lib/events.js";
|
||||
import { initAllowedPaths } from "./lib/security.js";
|
||||
import { authMiddleware, getAuthStatus } from "./lib/auth.js";
|
||||
import { createFsRoutes } from "./routes/fs/index.js";
|
||||
import { createHealthRoutes } from "./routes/health/index.js";
|
||||
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";
|
||||
import { createSuggestionsRoutes } from "./routes/suggestions/index.js";
|
||||
import { createModelsRoutes } from "./routes/models/index.js";
|
||||
import { createRunningAgentsRoutes } from "./routes/running-agents/index.js";
|
||||
import { createWorkspaceRoutes } from "./routes/workspace/index.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates/index.js";
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { authMiddleware, getAuthStatus } from './lib/auth.js';
|
||||
import { createFsRoutes } from './routes/fs/index.js';
|
||||
import { createHealthRoutes } from './routes/health/index.js';
|
||||
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';
|
||||
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
|
||||
import { createModelsRoutes } from './routes/models/index.js';
|
||||
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
||||
import { createWorkspaceRoutes } from './routes/workspace/index.js';
|
||||
import { createTemplatesRoutes } from './routes/templates/index.js';
|
||||
import {
|
||||
createTerminalRoutes,
|
||||
validateTerminalToken,
|
||||
isTerminalEnabled,
|
||||
isTerminalPasswordRequired,
|
||||
} from "./routes/terminal/index.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||
} from './routes/terminal/index.js';
|
||||
import { createSettingsRoutes } from './routes/settings/index.js';
|
||||
import { AgentService } from './services/agent-service.js';
|
||||
import { FeatureLoader } from './services/feature-loader.js';
|
||||
import { AutoModeService } from './services/auto-mode-service.js';
|
||||
import { getTerminalService } from './services/terminal-service.js';
|
||||
import { SettingsService } from './services/settings-service.js';
|
||||
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||
import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3008", 10);
|
||||
const DATA_DIR = process.env.DATA_DIR || "./data";
|
||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
|
||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
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
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
@@ -67,7 +73,7 @@ if (!hasAnthropicKey) {
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
|
||||
console.log('[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)');
|
||||
}
|
||||
|
||||
// Initialize security
|
||||
@@ -79,7 +85,7 @@ const app = express();
|
||||
// Middleware
|
||||
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
||||
if (ENABLE_REQUEST_LOGGING) {
|
||||
morgan.token("status-colored", (req, res) => {
|
||||
morgan.token('status-colored', (req, res) => {
|
||||
const status = res.statusCode;
|
||||
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
||||
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
||||
@@ -88,18 +94,18 @@ if (ENABLE_REQUEST_LOGGING) {
|
||||
});
|
||||
|
||||
app.use(
|
||||
morgan(":method :url :status-colored", {
|
||||
skip: (req) => req.url === "/api/health", // Skip health check logs
|
||||
morgan(':method :url :status-colored', {
|
||||
skip: (req) => req.url === '/api/health', // Skip health check logs
|
||||
})
|
||||
);
|
||||
}
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || "*",
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Create shared event emitter for streaming
|
||||
const events: EventEmitter = createEventEmitter();
|
||||
@@ -108,35 +114,41 @@ const events: EventEmitter = createEventEmitter();
|
||||
const agentService = new AgentService(DATA_DIR, events);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events);
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
console.log("[Server] Agent service initialized");
|
||||
console.log('[Server] Agent service initialized');
|
||||
})();
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
app.use("/api/health", createHealthRoutes());
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
|
||||
// Apply authentication to all other routes
|
||||
app.use("/api", authMiddleware);
|
||||
app.use('/api', authMiddleware);
|
||||
|
||||
app.use("/api/fs", createFsRoutes(events));
|
||||
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());
|
||||
app.use("/api/suggestions", createSuggestionsRoutes(events));
|
||||
app.use("/api/models", createModelsRoutes());
|
||||
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
app.use("/api/terminal", createTerminalRoutes());
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
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());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events));
|
||||
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
||||
app.use('/api/workspace', createWorkspaceRoutes());
|
||||
app.use('/api/templates', createTemplatesRoutes());
|
||||
app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes());
|
||||
app.use('/api/context', createContextRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -147,19 +159,16 @@ const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const { pathname } = new URL(
|
||||
request.url || "",
|
||||
`http://${request.headers.host}`
|
||||
);
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
if (pathname === "/api/events") {
|
||||
if (pathname === '/api/events') {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
} else if (pathname === "/api/terminal/ws") {
|
||||
} else if (pathname === '/api/terminal/ws') {
|
||||
terminalWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
terminalWss.emit("connection", ws, request);
|
||||
terminalWss.emit('connection', ws, request);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
@@ -167,8 +176,8 @@ server.on("upgrade", (request, socket, head) => {
|
||||
});
|
||||
|
||||
// Events WebSocket connection handler
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
console.log("[WebSocket] Client connected");
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
console.log('[WebSocket] Client connected');
|
||||
|
||||
// Subscribe to all events and forward to this client
|
||||
const unsubscribe = events.subscribe((type, payload) => {
|
||||
@@ -177,13 +186,13 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("[WebSocket] Client disconnected");
|
||||
ws.on('close', () => {
|
||||
console.log('[WebSocket] Client disconnected');
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error("[WebSocket] Error:", error);
|
||||
ws.on('error', (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -204,184 +213,199 @@ terminalService.onExit((sessionId) => {
|
||||
});
|
||||
|
||||
// Terminal WebSocket connection handler
|
||||
terminalWss.on(
|
||||
"connection",
|
||||
(ws: WebSocket, req: import("http").IncomingMessage) => {
|
||||
// Parse URL to get session ID and token
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const token = url.searchParams.get("token");
|
||||
terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => {
|
||||
// Parse URL to get session ID and token
|
||||
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log("[Terminal WS] Terminal is disabled");
|
||||
ws.close(4003, "Terminal access is disabled");
|
||||
return;
|
||||
}
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log('[Terminal WS] Terminal is disabled');
|
||||
ws.close(4003, 'Terminal access is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token if password is required
|
||||
if (
|
||||
isTerminalPasswordRequired() &&
|
||||
!validateTerminalToken(token || undefined)
|
||||
) {
|
||||
console.log("[Terminal WS] Invalid or missing token");
|
||||
ws.close(4001, "Authentication required");
|
||||
return;
|
||||
}
|
||||
// Validate token if password is required
|
||||
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
|
||||
console.log('[Terminal WS] Invalid or missing token');
|
||||
ws.close(4001, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log("[Terminal WS] No session ID provided");
|
||||
ws.close(4002, "Session ID required");
|
||||
return;
|
||||
}
|
||||
if (!sessionId) {
|
||||
console.log('[Terminal WS] No session ID provided');
|
||||
ws.close(4002, 'Session ID required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
ws.close(4004, 'Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
terminalConnections.set(sessionId, new Set());
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
terminalConnections.set(sessionId, new Set());
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
|
||||
// Send initial connection success FIRST
|
||||
// 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: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
type: 'scrollback',
|
||||
data: scrollback,
|
||||
})
|
||||
);
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to terminal exit
|
||||
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
ws.close(1000, "Session ended");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
// Write user input to terminal
|
||||
terminalService.write(sessionId, msg.data);
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal with deduplication and rate limiting
|
||||
if (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;
|
||||
|
||||
case "ping":
|
||||
// Respond to ping
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Terminal WS] Error processing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(
|
||||
`[Terminal WS] Client disconnected from session ${sessionId}`
|
||||
);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
// Remove from connections tracking
|
||||
const connections = terminalConnections.get(sessionId);
|
||||
if (connections) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to terminal exit
|
||||
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'exit', exitCode }));
|
||||
ws.close(1000, 'Session ended');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case 'input':
|
||||
// Validate input data type and length
|
||||
if (typeof msg.data !== 'string') {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid input type' }));
|
||||
break;
|
||||
}
|
||||
// Limit input size to 1MB to prevent memory issues
|
||||
if (msg.data.length > 1024 * 1024) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Input too large' }));
|
||||
break;
|
||||
}
|
||||
// Write user input to terminal
|
||||
terminalService.write(sessionId, msg.data);
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
// Validate resize dimensions are positive integers within reasonable bounds
|
||||
if (
|
||||
typeof msg.cols !== 'number' ||
|
||||
typeof msg.rows !== 'number' ||
|
||||
!Number.isInteger(msg.cols) ||
|
||||
!Number.isInteger(msg.rows) ||
|
||||
msg.cols < 1 ||
|
||||
msg.cols > 1000 ||
|
||||
msg.rows < 1 ||
|
||||
msg.rows > 500
|
||||
) {
|
||||
break; // Silently ignore invalid resize requests
|
||||
}
|
||||
// Resize terminal with deduplication and rate limiting
|
||||
if (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;
|
||||
|
||||
case 'ping':
|
||||
// Respond to ping
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Terminal WS] Error processing message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
// Remove from connections tracking
|
||||
const connections = terminalConnections.get(sessionId);
|
||||
if (connections) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
});
|
||||
|
||||
// Start server with error handling for port conflicts
|
||||
const startServer = (port: number) => {
|
||||
server.listen(port, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? isTerminalPasswordRequired()
|
||||
? "enabled (password protected)"
|
||||
: "enabled"
|
||||
: "disabled";
|
||||
? 'enabled (password protected)'
|
||||
: 'enabled'
|
||||
: 'disabled';
|
||||
const portStr = port.toString().padEnd(4);
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
@@ -396,8 +420,8 @@ const startServer = (port: number) => {
|
||||
`);
|
||||
});
|
||||
|
||||
server.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ❌ ERROR: Port ${port} is already in use ║
|
||||
@@ -418,7 +442,7 @@ const startServer = (port: number) => {
|
||||
`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("[Server] Error starting server:", error);
|
||||
console.error('[Server] Error starting server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -427,20 +451,20 @@ const startServer = (port: number) => {
|
||||
startServer(PORT);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("SIGTERM received, shutting down...");
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("SIGINT received, shutting down...");
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,139 +5,27 @@
|
||||
* 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,
|
||||
};
|
||||
// Import and re-export spec types from shared package
|
||||
export type { SpecOutput } from '@automaker/types';
|
||||
export { specOutputSchema } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
.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 = " ";
|
||||
export function specToXml(spec: import('@automaker/types').SpecOutput): string {
|
||||
const indent = ' ';
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
@@ -148,11 +36,11 @@ ${indent}${indent}${escapeXml(spec.overview)}
|
||||
${indent}</overview>
|
||||
|
||||
${indent}<technology_stack>
|
||||
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join("\n")}
|
||||
${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")}
|
||||
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join('\n')}
|
||||
${indent}</core_capabilities>
|
||||
|
||||
${indent}<implemented_features>
|
||||
@@ -163,13 +51,13 @@ ${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")}
|
||||
${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")}
|
||||
.join('\n')}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Optional sections
|
||||
@@ -177,7 +65,7 @@ ${indent}</implemented_features>`;
|
||||
xml += `
|
||||
|
||||
${indent}<additional_requirements>
|
||||
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join("\n")}
|
||||
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join('\n')}
|
||||
${indent}</additional_requirements>`;
|
||||
}
|
||||
|
||||
@@ -185,7 +73,7 @@ ${indent}</additional_requirements>`;
|
||||
xml += `
|
||||
|
||||
${indent}<development_guidelines>
|
||||
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join("\n")}
|
||||
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join('\n')}
|
||||
${indent}</development_guidelines>`;
|
||||
}
|
||||
|
||||
@@ -201,7 +89,7 @@ ${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
|
||||
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
|
||||
${indent}${indent}</phase>`
|
||||
)
|
||||
.join("\n")}
|
||||
.join('\n')}
|
||||
${indent}</implementation_roadmap>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Supports API key authentication via header or environment variable.
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// API key from environment (optional - if not set, auth is disabled)
|
||||
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
||||
@@ -23,12 +23,12 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
}
|
||||
|
||||
// Check for API key in header
|
||||
const providedKey = req.headers["x-api-key"] as string | undefined;
|
||||
const providedKey = req.headers['x-api-key'] as string | undefined;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: "Authentication required. Provide X-API-Key header.",
|
||||
error: 'Authentication required. Provide X-API-Key header.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
if (providedKey !== API_KEY) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Invalid API key.",
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -57,6 +57,6 @@ export function isAuthEnabled(): boolean {
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
return {
|
||||
enabled: !!API_KEY,
|
||||
method: API_KEY ? "api_key" : "none",
|
||||
method: API_KEY ? 'api_key' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,456 +1,25 @@
|
||||
/**
|
||||
* Enhancement Prompts Library - AI-powered text enhancement for task descriptions
|
||||
* Enhancement Prompts - Re-exported from @automaker/prompts
|
||||
*
|
||||
* 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.
|
||||
* This file now re-exports enhancement prompts from the shared @automaker/prompts package
|
||||
* to maintain backward compatibility with existing imports in the server codebase.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
export {
|
||||
IMPROVE_SYSTEM_PROMPT,
|
||||
TECHNICAL_SYSTEM_PROMPT,
|
||||
SIMPLIFY_SYSTEM_PROMPT,
|
||||
ACCEPTANCE_SYSTEM_PROMPT,
|
||||
IMPROVE_EXAMPLES,
|
||||
TECHNICAL_EXAMPLES,
|
||||
SIMPLIFY_EXAMPLES,
|
||||
ACCEPTANCE_EXAMPLES,
|
||||
getEnhancementPrompt,
|
||||
getSystemPrompt,
|
||||
getExamples,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
getAvailableEnhancementModes,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
export type { EnhancementMode, EnhancementExample } from '@automaker/prompts';
|
||||
|
||||
@@ -2,31 +2,10 @@
|
||||
* Event emitter for streaming events to WebSocket clients
|
||||
*/
|
||||
|
||||
export type EventType =
|
||||
| "agent:stream"
|
||||
| "auto-mode:event"
|
||||
| "auto-mode:started"
|
||||
| "auto-mode:stopped"
|
||||
| "auto-mode:idle"
|
||||
| "auto-mode:error"
|
||||
| "feature:started"
|
||||
| "feature:completed"
|
||||
| "feature:stopped"
|
||||
| "feature:error"
|
||||
| "feature:progress"
|
||||
| "feature:tool-use"
|
||||
| "feature:follow-up-started"
|
||||
| "feature:follow-up-completed"
|
||||
| "feature:verified"
|
||||
| "feature:committed"
|
||||
| "project:analysis-started"
|
||||
| "project:analysis-progress"
|
||||
| "project:analysis-completed"
|
||||
| "project:analysis-error"
|
||||
| "suggestions:event"
|
||||
| "spec-regeneration:event";
|
||||
import type { EventType, EventCallback } from '@automaker/types';
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
// Re-export event types from shared package
|
||||
export type { EventType, EventCallback };
|
||||
|
||||
export interface EventEmitter {
|
||||
emit: (type: EventType, payload: unknown) => void;
|
||||
@@ -42,7 +21,7 @@ export function createEventEmitter(): EventEmitter {
|
||||
try {
|
||||
callback(type, payload);
|
||||
} catch (error) {
|
||||
console.error("Error in event subscriber:", error);
|
||||
console.error('Error in event subscriber:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,48 +9,59 @@
|
||||
* - Chat: Full tool access for interactive coding
|
||||
*
|
||||
* Uses model-resolver for consistent model handling across the application.
|
||||
*
|
||||
* SECURITY: All factory functions validate the working directory (cwd) against
|
||||
* ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized
|
||||
* security check that applies to ALL AI model invocations, regardless of provider.
|
||||
*/
|
||||
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
resolveModelString,
|
||||
DEFAULT_MODELS,
|
||||
CLAUDE_MODEL_MAP,
|
||||
} from "./model-resolver.js";
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
|
||||
* This is the centralized security check for ALL AI model invocations.
|
||||
*
|
||||
* @param cwd - The working directory to validate
|
||||
* @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY
|
||||
*
|
||||
* This function is called by all create*Options() factory functions to ensure
|
||||
* that AI models can only operate within allowed directories. This applies to:
|
||||
* - All current models (Claude, future models)
|
||||
* - All invocation types (chat, auto-mode, spec generation, etc.)
|
||||
*/
|
||||
export function validateWorkingDirectory(cwd: string): void {
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
|
||||
if (!isPathAllowed(resolvedCwd)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
throw new PathNotAllowedError(
|
||||
`Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` +
|
||||
(allowedRoot
|
||||
? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
|
||||
: 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool presets for different use cases
|
||||
*/
|
||||
export const TOOL_PRESETS = {
|
||||
/** Read-only tools for analysis */
|
||||
readOnly: ["Read", "Glob", "Grep"] as const,
|
||||
readOnly: ['Read', 'Glob', 'Grep'] as const,
|
||||
|
||||
/** Tools for spec generation that needs to read the codebase */
|
||||
specGeneration: ["Read", "Glob", "Grep"] as const,
|
||||
specGeneration: ['Read', 'Glob', 'Grep'] as const,
|
||||
|
||||
/** Full tool access for feature implementation */
|
||||
fullAccess: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
] as const,
|
||||
fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const,
|
||||
|
||||
/** Tools for chat/interactive mode */
|
||||
chat: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
] as const,
|
||||
chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -81,7 +92,7 @@ export const MAX_TURNS = {
|
||||
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
|
||||
*/
|
||||
export function getModelForUseCase(
|
||||
useCase: "spec" | "features" | "suggestions" | "chat" | "auto" | "default",
|
||||
useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default',
|
||||
explicitModel?: string
|
||||
): string {
|
||||
// Explicit model takes precedence
|
||||
@@ -105,12 +116,12 @@ export function getModelForUseCase(
|
||||
}
|
||||
|
||||
const defaultModels: Record<string, string> = {
|
||||
spec: CLAUDE_MODEL_MAP["haiku"], // used to generate app specs
|
||||
features: CLAUDE_MODEL_MAP["haiku"], // used to generate features from app specs
|
||||
suggestions: CLAUDE_MODEL_MAP["haiku"], // used for suggestions
|
||||
chat: CLAUDE_MODEL_MAP["haiku"], // used for chat
|
||||
auto: CLAUDE_MODEL_MAP["opus"], // used to implement kanban cards
|
||||
default: CLAUDE_MODEL_MAP["opus"],
|
||||
spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs
|
||||
features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs
|
||||
suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions
|
||||
chat: CLAUDE_MODEL_MAP['haiku'], // used for chat
|
||||
auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards
|
||||
default: CLAUDE_MODEL_MAP['opus'],
|
||||
};
|
||||
|
||||
return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
|
||||
@@ -121,7 +132,7 @@ export function getModelForUseCase(
|
||||
*/
|
||||
function getBaseOptions(): Partial<Options> {
|
||||
return {
|
||||
permissionMode: "acceptEdits",
|
||||
permissionMode: 'acceptEdits',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,7 +157,7 @@ export interface CreateSdkOptionsConfig {
|
||||
|
||||
/** Optional output format for structured outputs */
|
||||
outputFormat?: {
|
||||
type: "json_schema";
|
||||
type: 'json_schema';
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
@@ -159,16 +170,17 @@ export interface CreateSdkOptionsConfig {
|
||||
* - Extended turns for thorough exploration
|
||||
* - Opus model by default (can be overridden)
|
||||
*/
|
||||
export function createSpecGenerationOptions(
|
||||
config: CreateSdkOptionsConfig
|
||||
): Options {
|
||||
export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
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),
|
||||
permissionMode: 'default',
|
||||
model: getModelForUseCase('spec', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||
@@ -186,14 +198,15 @@ export function createSpecGenerationOptions(
|
||||
* - Quick turns since it's mostly JSON generation
|
||||
* - Sonnet model by default for speed
|
||||
*/
|
||||
export function createFeatureGenerationOptions(
|
||||
config: CreateSdkOptionsConfig
|
||||
): Options {
|
||||
export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - feature generation only needs read-only tools
|
||||
permissionMode: "default",
|
||||
model: getModelForUseCase("features", config.model),
|
||||
permissionMode: 'default',
|
||||
model: getModelForUseCase('features', config.model),
|
||||
maxTurns: MAX_TURNS.quick,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
@@ -210,12 +223,13 @@ export function createFeatureGenerationOptions(
|
||||
* - Standard turns to allow thorough codebase exploration and structured output generation
|
||||
* - Opus model by default for thorough analysis
|
||||
*/
|
||||
export function createSuggestionsOptions(
|
||||
config: CreateSdkOptionsConfig
|
||||
): Options {
|
||||
export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("suggestions", config.model),
|
||||
model: getModelForUseCase('suggestions', config.model),
|
||||
maxTurns: MAX_TURNS.extended,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
@@ -235,12 +249,15 @@ export function createSuggestionsOptions(
|
||||
* - Sandbox enabled for bash safety
|
||||
*/
|
||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
// Model priority: explicit model > session model > chat default
|
||||
const effectiveModel = config.model || config.sessionModel;
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("chat", effectiveModel),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
@@ -263,9 +280,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
* - Sandbox enabled for bash safety
|
||||
*/
|
||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("auto", config.model),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
@@ -290,14 +310,15 @@ export function createCustomOptions(
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
|
||||
}
|
||||
): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("default", config.model),
|
||||
model: getModelForUseCase('default', config.model),
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
: [...TOOL_PRESETS.readOnly],
|
||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
|
||||
23
apps/server/src/lib/secure-fs.ts
Normal file
23
apps/server/src/lib/secure-fs.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Re-export secure file system utilities from @automaker/platform
|
||||
* This file exists for backward compatibility with existing imports
|
||||
*/
|
||||
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
export const {
|
||||
access,
|
||||
readFile,
|
||||
writeFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
stat,
|
||||
rm,
|
||||
unlink,
|
||||
copyFile,
|
||||
appendFile,
|
||||
rename,
|
||||
lstat,
|
||||
joinPath,
|
||||
resolvePath,
|
||||
} = secureFs;
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Security utilities for path validation
|
||||
* Note: All permission checks have been disabled to allow unrestricted access
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
||||
// Allowed project directories - kept for API compatibility
|
||||
const allowedPaths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Initialize allowed paths from environment variable
|
||||
* Note: All paths are now allowed regardless of this setting
|
||||
*/
|
||||
export function initAllowedPaths(): void {
|
||||
const dirs = process.env.ALLOWED_PROJECT_DIRS;
|
||||
if (dirs) {
|
||||
for (const dir of dirs.split(",")) {
|
||||
const trimmed = dir.trim();
|
||||
if (trimmed) {
|
||||
allowedPaths.add(path.resolve(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
allowedPaths.add(path.resolve(dataDir));
|
||||
}
|
||||
|
||||
const workspaceDir = process.env.WORKSPACE_DIR;
|
||||
if (workspaceDir) {
|
||||
allowedPaths.add(path.resolve(workspaceDir));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a path to the allowed list (no-op, all paths allowed)
|
||||
*/
|
||||
export function addAllowedPath(filePath: string): void {
|
||||
allowedPaths.add(path.resolve(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is allowed - always returns true
|
||||
*/
|
||||
export function isPathAllowed(_filePath: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path - just resolves the path without checking permissions
|
||||
*/
|
||||
export function validatePath(filePath: string): string {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of allowed paths (for debugging)
|
||||
*/
|
||||
export function getAllowedPaths(): string[] {
|
||||
return Array.from(allowedPaths);
|
||||
}
|
||||
180
apps/server/src/lib/worktree-metadata.ts
Normal file
180
apps/server/src/lib/worktree-metadata.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Worktree metadata storage utilities
|
||||
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
||||
*/
|
||||
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import * as path from 'path';
|
||||
|
||||
/** Maximum length for sanitized branch names in filesystem paths */
|
||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize branch name for cross-platform filesystem safety
|
||||
*/
|
||||
function sanitizeBranchName(branch: string): string {
|
||||
// Replace characters that are invalid or problematic on various filesystems:
|
||||
// - Forward and backslashes (path separators)
|
||||
// - Windows invalid chars: : * ? " < > |
|
||||
// - Other potentially problematic chars
|
||||
let safeBranch = branch
|
||||
.replace(/[/\\:*?"<>|]/g, '-') // Replace invalid chars with dash
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/\.+$/g, '') // Remove trailing dots (Windows issue)
|
||||
.replace(/-+/g, '-') // Collapse multiple dashes
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
|
||||
// Truncate to safe length (leave room for path components)
|
||||
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
|
||||
|
||||
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
|
||||
safeBranch = `_${safeBranch || 'branch'}`;
|
||||
}
|
||||
|
||||
return safeBranch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the worktree metadata directory
|
||||
*/
|
||||
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
|
||||
const safeBranch = sanitizeBranchName(branch);
|
||||
return path.join(projectPath, '.automaker', 'worktrees', safeBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the worktree metadata file
|
||||
*/
|
||||
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
|
||||
return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read worktree metadata for a branch
|
||||
*/
|
||||
export async function readWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<WorktreeMetadata | null> {
|
||||
try {
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as WorktreeMetadata;
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write worktree metadata for a branch
|
||||
*/
|
||||
export async function writeWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
metadata: WorktreeMetadata
|
||||
): Promise<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
|
||||
// Ensure directory exists
|
||||
await secureFs.mkdir(metadataDir, { recursive: true });
|
||||
|
||||
// Write metadata
|
||||
await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update PR info in worktree metadata
|
||||
*/
|
||||
export async function updateWorktreePRInfo(
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
prInfo: WorktreePRInfo
|
||||
): Promise<void> {
|
||||
// Read existing metadata or create new
|
||||
let metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
|
||||
if (!metadata) {
|
||||
metadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Update PR info
|
||||
metadata.pr = prInfo;
|
||||
|
||||
// Write back
|
||||
await writeWorktreeMetadata(projectPath, branch, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR info for a branch from metadata
|
||||
*/
|
||||
export async function getWorktreePRInfo(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<WorktreePRInfo | null> {
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
return metadata?.pr || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all worktree metadata for a project
|
||||
*/
|
||||
export async function readAllWorktreeMetadata(
|
||||
projectPath: string
|
||||
): Promise<Map<string, WorktreeMetadata>> {
|
||||
const result = new Map<string, WorktreeMetadata>();
|
||||
const worktreesDir = path.join(projectPath, '.automaker', 'worktrees');
|
||||
|
||||
try {
|
||||
const dirs = await secureFs.readdir(worktreesDir, { withFileTypes: true });
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (dir.isDirectory()) {
|
||||
const metadataPath = path.join(worktreesDir, dir.name, 'worktree.json');
|
||||
try {
|
||||
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
||||
const metadata = JSON.parse(content) as WorktreeMetadata;
|
||||
result.set(metadata.branch, metadata);
|
||||
} catch {
|
||||
// Skip if file doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete worktree metadata for a branch
|
||||
*/
|
||||
export async function deleteWorktreeMetadata(projectPath: string, branch: string): Promise<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
try {
|
||||
await secureFs.rm(metadataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
}
|
||||
69
apps/server/src/middleware/validate-paths.ts
Normal file
69
apps/server/src/middleware/validate-paths.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Middleware for validating path parameters against ALLOWED_ROOT_DIRECTORY
|
||||
* Provides a clean, reusable way to validate paths without repeating the same
|
||||
* try-catch block in every route handler
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { validatePath, PathNotAllowedError } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Creates a middleware that validates specified path parameters in req.body
|
||||
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
||||
* @example
|
||||
* router.post('/create', validatePathParams('projectPath'), handler);
|
||||
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
|
||||
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
|
||||
*
|
||||
* Special syntax:
|
||||
* - 'paramName?' - Optional parameter (only validated if present)
|
||||
* - 'paramName[]' - Array parameter (validates each element)
|
||||
*/
|
||||
export function validatePathParams(...paramNames: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
for (const paramName of paramNames) {
|
||||
// Handle optional parameters (paramName?)
|
||||
if (paramName.endsWith('?')) {
|
||||
const actualName = paramName.slice(0, -1);
|
||||
const value = req.body[actualName];
|
||||
if (value) {
|
||||
validatePath(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle array parameters (paramName[])
|
||||
if (paramName.endsWith('[]')) {
|
||||
const actualName = paramName.slice(0, -2);
|
||||
const values = req.body[actualName];
|
||||
if (Array.isArray(values) && values.length > 0) {
|
||||
for (const value of values) {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regular parameters
|
||||
const value = req.body[paramName];
|
||||
if (value) {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Base provider class that all provider implementations must extend
|
||||
@@ -33,9 +33,7 @@ export abstract class BaseProvider {
|
||||
* @param options Execution options
|
||||
* @returns AsyncGenerator yielding provider messages
|
||||
*/
|
||||
abstract executeQuery(
|
||||
options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage>;
|
||||
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
|
||||
|
||||
/**
|
||||
* Detect if the provider is installed and configured
|
||||
@@ -59,7 +57,7 @@ export abstract class BaseProvider {
|
||||
|
||||
// Base validation (can be overridden)
|
||||
if (!this.config) {
|
||||
errors.push("Provider config is missing");
|
||||
errors.push('Provider config is missing');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -76,7 +74,7 @@ export abstract class BaseProvider {
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
// Default implementation - override in subclasses
|
||||
const commonFeatures = ["tools", "text"];
|
||||
const commonFeatures = ['tools', 'text'];
|
||||
return commonFeatures.includes(feature);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,26 +5,24 @@
|
||||
* with the provider architecture.
|
||||
*/
|
||||
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
} from './types.js';
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return "claude";
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query using Claude Agent SDK
|
||||
*/
|
||||
async *executeQuery(
|
||||
options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage> {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
const {
|
||||
prompt,
|
||||
model,
|
||||
@@ -38,16 +36,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
} = options;
|
||||
|
||||
// Build Claude SDK options
|
||||
const defaultTools = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
];
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
const toolsToUse = allowedTools || defaultTools;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
@@ -56,7 +45,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: toolsToUse,
|
||||
permissionMode: "acceptEdits",
|
||||
permissionMode: 'acceptEdits',
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
@@ -75,10 +64,10 @@ export class ClaudeProvider extends BaseProvider {
|
||||
// Multi-part prompt (with images)
|
||||
promptPayload = (async function* () {
|
||||
const multiPartPrompt = {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: {
|
||||
role: "user" as const,
|
||||
role: 'user' as const,
|
||||
content: prompt,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
@@ -99,10 +88,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
yield msg as ProviderMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ClaudeProvider] executeQuery() error during execution:",
|
||||
error
|
||||
);
|
||||
console.error('[ClaudeProvider] executeQuery() error during execution:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -116,7 +102,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
|
||||
const status: InstallationStatus = {
|
||||
installed: true,
|
||||
method: "sdk",
|
||||
method: 'sdk',
|
||||
hasApiKey,
|
||||
authenticated: hasApiKey,
|
||||
};
|
||||
@@ -130,53 +116,53 @@ export class ClaudeProvider extends BaseProvider {
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
const models = [
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
modelString: "claude-opus-4-5-20251101",
|
||||
provider: "anthropic",
|
||||
description: "Most capable Claude model",
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
modelString: 'claude-opus-4-5-20251101',
|
||||
provider: 'anthropic',
|
||||
description: 'Most capable Claude model',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "premium" as const,
|
||||
tier: 'premium' as const,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
modelString: "claude-sonnet-4-20250514",
|
||||
provider: "anthropic",
|
||||
description: "Balanced performance and cost",
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
modelString: 'claude-sonnet-4-20250514',
|
||||
provider: 'anthropic',
|
||||
description: 'Balanced performance and cost',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "standard" as const,
|
||||
tier: 'standard' as const,
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-sonnet-20241022",
|
||||
name: "Claude 3.5 Sonnet",
|
||||
modelString: "claude-3-5-sonnet-20241022",
|
||||
provider: "anthropic",
|
||||
description: "Fast and capable",
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
modelString: 'claude-3-5-sonnet-20241022',
|
||||
provider: 'anthropic',
|
||||
description: 'Fast and capable',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "standard" as const,
|
||||
tier: 'standard' as const,
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-haiku-20241022",
|
||||
name: "Claude 3.5 Haiku",
|
||||
modelString: "claude-3-5-haiku-20241022",
|
||||
provider: "anthropic",
|
||||
description: "Fastest Claude model",
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
name: 'Claude Haiku 4.5',
|
||||
modelString: 'claude-haiku-4-5-20251001',
|
||||
provider: 'anthropic',
|
||||
description: 'Fastest Claude model',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "basic" as const,
|
||||
tier: 'basic' as const,
|
||||
},
|
||||
] satisfies ModelDefinition[];
|
||||
return models;
|
||||
@@ -186,7 +172,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
* Check if the provider supports a specific feature
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supportedFeatures = ["tools", "text", "vision", "thinking"];
|
||||
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
|
||||
*/
|
||||
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import { ClaudeProvider } from "./claude-provider.js";
|
||||
import type { InstallationStatus } from "./types.js";
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { ClaudeProvider } from './claude-provider.js';
|
||||
import type { InstallationStatus } from './types.js';
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
@@ -21,10 +21,7 @@ export class ProviderFactory {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Claude models (claude-*, opus, sonnet, haiku)
|
||||
if (
|
||||
lowerModel.startsWith("claude-") ||
|
||||
["haiku", "sonnet", "opus"].includes(lowerModel)
|
||||
) {
|
||||
if (lowerModel.startsWith('claude-') || ['haiku', 'sonnet', 'opus'].includes(lowerModel)) {
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
@@ -37,9 +34,7 @@ export class ProviderFactory {
|
||||
// }
|
||||
|
||||
// Default to Claude for unknown models
|
||||
console.warn(
|
||||
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
|
||||
);
|
||||
console.warn(`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`);
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
@@ -58,9 +53,7 @@ export class ProviderFactory {
|
||||
*
|
||||
* @returns Map of provider name to installation status
|
||||
*/
|
||||
static async checkAllProviders(): Promise<
|
||||
Record<string, InstallationStatus>
|
||||
> {
|
||||
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
|
||||
const providers = this.getAllProviders();
|
||||
const statuses: Record<string, InstallationStatus> = {};
|
||||
|
||||
@@ -83,8 +76,8 @@ export class ProviderFactory {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
switch (lowerName) {
|
||||
case "claude":
|
||||
case "anthropic":
|
||||
case 'claude':
|
||||
case 'anthropic':
|
||||
return new ClaudeProvider();
|
||||
|
||||
// Future providers:
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ProviderConfig {
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: "user" | "assistant";
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ExecuteOptions {
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
*/
|
||||
export interface ContentBlock {
|
||||
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||
type: 'text' | 'tool_use' | 'thinking' | 'tool_result';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
@@ -48,52 +48,20 @@ 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)
|
||||
*/
|
||||
export interface ProviderMessage {
|
||||
type: "assistant" | "user" | "error" | "result";
|
||||
subtype?: "success" | "error";
|
||||
type: 'assistant' | 'user' | 'error' | 'result';
|
||||
subtype?: 'success' | 'error';
|
||||
session_id?: string;
|
||||
message?: {
|
||||
role: "user" | "assistant";
|
||||
role: 'user' | 'assistant';
|
||||
content: ContentBlock[];
|
||||
};
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +71,7 @@ export interface InstallationStatus {
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
method?: "cli" | "npm" | "brew" | "sdk";
|
||||
method?: 'cli' | 'npm' | 'brew' | 'sdk';
|
||||
hasApiKey?: boolean;
|
||||
authenticated?: boolean;
|
||||
error?: string;
|
||||
@@ -131,6 +99,6 @@ export interface ModelDefinition {
|
||||
maxOutputTokens?: number;
|
||||
supportsVision?: boolean;
|
||||
supportsTools?: boolean;
|
||||
tier?: "basic" | "standard" | "premium";
|
||||
tier?: 'basic' | 'standard' | 'premium';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* Common utilities for agent routes
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger("Agent");
|
||||
const logger = createLogger('Agent');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
@@ -2,28 +2,30 @@
|
||||
* Agent routes - HTTP API for Claude agent interactions
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { AgentService } from "../../services/agent-service.js";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createStartHandler } from "./routes/start.js";
|
||||
import { createSendHandler } from "./routes/send.js";
|
||||
import { createHistoryHandler } from "./routes/history.js";
|
||||
import { createStopHandler } from "./routes/stop.js";
|
||||
import { createClearHandler } from "./routes/clear.js";
|
||||
import { createModelHandler } from "./routes/model.js";
|
||||
import { Router } from 'express';
|
||||
import { AgentService } from '../../services/agent-service.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStartHandler } from './routes/start.js';
|
||||
import { createSendHandler } from './routes/send.js';
|
||||
import { createHistoryHandler } from './routes/history.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createClearHandler } from './routes/clear.js';
|
||||
import { createModelHandler } from './routes/model.js';
|
||||
|
||||
export function createAgentRoutes(
|
||||
agentService: AgentService,
|
||||
_events: EventEmitter
|
||||
): Router {
|
||||
export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/start", createStartHandler(agentService));
|
||||
router.post("/send", createSendHandler(agentService));
|
||||
router.post("/history", createHistoryHandler(agentService));
|
||||
router.post("/stop", createStopHandler(agentService));
|
||||
router.post("/clear", createClearHandler(agentService));
|
||||
router.post("/model", createModelHandler(agentService));
|
||||
router.post('/start', validatePathParams('workingDirectory?'), createStartHandler(agentService));
|
||||
router.post(
|
||||
'/send',
|
||||
validatePathParams('workingDirectory?', 'imagePaths[]'),
|
||||
createSendHandler(agentService)
|
||||
);
|
||||
router.post('/history', createHistoryHandler(agentService));
|
||||
router.post('/stop', createStopHandler(agentService));
|
||||
router.post('/clear', createClearHandler(agentService));
|
||||
router.post('/model', createModelHandler(agentService));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /clear endpoint - Clear conversation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AgentService } from "../../../services/agent-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createClearHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -12,16 +12,14 @@ export function createClearHandler(agentService: AgentService) {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId is required" });
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.clearSession(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, "Clear session failed");
|
||||
logError(error, 'Clear session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /history endpoint - Get conversation history
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AgentService } from "../../../services/agent-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createHistoryHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -12,16 +12,14 @@ export function createHistoryHandler(agentService: AgentService) {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId is required" });
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = agentService.getHistory(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, "Get history failed");
|
||||
logError(error, 'Get history failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /model endpoint - Set session model
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AgentService } from "../../../services/agent-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createModelHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,16 +15,14 @@ export function createModelHandler(agentService: AgentService) {
|
||||
};
|
||||
|
||||
if (!sessionId || !model) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId and model are required" });
|
||||
res.status(400).json({ success: false, error: 'sessionId and model are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.setSessionModel(sessionId, model);
|
||||
res.json({ success: result });
|
||||
} catch (error) {
|
||||
logError(error, "Set session model failed");
|
||||
logError(error, 'Set session model failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,29 +2,27 @@
|
||||
* POST /send endpoint - Send a message
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AgentService } from "../../../services/agent-service.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const logger = createLogger("Agent");
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
const logger = createLogger('Agent');
|
||||
|
||||
export function createSendHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, message, workingDirectory, imagePaths, model } =
|
||||
req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
};
|
||||
const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
if (!sessionId || !message) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "sessionId and message are required",
|
||||
error: 'sessionId and message are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -39,13 +37,13 @@ export function createSendHandler(agentService: AgentService) {
|
||||
model,
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error, "Send message failed (background)");
|
||||
logError(error, 'Send message failed (background)');
|
||||
});
|
||||
|
||||
// Return immediately - responses come via WebSocket
|
||||
res.json({ success: true, message: "Message sent" });
|
||||
res.json({ success: true, message: 'Message sent' });
|
||||
} catch (error) {
|
||||
logError(error, "Send message failed");
|
||||
logError(error, 'Send message failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* POST /start endpoint - Start a conversation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AgentService } from "../../../services/agent-service.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const logger = createLogger("Agent");
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
const logger = createLogger('Agent');
|
||||
|
||||
export function createStartHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -18,9 +17,7 @@ export function createStartHandler(agentService: AgentService) {
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId is required" });
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,7 +28,7 @@ export function createStartHandler(agentService: AgentService) {
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, "Start conversation failed");
|
||||
logError(error, 'Start conversation failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /stop endpoint - Stop execution
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AgentService } from "../../../services/agent-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -12,16 +12,14 @@ export function createStopHandler(agentService: AgentService) {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId is required" });
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.stopExecution(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, "Stop execution failed");
|
||||
logError(error, 'Stop execution failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Common utilities and state management for spec regeneration
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
// Shared state for tracking generation status - private
|
||||
let isRunning = false;
|
||||
@@ -23,10 +23,7 @@ export function getSpecRegenerationStatus(): {
|
||||
/**
|
||||
* Set the running state and abort controller
|
||||
*/
|
||||
export function setRunningState(
|
||||
running: boolean,
|
||||
controller: AbortController | null = null
|
||||
): void {
|
||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
@@ -40,14 +37,12 @@ export function logAuthStatus(context: string): void {
|
||||
logger.info(`${context} - Auth Status:`);
|
||||
logger.info(
|
||||
` ANTHROPIC_API_KEY: ${
|
||||
hasApiKey
|
||||
? "SET (" + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + "...)"
|
||||
: "NOT SET"
|
||||
hasApiKey ? 'SET (' + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + '...)' : 'NOT SET'
|
||||
}`
|
||||
);
|
||||
|
||||
if (!hasApiKey) {
|
||||
logger.warn("⚠️ WARNING: No authentication configured! SDK will fail.");
|
||||
logger.warn('⚠️ WARNING: No authentication configured! SDK will fail.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,16 +51,13 @@ export function logAuthStatus(context: string): void {
|
||||
*/
|
||||
export function logError(error: unknown, context: string): void {
|
||||
logger.error(`❌ ${context}:`);
|
||||
logger.error("Error name:", (error as any)?.name);
|
||||
logger.error("Error message:", (error as Error)?.message);
|
||||
logger.error("Error stack:", (error as Error)?.stack);
|
||||
logger.error(
|
||||
"Full error object:",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
logger.error('Error name:', (error as any)?.name);
|
||||
logger.error('Error message:', (error as Error)?.message);
|
||||
logger.error('Error stack:', (error as Error)?.stack);
|
||||
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||
}
|
||||
|
||||
import { getErrorMessage as getErrorMessageShared } from "../common.js";
|
||||
import { getErrorMessage as getErrorMessageShared } from '../common.js';
|
||||
|
||||
// Re-export shared utility
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* Generate features from existing app_spec.txt
|
||||
*/
|
||||
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
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";
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
const DEFAULT_MAX_FEATURES = 50;
|
||||
|
||||
@@ -22,28 +22,26 @@ export async function generateFeaturesFromSpec(
|
||||
maxFeatures?: number
|
||||
): Promise<void> {
|
||||
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
|
||||
logger.debug("========== generateFeaturesFromSpec() started ==========");
|
||||
logger.debug("projectPath:", projectPath);
|
||||
logger.debug("maxFeatures:", featureCount);
|
||||
logger.debug('========== generateFeaturesFromSpec() started ==========');
|
||||
logger.debug('projectPath:', projectPath);
|
||||
logger.debug('maxFeatures:', featureCount);
|
||||
|
||||
// Read existing spec from .automaker directory
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
let spec: string;
|
||||
|
||||
logger.debug("Reading spec from:", specPath);
|
||||
logger.debug('Reading spec from:', specPath);
|
||||
|
||||
try {
|
||||
spec = await fs.readFile(specPath, "utf-8");
|
||||
spec = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
logger.info(`Spec loaded successfully (${spec.length} chars)`);
|
||||
logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`);
|
||||
logger.info(
|
||||
`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`
|
||||
);
|
||||
logger.info(`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`);
|
||||
} catch (readError) {
|
||||
logger.error("❌ Failed to read spec file:", readError);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: "No project spec found. Generate spec first.",
|
||||
logger.error('❌ Failed to read spec file:', readError);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: 'No project spec found. Generate spec first.',
|
||||
projectPath: projectPath,
|
||||
});
|
||||
return;
|
||||
@@ -82,16 +80,14 @@ Generate ${featureCount} features that build on each other logically.
|
||||
|
||||
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
|
||||
|
||||
logger.info("========== PROMPT BEING SENT ==========");
|
||||
logger.info('========== PROMPT BEING SENT ==========');
|
||||
logger.info(`Prompt length: ${prompt.length} chars`);
|
||||
logger.info(
|
||||
`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`
|
||||
);
|
||||
logger.info("========== END PROMPT PREVIEW ==========");
|
||||
logger.info(`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`);
|
||||
logger.info('========== END PROMPT PREVIEW ==========');
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: "Analyzing spec and generating features...\n",
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: 'Analyzing spec and generating features...\n',
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
@@ -100,73 +96,67 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
abortController,
|
||||
});
|
||||
|
||||
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
|
||||
logger.info("Calling Claude Agent SDK query() for features...");
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query() for features...');
|
||||
|
||||
logAuthStatus("Right before SDK query() for features");
|
||||
logAuthStatus('Right before SDK query() for features');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug("query() returned stream successfully");
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error("❌ query() threw an exception:");
|
||||
logger.error("Error:", queryError);
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
|
||||
logger.debug("Starting to iterate over feature stream...");
|
||||
logger.debug('Starting to iterate over feature stream...');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.debug(
|
||||
`Feature stream message #${messageCount}:`,
|
||||
JSON.stringify(
|
||||
{ type: msg.type, subtype: (msg as any).subtype },
|
||||
null,
|
||||
2
|
||||
)
|
||||
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
|
||||
);
|
||||
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.debug(
|
||||
`Feature text block received (${block.text.length} chars)`
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
logger.debug(`Feature text block received (${block.text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
logger.debug("Received success result for features");
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.debug('Received success result for features');
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
logger.error("❌ Received error message from feature stream:");
|
||||
logger.error("Error message:", JSON.stringify(msg, null, 2));
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from feature stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error("❌ Error while iterating feature stream:");
|
||||
logger.error("Stream error:", streamError);
|
||||
logger.error('❌ Error while iterating feature stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info("========== FULL RESPONSE TEXT ==========");
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info("========== END RESPONSE TEXT ==========");
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
|
||||
logger.debug("========== generateFeaturesFromSpec() completed ==========");
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
* Generate app_spec.txt from project overview
|
||||
*/
|
||||
|
||||
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 { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.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";
|
||||
} from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export async function generateSpec(
|
||||
projectPath: string,
|
||||
@@ -29,17 +29,17 @@ export async function generateSpec(
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number
|
||||
): Promise<void> {
|
||||
logger.info("========== generateSpec() started ==========");
|
||||
logger.info("projectPath:", projectPath);
|
||||
logger.info("projectOverview length:", `${projectOverview.length} chars`);
|
||||
logger.info("projectOverview preview:", projectOverview.substring(0, 300));
|
||||
logger.info("generateFeatures:", generateFeatures);
|
||||
logger.info("analyzeProject:", analyzeProject);
|
||||
logger.info("maxFeatures:", maxFeatures);
|
||||
logger.info('========== generateSpec() started ==========');
|
||||
logger.info('projectPath:', projectPath);
|
||||
logger.info('projectOverview length:', `${projectOverview.length} chars`);
|
||||
logger.info('projectOverview preview:', projectOverview.substring(0, 300));
|
||||
logger.info('generateFeatures:', generateFeatures);
|
||||
logger.info('analyzeProject:', analyzeProject);
|
||||
logger.info('maxFeatures:', maxFeatures);
|
||||
|
||||
// Build the prompt based on whether we should analyze the project
|
||||
let analysisInstructions = "";
|
||||
let techStackDefaults = "";
|
||||
let analysisInstructions = '';
|
||||
let techStackDefaults = '';
|
||||
|
||||
if (analyzeProject !== false) {
|
||||
// Default to true - analyze the project
|
||||
@@ -73,114 +73,110 @@ ${analysisInstructions}
|
||||
|
||||
${getStructuredSpecPromptInstruction()}`;
|
||||
|
||||
logger.info("========== PROMPT BEING SENT ==========");
|
||||
logger.info('========== PROMPT BEING SENT ==========');
|
||||
logger.info(`Prompt length: ${prompt.length} chars`);
|
||||
logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`);
|
||||
logger.info("========== END PROMPT PREVIEW ==========");
|
||||
logger.info('========== END PROMPT PREVIEW ==========');
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_progress",
|
||||
content: "Starting spec generation...\n",
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_progress',
|
||||
content: 'Starting spec generation...\n',
|
||||
});
|
||||
|
||||
const options = createSpecGenerationOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
outputFormat: {
|
||||
type: "json_schema",
|
||||
type: 'json_schema',
|
||||
schema: specOutputSchema,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
|
||||
logger.info("Calling Claude Agent SDK query()...");
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query()...');
|
||||
|
||||
// Log auth status right before the SDK call
|
||||
logAuthStatus("Right before SDK query()");
|
||||
logAuthStatus('Right before SDK query()');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug("query() returned stream successfully");
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error("❌ query() threw an exception:");
|
||||
logger.error("Error:", queryError);
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
logger.info("Starting to iterate over stream...");
|
||||
logger.info('Starting to iterate over stream...');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.info(
|
||||
`Stream message #${messageCount}: type=${msg.type}, subtype=${
|
||||
(msg as any).subtype
|
||||
}`
|
||||
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
|
||||
);
|
||||
|
||||
if (msg.type === "assistant") {
|
||||
if (msg.type === 'assistant') {
|
||||
const msgAny = msg as any;
|
||||
if (msgAny.message?.content) {
|
||||
for (const block of msgAny.message.content) {
|
||||
if (block.type === "text") {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
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",
|
||||
} 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 if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
logger.info("Received success result");
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.info('Received success 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));
|
||||
logger.info('✅ Received structured output');
|
||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||
} else {
|
||||
logger.warn("⚠️ No structured output in result, will fall back to text parsing");
|
||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
||||
}
|
||||
} else if (msg.type === "result") {
|
||||
} else if (msg.type === 'result') {
|
||||
// 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!");
|
||||
} 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");
|
||||
if (subtype === 'error_max_turns') {
|
||||
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:");
|
||||
logger.error("Error message:", JSON.stringify(msg, null, 2));
|
||||
} else if (msg.type === "user") {
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
} else if (msg.type === 'user') {
|
||||
// Log user messages (tool results)
|
||||
logger.info(
|
||||
`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`
|
||||
);
|
||||
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error("❌ Error while iterating stream:");
|
||||
logger.error("Stream error:", streamError);
|
||||
logger.error('❌ Error while iterating stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
@@ -192,40 +188,42 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
|
||||
if (structuredOutput) {
|
||||
// Use structured output - convert JSON to XML
|
||||
logger.info("✅ Using structured output for XML generation");
|
||||
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 ==========");
|
||||
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");
|
||||
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>");
|
||||
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);
|
||||
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");
|
||||
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."
|
||||
'Failed to generate spec: No valid XML structure found in response. ' +
|
||||
'The response contained conversational text but no <project_specification> tags. ' +
|
||||
'Please try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,60 +232,55 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
|
||||
logger.info("Saving spec to:", specPath);
|
||||
logger.info('Saving spec to:', specPath);
|
||||
logger.info(`Content to save (${xmlContent.length} chars)`);
|
||||
|
||||
await fs.writeFile(specPath, xmlContent);
|
||||
await secureFs.writeFile(specPath, xmlContent);
|
||||
|
||||
// Verify the file was written
|
||||
const savedContent = await fs.readFile(specPath, "utf-8");
|
||||
const savedContent = await secureFs.readFile(specPath, 'utf-8');
|
||||
logger.info(`Verified saved file: ${savedContent.length} chars`);
|
||||
if (savedContent.length === 0) {
|
||||
logger.error("❌ File was saved but is empty!");
|
||||
logger.error('❌ File was saved but is empty!');
|
||||
}
|
||||
|
||||
logger.info("Spec saved successfully");
|
||||
logger.info('Spec saved successfully');
|
||||
|
||||
// Emit spec completion event
|
||||
if (generateFeatures) {
|
||||
// If features will be generated, emit intermediate completion
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: "[Phase: spec_complete] Spec created! Generating features...\n",
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: '[Phase: spec_complete] Spec created! Generating features...\n',
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else {
|
||||
// If no features, emit final completion
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_complete",
|
||||
message: "Spec regeneration complete!",
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_complete',
|
||||
message: 'Spec regeneration complete!',
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// If generate features was requested, generate them from the spec
|
||||
if (generateFeatures) {
|
||||
logger.info("Starting feature generation from spec...");
|
||||
logger.info('Starting feature generation from spec...');
|
||||
// Create a new abort controller for feature generation
|
||||
const featureAbortController = new AbortController();
|
||||
try {
|
||||
await generateFeaturesFromSpec(
|
||||
projectPath,
|
||||
events,
|
||||
featureAbortController,
|
||||
maxFeatures
|
||||
);
|
||||
await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures);
|
||||
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
|
||||
} catch (featureError) {
|
||||
logger.error("Feature generation failed:", featureError);
|
||||
logger.error('Feature generation failed:', featureError);
|
||||
// Don't throw - spec generation succeeded, feature generation is optional
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: (featureError as Error).message || "Feature generation failed",
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: (featureError as Error).message || 'Feature generation failed',
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("========== generateSpec() completed ==========");
|
||||
logger.debug('========== generateSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -2,25 +2,22 @@
|
||||
* Spec Regeneration routes - HTTP API for AI-powered spec generation
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createGenerateHandler } from "./routes/generate.js";
|
||||
import { createGenerateFeaturesHandler } from "./routes/generate-features.js";
|
||||
import { createStopHandler } from "./routes/stop.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createGenerateFeaturesHandler } from './routes/generate-features.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
|
||||
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/create", createCreateHandler(events));
|
||||
router.post("/generate", createGenerateHandler(events));
|
||||
router.post("/generate-features", createGenerateFeaturesHandler(events));
|
||||
router.post("/stop", createStopHandler());
|
||||
router.get("/status", createStatusHandler());
|
||||
router.post('/create', createCreateHandler(events));
|
||||
router.post('/generate', createGenerateHandler(events));
|
||||
router.post('/generate-features', createGenerateFeaturesHandler(events));
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,71 +2,71 @@
|
||||
* Parse agent response and create feature files
|
||||
*/
|
||||
|
||||
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";
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export async function parseAndCreateFeatures(
|
||||
projectPath: string,
|
||||
content: string,
|
||||
events: EventEmitter
|
||||
): Promise<void> {
|
||||
logger.info("========== parseAndCreateFeatures() started ==========");
|
||||
logger.info('========== parseAndCreateFeatures() started ==========');
|
||||
logger.info(`Content length: ${content.length} chars`);
|
||||
logger.info("========== CONTENT RECEIVED FOR PARSING ==========");
|
||||
logger.info('========== CONTENT RECEIVED FOR PARSING ==========');
|
||||
logger.info(content);
|
||||
logger.info("========== END CONTENT ==========");
|
||||
logger.info('========== END CONTENT ==========');
|
||||
|
||||
try {
|
||||
// Extract JSON from response
|
||||
logger.info("Extracting JSON from response...");
|
||||
logger.info('Extracting JSON from response...');
|
||||
logger.info(`Looking for pattern: /{[\\s\\S]*"features"[\\s\\S]*}/`);
|
||||
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
logger.error("❌ No valid JSON found in response");
|
||||
logger.error("Full content received:");
|
||||
logger.error('❌ No valid JSON found in response');
|
||||
logger.error('Full content received:');
|
||||
logger.error(content);
|
||||
throw new Error("No valid JSON found in response");
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
|
||||
logger.info(`JSON match found (${jsonMatch[0].length} chars)`);
|
||||
logger.info("========== MATCHED JSON ==========");
|
||||
logger.info('========== MATCHED JSON ==========');
|
||||
logger.info(jsonMatch[0]);
|
||||
logger.info("========== END MATCHED JSON ==========");
|
||||
logger.info('========== END MATCHED JSON ==========');
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
logger.info(`Parsed ${parsed.features?.length || 0} features`);
|
||||
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
|
||||
logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2));
|
||||
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
await fs.mkdir(featuresDir, { recursive: true });
|
||||
await secureFs.mkdir(featuresDir, { recursive: true });
|
||||
|
||||
const createdFeatures: Array<{ id: string; title: string }> = [];
|
||||
|
||||
for (const feature of parsed.features) {
|
||||
logger.debug("Creating feature:", feature.id);
|
||||
logger.debug('Creating feature:', feature.id);
|
||||
const featureDir = path.join(featuresDir, feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
await secureFs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const featureData = {
|
||||
id: feature.id,
|
||||
category: feature.category || "Uncategorized",
|
||||
category: feature.category || 'Uncategorized',
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
status: "backlog", // Features go to backlog - user must manually start them
|
||||
status: 'backlog', // Features go to backlog - user must manually start them
|
||||
priority: feature.priority || 2,
|
||||
complexity: feature.complexity || "moderate",
|
||||
complexity: feature.complexity || 'moderate',
|
||||
dependencies: feature.dependencies || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, "feature.json"),
|
||||
await secureFs.writeFile(
|
||||
path.join(featureDir, 'feature.json'),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
|
||||
@@ -75,20 +75,20 @@ export async function parseAndCreateFeatures(
|
||||
|
||||
logger.info(`✓ Created ${createdFeatures.length} features successfully`);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_complete",
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_complete',
|
||||
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ parseAndCreateFeatures() failed:");
|
||||
logger.error("Error:", error);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
logger.error('❌ parseAndCreateFeatures() failed:');
|
||||
logger.error('Error:', error);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: (error as Error).message,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("========== parseAndCreateFeatures() completed ==========");
|
||||
logger.debug('========== parseAndCreateFeatures() completed ==========');
|
||||
}
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
* POST /create endpoint - Create project spec from overview
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
getSpecRegenerationStatus,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { generateSpec } from "../generate-spec.js";
|
||||
} from '../common.js';
|
||||
import { generateSpec } from '../generate-spec.js';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export function createCreateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /create endpoint called ==========");
|
||||
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
|
||||
logger.info('========== /create endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures } =
|
||||
@@ -31,37 +31,34 @@ export function createCreateHandler(events: EventEmitter) {
|
||||
maxFeatures?: number;
|
||||
};
|
||||
|
||||
logger.debug("Parsed params:");
|
||||
logger.debug(" projectPath:", projectPath);
|
||||
logger.debug(
|
||||
" projectOverview length:",
|
||||
`${projectOverview?.length || 0} chars`
|
||||
);
|
||||
logger.debug(" generateFeatures:", generateFeatures);
|
||||
logger.debug(" analyzeProject:", analyzeProject);
|
||||
logger.debug(" maxFeatures:", maxFeatures);
|
||||
logger.debug('Parsed params:');
|
||||
logger.debug(' projectPath:', projectPath);
|
||||
logger.debug(' projectOverview length:', `${projectOverview?.length || 0} chars`);
|
||||
logger.debug(' generateFeatures:', generateFeatures);
|
||||
logger.debug(' analyzeProject:', analyzeProject);
|
||||
logger.debug(' maxFeatures:', maxFeatures);
|
||||
|
||||
if (!projectPath || !projectOverview) {
|
||||
logger.error("Missing required parameters");
|
||||
logger.error('Missing required parameters');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectOverview required",
|
||||
error: 'projectPath and projectOverview required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
if (isRunning) {
|
||||
logger.warn("Generation already running, rejecting request");
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Spec generation already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting generation");
|
||||
logAuthStatus('Before starting generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
logger.info("Starting background generation task...");
|
||||
logger.info('Starting background generation task...');
|
||||
|
||||
// Start generation in background
|
||||
generateSpec(
|
||||
@@ -74,24 +71,22 @@ export function createCreateHandler(events: EventEmitter) {
|
||||
maxFeatures
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, "Generation failed with error");
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
logError(error, 'Generation failed with error');
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: getErrorMessage(error),
|
||||
projectPath: projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Generation task finished (success or error)");
|
||||
logger.info('Generation task finished (success or error)');
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"Returning success response (generation running in background)"
|
||||
);
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Create spec route handler failed");
|
||||
logError(error, 'Create spec route handler failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
* POST /generate-features endpoint - Generate features from existing spec
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
getSpecRegenerationStatus,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { generateFeaturesFromSpec } from "../generate-features-from-spec.js";
|
||||
} from '../common.js';
|
||||
import { generateFeaturesFromSpec } from '../generate-features-from-spec.js';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export function createGenerateFeaturesHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /generate-features endpoint called ==========");
|
||||
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
|
||||
logger.info('========== /generate-features endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const { projectPath, maxFeatures } = req.body as {
|
||||
@@ -27,52 +27,45 @@ export function createGenerateFeaturesHandler(events: EventEmitter) {
|
||||
maxFeatures?: number;
|
||||
};
|
||||
|
||||
logger.debug("projectPath:", projectPath);
|
||||
logger.debug("maxFeatures:", maxFeatures);
|
||||
logger.debug('projectPath:', projectPath);
|
||||
logger.debug('maxFeatures:', maxFeatures);
|
||||
|
||||
if (!projectPath) {
|
||||
logger.error("Missing projectPath parameter");
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
logger.error('Missing projectPath parameter');
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
if (isRunning) {
|
||||
logger.warn("Generation already running, rejecting request");
|
||||
res.json({ success: false, error: "Generation already running" });
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Generation already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting feature generation");
|
||||
logAuthStatus('Before starting feature generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
logger.info("Starting background feature generation task...");
|
||||
logger.info('Starting background feature generation task...');
|
||||
|
||||
generateFeaturesFromSpec(
|
||||
projectPath,
|
||||
events,
|
||||
abortController,
|
||||
maxFeatures
|
||||
)
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures)
|
||||
.catch((error) => {
|
||||
logError(error, "Feature generation failed with error");
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_error",
|
||||
logError(error, 'Feature generation failed with error');
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'features_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Feature generation task finished (success or error)");
|
||||
logger.info('Feature generation task finished (success or error)');
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"Returning success response (generation running in background)"
|
||||
);
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Generate features route handler failed");
|
||||
logError(error, 'Generate features route handler failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,71 +2,63 @@
|
||||
* POST /generate endpoint - Generate spec from project definition
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
getSpecRegenerationStatus,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { generateSpec } from "../generate-spec.js";
|
||||
} from '../common.js';
|
||||
import { generateSpec } from '../generate-spec.js';
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /generate endpoint called ==========");
|
||||
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
|
||||
logger.info('========== /generate endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
maxFeatures,
|
||||
} = req.body as {
|
||||
projectPath: string;
|
||||
projectDefinition: string;
|
||||
generateFeatures?: boolean;
|
||||
analyzeProject?: boolean;
|
||||
maxFeatures?: number;
|
||||
};
|
||||
const { projectPath, projectDefinition, generateFeatures, analyzeProject, maxFeatures } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
projectDefinition: string;
|
||||
generateFeatures?: boolean;
|
||||
analyzeProject?: boolean;
|
||||
maxFeatures?: number;
|
||||
};
|
||||
|
||||
logger.debug("Parsed params:");
|
||||
logger.debug(" projectPath:", projectPath);
|
||||
logger.debug(
|
||||
" projectDefinition length:",
|
||||
`${projectDefinition?.length || 0} chars`
|
||||
);
|
||||
logger.debug(" generateFeatures:", generateFeatures);
|
||||
logger.debug(" analyzeProject:", analyzeProject);
|
||||
logger.debug(" maxFeatures:", maxFeatures);
|
||||
logger.debug('Parsed params:');
|
||||
logger.debug(' projectPath:', projectPath);
|
||||
logger.debug(' projectDefinition length:', `${projectDefinition?.length || 0} chars`);
|
||||
logger.debug(' generateFeatures:', generateFeatures);
|
||||
logger.debug(' analyzeProject:', analyzeProject);
|
||||
logger.debug(' maxFeatures:', maxFeatures);
|
||||
|
||||
if (!projectPath || !projectDefinition) {
|
||||
logger.error("Missing required parameters");
|
||||
logger.error('Missing required parameters');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectDefinition required",
|
||||
error: 'projectPath and projectDefinition required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
if (isRunning) {
|
||||
logger.warn("Generation already running, rejecting request");
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Spec generation already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting generation");
|
||||
logAuthStatus('Before starting generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
logger.info("Starting background generation task...");
|
||||
logger.info('Starting background generation task...');
|
||||
|
||||
generateSpec(
|
||||
projectPath,
|
||||
@@ -78,24 +70,22 @@ export function createGenerateHandler(events: EventEmitter) {
|
||||
maxFeatures
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, "Generation failed with error");
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
logError(error, 'Generation failed with error');
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: getErrorMessage(error),
|
||||
projectPath: projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Generation task finished (success or error)");
|
||||
logger.info('Generation task finished (success or error)');
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"Returning success response (generation running in background)"
|
||||
);
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Generate spec route handler failed");
|
||||
logError(error, 'Generate spec route handler failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* GET /status endpoint - Get generation status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { getSpecRegenerationStatus, getErrorMessage } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
* POST /stop endpoint - Stop generation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
getSpecRegenerationStatus,
|
||||
setRunningState,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* Common utilities for auto-mode routes
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
@@ -4,35 +4,65 @@
|
||||
* Uses the AutoModeService for real feature execution with Claude Agent SDK
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import type { AutoModeService } from "../../services/auto-mode-service.js";
|
||||
import { createStopFeatureHandler } from "./routes/stop-feature.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
import { createRunFeatureHandler } from "./routes/run-feature.js";
|
||||
import { createVerifyFeatureHandler } from "./routes/verify-feature.js";
|
||||
import { createResumeFeatureHandler } from "./routes/resume-feature.js";
|
||||
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";
|
||||
import { Router } from 'express';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStopFeatureHandler } from './routes/stop-feature.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createRunFeatureHandler } from './routes/run-feature.js';
|
||||
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
|
||||
import { createResumeFeatureHandler } from './routes/resume-feature.js';
|
||||
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("/stop-feature", createStopFeatureHandler(autoModeService));
|
||||
router.post("/status", createStatusHandler(autoModeService));
|
||||
router.post("/run-feature", createRunFeatureHandler(autoModeService));
|
||||
router.post("/verify-feature", createVerifyFeatureHandler(autoModeService));
|
||||
router.post("/resume-feature", createResumeFeatureHandler(autoModeService));
|
||||
router.post("/context-exists", createContextExistsHandler(autoModeService));
|
||||
router.post("/analyze-project", createAnalyzeProjectHandler(autoModeService));
|
||||
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
|
||||
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
|
||||
router.post(
|
||||
"/follow-up-feature",
|
||||
'/run-feature',
|
||||
validatePathParams('projectPath'),
|
||||
createRunFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/verify-feature',
|
||||
validatePathParams('projectPath'),
|
||||
createVerifyFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/resume-feature',
|
||||
validatePathParams('projectPath'),
|
||||
createResumeFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/context-exists',
|
||||
validatePathParams('projectPath'),
|
||||
createContextExistsHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/analyze-project',
|
||||
validatePathParams('projectPath'),
|
||||
createAnalyzeProjectHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/follow-up-feature',
|
||||
validatePathParams('projectPath', 'imagePaths[]'),
|
||||
createFollowUpFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
|
||||
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
|
||||
router.post(
|
||||
'/commit-feature',
|
||||
validatePathParams('projectPath', 'worktreePath?'),
|
||||
createCommitFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/approve-plan',
|
||||
validatePathParams('projectPath'),
|
||||
createApprovePlanHandler(autoModeService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* POST /analyze-project endpoint - Analyze project
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,9 +15,7 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath is required" });
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,9 +24,9 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
|
||||
logger.error(`[AutoMode] Project analysis error:`, error);
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "Project analysis started" });
|
||||
res.json({ success: true, message: 'Project analysis started' });
|
||||
} catch (error) {
|
||||
logError(error, "Analyze project failed");
|
||||
logError(error, 'Analyze project failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* 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";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -23,15 +23,15 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
if (!featureId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "featureId is required",
|
||||
error: 'featureId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof approved !== "boolean") {
|
||||
if (typeof approved !== 'boolean') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "approved must be a boolean",
|
||||
error: 'approved must be a boolean',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -41,9 +41,9 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
// 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}` : ""}`
|
||||
`[AutoMode] Plan ${approved ? 'approved' : 'rejected'} for feature ${featureId}${
|
||||
editedPlan ? ' (with edits)' : ''
|
||||
}${feedback ? ` - Feedback: ${feedback}` : ''}`
|
||||
);
|
||||
|
||||
// Resolve the pending approval (with recovery support)
|
||||
@@ -67,11 +67,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
success: true,
|
||||
approved,
|
||||
message: approved
|
||||
? "Plan approved - implementation will continue"
|
||||
: "Plan rejected - feature execution stopped",
|
||||
? 'Plan approved - implementation will continue'
|
||||
: 'Plan rejected - feature execution stopped',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Approve plan failed");
|
||||
logError(error, 'Approve plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /commit-feature endpoint - Commit feature changes
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -16,23 +16,17 @@ export function createCommitFeatureHandler(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;
|
||||
}
|
||||
|
||||
const commitHash = await autoModeService.commitFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
worktreePath
|
||||
);
|
||||
const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath);
|
||||
res.json({ success: true, commitHash });
|
||||
} catch (error) {
|
||||
logError(error, "Commit feature failed");
|
||||
logError(error, 'Commit feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /context-exists endpoint - Check if context exists for a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createContextExistsHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,22 +15,17 @@ export function createContextExistsHandler(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;
|
||||
}
|
||||
|
||||
const exists = await autoModeService.contextExists(
|
||||
projectPath,
|
||||
featureId
|
||||
);
|
||||
const exists = await autoModeService.contextExists(projectPath, featureId);
|
||||
res.json({ success: true, exists });
|
||||
} catch (error) {
|
||||
logError(error, "Check context exists failed");
|
||||
logError(error, 'Check context exists failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,29 +2,28 @@
|
||||
* POST /follow-up-feature endpoint - Follow up on a feature
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
imagePaths?: string[];
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
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({
|
||||
success: false,
|
||||
error: "projectPath, featureId, and prompt are required",
|
||||
error: 'projectPath, featureId, and prompt are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -32,18 +31,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
// Start follow-up in background
|
||||
// followUpFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
.followUpFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
useWorktrees ?? true
|
||||
)
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
`[AutoMode] Follow up feature ${featureId} error:`,
|
||||
error
|
||||
);
|
||||
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when follow-up completes (success or error)
|
||||
@@ -52,7 +42,7 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Follow up feature failed");
|
||||
logError(error, 'Follow up feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* POST /resume-feature endpoint - Resume a feature
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -21,7 +21,7 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
if (!projectPath || !featureId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
error: 'projectPath and featureId are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Resume feature failed");
|
||||
logError(error, 'Resume feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* POST /run-feature endpoint - Run a single feature
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -21,7 +21,7 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
if (!projectPath || !featureId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
error: 'projectPath and featureId are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Run feature failed");
|
||||
logError(error, 'Run feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /status endpoint - Get auto mode status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,7 +15,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
...status,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get status failed");
|
||||
logError(error, 'Get status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /stop-feature endpoint - Stop a specific feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -12,16 +12,14 @@ export function createStopFeatureHandler(autoModeService: AutoModeService) {
|
||||
const { featureId } = req.body as { featureId: string };
|
||||
|
||||
if (!featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "featureId is required" });
|
||||
res.status(400).json({ success: false, error: 'featureId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stopped = await autoModeService.stopFeature(featureId);
|
||||
res.json({ success: true, stopped });
|
||||
} catch (error) {
|
||||
logError(error, "Stop feature failed");
|
||||
logError(error, 'Stop feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /verify-feature endpoint - Verify a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,22 +15,17 @@ export function createVerifyFeatureHandler(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;
|
||||
}
|
||||
|
||||
const passes = await autoModeService.verifyFeature(
|
||||
projectPath,
|
||||
featureId
|
||||
);
|
||||
const passes = await autoModeService.verifyFeature(projectPath, featureId);
|
||||
res.json({ success: true, passes });
|
||||
} catch (error) {
|
||||
logError(error, "Verify feature failed");
|
||||
logError(error, 'Verify feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
43
apps/server/src/routes/claude/index.ts
Normal file
43
apps/server/src/routes/claude/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { ClaudeUsageService } from '../../services/claude-usage-service.js';
|
||||
|
||||
export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get current usage (fetches from Claude CLI)
|
||||
router.get('/usage', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if Claude CLI is available first
|
||||
const isAvailable = await service.isAvailable();
|
||||
if (!isAvailable) {
|
||||
res.status(503).json({
|
||||
error: 'Claude CLI not found',
|
||||
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await service.fetchUsageData();
|
||||
res.json(usage);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
||||
res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: "Please run 'claude login' to authenticate",
|
||||
});
|
||||
} else if (message.includes('timed out')) {
|
||||
res.status(504).json({
|
||||
error: 'Command timed out',
|
||||
message: 'The Claude CLI took too long to respond',
|
||||
});
|
||||
} else {
|
||||
console.error('Error fetching usage:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
35
apps/server/src/routes/claude/types.ts
Normal file
35
apps/server/src/routes/claude/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Claude Usage types for CLI-based usage tracking
|
||||
*/
|
||||
|
||||
export type ClaudeUsage = {
|
||||
sessionTokensUsed: number;
|
||||
sessionLimit: number;
|
||||
sessionPercentage: number;
|
||||
sessionResetTime: string; // ISO date string
|
||||
sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
|
||||
|
||||
weeklyTokensUsed: number;
|
||||
weeklyLimit: number;
|
||||
weeklyPercentage: number;
|
||||
weeklyResetTime: string; // ISO date string
|
||||
weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
|
||||
|
||||
sonnetWeeklyTokensUsed: number;
|
||||
sonnetWeeklyPercentage: number;
|
||||
sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
|
||||
|
||||
costUsed: number | null;
|
||||
costLimit: number | null;
|
||||
costCurrency: string | null;
|
||||
|
||||
lastUpdated: string; // ISO date string
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
export type ClaudeStatus = {
|
||||
indicator: {
|
||||
color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
|
||||
};
|
||||
description: string;
|
||||
};
|
||||
@@ -2,373 +2,29 @@
|
||||
* Common utilities shared across all route modules
|
||||
*/
|
||||
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
// Re-export git utilities from shared package
|
||||
export {
|
||||
BINARY_EXTENSIONS,
|
||||
GIT_STATUS_MAP,
|
||||
type FileStatus,
|
||||
isGitRepo,
|
||||
parseGitStatus,
|
||||
generateSyntheticDiffForNewFile,
|
||||
appendUntrackedFileDiffs,
|
||||
listAllFilesInDirectory,
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from '@automaker/git-utils';
|
||||
|
||||
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
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown error";
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
24
apps/server/src/routes/context/index.ts
Normal file
24
apps/server/src/routes/context/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Context routes - HTTP API for context file operations
|
||||
*
|
||||
* Provides endpoints for managing context files including
|
||||
* AI-powered image description generation.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createDescribeImageHandler } from './routes/describe-image.js';
|
||||
import { createDescribeFileHandler } from './routes/describe-file.js';
|
||||
|
||||
/**
|
||||
* Create the context router
|
||||
*
|
||||
* @returns Express router with context endpoints
|
||||
*/
|
||||
export function createContextRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/describe-image', createDescribeImageHandler());
|
||||
router.post('/describe-file', createDescribeFileHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
220
apps/server/src/routes/context/routes/describe-file.ts
Normal file
220
apps/server/src/routes/context/routes/describe-file.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* POST /context/describe-file endpoint - Generate description for a text file
|
||||
*
|
||||
* Uses Claude Haiku to analyze a text file and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
*
|
||||
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
|
||||
* and reads file content directly (not via Claude's Read tool) to prevent
|
||||
* arbitrary file reads and prompt injection attacks.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
|
||||
/**
|
||||
* Request body for the describe-file endpoint
|
||||
*/
|
||||
interface DescribeFileRequestBody {
|
||||
/** Path to the file */
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success response from the describe-file endpoint
|
||||
*/
|
||||
interface DescribeFileSuccessResponse {
|
||||
success: true;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from the describe-file endpoint
|
||||
*/
|
||||
interface DescribeFileErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from Claude SDK response messages
|
||||
*/
|
||||
async function extractTextFromStream(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stream: AsyncIterable<any>
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
||||
for (const block of blocks) {
|
||||
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 describe-file request handler
|
||||
*
|
||||
* @returns Express request handler for file description
|
||||
*/
|
||||
export function createDescribeFileHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { filePath } = req.body as DescribeFileRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: 'filePath is required and must be a string',
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[DescribeFile] Starting description generation for: ${filePath}`);
|
||||
|
||||
// Resolve the path for logging and cwd derivation
|
||||
const resolvedPath = secureFs.resolvePath(filePath);
|
||||
|
||||
// Read file content using secureFs (validates path against ALLOWED_ROOT_DIRECTORY)
|
||||
// This prevents arbitrary file reads (e.g., /etc/passwd, ~/.ssh/id_rsa)
|
||||
// and prompt injection attacks where malicious filePath values could inject instructions
|
||||
let fileContent: string;
|
||||
try {
|
||||
const content = await secureFs.readFile(resolvedPath, 'utf-8');
|
||||
fileContent = typeof content === 'string' ? content : content.toString('utf-8');
|
||||
} catch (readError) {
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (readError instanceof PathNotAllowedError) {
|
||||
logger.warn(`[DescribeFile] Path not allowed: ${filePath}`);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: 'File path is not within the allowed directory',
|
||||
};
|
||||
res.status(403).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// File not found
|
||||
if (
|
||||
readError !== null &&
|
||||
typeof readError === 'object' &&
|
||||
'code' in readError &&
|
||||
readError.code === 'ENOENT'
|
||||
) {
|
||||
logger.warn(`[DescribeFile] File not found: ${resolvedPath}`);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: `File not found: ${filePath}`,
|
||||
};
|
||||
res.status(404).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = readError instanceof Error ? readError.message : 'Unknown error';
|
||||
logger.error(`[DescribeFile] Failed to read file: ${errorMessage}`);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: `Failed to read file: ${errorMessage}`,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Truncate very large files to avoid token limits
|
||||
const MAX_CONTENT_LENGTH = 50000;
|
||||
const truncated = fileContent.length > MAX_CONTENT_LENGTH;
|
||||
const contentToAnalyze = truncated
|
||||
? fileContent.substring(0, MAX_CONTENT_LENGTH)
|
||||
: fileContent;
|
||||
|
||||
// Get the filename for context
|
||||
const fileName = path.basename(resolvedPath);
|
||||
|
||||
// Build prompt with file content passed as structured data
|
||||
// The file content is included directly, not via tool invocation
|
||||
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
||||
|
||||
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
||||
|
||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
|
||||
const promptContent = [
|
||||
{ type: 'text' as const, text: instructionText },
|
||||
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
|
||||
];
|
||||
|
||||
// Use the file's directory as the working directory
|
||||
const cwd = path.dirname(resolvedPath);
|
||||
|
||||
// Use centralized SDK options with proper cwd validation
|
||||
// No tools needed since we're passing file content directly
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
});
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
|
||||
// Extract the description from the response
|
||||
const description = await extractTextFromStream(stream);
|
||||
|
||||
if (!description || description.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate description - empty response',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Description generated, length: ${description.length} chars`);
|
||||
|
||||
const response: DescribeFileSuccessResponse = {
|
||||
success: true,
|
||||
description: description.trim(),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
logger.error('File description failed:', errorMessage);
|
||||
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
416
apps/server/src/routes/context/routes/describe-image.ts
Normal file
416
apps/server/src/routes/context/routes/describe-image.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* POST /context/describe-image endpoint - Generate description for an image
|
||||
*
|
||||
* Uses Claude Haiku to analyze an image and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
|
||||
* not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach
|
||||
* so it doesn't depend on Claude's filesystem tool access or working directory restrictions.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const logger = createLogger('DescribeImage');
|
||||
|
||||
/**
|
||||
* Allowlist of safe headers to log
|
||||
* All other headers are excluded to prevent leaking sensitive values
|
||||
*/
|
||||
const SAFE_HEADERS_ALLOWLIST = new Set([
|
||||
'content-type',
|
||||
'accept',
|
||||
'user-agent',
|
||||
'host',
|
||||
'referer',
|
||||
'content-length',
|
||||
'origin',
|
||||
'x-request-id',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Filter request headers to only include safe, non-sensitive values
|
||||
*/
|
||||
function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unknown> {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (SAFE_HEADERS_ALLOWLIST.has(key.toLowerCase())) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the actual file path, handling Unicode character variations.
|
||||
* macOS screenshots use U+202F (NARROW NO-BREAK SPACE) before AM/PM,
|
||||
* but this may be transmitted as a regular space through the API.
|
||||
*/
|
||||
function findActualFilePath(requestedPath: string): string | null {
|
||||
// First, try the exact path
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
// Try with Unicode normalization
|
||||
const normalizedPath = requestedPath.normalize('NFC');
|
||||
if (fs.existsSync(normalizedPath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
// If not found, try to find the file in the directory by matching the basename
|
||||
// This handles cases where the space character differs (U+0020 vs U+202F vs U+00A0)
|
||||
const dir = path.dirname(requestedPath);
|
||||
const baseName = path.basename(requestedPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
// Normalize the requested basename for comparison
|
||||
// Replace various space-like characters with regular space for comparison
|
||||
const normalizeSpaces = (s: string): string => s.replace(/[\u00A0\u202F\u2009\u200A]/g, ' ');
|
||||
|
||||
const normalizedBaseName = normalizeSpaces(baseName);
|
||||
|
||||
for (const file of files) {
|
||||
if (normalizeSpaces(file) === normalizedBaseName) {
|
||||
logger.info(`Found matching file with different space encoding: ${file}`);
|
||||
return path.join(dir, file);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error reading directory ${dir}: ${err}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for the describe-image endpoint
|
||||
*/
|
||||
interface DescribeImageRequestBody {
|
||||
/** Path to the image file */
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success response from the describe-image endpoint
|
||||
*/
|
||||
interface DescribeImageSuccessResponse {
|
||||
success: true;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from the describe-image endpoint
|
||||
*/
|
||||
interface DescribeImageErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map SDK/CLI errors to a stable status + user-facing message.
|
||||
*/
|
||||
function mapDescribeImageError(rawMessage: string | undefined): {
|
||||
statusCode: number;
|
||||
userMessage: string;
|
||||
} {
|
||||
const baseResponse = {
|
||||
statusCode: 500,
|
||||
userMessage: 'Failed to generate an image description. Please try again.',
|
||||
};
|
||||
|
||||
if (!rawMessage) return baseResponse;
|
||||
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage:
|
||||
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
rawMessage.includes('Failed to spawn Claude Code process') ||
|
||||
rawMessage.includes('Claude Code executable not found') ||
|
||||
rawMessage.includes('Claude Code native binary not found')
|
||||
) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage:
|
||||
'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
|
||||
return {
|
||||
statusCode: 429,
|
||||
userMessage: 'Rate limited while describing the image. Please wait a moment and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (rawMessage.toLowerCase().includes('payload too large') || rawMessage.includes('413')) {
|
||||
return {
|
||||
statusCode: 413,
|
||||
userMessage:
|
||||
'The image is too large to send for description. Please resize/compress it and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from Claude SDK response messages and log high-signal stream events.
|
||||
*/
|
||||
async function extractTextFromStream(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stream: AsyncIterable<any>,
|
||||
requestId: string
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
|
||||
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
|
||||
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
const msgType = msg?.type;
|
||||
const msgSubtype = msg?.subtype;
|
||||
|
||||
// Keep this concise but informative. Full error object is logged in catch blocks.
|
||||
logger.info(
|
||||
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
|
||||
);
|
||||
|
||||
if (msgType === 'assistant' && msg.message?.content) {
|
||||
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
||||
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msgType === 'result' && msgSubtype === 'success') {
|
||||
if (typeof msg.result === 'string' && msg.result.length > 0) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
|
||||
);
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the describe-image request handler
|
||||
*
|
||||
* Uses Claude SDK query with multi-part content blocks to include the image (base64),
|
||||
* matching the agent runner behavior.
|
||||
*
|
||||
* @returns Express request handler for image description
|
||||
*/
|
||||
export function createDescribeImageHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Request envelope logs (high value when correlating failures)
|
||||
// Only log safe headers to prevent leaking sensitive values (auth tokens, cookies, etc.)
|
||||
logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`);
|
||||
logger.info(`[${requestId}] headers=${JSON.stringify(filterSafeHeaders(req.headers))}`);
|
||||
logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`);
|
||||
|
||||
try {
|
||||
const { imagePath } = req.body as DescribeImageRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!imagePath || typeof imagePath !== 'string') {
|
||||
const response: DescribeImageErrorResponse = {
|
||||
success: false,
|
||||
error: 'imagePath is required and must be a string',
|
||||
requestId,
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] imagePath="${imagePath}" type=${typeof imagePath}`);
|
||||
|
||||
// Find the actual file path (handles Unicode space character variations)
|
||||
const actualPath = findActualFilePath(imagePath);
|
||||
if (!actualPath) {
|
||||
logger.error(`[${requestId}] File not found: ${imagePath}`);
|
||||
// Log hex representation of the path for debugging
|
||||
const hexPath = Buffer.from(imagePath).toString('hex');
|
||||
logger.error(`[${requestId}] imagePath hex: ${hexPath}`);
|
||||
const response: DescribeImageErrorResponse = {
|
||||
success: false,
|
||||
error: `File not found: ${imagePath}`,
|
||||
requestId,
|
||||
};
|
||||
res.status(404).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (actualPath !== imagePath) {
|
||||
logger.info(`[${requestId}] Using actual path: ${actualPath}`);
|
||||
}
|
||||
|
||||
// Log path + stats (this is often where issues start: missing file, perms, size)
|
||||
let stat: fs.Stats | null = null;
|
||||
try {
|
||||
stat = fs.statSync(actualPath);
|
||||
logger.info(
|
||||
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
|
||||
);
|
||||
} catch (statErr) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unable to stat image file (continuing to read base64): ${String(statErr)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Read image and convert to base64 (same as agent runner)
|
||||
logger.info(`[${requestId}] Reading image into base64...`);
|
||||
const imageReadStart = Date.now();
|
||||
const imageData = await readImageAsBase64(actualPath);
|
||||
const imageReadMs = Date.now() - imageReadStart;
|
||||
|
||||
const base64Length = imageData.base64.length;
|
||||
const estimatedBytes = Math.ceil((base64Length * 3) / 4);
|
||||
logger.info(`[${requestId}] imageReadMs=${imageReadMs}`);
|
||||
logger.info(
|
||||
`[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}`
|
||||
);
|
||||
|
||||
// Build multi-part prompt with image block (no Read tool required)
|
||||
const instructionText =
|
||||
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
|
||||
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
|
||||
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
||||
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
||||
|
||||
const promptContent = [
|
||||
{ type: 'text' as const, text: instructionText },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: imageData.mimeType,
|
||||
data: imageData.base64,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
|
||||
|
||||
const cwd = path.dirname(actualPath);
|
||||
logger.info(`[${requestId}] Using cwd=${cwd}`);
|
||||
|
||||
// Use the same centralized option builder used across the server (validates cwd)
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||
sdkOptions.allowedTools
|
||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||
);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
|
||||
logger.info(`[${requestId}] Calling query()...`);
|
||||
const queryStart = Date.now();
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
|
||||
|
||||
// Extract the description from the response
|
||||
const extractStart = Date.now();
|
||||
const description = await extractTextFromStream(stream, requestId);
|
||||
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
|
||||
|
||||
if (!description || description.trim().length === 0) {
|
||||
logger.warn(`[${requestId}] Received empty response from Claude`);
|
||||
const response: DescribeImageErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate description - empty response',
|
||||
requestId,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - startedAt;
|
||||
logger.info(`[${requestId}] Success descriptionLen=${description.length} totalMs=${totalMs}`);
|
||||
|
||||
const response: DescribeImageSuccessResponse = {
|
||||
success: true,
|
||||
description: description.trim(),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const totalMs = Date.now() - startedAt;
|
||||
const err = error as unknown;
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
const errName = err instanceof Error ? err.name : 'UnknownError';
|
||||
const errStack = err instanceof Error ? err.stack : undefined;
|
||||
|
||||
logger.error(`[${requestId}] FAILED totalMs=${totalMs}`);
|
||||
logger.error(`[${requestId}] errorName=${errName}`);
|
||||
logger.error(`[${requestId}] errorMessage=${errMessage}`);
|
||||
if (errStack) logger.error(`[${requestId}] errorStack=${errStack}`);
|
||||
|
||||
// Dump all enumerable + non-enumerable props (this is where stderr/stdout/exitCode often live)
|
||||
try {
|
||||
const props = err && typeof err === 'object' ? Object.getOwnPropertyNames(err) : [];
|
||||
logger.error(`[${requestId}] errorProps=${JSON.stringify(props)}`);
|
||||
if (err && typeof err === 'object') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any;
|
||||
const details = JSON.stringify(anyErr, props as unknown as string[]);
|
||||
logger.error(`[${requestId}] errorDetails=${details}`);
|
||||
}
|
||||
} catch (stringifyErr) {
|
||||
logger.error(`[${requestId}] Failed to serialize error object: ${String(stringifyErr)}`);
|
||||
}
|
||||
|
||||
const { statusCode, userMessage } = mapDescribeImageError(errMessage);
|
||||
const response: DescribeImageErrorResponse = {
|
||||
success: false,
|
||||
error: `${userMessage} (requestId: ${requestId})`,
|
||||
requestId,
|
||||
};
|
||||
res.status(statusCode).json(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
* with different enhancement modes (improve, expand, simplify, etc.)
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { createEnhanceHandler } from "./routes/enhance.js";
|
||||
import { Router } from 'express';
|
||||
import { createEnhanceHandler } from './routes/enhance.js';
|
||||
|
||||
/**
|
||||
* Create the enhance-prompt router
|
||||
@@ -16,7 +16,7 @@ import { createEnhanceHandler } from "./routes/enhance.js";
|
||||
export function createEnhancePromptRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/", createEnhanceHandler());
|
||||
router.post('/', createEnhanceHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
* 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 type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import {
|
||||
getSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from "../../../lib/enhancement-prompts.js";
|
||||
import { resolveModelString, CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
|
||||
const logger = createLogger("EnhancePrompt");
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
/**
|
||||
* Request body for the enhance endpoint
|
||||
@@ -62,16 +63,16 @@ async function extractTextFromStream(
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = "";
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
@@ -84,29 +85,25 @@ async function extractTextFromStream(
|
||||
*
|
||||
* @returns Express request handler for text enhancement
|
||||
*/
|
||||
export function createEnhanceHandler(): (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => Promise<void> {
|
||||
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;
|
||||
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!originalText || typeof originalText !== "string") {
|
||||
if (!originalText || typeof originalText !== 'string') {
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "originalText is required and must be a string",
|
||||
error: 'originalText is required and must be a string',
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enhancementMode || typeof enhancementMode !== "string") {
|
||||
if (!enhancementMode || typeof enhancementMode !== 'string') {
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "enhancementMode is required and must be a string",
|
||||
error: 'enhancementMode is required and must be a string',
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
@@ -117,7 +114,7 @@ export function createEnhanceHandler(): (
|
||||
if (trimmedText.length === 0) {
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "originalText cannot be empty",
|
||||
error: 'originalText cannot be empty',
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
@@ -127,11 +124,9 @@ export function createEnhanceHandler(): (
|
||||
const normalizedMode = enhancementMode.toLowerCase();
|
||||
const validMode: EnhancementMode = isValidEnhancementMode(normalizedMode)
|
||||
? normalizedMode
|
||||
: "improve";
|
||||
: 'improve';
|
||||
|
||||
logger.info(
|
||||
`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`
|
||||
);
|
||||
logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
|
||||
|
||||
// Get the system prompt for this mode
|
||||
const systemPrompt = getSystemPrompt(validMode);
|
||||
@@ -154,7 +149,7 @@ export function createEnhanceHandler(): (
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: "acceptEdits",
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,18 +157,16 @@ export function createEnhanceHandler(): (
|
||||
const enhancedText = await extractTextFromStream(stream);
|
||||
|
||||
if (!enhancedText || enhancedText.trim().length === 0) {
|
||||
logger.warn("Received empty response from Claude");
|
||||
logger.warn('Received empty response from Claude');
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: "Failed to generate enhanced text - empty response",
|
||||
error: 'Failed to generate enhanced text - empty response',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Enhancement complete, output length: ${enhancedText.length} chars`
|
||||
);
|
||||
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
|
||||
|
||||
const response: EnhanceSuccessResponse = {
|
||||
success: true,
|
||||
@@ -181,9 +174,8 @@ export function createEnhanceHandler(): (
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
logger.error("Enhancement failed:", errorMessage);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
logger.error('Enhancement failed:', errorMessage);
|
||||
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* Common utilities for features routes
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger("Features");
|
||||
const logger = createLogger('Features');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
@@ -2,24 +2,27 @@
|
||||
* Features routes - HTTP API for feature management
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { FeatureLoader } from "../../services/feature-loader.js";
|
||||
import { createListHandler } from "./routes/list.js";
|
||||
import { createGetHandler } from "./routes/get.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createUpdateHandler } from "./routes/update.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createAgentOutputHandler } from "./routes/agent-output.js";
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createUpdateHandler } from './routes/update.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
|
||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/list", createListHandler(featureLoader));
|
||||
router.post("/get", createGetHandler(featureLoader));
|
||||
router.post("/create", createCreateHandler(featureLoader));
|
||||
router.post("/update", createUpdateHandler(featureLoader));
|
||||
router.post("/delete", createDeleteHandler(featureLoader));
|
||||
router.post("/agent-output", createAgentOutputHandler(featureLoader));
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/generate-title', createGenerateTitleHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /agent-output endpoint - Get agent output for a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { FeatureLoader } from "../../../services/feature-loader.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAgentOutputHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,22 +15,17 @@ export function createAgentOutputHandler(featureLoader: FeatureLoader) {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const content = await featureLoader.getAgentOutput(
|
||||
projectPath,
|
||||
featureId
|
||||
);
|
||||
const content = await featureLoader.getAgentOutput(projectPath, featureId);
|
||||
res.json({ success: true, content });
|
||||
} catch (error) {
|
||||
logError(error, "Get agent output failed");
|
||||
logError(error, 'Get agent output failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* POST /create endpoint - Create a new feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
FeatureLoader,
|
||||
type Feature,
|
||||
} from "../../../services/feature-loader.js";
|
||||
import { addAllowedPath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -19,22 +16,17 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
};
|
||||
|
||||
if (!projectPath || !feature) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "projectPath and feature are required",
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath and feature are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
res.json({ success: true, feature: created });
|
||||
} catch (error) {
|
||||
logError(error, "Create feature failed");
|
||||
logError(error, 'Create feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /delete endpoint - Delete a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { FeatureLoader } from "../../../services/feature-loader.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,19 +15,17 @@ export function createDeleteHandler(featureLoader: FeatureLoader) {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
logError(error, "Delete feature failed");
|
||||
logError(error, 'Delete feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
133
apps/server/src/routes/features/routes/generate-title.ts
Normal file
133
apps/server/src/routes/features/routes/generate-title.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||
*
|
||||
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||
|
||||
const logger = createLogger('GenerateTitle');
|
||||
|
||||
interface GenerateTitleRequestBody {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleSuccessResponse {
|
||||
success: true;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the title, nothing else
|
||||
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
|
||||
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
|
||||
- No quotes, periods, or extra formatting
|
||||
- Capture the essence of the feature in a scannable way`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: 'description is required and must be a string',
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedDescription.length === 0) {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: 'description cannot be empty',
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
});
|
||||
|
||||
const title = await extractTextFromStream(stream);
|
||||
|
||||
if (!title || title.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate title - empty response',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generated title: ${title.trim()}`);
|
||||
|
||||
const response: GenerateTitleSuccessResponse = {
|
||||
success: true,
|
||||
title: title.trim(),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
logger.error('Title generation failed:', errorMessage);
|
||||
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
* POST /get endpoint - Get a single feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { FeatureLoader } from "../../../services/feature-loader.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,24 +15,22 @@ export function createGetHandler(featureLoader: FeatureLoader) {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const feature = await featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
res.status(404).json({ success: false, error: "Feature not found" });
|
||||
res.status(404).json({ success: false, error: 'Feature not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, feature });
|
||||
} catch (error) {
|
||||
logError(error, "Get feature failed");
|
||||
logError(error, 'Get feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
* POST /list endpoint - List all features for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { FeatureLoader } from "../../../services/feature-loader.js";
|
||||
import { addAllowedPath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -13,19 +12,14 @@ export function createListHandler(featureLoader: FeatureLoader) {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath is required" });
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
res.json({ success: true, features });
|
||||
} catch (error) {
|
||||
logError(error, "List features failed");
|
||||
logError(error, 'List features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
* POST /update endpoint - Update a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
FeatureLoader,
|
||||
type Feature,
|
||||
} from "../../../services/feature-loader.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -21,19 +19,15 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
if (!projectPath || !featureId || !updates) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath, featureId, and updates are required",
|
||||
error: 'projectPath, featureId, and updates are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
projectPath,
|
||||
featureId,
|
||||
updates
|
||||
);
|
||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
logError(error, "Update feature failed");
|
||||
logError(error, 'Update feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* Common utilities for fs routes
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger("FS");
|
||||
const logger = createLogger('FS');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
@@ -3,40 +3,40 @@
|
||||
* Provides REST API equivalents for Electron IPC file operations
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createReadHandler } from "./routes/read.js";
|
||||
import { createWriteHandler } from "./routes/write.js";
|
||||
import { createMkdirHandler } from "./routes/mkdir.js";
|
||||
import { createReaddirHandler } from "./routes/readdir.js";
|
||||
import { createExistsHandler } from "./routes/exists.js";
|
||||
import { createStatHandler } from "./routes/stat.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createValidatePathHandler } from "./routes/validate-path.js";
|
||||
import { createResolveDirectoryHandler } from "./routes/resolve-directory.js";
|
||||
import { createSaveImageHandler } from "./routes/save-image.js";
|
||||
import { createBrowseHandler } from "./routes/browse.js";
|
||||
import { createImageHandler } from "./routes/image.js";
|
||||
import { createSaveBoardBackgroundHandler } from "./routes/save-board-background.js";
|
||||
import { createDeleteBoardBackgroundHandler } from "./routes/delete-board-background.js";
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createReadHandler } from './routes/read.js';
|
||||
import { createWriteHandler } from './routes/write.js';
|
||||
import { createMkdirHandler } from './routes/mkdir.js';
|
||||
import { createReaddirHandler } from './routes/readdir.js';
|
||||
import { createExistsHandler } from './routes/exists.js';
|
||||
import { createStatHandler } from './routes/stat.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createValidatePathHandler } from './routes/validate-path.js';
|
||||
import { createResolveDirectoryHandler } from './routes/resolve-directory.js';
|
||||
import { createSaveImageHandler } from './routes/save-image.js';
|
||||
import { createBrowseHandler } from './routes/browse.js';
|
||||
import { createImageHandler } from './routes/image.js';
|
||||
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
||||
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
||||
|
||||
export function createFsRoutes(_events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/read", createReadHandler());
|
||||
router.post("/write", createWriteHandler());
|
||||
router.post("/mkdir", createMkdirHandler());
|
||||
router.post("/readdir", createReaddirHandler());
|
||||
router.post("/exists", createExistsHandler());
|
||||
router.post("/stat", createStatHandler());
|
||||
router.post("/delete", createDeleteHandler());
|
||||
router.post("/validate-path", createValidatePathHandler());
|
||||
router.post("/resolve-directory", createResolveDirectoryHandler());
|
||||
router.post("/save-image", createSaveImageHandler());
|
||||
router.post("/browse", createBrowseHandler());
|
||||
router.get("/image", createImageHandler());
|
||||
router.post("/save-board-background", createSaveBoardBackgroundHandler());
|
||||
router.post("/delete-board-background", createDeleteBoardBackgroundHandler());
|
||||
router.post('/read', createReadHandler());
|
||||
router.post('/write', createWriteHandler());
|
||||
router.post('/mkdir', createMkdirHandler());
|
||||
router.post('/readdir', createReaddirHandler());
|
||||
router.post('/exists', createExistsHandler());
|
||||
router.post('/stat', createStatHandler());
|
||||
router.post('/delete', createDeleteHandler());
|
||||
router.post('/validate-path', createValidatePathHandler());
|
||||
router.post('/resolve-directory', createResolveDirectoryHandler());
|
||||
router.post('/save-image', createSaveImageHandler());
|
||||
router.post('/browse', createBrowseHandler());
|
||||
router.get('/image', createImageHandler());
|
||||
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
||||
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,33 +2,35 @@
|
||||
* POST /browse endpoint - Browse directories for file browser UI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createBrowseHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { dirPath } = req.body as { dirPath?: string };
|
||||
|
||||
// Default to home directory if no path provided
|
||||
const targetPath = dirPath ? path.resolve(dirPath) : os.homedir();
|
||||
// Default to ALLOWED_ROOT_DIRECTORY if set, otherwise home directory
|
||||
const defaultPath = getAllowedRootDirectory() || os.homedir();
|
||||
const targetPath = dirPath ? path.resolve(dirPath) : defaultPath;
|
||||
|
||||
// Detect available drives on Windows
|
||||
const detectDrives = async (): Promise<string[]> => {
|
||||
if (os.platform() !== "win32") {
|
||||
if (os.platform() !== 'win32') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const drives: string[] = [];
|
||||
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
for (const letter of letters) {
|
||||
const drivePath = `${letter}:\\`;
|
||||
try {
|
||||
await fs.access(drivePath);
|
||||
await secureFs.access(drivePath);
|
||||
drives.push(drivePath);
|
||||
} catch {
|
||||
// Drive doesn't exist, skip it
|
||||
@@ -46,21 +48,19 @@ export function createBrowseHandler() {
|
||||
const drives = await detectDrives();
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(targetPath);
|
||||
const stats = await secureFs.stat(targetPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Path is not a directory" });
|
||||
res.status(400).json({ success: false, error: 'Path is not a directory' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
||||
const entries = await secureFs.readdir(targetPath, { withFileTypes: true });
|
||||
|
||||
// Filter for directories only and add parent directory option
|
||||
const directories = entries
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: path.join(targetPath, entry.name),
|
||||
@@ -76,10 +76,8 @@ export function createBrowseHandler() {
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle permission errors gracefully - still return path info so user can navigate away
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to read directory";
|
||||
const isPermissionError =
|
||||
errorMessage.includes("EPERM") || errorMessage.includes("EACCES");
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
|
||||
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
|
||||
|
||||
if (isPermissionError) {
|
||||
// Return success with empty directories so user can still navigate to parent
|
||||
@@ -90,7 +88,7 @@ export function createBrowseHandler() {
|
||||
directories: [],
|
||||
drives,
|
||||
warning:
|
||||
"Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security",
|
||||
'Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security',
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
@@ -100,7 +98,13 @@ export function createBrowseHandler() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Browse directories failed");
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Browse directories failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* POST /delete-board-background endpoint - Delete board background image
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getBoardDir } from '@automaker/platform';
|
||||
|
||||
export function createDeleteBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -16,7 +16,7 @@ export function createDeleteBoardBackgroundHandler() {
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -26,10 +26,10 @@ export function createDeleteBoardBackgroundHandler() {
|
||||
|
||||
try {
|
||||
// Try to remove all background files in the board directory
|
||||
const files = await fs.readdir(boardDir);
|
||||
const files = await secureFs.readdir(boardDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith("background")) {
|
||||
await fs.unlink(path.join(boardDir, file));
|
||||
if (file.startsWith('background')) {
|
||||
await secureFs.unlink(path.join(boardDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -38,7 +38,7 @@ export function createDeleteBoardBackgroundHandler() {
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Delete board background failed");
|
||||
logError(error, 'Delete board background failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* POST /delete endpoint - Delete file
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import { validatePath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -13,16 +13,21 @@ export function createDeleteHandler() {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
await fs.rm(resolvedPath, { recursive: true });
|
||||
await secureFs.rm(filePath, { recursive: true });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Delete file failed");
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Delete file failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* POST /exists endpoint - Check if file/directory exists
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createExistsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -13,22 +13,28 @@ export function createExistsHandler() {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// For exists, we check but don't require the path to be pre-allowed
|
||||
// This allows the UI to validate user-entered paths
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
try {
|
||||
await fs.access(resolvedPath);
|
||||
await secureFs.access(filePath);
|
||||
res.json({ success: true, exists: true });
|
||||
} catch {
|
||||
} catch (accessError) {
|
||||
// Check if it's a path not allowed error vs file not existing
|
||||
if (accessError instanceof PathNotAllowedError) {
|
||||
throw accessError;
|
||||
}
|
||||
res.json({ success: true, exists: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Check exists failed");
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Check exists failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* GET /image endpoint - Serve image files
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -16,7 +17,7 @@ export function createImageHandler() {
|
||||
};
|
||||
|
||||
if (!imagePath) {
|
||||
res.status(400).json({ success: false, error: "path is required" });
|
||||
res.status(400).json({ success: false, error: 'path is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,40 +25,41 @@ export function createImageHandler() {
|
||||
const fullPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: projectPath
|
||||
? path.join(projectPath, imagePath)
|
||||
: imagePath;
|
||||
? path.join(projectPath, imagePath)
|
||||
: imagePath;
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch {
|
||||
res.status(404).json({ success: false, error: "Image not found" });
|
||||
await secureFs.access(fullPath);
|
||||
} catch (accessError) {
|
||||
if (accessError instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: 'Path not allowed' });
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ success: false, error: 'Image not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const buffer = await fs.readFile(fullPath);
|
||||
const buffer = await secureFs.readFile(fullPath);
|
||||
|
||||
// Determine MIME type from extension
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
};
|
||||
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
mimeTypes[ext] || "application/octet-stream"
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
logError(error, "Serve image failed");
|
||||
logError(error, 'Serve image failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Handles symlinks safely to avoid ELOOP errors
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { addAllowedPath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createMkdirHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -15,7 +15,7 @@ export function createMkdirHandler() {
|
||||
const { dirPath } = req.body as { dirPath: string };
|
||||
|
||||
if (!dirPath) {
|
||||
res.status(400).json({ success: false, error: "dirPath is required" });
|
||||
res.status(400).json({ success: false, error: 'dirPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,45 +23,47 @@ export function createMkdirHandler() {
|
||||
|
||||
// Check if path already exists using lstat (doesn't follow symlinks)
|
||||
try {
|
||||
const stats = await fs.lstat(resolvedPath);
|
||||
const stats = await secureFs.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",
|
||||
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") {
|
||||
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);
|
||||
await secureFs.mkdir(resolvedPath, { recursive: true });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle ELOOP specifically
|
||||
if (error.code === "ELOOP") {
|
||||
logError(error, "Create directory failed - symlink loop detected");
|
||||
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",
|
||||
error: 'Cannot create directory: symlink loop detected in path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
logError(error, "Create directory failed");
|
||||
logError(error, 'Create directory failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,26 +2,21 @@
|
||||
* POST /read endpoint - Read file
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import { validatePath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
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"];
|
||||
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"
|
||||
);
|
||||
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
export function createReadHandler() {
|
||||
@@ -30,19 +25,24 @@ export function createReadHandler() {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
const content = await fs.readFile(resolvedPath, "utf-8");
|
||||
const content = await secureFs.readFile(filePath, 'utf-8');
|
||||
|
||||
res.json({ success: true, content });
|
||||
} catch (error) {
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
|
||||
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
|
||||
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
|
||||
if (shouldLog) {
|
||||
logError(error, "Read file failed");
|
||||
logError(error, 'Read file failed');
|
||||
}
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* POST /readdir endpoint - Read directory
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import { validatePath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createReaddirHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -13,12 +13,11 @@ export function createReaddirHandler() {
|
||||
const { dirPath } = req.body as { dirPath: string };
|
||||
|
||||
if (!dirPath) {
|
||||
res.status(400).json({ success: false, error: "dirPath is required" });
|
||||
res.status(400).json({ success: false, error: 'dirPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(dirPath);
|
||||
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
const result = entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
@@ -28,7 +27,13 @@ export function createReaddirHandler() {
|
||||
|
||||
res.json({ success: true, entries: result });
|
||||
} catch (error) {
|
||||
logError(error, "Read directory failed");
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Read directory failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* POST /resolve-directory endpoint - Resolve directory path from directory name
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { addAllowedPath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createResolveDirectoryHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -18,9 +17,7 @@ export function createResolveDirectoryHandler() {
|
||||
};
|
||||
|
||||
if (!directoryName) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "directoryName is required" });
|
||||
res.status(400).json({ success: false, error: 'directoryName is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,9 +25,8 @@ export function createResolveDirectoryHandler() {
|
||||
if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) {
|
||||
try {
|
||||
const resolvedPath = path.resolve(directoryName);
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
const stats = await secureFs.stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
addAllowedPath(resolvedPath);
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
@@ -45,17 +41,11 @@ export function createResolveDirectoryHandler() {
|
||||
// Search for directory in common locations
|
||||
const searchPaths: string[] = [
|
||||
process.cwd(), // Current working directory
|
||||
process.env.HOME || process.env.USERPROFILE || "", // User home
|
||||
path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || "",
|
||||
"Documents"
|
||||
),
|
||||
path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"),
|
||||
process.env.HOME || process.env.USERPROFILE || '', // User home
|
||||
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Documents'),
|
||||
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Desktop'),
|
||||
// Common project locations
|
||||
path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || "",
|
||||
"Projects"
|
||||
),
|
||||
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Projects'),
|
||||
].filter(Boolean);
|
||||
|
||||
// Also check parent of current working directory
|
||||
@@ -72,7 +62,7 @@ export function createResolveDirectoryHandler() {
|
||||
for (const searchPath of searchPaths) {
|
||||
try {
|
||||
const candidatePath = path.join(searchPath, directoryName);
|
||||
const stats = await fs.stat(candidatePath);
|
||||
const stats = await secureFs.stat(candidatePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Verify it matches by checking for sample files
|
||||
@@ -80,15 +70,15 @@ export function createResolveDirectoryHandler() {
|
||||
let matches = 0;
|
||||
for (const sampleFile of sampleFiles.slice(0, 5)) {
|
||||
// Remove directory name prefix from sample file path
|
||||
const relativeFile = sampleFile.startsWith(directoryName + "/")
|
||||
const relativeFile = sampleFile.startsWith(directoryName + '/')
|
||||
? sampleFile.substring(directoryName.length + 1)
|
||||
: sampleFile.split("/").slice(1).join("/") ||
|
||||
sampleFile.split("/").pop() ||
|
||||
: sampleFile.split('/').slice(1).join('/') ||
|
||||
sampleFile.split('/').pop() ||
|
||||
sampleFile;
|
||||
|
||||
try {
|
||||
const filePath = path.join(candidatePath, relativeFile);
|
||||
await fs.access(filePath);
|
||||
await secureFs.access(filePath);
|
||||
matches++;
|
||||
} catch {
|
||||
// File doesn't exist, continue checking
|
||||
@@ -102,7 +92,6 @@ export function createResolveDirectoryHandler() {
|
||||
}
|
||||
|
||||
// Found matching directory
|
||||
addAllowedPath(candidatePath);
|
||||
res.json({
|
||||
success: true,
|
||||
path: candidatePath,
|
||||
@@ -121,7 +110,7 @@ export function createResolveDirectoryHandler() {
|
||||
error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Resolve directory failed");
|
||||
logError(error, 'Resolve directory failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* POST /save-board-background endpoint - Save board background image
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getBoardDir } from '@automaker/platform';
|
||||
|
||||
export function createSaveBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -22,34 +21,31 @@ export function createSaveBoardBackgroundHandler() {
|
||||
if (!data || !filename || !projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "data, filename, and projectPath are required",
|
||||
error: 'data, filename, and projectPath are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get board directory
|
||||
const boardDir = getBoardDir(projectPath);
|
||||
await fs.mkdir(boardDir, { recursive: true });
|
||||
await secureFs.mkdir(boardDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Use a fixed filename for the board background (overwrite previous)
|
||||
const ext = path.extname(filename) || ".png";
|
||||
const ext = path.extname(filename) || '.png';
|
||||
const uniqueFilename = `background${ext}`;
|
||||
const filePath = path.join(boardDir, uniqueFilename);
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Add board directory to allowed paths
|
||||
addAllowedPath(boardDir);
|
||||
await secureFs.writeFile(filePath, buffer);
|
||||
|
||||
// Return the absolute path
|
||||
res.json({ success: true, path: filePath });
|
||||
} catch (error) {
|
||||
logError(error, "Save board background failed");
|
||||
logError(error, 'Save board background failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
* POST /save-image endpoint - Save image to .automaker images directory
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getImagesDir } from '@automaker/platform';
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -22,36 +21,33 @@ export function createSaveImageHandler() {
|
||||
if (!data || !filename || !projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "data, filename, and projectPath are required",
|
||||
error: 'data, filename, and projectPath are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get images directory
|
||||
const imagesDir = getImagesDir(projectPath);
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
await secureFs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(filename) || ".png";
|
||||
const ext = path.extname(filename) || '.png';
|
||||
const baseName = path.basename(filename, ext);
|
||||
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||
const filePath = path.join(imagesDir, uniqueFilename);
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Add automaker directory to allowed paths
|
||||
addAllowedPath(imagesDir);
|
||||
await secureFs.writeFile(filePath, buffer);
|
||||
|
||||
// Return the absolute path
|
||||
res.json({ success: true, path: filePath });
|
||||
} catch (error) {
|
||||
logError(error, "Save image failed");
|
||||
logError(error, 'Save image failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* POST /stat endpoint - Get file stats
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import { validatePath } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -13,12 +13,11 @@ export function createStatHandler() {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
const stats = await secureFs.stat(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -30,7 +29,13 @@ export function createStatHandler() {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get file stats failed");
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Get file stats failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* POST /validate-path endpoint - Validate and add path to allowed list
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { addAllowedPath, isPathAllowed } from "../../../lib/security.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { isPathAllowed } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -14,7 +14,7 @@ export function createValidatePathHandler() {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,28 +22,23 @@ export function createValidatePathHandler() {
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
const stats = await secureFs.stat(resolvedPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Path is not a directory" });
|
||||
res.status(400).json({ success: false, error: 'Path is not a directory' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to allowed paths
|
||||
addAllowedPath(resolvedPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
isAllowed: isPathAllowed(resolvedPath),
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({ success: false, error: "Path does not exist" });
|
||||
res.status(400).json({ success: false, error: 'Path does not exist' });
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Validate path failed");
|
||||
logError(error, 'Validate path failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* POST /write endpoint - Write file
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
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";
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createWriteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -18,19 +18,23 @@ export function createWriteHandler() {
|
||||
};
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
|
||||
// Ensure parent directory exists (symlink-safe)
|
||||
await mkdirSafe(path.dirname(resolvedPath));
|
||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
||||
await secureFs.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Write file failed");
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Write file failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* Common utilities for git routes
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger("Git");
|
||||
const logger = createLogger('Git');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user