mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Compare commits
10 Commits
88864ad6bc
...
v0.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d43106c8 | ||
|
|
9c304eeec3 | ||
|
|
3563dd55da | ||
|
|
d06d25b1b5 | ||
|
|
84570842d3 | ||
|
|
63cae19aec | ||
|
|
c9e721bda7 | ||
|
|
d4b7a0c57d | ||
|
|
0b6e84ec6e | ||
|
|
e9c2afcc02 |
158
LICENSE
158
LICENSE
@@ -1,141 +1,27 @@
|
||||
AUTOMAKER LICENSE AGREEMENT
|
||||
## Project Status
|
||||
|
||||
This License Agreement ("Agreement") is entered into between you ("Licensee") and the copyright holders of Automaker ("Licensor"). By using, copying, modifying, downloading, cloning, or distributing the Software (as defined below), you agree to be bound by the terms of this Agreement.
|
||||
|
||||
1. DEFINITIONS
|
||||
|
||||
"Software" means the Automaker software, including all source code, object code, documentation, and related materials.
|
||||
|
||||
"Generated Files" means files created by the Software during normal operation to store internal state, configuration, or working data, including but not limited to app_spec.txt, feature.json, and similar files generated by the Software. Generated Files are not considered part of the Software for the purposes of this license and are not subject to the restrictions herein.
|
||||
|
||||
"Derivative Work" means any work that is based on, derived from, or incorporates the Software or any substantial portion of it, including but not limited to modifications, forks, adaptations, translations, or any altered version of the Software.
|
||||
|
||||
"Monetization" means any activity that generates revenue, income, or commercial benefit from the Software itself or any Derivative Work, including but not limited to:
|
||||
|
||||
- Reselling, redistributing, or sublicensing the Software, any Derivative Work, or any substantial portion thereof
|
||||
- Including the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||
- Offering the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||
- Hosting the Software or any Derivative Work as a service (whether free or paid) for use by others, including cloud hosting, Software-as-a-Service (SaaS), or any other form of hosted access for third parties
|
||||
- Extracting, reselling, redistributing, or sublicensing any prompts, context, or other instructional content bundled within the Software
|
||||
- Creating, distributing, or selling modified versions, forks, or Derivative Works of the Software
|
||||
|
||||
Monetization does NOT include:
|
||||
|
||||
- Using the Software internally within your organization, regardless of whether your organization is for-profit
|
||||
- Using the Software to build products or services that generate revenue, as long as you are not reselling or redistributing the Software itself
|
||||
- Using the Software to provide services for which fees are charged, as long as the Software itself is not being resold or redistributed
|
||||
- Hosting the Software anywhere for personal use by a single developer, as long as the Software is not made accessible to others
|
||||
|
||||
"Core Contributors" means the following individuals who are granted perpetual, royalty-free licenses:
|
||||
|
||||
- Cody Seibert (webdevcody)
|
||||
- SuperComboGamer (SCG)
|
||||
- Kacper Lachowicz (Shironex, Shirone)
|
||||
- Ben Scott (trueheads)
|
||||
|
||||
2. GRANT OF LICENSE
|
||||
|
||||
Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable license to use, copy, modify, and distribute the Software, provided that:
|
||||
|
||||
a) Licensee may freely clone, install, and use the Software locally or within an organization for the purpose of building, developing, and maintaining other products, software, or services. There are no restrictions on the products you build _using_ the Software.
|
||||
|
||||
b) Licensee may run the Software on personal or organizational infrastructure for internal use.
|
||||
|
||||
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
|
||||
|
||||
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
|
||||
|
||||
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
|
||||
|
||||
3. RESTRICTIONS
|
||||
|
||||
Licensee may NOT:
|
||||
|
||||
- Engage in any Monetization of the Software or any Derivative Work without explicit written permission from all Core Contributors
|
||||
- Resell, redistribute, or sublicense the Software, any Derivative Work, or any substantial portion thereof
|
||||
- Create, distribute, or sell modified versions, forks, or Derivative Works of the Software for any commercial purpose
|
||||
- Include the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||
- Offer the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||
- Extract, resell, redistribute, or sublicense any prompts, context, or other instructional content bundled within the Software
|
||||
- Host the Software or any Derivative Work as a service (whether free or paid) for use by others (except Core Contributors)
|
||||
- Remove or alter any copyright notices or license terms
|
||||
- Use the Software in any manner that violates applicable laws or regulations
|
||||
|
||||
Licensee MAY:
|
||||
|
||||
- Use the Software internally within their organization (commercial or non-profit)
|
||||
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
|
||||
- Modify the Software for internal use within their organization (commercial or non-profit)
|
||||
|
||||
4. CORE CONTRIBUTOR STATUS MANAGEMENT
|
||||
|
||||
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
|
||||
|
||||
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
|
||||
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
|
||||
- Discussing the Software through project communication channels
|
||||
- Committing code changes to the project repository
|
||||
- Submitting bug fixes or patches
|
||||
- Participating in project-related discussions or decision-making
|
||||
|
||||
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
|
||||
|
||||
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
|
||||
|
||||
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
|
||||
|
||||
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
|
||||
|
||||
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
|
||||
|
||||
a) **Full Ownership Transfer & Rights Grant:** You hereby assign to the Core Contributors all right, title, and interest in and to your Contributions, including all copyrights, patents, and other intellectual property rights. If such assignment is not effective under applicable law, you grant the Core Contributors an unrestricted, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, irrevocable, sublicensable, and transferable license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute, perform, display, and otherwise exploit your Contributions in any manner they see fit, including for any commercial purpose or Monetization.
|
||||
|
||||
b) **No Take-Backs:** You understand and agree that this grant of rights is irrevocable ("no take-backs"). You cannot revoke, rescind, or terminate this grant of rights once your Contribution has been submitted.
|
||||
|
||||
c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with respect to attribution of authorship or integrity of materials regarding your Contributions that you may have under any applicable law.
|
||||
|
||||
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
|
||||
|
||||
6. TERMINATION
|
||||
|
||||
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
|
||||
|
||||
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
|
||||
|
||||
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
|
||||
|
||||
b) **USE AT YOUR OWN RISK:** YOU AGREE THAT YOUR USE OF THE SOFTWARE IS SOLELY AT YOUR OWN RISK. THE CORE CONTRIBUTORS AND LICENSOR DO NOT GUARANTEE THAT THE SOFTWARE OR ANY CODE GENERATED BY IT WILL BE SAFE, BUG-FREE, OR FUNCTIONAL.
|
||||
|
||||
c) **NO WARRANTY:** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
||||
|
||||
d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, INCLUDING BUT NOT LIMITED TO:
|
||||
|
||||
- DAMAGE TO HARDWARE OR COMPUTER SYSTEMS
|
||||
- DATA LOSS OR CORRUPTION
|
||||
- GENERATION OF BAD, VULNERABLE, OR MALICIOUS CODE
|
||||
- FINANCIAL LOSSES
|
||||
- BUSINESS INTERRUPTION
|
||||
|
||||
8. LICENSE AMENDMENTS
|
||||
|
||||
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
|
||||
|
||||
9. CONTACT
|
||||
|
||||
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
|
||||
|
||||
- Agentic Jumpstart Discord: https://discord.gg/JUDWZDN3VT
|
||||
- Website: https://automaker.app
|
||||
- Email: automakerapp@gmail.com
|
||||
|
||||
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
|
||||
|
||||
10. GOVERNING LAW
|
||||
|
||||
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
|
||||
|
||||
By using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions.
|
||||
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Automaker Core Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
55
README.md
55
README.md
@@ -14,6 +14,10 @@
|
||||
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
> **[!WARNING]**
|
||||
>
|
||||
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
|
||||
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
@@ -288,6 +292,31 @@ services:
|
||||
|
||||
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
||||
|
||||
> **⚠️ Important: Linux/WSL Users**
|
||||
>
|
||||
> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
|
||||
>
|
||||
> ```bash
|
||||
> # Check your UID/GID
|
||||
> id -u # outputs your UID (e.g., 1000)
|
||||
> id -g # outputs your GID (e.g., 1000)
|
||||
> ```
|
||||
>
|
||||
> Create a `.env` file in the automaker directory:
|
||||
>
|
||||
> ```
|
||||
> UID=1000
|
||||
> GID=1000
|
||||
> ```
|
||||
>
|
||||
> Then rebuild the images:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose build
|
||||
> ```
|
||||
>
|
||||
> Without this, files written by the container will be inaccessible to your host user.
|
||||
|
||||
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
||||
|
||||
To enable git push and GitHub CLI operations inside the container:
|
||||
@@ -644,26 +673,10 @@ Join the **Agentic Jumpstart** Discord to connect with other builders exploring
|
||||
|
||||
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
|
||||
## Project Status
|
||||
|
||||
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Automaker License Agreement**. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
**Summary of Terms:**
|
||||
|
||||
- **Allowed:**
|
||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||
|
||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||
- **No Resale:** You cannot resell Automaker itself.
|
||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||
|
||||
- **Liability:**
|
||||
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||
|
||||
- **Contributing:**
|
||||
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).
|
||||
|
||||
**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.
|
||||
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
|
||||
import {
|
||||
CliProvider,
|
||||
type CliSpawnConfig,
|
||||
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
|
||||
windowsStrategy: 'direct',
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
||||
// Windows paths are not used - we check for WSL installation instead
|
||||
win32: [],
|
||||
win32: [
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
|
||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
if (process.platform === 'win32') {
|
||||
const findInPath = (command: string): string | null => {
|
||||
try {
|
||||
const result = execSync(`where ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim()
|
||||
.split(/\r?\n/)[0];
|
||||
|
||||
if (result && fs.existsSync(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isCursorAgentBinary = (cliPath: string) =>
|
||||
cliPath.toLowerCase().includes('cursor-agent');
|
||||
|
||||
const supportsCursorAgentSubcommand = (cliPath: string) => {
|
||||
try {
|
||||
execSync(`"${cliPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
|
||||
if (pathResult) {
|
||||
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
|
||||
return {
|
||||
cliPath: pathResult,
|
||||
useWsl: false,
|
||||
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const config = this.getSpawnConfig();
|
||||
for (const candidate of config.commonPaths.win32 || []) {
|
||||
const resolved = candidate;
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
|
||||
return {
|
||||
cliPath: resolved,
|
||||
useWsl: false,
|
||||
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wslLogger = (msg: string) => logger.debug(msg);
|
||||
if (isWslAvailable({ logger: wslLogger })) {
|
||||
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
|
||||
if (wslResult) {
|
||||
logger.debug(
|
||||
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||
);
|
||||
return {
|
||||
cliPath: 'wsl.exe',
|
||||
useWsl: true,
|
||||
wslCliPath: wslResult.wslPath,
|
||||
wslDistribution: wslResult.distribution,
|
||||
strategy: 'wsl',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cursor-agent not found on Windows');
|
||||
return { cliPath: null, useWsl: false, strategy: 'direct' };
|
||||
}
|
||||
|
||||
// First try standard detection (PATH, common paths, WSL)
|
||||
const result = super.detectCli();
|
||||
if (result.cliPath) {
|
||||
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// Cursor-specific: Check versions directory for any installed version
|
||||
// This handles cases where cursor-agent is installed but not in PATH
|
||||
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
try {
|
||||
const versions = fs
|
||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||
@@ -521,33 +706,31 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
||||
if (process.platform !== 'win32') {
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
import {
|
||||
extractTechnologyStack,
|
||||
extractXmlElements,
|
||||
extractXmlSection,
|
||||
unescapeXml,
|
||||
} from '../../../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function buildProjectContext(projectPath: string): Promise<string | null> {
|
||||
const contextBlocks: string[] = [];
|
||||
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
const projectName = extractXmlSection(specContent, 'project_name');
|
||||
const overview = extractXmlSection(specContent, 'overview');
|
||||
const techStack = extractTechnologyStack(specContent);
|
||||
const coreSection = extractXmlSection(specContent, 'core_capabilities');
|
||||
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
if (projectName) {
|
||||
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
|
||||
}
|
||||
if (overview) {
|
||||
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
|
||||
}
|
||||
if (techStack.length > 0) {
|
||||
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
|
||||
}
|
||||
if (coreCapabilities.length > 0) {
|
||||
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
|
||||
}
|
||||
|
||||
if (summaryLines.length > 0) {
|
||||
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('No app_spec.txt context available for enhancement', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const featureTitles = features
|
||||
.map((feature) => feature.title || feature.name || feature.id)
|
||||
.filter((title) => Boolean(title));
|
||||
|
||||
if (featureTitles.length > 0) {
|
||||
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
|
||||
contextBlocks.push(
|
||||
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
|
||||
featureTitles.length > 30 ? '\n- ...' : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to load existing features for enhancement context', error);
|
||||
}
|
||||
|
||||
if (contextBlocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contextBlocks.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
|
||||
if (projectContext) {
|
||||
logger.debug('Including project context in enhancement prompt');
|
||||
}
|
||||
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
const result = await simpleQuery({
|
||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
|
||||
model: resolvedModel,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
maxTurns: 1,
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getImagesDir } from '@automaker/platform';
|
||||
import { sanitizeFilename } from '@automaker/utils';
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -39,7 +40,7 @@ export function createSaveImageHandler() {
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(filename) || '.png';
|
||||
const baseName = path.basename(filename, ext);
|
||||
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
|
||||
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||
const filePath = path.join(imagesDir, uniqueFilename);
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isExcludedWorktreeLine(line: string): boolean {
|
||||
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
||||
}
|
||||
|
||||
function isBlockingChangeLine(line: string): boolean {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
if (isUntrackedLine(line)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are uncommitted changes in the working directory
|
||||
* Excludes .worktrees/ directory which is created by automaker
|
||||
@@ -23,15 +38,7 @@ const execAsync = promisify(exec);
|
||||
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory (created by automaker)
|
||||
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
|
||||
return true;
|
||||
});
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -45,15 +52,7 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --short', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory
|
||||
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
|
||||
return true;
|
||||
});
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
if (lines.length === 0) return '';
|
||||
if (lines.length <= 5) return lines.join(', ');
|
||||
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
|
||||
|
||||
@@ -2657,13 +2657,67 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
// Load feature for commit message
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
const commitMessage = feature
|
||||
? `feat: ${this.extractTitleFromDescription(
|
||||
feature.description
|
||||
)}\n\nImplemented by Automaker auto-mode`
|
||||
: `feat: Feature ${featureId}`;
|
||||
? await this.generateCommitMessage(feature, workDir)
|
||||
: `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`;
|
||||
|
||||
// Stage and commit
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
// Determine which files to stage
|
||||
// For feature branches, only stage files changed on this branch to avoid committing unrelated changes
|
||||
let filesToStage: string[] = [];
|
||||
|
||||
try {
|
||||
// Get the current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: workDir,
|
||||
});
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
// Get the base branch (usually main/master)
|
||||
const { stdout: baseBranchOutput } = await execAsync(
|
||||
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"',
|
||||
{ cwd: workDir }
|
||||
);
|
||||
const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', '');
|
||||
|
||||
// If we're on a feature branch (not the base branch), only stage files changed on this branch
|
||||
if (branch !== baseBranch && feature?.branchName) {
|
||||
try {
|
||||
// Get files changed on this branch compared to base
|
||||
const { stdout: branchFiles } = await execAsync(
|
||||
`git diff --name-only ${baseBranch}...HEAD`,
|
||||
{ cwd: workDir }
|
||||
);
|
||||
|
||||
if (branchFiles.trim()) {
|
||||
filesToStage = branchFiles.trim().split('\n').filter(Boolean);
|
||||
logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`);
|
||||
}
|
||||
} catch (diffError) {
|
||||
// If diff fails (e.g., base branch doesn't exist), fall back to staging all changes
|
||||
logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`);
|
||||
filesToStage = [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Could not determine branch-specific files: ${error}`);
|
||||
}
|
||||
|
||||
// Stage files
|
||||
if (filesToStage.length > 0) {
|
||||
// Stage only the specific files changed on this branch
|
||||
for (const file of filesToStage) {
|
||||
try {
|
||||
await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir });
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to stage file ${file}: ${error}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: stage all changes (original behavior)
|
||||
// This happens for main branch features or when branch detection fails
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
}
|
||||
|
||||
// Commit
|
||||
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
||||
cwd: workDir,
|
||||
});
|
||||
@@ -3664,13 +3718,14 @@ Format your response as a structured markdown document.`;
|
||||
// Recovery cases:
|
||||
// 1. Standard pending/ready/backlog statuses
|
||||
// 2. Features with approved plans that have incomplete tasks (crash recovery)
|
||||
// 3. Features stuck in 'in_progress' status (crash recovery)
|
||||
// 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery)
|
||||
// 4. Features with 'generating' planSpec status (spec generation was interrupted)
|
||||
const needsRecovery =
|
||||
feature.status === 'pending' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'in_progress' || // Recover features that were in progress when server crashed
|
||||
feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown
|
||||
(feature.planSpec?.status === 'approved' &&
|
||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ||
|
||||
feature.planSpec?.status === 'generating'; // Recover interrupted spec generation
|
||||
@@ -3710,7 +3765,7 @@ Format your response as a structured markdown document.`;
|
||||
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
@@ -3840,6 +3895,58 @@ Format your response as a structured markdown document.`;
|
||||
return firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comprehensive commit message for a feature
|
||||
* Includes title, description summary, and file statistics
|
||||
*/
|
||||
private async generateCommitMessage(feature: Feature, workDir: string): Promise<string> {
|
||||
const title = this.extractTitleFromDescription(feature.description);
|
||||
|
||||
// Extract description summary (first 3-5 lines, up to 300 chars)
|
||||
let descriptionSummary = '';
|
||||
if (feature.description && feature.description.trim()) {
|
||||
const lines = feature.description.split('\n').filter((l) => l.trim());
|
||||
const summaryLines = lines.slice(0, 5); // First 5 non-empty lines
|
||||
descriptionSummary = summaryLines.join('\n');
|
||||
|
||||
// Limit to 300 characters
|
||||
if (descriptionSummary.length > 300) {
|
||||
descriptionSummary = descriptionSummary.substring(0, 297) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Get file statistics to add context
|
||||
let fileStats = '';
|
||||
try {
|
||||
const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir });
|
||||
if (diffStat.trim()) {
|
||||
// Extract just the summary line (last line with file count)
|
||||
const statLines = diffStat.trim().split('\n');
|
||||
const summaryLine = statLines[statLines.length - 1];
|
||||
if (summaryLine && summaryLine.includes('file')) {
|
||||
fileStats = `\n${summaryLine.trim()}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors getting stats
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
let message = `feat: ${title}`;
|
||||
|
||||
if (descriptionSummary && descriptionSummary !== title) {
|
||||
message += `\n\n${descriptionSummary}`;
|
||||
}
|
||||
|
||||
if (fileStats) {
|
||||
message += fileStats;
|
||||
}
|
||||
|
||||
message += '\n\nImplemented by Automaker auto-mode';
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
@@ -5430,9 +5537,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||
// Check if feature was interrupted (in_progress/interrupted or pipeline_*)
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
feature.status === 'interrupted' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
// Check if context (agent-output.md) exists
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface DevServerInfo {
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
// Flag to indicate server is stopping (prevents output after stop)
|
||||
stopping: boolean;
|
||||
// Flag to indicate if URL has been detected from output
|
||||
urlDetected: boolean;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
@@ -103,6 +105,54 @@ class DevServerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect actual server URL from output
|
||||
* Parses stdout/stderr for common URL patterns from dev servers
|
||||
*/
|
||||
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||
// Skip if URL already detected
|
||||
if (server.urlDetected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Common URL patterns from various dev servers:
|
||||
// - Vite: "Local: http://localhost:5173/"
|
||||
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
||||
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
|
||||
// - Generic: Any http:// or https:// URL
|
||||
const urlPatterns = [
|
||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
||||
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
|
||||
];
|
||||
|
||||
for (const pattern of urlPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const detectedUrl = match[1].trim();
|
||||
// Validate it looks like a reasonable URL
|
||||
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
|
||||
server.url = detectedUrl;
|
||||
server.urlDetected = true;
|
||||
logger.info(
|
||||
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
worktreePath: server.worktreePath,
|
||||
url: detectedUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
@@ -115,6 +165,9 @@ class DevServerService {
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Try to detect actual server URL from output
|
||||
this.detectUrlFromOutput(server, content);
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
|
||||
@@ -446,13 +499,14 @@ class DevServerService {
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://${hostname}:${port}`,
|
||||
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
|
||||
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
exec: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: unknown) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
|
||||
|
||||
const mockExec = exec as Mock;
|
||||
|
||||
describe('switch-branch route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
it('should allow switching when only untracked files exist', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/repo/path',
|
||||
branchName: 'feature/test',
|
||||
};
|
||||
|
||||
mockExec.mockImplementation(async (command: string) => {
|
||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||
return { stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git rev-parse --verify feature/test') {
|
||||
return { stdout: 'abc123\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --porcelain') {
|
||||
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git checkout "feature/test"') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
const handler = createSwitchBranchHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch: 'main',
|
||||
currentBranch: 'feature/test',
|
||||
message: "Switched to branch 'feature/test'",
|
||||
},
|
||||
});
|
||||
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
|
||||
});
|
||||
|
||||
it('should block switching when tracked files are modified', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/repo/path',
|
||||
branchName: 'feature/test',
|
||||
};
|
||||
|
||||
mockExec.mockImplementation(async (command: string) => {
|
||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||
return { stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git rev-parse --verify feature/test') {
|
||||
return { stdout: 'abc123\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --porcelain') {
|
||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --short') {
|
||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
const handler = createSwitchBranchHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.',
|
||||
code: 'UNCOMMITTED_CHANGES',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
@@ -842,4 +845,76 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(service.isFeatureRunning('feature-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interrupted recovery', () => {
|
||||
async function createFeatureFixture(
|
||||
projectPath: string,
|
||||
feature: Partial<Feature> & Pick<Feature, 'id'>
|
||||
): Promise<string> {
|
||||
const featureDir = path.join(projectPath, '.automaker', 'features', feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, 'feature.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
title: 'Feature',
|
||||
description: 'Feature description',
|
||||
category: 'implementation',
|
||||
status: 'backlog',
|
||||
...feature,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return featureDir;
|
||||
}
|
||||
|
||||
it('should resume features marked as interrupted after restart', async () => {
|
||||
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-resume-'));
|
||||
try {
|
||||
const featureDir = await createFeatureFixture(projectPath, {
|
||||
id: 'feature-interrupted',
|
||||
status: 'interrupted',
|
||||
});
|
||||
await fs.writeFile(path.join(featureDir, 'agent-output.md'), 'partial progress');
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-complete',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const resumeFeatureMock = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).resumeFeature = resumeFeatureMock;
|
||||
|
||||
await (service as any).resumeInterruptedFeatures(projectPath);
|
||||
|
||||
expect(resumeFeatureMock).toHaveBeenCalledTimes(1);
|
||||
expect(resumeFeatureMock).toHaveBeenCalledWith(projectPath, 'feature-interrupted', true);
|
||||
} finally {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should include interrupted features in pending recovery candidates', async () => {
|
||||
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-pending-'));
|
||||
try {
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-interrupted',
|
||||
status: 'interrupted',
|
||||
});
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-waiting-approval',
|
||||
status: 'waiting_approval',
|
||||
});
|
||||
|
||||
const pendingFeatures = await (service as any).loadPendingFeatures(projectPath, null);
|
||||
const pendingIds = pendingFeatures.map((feature: Feature) => feature.id);
|
||||
|
||||
expect(pendingIds).toContain('feature-interrupted');
|
||||
expect(pendingIds).not.toContain('feature-waiting-approval');
|
||||
} finally {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,6 +380,148 @@ describe('dev-server-service.ts', () => {
|
||||
expect(service.listDevServers().result.servers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL detection from output', () => {
|
||||
it('should detect Vite format URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
// Start server
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate Vite output
|
||||
mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n'));
|
||||
mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n'));
|
||||
|
||||
// Give it a moment to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Next.js format URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate Next.js output
|
||||
mockProcess.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n')
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect generic localhost URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate generic output with URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:8080');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep initial URL if no URL detected in output', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
const result = await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate output without URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server starting...\n'));
|
||||
mockProcess.stdout.emit('data', Buffer.from('Ready!\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
// Should keep the initial allocated URL
|
||||
expect(serverInfo?.url).toBe(result.result?.url);
|
||||
expect(serverInfo?.urlDetected).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect HTTPS URLs', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate HTTPS dev server
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('https://localhost:3443');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should only detect URL once (not update after first detection)', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// First URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const firstUrl = service.getServerInfo(testDir)?.url;
|
||||
|
||||
// Try to emit another URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should keep the first detected URL
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe(firstUrl);
|
||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
|
||||
@@ -405,9 +405,28 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - shows there's more content below */}
|
||||
{canScrollDown && sidebarOpen && (
|
||||
<div className="flex justify-center py-1 border-t border-border/30">
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
|
||||
{canScrollDown && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex justify-center py-2 border-t border-border/30',
|
||||
'bg-gradient-to-t from-background via-background/95 to-transparent',
|
||||
'-mt-8 pt-8',
|
||||
'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto flex flex-col items-center gap-0.5">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-brand-500/70 animate-bounce',
|
||||
sidebarOpen ? 'block' : 'w-3 h-3'
|
||||
)}
|
||||
/>
|
||||
{sidebarOpen && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||
Scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -437,6 +437,63 @@ export function BoardView() {
|
||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||
// Must be after selectedWorktree is defined
|
||||
const autoMode = useAutoMode(selectedWorktree);
|
||||
|
||||
const refreshBoardState = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const projectPath = currentProject.path;
|
||||
const beforeFeatures = (
|
||||
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||
)?.length;
|
||||
const beforeWorktrees = (
|
||||
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||
| { worktrees?: unknown[] }
|
||||
| undefined
|
||||
)?.worktrees?.length;
|
||||
const beforeRunningAgents = (
|
||||
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||
)?.count;
|
||||
const beforeAutoModeRunning = autoMode.isRunning;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.features.all(projectPath) }),
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.runningAgents.all() }),
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.worktrees.all(projectPath) }),
|
||||
autoMode.refreshStatus(),
|
||||
]);
|
||||
|
||||
const afterFeatures = (
|
||||
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||
)?.length;
|
||||
const afterWorktrees = (
|
||||
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||
| { worktrees?: unknown[] }
|
||||
| undefined
|
||||
)?.worktrees?.length;
|
||||
const afterRunningAgents = (
|
||||
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||
)?.count;
|
||||
const afterAutoModeRunning = autoMode.isRunning;
|
||||
|
||||
if (
|
||||
beforeFeatures !== afterFeatures ||
|
||||
beforeWorktrees !== afterWorktrees ||
|
||||
beforeRunningAgents !== afterRunningAgents ||
|
||||
beforeAutoModeRunning !== afterAutoModeRunning
|
||||
) {
|
||||
logger.info('[Board] Refresh detected state mismatch', {
|
||||
features: { before: beforeFeatures, after: afterFeatures },
|
||||
worktrees: { before: beforeWorktrees, after: afterWorktrees },
|
||||
runningAgents: { before: beforeRunningAgents, after: afterRunningAgents },
|
||||
autoModeRunning: { before: beforeAutoModeRunning, after: afterAutoModeRunning },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Board] Failed to refresh board state:', error);
|
||||
toast.error('Failed to refresh board state');
|
||||
}
|
||||
}, [autoMode, currentProject, queryClient]);
|
||||
// Get runningTasks from the hook (scoped to current project/worktree)
|
||||
const runningAutoTasks = autoMode.runningTasks;
|
||||
// Get worktree-specific maxConcurrency from the hook
|
||||
@@ -1321,6 +1378,7 @@ export function BoardView() {
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onRefreshBoard={refreshBoardState}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Wand2, GitBranch, ClipboardCheck, RefreshCw } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
|
||||
creatingSpecProjectPath?: string;
|
||||
// Board controls props
|
||||
onShowBoardBackground: () => void;
|
||||
onRefreshBoard: () => Promise<void>;
|
||||
// View toggle props
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
@@ -60,6 +63,7 @@ export function BoardHeader({
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onRefreshBoard,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: BoardHeaderProps) {
|
||||
@@ -110,9 +114,20 @@ export function BoardHeader({
|
||||
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const handleRefreshBoard = useCallback(async () => {
|
||||
if (isRefreshingBoard) return;
|
||||
setIsRefreshingBoard(true);
|
||||
try {
|
||||
await onRefreshBoard();
|
||||
} finally {
|
||||
setIsRefreshingBoard(false);
|
||||
}
|
||||
}, [isRefreshingBoard, onRefreshBoard]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -127,6 +142,22 @@ export function BoardHeader({
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{isMounted && !isTablet && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={handleRefreshBoard}
|
||||
disabled={isRefreshingBoard}
|
||||
aria-label="Refresh board state from server"
|
||||
>
|
||||
<RefreshCw className={isRefreshingBoard ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
|
||||
@@ -241,9 +241,9 @@ export function CreatePRDialog({
|
||||
<GitPullRequest className="w-5 h-5" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="break-words">
|
||||
Push changes and create a pull request from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import { createSmartPollingInterval, getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
const FEATURES_REFETCH_ON_FOCUS = false;
|
||||
const FEATURES_REFETCH_ON_RECONNECT = false;
|
||||
const FEATURES_POLLING_INTERVAL = 30000;
|
||||
/** Default polling interval for agent output when WebSocket is inactive */
|
||||
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||
|
||||
@@ -43,6 +44,7 @@ export function useFeatures(projectPath: string | undefined) {
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
staleTime: STALE_TIMES.FEATURES,
|
||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -9,9 +9,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
|
||||
|
||||
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
|
||||
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
|
||||
const RUNNING_AGENTS_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface RunningAgentsResult {
|
||||
agents: RunningAgent[];
|
||||
@@ -47,8 +49,7 @@ export function useRunningAgents() {
|
||||
};
|
||||
},
|
||||
staleTime: STALE_TIMES.RUNNING_AGENTS,
|
||||
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
|
||||
// for real-time updates instead of polling
|
||||
refetchInterval: createSmartPollingInterval(RUNNING_AGENTS_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
|
||||
|
||||
const WORKTREE_REFETCH_ON_FOCUS = false;
|
||||
const WORKTREE_REFETCH_ON_RECONNECT = false;
|
||||
const WORKTREES_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -65,6 +67,7 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
staleTime: STALE_TIMES.WORKTREES,
|
||||
refetchInterval: createSmartPollingInterval(WORKTREES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
|
||||
const AUTO_MODE_POLLING_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
* Generate a worktree key for session storage
|
||||
@@ -140,42 +142,54 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
}, [branchName, currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
// Periodic polling fallback when WebSocket events are stale.
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncWithBackend = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
const interval = setInterval(() => {
|
||||
if (getGlobalEventsRecent()) return;
|
||||
void refreshStatus();
|
||||
}, AUTO_MODE_POLLING_INTERVAL);
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncWithBackend();
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentProject, refreshStatus]);
|
||||
|
||||
// Handle auto mode events - listen globally for all projects/worktrees
|
||||
useEffect(() => {
|
||||
@@ -672,5 +686,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
start,
|
||||
stop,
|
||||
stopFeature,
|
||||
refreshStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export {
|
||||
} from './atomic-writer.js';
|
||||
|
||||
// Path utilities
|
||||
export { normalizePath, pathsEqual } from './path-utils.js';
|
||||
export { normalizePath, pathsEqual, sanitizeFilename } from './path-utils.js';
|
||||
|
||||
// Context file loading
|
||||
export {
|
||||
|
||||
@@ -49,3 +49,54 @@ export function pathsEqual(p1: string | undefined | null, p2: string | undefined
|
||||
if (!p1 || !p2) return p1 === p2;
|
||||
return normalizePath(p1) === normalizePath(p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename to be safe for cross-platform file system usage
|
||||
*
|
||||
* Removes or replaces characters that are invalid on various file systems
|
||||
* and prevents Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
|
||||
*
|
||||
* @param filename - The filename to sanitize (without path, just the name)
|
||||
* @param fallback - Fallback name if sanitization results in empty string (default: 'file')
|
||||
* @returns A sanitized filename safe for all platforms
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* sanitizeFilename("my file.txt"); // "my_file.txt"
|
||||
* sanitizeFilename("nul.txt"); // "_nul.txt" (Windows reserved)
|
||||
* sanitizeFilename("con"); // "_con" (Windows reserved)
|
||||
* sanitizeFilename("file?.txt"); // "file.txt"
|
||||
* sanitizeFilename(""); // "file"
|
||||
* sanitizeFilename("", "unnamed"); // "unnamed"
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeFilename(filename: string, fallback: string = 'file'): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Remove or replace invalid characters:
|
||||
// - Path separators: / \
|
||||
// - Windows invalid chars: : * ? " < > |
|
||||
// - Control characters and other problematic chars
|
||||
let safeName = filename
|
||||
.replace(/[/\\:*?"<>|]/g, '') // Remove invalid chars
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/\.+$/g, '') // Remove trailing dots (Windows issue)
|
||||
.replace(/^\.+/g, '') // Remove leading dots
|
||||
.trim();
|
||||
|
||||
// If empty after sanitization, use fallback
|
||||
if (!safeName || safeName.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Handle Windows reserved device names (case-insensitive)
|
||||
// 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(safeName)) {
|
||||
safeName = `_${safeName}`;
|
||||
}
|
||||
|
||||
return safeName;
|
||||
}
|
||||
|
||||
152
libs/utils/tests/path-utils.test.ts
Normal file
152
libs/utils/tests/path-utils.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Path Utilities Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizePath, pathsEqual, sanitizeFilename } from '../src/path-utils.js';
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('should convert backslashes to forward slashes', () => {
|
||||
expect(normalizePath('C:\\Users\\foo\\bar')).toBe('C:/Users/foo/bar');
|
||||
});
|
||||
|
||||
it('should leave forward slashes unchanged', () => {
|
||||
expect(normalizePath('/home/foo/bar')).toBe('/home/foo/bar');
|
||||
});
|
||||
|
||||
it('should handle mixed separators', () => {
|
||||
expect(normalizePath('C:\\Users/foo\\bar')).toBe('C:/Users/foo/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathsEqual', () => {
|
||||
it('should return true for equal paths', () => {
|
||||
expect(pathsEqual('/home/user', '/home/user')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for paths with different separators', () => {
|
||||
expect(pathsEqual('C:\\foo\\bar', 'C:/foo/bar')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different paths', () => {
|
||||
expect(pathsEqual('/home/user', '/home/other')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null and undefined', () => {
|
||||
expect(pathsEqual(null, null)).toBe(true);
|
||||
expect(pathsEqual(undefined, undefined)).toBe(true);
|
||||
expect(pathsEqual(null, undefined)).toBe(false);
|
||||
expect(pathsEqual(null, '/path')).toBe(false);
|
||||
expect(pathsEqual('/path', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
describe('Windows reserved names', () => {
|
||||
it('should prefix Windows reserved device names', () => {
|
||||
expect(sanitizeFilename('nul')).toBe('_nul');
|
||||
expect(sanitizeFilename('NUL')).toBe('_NUL');
|
||||
expect(sanitizeFilename('con')).toBe('_con');
|
||||
expect(sanitizeFilename('CON')).toBe('_CON');
|
||||
expect(sanitizeFilename('prn')).toBe('_prn');
|
||||
expect(sanitizeFilename('aux')).toBe('_aux');
|
||||
});
|
||||
|
||||
it('should prefix COM and LPT port names', () => {
|
||||
expect(sanitizeFilename('com1')).toBe('_com1');
|
||||
expect(sanitizeFilename('COM5')).toBe('_COM5');
|
||||
expect(sanitizeFilename('lpt1')).toBe('_lpt1');
|
||||
expect(sanitizeFilename('LPT9')).toBe('_LPT9');
|
||||
});
|
||||
|
||||
it('should not prefix reserved names with extensions', () => {
|
||||
// After removing extension, baseName might be reserved
|
||||
expect(sanitizeFilename('nul')).toBe('_nul');
|
||||
});
|
||||
|
||||
it('should not prefix non-reserved names that contain reserved words', () => {
|
||||
expect(sanitizeFilename('null')).toBe('null'); // "null" is not reserved, only "nul"
|
||||
expect(sanitizeFilename('console')).toBe('console');
|
||||
expect(sanitizeFilename('auxiliary')).toBe('auxiliary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid characters', () => {
|
||||
it('should remove path separators', () => {
|
||||
expect(sanitizeFilename('foo/bar')).toBe('foobar');
|
||||
expect(sanitizeFilename('foo\\bar')).toBe('foobar');
|
||||
});
|
||||
|
||||
it('should remove Windows invalid characters', () => {
|
||||
expect(sanitizeFilename('file:name')).toBe('filename');
|
||||
expect(sanitizeFilename('file*name')).toBe('filename');
|
||||
expect(sanitizeFilename('file?name')).toBe('filename');
|
||||
expect(sanitizeFilename('file"name')).toBe('filename');
|
||||
expect(sanitizeFilename('file<name>')).toBe('filename');
|
||||
expect(sanitizeFilename('file|name')).toBe('filename');
|
||||
});
|
||||
|
||||
it('should replace spaces with underscores', () => {
|
||||
expect(sanitizeFilename('my file name')).toBe('my_file_name');
|
||||
expect(sanitizeFilename('file name')).toBe('file_name'); // multiple spaces
|
||||
});
|
||||
|
||||
it('should remove leading and trailing dots', () => {
|
||||
expect(sanitizeFilename('.hidden')).toBe('hidden');
|
||||
expect(sanitizeFilename('file...')).toBe('file');
|
||||
expect(sanitizeFilename('...file...')).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return fallback for empty strings', () => {
|
||||
expect(sanitizeFilename('')).toBe('file');
|
||||
expect(sanitizeFilename('', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('should return fallback for null/undefined', () => {
|
||||
expect(sanitizeFilename(null as any)).toBe('file');
|
||||
expect(sanitizeFilename(undefined as any)).toBe('file');
|
||||
expect(sanitizeFilename(null as any, 'image')).toBe('image');
|
||||
});
|
||||
|
||||
it('should return fallback for strings that become empty after sanitization', () => {
|
||||
expect(sanitizeFilename('...')).toBe('file');
|
||||
expect(sanitizeFilename('///\\\\\\')).toBe('file');
|
||||
expect(sanitizeFilename('???')).toBe('file');
|
||||
});
|
||||
|
||||
it('should handle non-string inputs', () => {
|
||||
expect(sanitizeFilename(123 as any)).toBe('file');
|
||||
expect(sanitizeFilename({} as any)).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Normal filenames', () => {
|
||||
it('should preserve normal filenames', () => {
|
||||
expect(sanitizeFilename('document')).toBe('document');
|
||||
expect(sanitizeFilename('file123')).toBe('file123');
|
||||
expect(sanitizeFilename('my-file_name')).toBe('my-file_name');
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
expect(sanitizeFilename('文件')).toBe('文件');
|
||||
expect(sanitizeFilename('файл')).toBe('файл');
|
||||
expect(sanitizeFilename('café')).toBe('café');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world examples from bug report', () => {
|
||||
it('should handle filename that might become "nul"', () => {
|
||||
// If a filename is "null.png", basename would be "null"
|
||||
expect(sanitizeFilename('null')).toBe('null'); // "null" is ok
|
||||
expect(sanitizeFilename('nul')).toBe('_nul'); // "nul" is reserved
|
||||
});
|
||||
|
||||
it('should sanitize typical image filenames', () => {
|
||||
expect(sanitizeFilename('screenshot')).toBe('screenshot');
|
||||
expect(sanitizeFilename('image 1')).toBe('image_1');
|
||||
expect(sanitizeFilename('photo?.jpg')).toBe('photo.jpg'); // ? removed, . is valid
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
|
||||
Reference in New Issue
Block a user