fix: resolve three critical bugs from GitHub issue tracker

Fix #684: Prevent Windows reserved filename creation
- Add sanitizeFilename() utility to detect and prefix Windows reserved names
  (NUL, CON, PRN, AUX, COM1-9, LPT1-9)
- Apply sanitization to save-image route to prevent "nul" file creation
- Add 23 comprehensive tests for filename sanitization edge cases

Fix #576: Detect actual dev server port from output
- Parse stdout/stderr for real server URLs (Vite, Next.js, generic formats)
- Update server URL when detected instead of using allocated PORT
- Emit dev-server:url-detected event for frontend updates
- Add 6 tests for URL detection patterns

Fix #193: Commit only feature-specific changes
- Change from 'git add -A' to branch-aware file staging
- Use git diff to find files changed on feature branch only
- Prevent committing unrelated changes from other features
- Maintain backward compatibility with main branch workflow

All fixes include comprehensive tests and maintain backward compatibility.
Test results: 1,968 tests passed (547 package + 1,421 server tests)
This commit is contained in:
DhanushSantosh
2026-02-05 10:42:56 +05:30
parent 63cae19aec
commit 84570842d3
7 changed files with 461 additions and 5 deletions

View File

@@ -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);

View File

@@ -2662,8 +2662,64 @@ Address the follow-up instructions above. Review the previous work and make the
)}\n\nImplemented by Automaker auto-mode`
: `feat: Feature ${featureId}`;
// 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,
});

View File

@@ -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

View File

@@ -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