feat: add GitHub setup step and enhance setup flow

- Introduced a new GitHubSetupStep component for GitHub CLI configuration during the setup process.
- Updated SetupView to include the GitHub step in the setup flow, allowing users to skip or proceed based on their GitHub CLI status.
- Enhanced state management to track GitHub CLI installation and authentication status.
- Added logging for transitions between setup steps to improve user feedback.
- Updated related files to ensure cross-platform path normalization and compatibility.
This commit is contained in:
Cody Seibert
2025-12-16 13:56:53 -05:00
parent 8482cdab87
commit 8c24381759
26 changed files with 1302 additions and 466 deletions

View File

@@ -1,322 +1,84 @@
/**
* Automaker Paths - Utilities for managing automaker data storage
*
* Stores project data in an external location (~/.automaker/projects/{project-id}/)
* to avoid conflicts with git worktrees and symlink issues.
*
* The project-id is derived from the git remote URL (if available) or project path,
* ensuring each project has a unique storage location that persists across worktrees.
* Stores project data inside the project directory at {projectPath}/.automaker/
*/
import fs from "fs/promises";
import path from "path";
import { createHash } from "crypto";
import { exec } from "child_process";
import { promisify } from "util";
import os from "os";
const execAsync = promisify(exec);
/**
* Get the base automaker directory in user's home
*/
export function getAutomakerBaseDir(): string {
return path.join(os.homedir(), ".automaker");
}
/**
* Get the projects directory
*/
export function getProjectsDir(): string {
return path.join(getAutomakerBaseDir(), "projects");
}
/**
* Generate a project ID from a unique identifier (git remote or path)
*/
function generateProjectId(identifier: string): string {
const hash = createHash("sha256").update(identifier).digest("hex");
return hash.substring(0, 16);
}
/**
* Get the main git repository root path (resolves worktree paths to main repo)
*/
async function getMainRepoPath(projectPath: string): Promise<string> {
try {
// Get the main worktree path (handles worktrees)
const { stdout } = await execAsync(
"git worktree list --porcelain | head -1 | sed 's/worktree //'",
{ cwd: projectPath }
);
const mainPath = stdout.trim();
return mainPath || projectPath;
} catch {
return projectPath;
}
}
/**
* Get a unique identifier for a git project
* Prefers git remote URL, falls back to main repo path
*/
async function getProjectIdentifier(projectPath: string): Promise<string> {
const mainPath = await getMainRepoPath(projectPath);
try {
// Try to get the git remote URL first (most stable identifier)
const { stdout } = await execAsync("git remote get-url origin", {
cwd: mainPath,
});
const remoteUrl = stdout.trim();
if (remoteUrl) {
return remoteUrl;
}
} catch {
// No remote configured, fall through
}
// Fall back to the absolute main repo path
return path.resolve(mainPath);
}
/**
* Get the automaker data directory for a project
* This is the external location where all .automaker data is stored
* This is stored inside the project at .automaker/
*/
export async function getAutomakerDir(projectPath: string): Promise<string> {
const identifier = await getProjectIdentifier(projectPath);
const projectId = generateProjectId(identifier);
return path.join(getProjectsDir(), projectId);
export function getAutomakerDir(projectPath: string): string {
return path.join(projectPath, ".automaker");
}
/**
* Get the features directory for a project
*/
export async function getFeaturesDir(projectPath: string): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
return path.join(automakerDir, "features");
export function getFeaturesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "features");
}
/**
* Get the directory for a specific feature
*/
export async function getFeatureDir(
projectPath: string,
featureId: string
): Promise<string> {
const featuresDir = await getFeaturesDir(projectPath);
return path.join(featuresDir, featureId);
export function getFeatureDir(projectPath: string, featureId: string): string {
return path.join(getFeaturesDir(projectPath), featureId);
}
/**
* Get the images directory for a feature
*/
export async function getFeatureImagesDir(
export function getFeatureImagesDir(
projectPath: string,
featureId: string
): Promise<string> {
const featureDir = await getFeatureDir(projectPath, featureId);
return path.join(featureDir, "images");
): string {
return path.join(getFeatureDir(projectPath, featureId), "images");
}
/**
* Get the board directory for a project (board backgrounds, etc.)
*/
export async function getBoardDir(projectPath: string): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
return path.join(automakerDir, "board");
export function getBoardDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "board");
}
/**
* Get the images directory for a project (general images)
*/
export async function getImagesDir(projectPath: string): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
return path.join(automakerDir, "images");
export function getImagesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "images");
}
/**
* Get the worktrees metadata directory for a project
*/
export async function getWorktreesDir(projectPath: string): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
return path.join(automakerDir, "worktrees");
export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "worktrees");
}
/**
* Get the app spec file path for a project
*/
export async function getAppSpecPath(projectPath: string): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
return path.join(automakerDir, "app_spec.txt");
export function getAppSpecPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
}
/**
* Get the branch tracking file path for a project
*/
export async function getBranchTrackingPath(
projectPath: string
): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
return path.join(automakerDir, "active-branches.json");
export function getBranchTrackingPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "active-branches.json");
}
/**
* Ensure the automaker directory structure exists for a project
*/
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
const automakerDir = getAutomakerDir(projectPath);
await fs.mkdir(automakerDir, { recursive: true });
return automakerDir;
}
/**
* Check if there's existing .automaker data in the project directory that needs migration
*/
export async function hasLegacyAutomakerDir(
projectPath: string
): Promise<boolean> {
const mainPath = await getMainRepoPath(projectPath);
const legacyPath = path.join(mainPath, ".automaker");
try {
const stats = await fs.lstat(legacyPath);
// Only count it as legacy if it's a directory (not a symlink)
return stats.isDirectory() && !stats.isSymbolicLink();
} catch {
return false;
}
}
/**
* Get the legacy .automaker path in the project directory
*/
export async function getLegacyAutomakerDir(
projectPath: string
): Promise<string> {
const mainPath = await getMainRepoPath(projectPath);
return path.join(mainPath, ".automaker");
}
/**
* Migrate data from legacy in-repo .automaker to external location
* Returns true if migration was performed, false if not needed
*/
export async function migrateLegacyData(projectPath: string): Promise<boolean> {
if (!(await hasLegacyAutomakerDir(projectPath))) {
return false;
}
const legacyDir = await getLegacyAutomakerDir(projectPath);
const newDir = await ensureAutomakerDir(projectPath);
console.log(`[automaker-paths] Migrating data from ${legacyDir} to ${newDir}`);
try {
// Copy all contents from legacy to new location
const entries = await fs.readdir(legacyDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(legacyDir, entry.name);
const destPath = path.join(newDir, entry.name);
// Skip if destination already exists
try {
await fs.access(destPath);
console.log(
`[automaker-paths] Skipping ${entry.name} (already exists in destination)`
);
continue;
} catch {
// Destination doesn't exist, proceed with copy
}
if (entry.isDirectory()) {
await fs.cp(srcPath, destPath, { recursive: true });
} else if (entry.isFile()) {
await fs.copyFile(srcPath, destPath);
}
// Skip symlinks
}
console.log(`[automaker-paths] Migration complete`);
// Optionally rename the old directory to mark it as migrated
const backupPath = path.join(
path.dirname(legacyDir),
".automaker-migrated"
);
try {
await fs.rename(legacyDir, backupPath);
console.log(
`[automaker-paths] Renamed legacy directory to .automaker-migrated`
);
} catch (error) {
console.warn(
`[automaker-paths] Could not rename legacy directory:`,
error
);
}
return true;
} catch (error) {
console.error(`[automaker-paths] Migration failed:`, error);
throw error;
}
}
/**
* Convert a legacy relative path (e.g., ".automaker/features/...")
* to the new external absolute path
*/
export async function convertLegacyPath(
projectPath: string,
legacyRelativePath: string
): Promise<string> {
// If it doesn't start with .automaker, return as-is
if (!legacyRelativePath.startsWith(".automaker")) {
return legacyRelativePath;
}
const automakerDir = await getAutomakerDir(projectPath);
// Remove ".automaker/" prefix and join with new base
const relativePart = legacyRelativePath.replace(/^\.automaker\/?/, "");
return path.join(automakerDir, relativePart);
}
/**
* Get a relative path for display/storage (relative to external automaker dir)
* The path is prefixed with "automaker:" to indicate it's an external path
*/
export async function getDisplayPath(
projectPath: string,
absolutePath: string
): Promise<string> {
const automakerDir = await getAutomakerDir(projectPath);
if (absolutePath.startsWith(automakerDir)) {
const relativePart = absolutePath.substring(automakerDir.length + 1);
return `automaker:${relativePart}`;
}
return absolutePath;
}
/**
* Resolve a display path back to absolute path
*/
export async function resolveDisplayPath(
projectPath: string,
displayPath: string
): Promise<string> {
if (displayPath.startsWith("automaker:")) {
const automakerDir = await getAutomakerDir(projectPath);
const relativePart = displayPath.substring("automaker:".length);
return path.join(automakerDir, relativePart);
}
// Legacy ".automaker" paths
if (displayPath.startsWith(".automaker")) {
return convertLegacyPath(projectPath, displayPath);
}
// Already absolute or project-relative path
return displayPath;
}

View File

@@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec(
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", featureCount);
// Read existing spec from external automaker directory
const specPath = await getAppSpecPath(projectPath);
// Read existing spec from .automaker directory
const specPath = getAppSpecPath(projectPath);
let spec: string;
logger.debug("Reading spec from:", specPath);

View File

@@ -210,9 +210,9 @@ ${getAppSpecFormatInstruction()}`;
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
}
// Save spec to external automaker directory
// Save spec to .automaker directory
const specDir = await ensureAutomakerDir(projectPath);
const specPath = await getAppSpecPath(projectPath);
const specPath = getAppSpecPath(projectPath);
logger.info("Saving spec to:", specPath);
logger.info(`Content to save (${responseText.length} chars)`);

View File

@@ -42,7 +42,7 @@ export async function parseAndCreateFeatures(
logger.info(`Parsed ${parsed.features?.length || 0} features`);
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
const featuresDir = await getFeaturesDir(projectPath);
const featuresDir = getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
const createdFeatures: Array<{ id: string; title: string }> = [];

View File

@@ -21,8 +21,8 @@ export function createDeleteBoardBackgroundHandler() {
return;
}
// Get external board directory
const boardDir = await getBoardDir(projectPath);
// Get board directory
const boardDir = getBoardDir(projectPath);
try {
// Try to remove all background files in the board directory

View File

@@ -27,8 +27,8 @@ export function createSaveBoardBackgroundHandler() {
return;
}
// Get external board directory
const boardDir = await getBoardDir(projectPath);
// Get board directory
const boardDir = getBoardDir(projectPath);
await fs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)

View File

@@ -1,5 +1,5 @@
/**
* POST /save-image endpoint - Save image to external automaker images directory
* POST /save-image endpoint - Save image to .automaker images directory
*/
import type { Request, Response } from "express";
@@ -27,8 +27,8 @@ export function createSaveImageHandler() {
return;
}
// Get external images directory
const imagesDir = await getImagesDir(projectPath);
// Get images directory
const imagesDir = getImagesDir(projectPath);
await fs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)

View File

@@ -11,6 +11,7 @@ import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js";
import { createApiKeysHandler } from "./routes/api-keys.js";
import { createPlatformHandler } from "./routes/platform.js";
import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js";
import { createGhStatusHandler } from "./routes/gh-status.js";
export function createSetupRoutes(): Router {
const router = Router();
@@ -23,6 +24,7 @@ export function createSetupRoutes(): Router {
router.get("/api-keys", createApiKeysHandler());
router.get("/platform", createPlatformHandler());
router.post("/verify-claude-auth", createVerifyClaudeAuthHandler());
router.get("/gh-status", createGhStatusHandler());
return router;
}

View File

@@ -0,0 +1,131 @@
/**
* GET /gh-status endpoint - Get GitHub CLI status
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import os from "os";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
const extendedPath = [
process.env.PATH,
"/opt/homebrew/bin",
"/usr/local/bin",
"/home/linuxbrew/.linuxbrew/bin",
`${process.env.HOME}/.local/bin`,
].filter(Boolean).join(":");
const execEnv = {
...process.env,
PATH: extendedPath,
};
export interface GhStatus {
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}
async function getGhStatus(): Promise<GhStatus> {
const status: GhStatus = {
installed: false,
authenticated: false,
version: null,
path: null,
user: null,
};
const isWindows = process.platform === "win32";
// Check if gh CLI is installed
try {
const findCommand = isWindows ? "where gh" : "command -v gh";
const { stdout } = await execAsync(findCommand, { env: execEnv });
status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true;
} catch {
// gh not in PATH, try common locations
const commonPaths = isWindows
? [
path.join(process.env.LOCALAPPDATA || "", "Programs", "gh", "bin", "gh.exe"),
path.join(process.env.ProgramFiles || "", "GitHub CLI", "gh.exe"),
]
: [
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
path.join(os.homedir(), ".local", "bin", "gh"),
"/home/linuxbrew/.linuxbrew/bin/gh",
];
for (const p of commonPaths) {
try {
await fs.access(p);
status.path = p;
status.installed = true;
break;
} catch {
// Not found at this path
}
}
}
if (!status.installed) {
return status;
}
// Get version
try {
const { stdout } = await execAsync("gh --version", { env: execEnv });
// Extract version from output like "gh version 2.40.1 (2024-01-09)"
const versionMatch = stdout.match(/gh version ([\d.]+)/);
status.version = versionMatch ? versionMatch[1] : stdout.trim().split("\n")[0];
} catch {
// Version command failed
}
// Check authentication status
try {
const { stdout } = await execAsync("gh auth status", { env: execEnv });
// If this succeeds without error, we're authenticated
status.authenticated = true;
// Try to extract username from output
const userMatch = stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) {
status.user = userMatch[1];
}
} catch (error: unknown) {
// Auth status returns non-zero if not authenticated
const err = error as { stderr?: string };
if (err.stderr?.includes("not logged in")) {
status.authenticated = false;
}
}
return status;
}
export function createGhStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const status = await getGhStatus();
res.json({
success: true,
...status,
});
} catch (error) {
logError(error, "Get GitHub CLI status failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,10 +1,8 @@
/**
* Branch tracking utilities
*
* Tracks active branches in external automaker storage so users
* Tracks active branches in .automaker so users
* can switch between branches even after worktrees are removed.
*
* Data is stored outside the git repo to avoid worktree/symlink conflicts.
*/
import { readFile, writeFile } from "fs/promises";
@@ -31,7 +29,7 @@ export async function getTrackedBranches(
projectPath: string
): Promise<TrackedBranch[]> {
try {
const filePath = await getBranchTrackingPath(projectPath);
const filePath = getBranchTrackingPath(projectPath);
const content = await readFile(filePath, "utf-8");
const data: BranchTrackingData = JSON.parse(content);
return data.branches || [];

View File

@@ -111,7 +111,7 @@ export function createCreatePRHandler() {
return;
}
// Create PR using gh CLI
// Create PR using gh CLI or provide browser fallback
const base = baseBranch || "main";
const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`;
@@ -119,65 +119,97 @@ export function createCreatePRHandler() {
let prUrl: string | null = null;
let prError: string | null = null;
let browserUrl: string | null = null;
let ghCliAvailable = false;
// Check if gh CLI is available
try {
// Check if gh CLI is available (use extended PATH for Homebrew/etc)
await execAsync("command -v gh", { env: execEnv });
ghCliAvailable = true;
} catch {
ghCliAvailable = false;
}
// Check if this is a fork by looking for upstream remote
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync("git remote -v", {
cwd: worktreePath,
env: execEnv,
});
// Parse remotes to detect fork workflow
const lines = remotes.split("\n");
for (const line of lines) {
const match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (match) {
const [, remoteName, owner] = match;
if (remoteName === "upstream") {
upstreamRepo = line.match(/[:/]([^/]+\/[^/\s]+?)(?:\.git)?\s+\(fetch\)/)?.[1] || null;
} else if (remoteName === "origin") {
originOwner = owner;
}
}
}
} catch {
// Couldn't parse remotes, continue without fork detection
}
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
console.log("[CreatePR] Running:", prCmd);
const { stdout: prOutput } = await execAsync(prCmd, {
// Get repository URL for browser fallback
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync("git remote -v", {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
} catch (ghError: unknown) {
// gh CLI not available or PR creation failed
const err = ghError as { stderr?: string; message?: string };
prError = err.stderr || err.message || "PR creation failed";
console.warn("[CreatePR] gh CLI error:", prError);
// Parse remotes to detect fork workflow and get repo URL
const lines = remotes.split("\n");
for (const line of lines) {
const match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") {
upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === "origin") {
originOwner = owner;
if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`;
}
}
}
}
} catch {
// Couldn't parse remotes
}
// Return result with any error info
// Construct browser URL for PR creation
if (repoUrl) {
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
if (upstreamRepo && originOwner) {
// Fork workflow: PR to upstream from origin
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
} else {
// Regular repo
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
}
}
if (ghCliAvailable) {
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
console.log("[CreatePR] Running:", prCmd);
const { stdout: prOutput } = await execAsync(prCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
} catch (ghError: unknown) {
// gh CLI failed
const err = ghError as { stderr?: string; message?: string };
prError = err.stderr || err.message || "PR creation failed";
console.warn("[CreatePR] gh CLI error:", prError);
}
} else {
prError = "gh_cli_not_available";
console.log("[CreatePR] gh CLI not available, returning browser URL");
}
// Return result with browser fallback URL
res.json({
success: true,
result: {
@@ -188,6 +220,8 @@ export function createCreatePRHandler() {
prUrl,
prCreated: !!prUrl,
prError: prError || undefined,
browserUrl: browserUrl || undefined,
ghCliAvailable,
},
});
} catch (error) {

View File

@@ -1,63 +1,32 @@
/**
* POST /migrate endpoint - Migrate legacy .automaker data to external storage
* POST /migrate endpoint - Migration endpoint (no longer needed)
*
* This endpoint checks if there's legacy .automaker data in the project directory
* and migrates it to the external ~/.automaker/projects/{project-id}/ location.
* This endpoint is kept for backwards compatibility but no longer performs
* any migration since .automaker is now stored in the project directory.
*/
import type { Request, Response } from "express";
import { getErrorMessage, logError } from "../common.js";
import {
hasLegacyAutomakerDir,
migrateLegacyData,
getAutomakerDir,
getLegacyAutomakerDir,
} from "../../../lib/automaker-paths.js";
import { getAutomakerDir } from "../../../lib/automaker-paths.js";
export function createMigrateHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
// Check if migration is needed
const hasLegacy = await hasLegacyAutomakerDir(projectPath);
if (!hasLegacy) {
const automakerDir = await getAutomakerDir(projectPath);
res.json({
success: true,
migrated: false,
message: "No legacy .automaker directory found - nothing to migrate",
externalPath: automakerDir,
});
return;
}
// Perform migration
console.log(`[migrate] Starting migration for project: ${projectPath}`);
const legacyPath = await getLegacyAutomakerDir(projectPath);
const externalPath = await getAutomakerDir(projectPath);
await migrateLegacyData(projectPath);
res.json({
success: true,
migrated: true,
message: "Successfully migrated .automaker data to external storage",
legacyPath,
externalPath,
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
});
} catch (error) {
logError(error, "Migration failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
return;
}
// Migration is no longer needed - .automaker is stored in project directory
const automakerDir = getAutomakerDir(projectPath);
res.json({
success: true,
migrated: false,
message: "No migration needed - .automaker is stored in project directory",
path: automakerDir,
});
};
}

View File

@@ -337,8 +337,8 @@ export class AutoModeService {
featureId: string,
useWorktrees = true
): Promise<void> {
// Check if context exists in external automaker directory
const featureDir = await getFeatureDir(projectPath, featureId);
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
let hasContext = false;
@@ -399,8 +399,8 @@ export class AutoModeService {
// Load feature info for context
const feature = await this.loadFeature(projectPath, featureId);
// Load previous agent output if it exists (from external automaker)
const featureDir = await getFeatureDir(projectPath, featureId);
// Load previous agent output if it exists
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
let previousContext = "";
try {
@@ -461,10 +461,10 @@ Address the follow-up instructions above. Review the previous work and make the
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
// Copy follow-up images to feature folder (external automaker)
// Copy follow-up images to feature folder
const copiedImagePaths: string[] = [];
if (imagePaths && imagePaths.length > 0) {
const featureDirForImages = await getFeatureDir(projectPath, featureId);
const featureDirForImages = getFeatureDir(projectPath, featureId);
const featureImagesDir = path.join(featureDirForImages, "images");
await fs.mkdir(featureImagesDir, { recursive: true });
@@ -512,9 +512,9 @@ Address the follow-up instructions above. Review the previous work and make the
allImagePaths.push(...allPaths);
}
// Save updated feature.json with new images (external automaker)
// Save updated feature.json with new images
if (copiedImagePaths.length > 0 && feature) {
const featureDirForSave = await getFeatureDir(projectPath, featureId);
const featureDirForSave = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDirForSave, "feature.json");
try {
@@ -707,8 +707,8 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath: string,
featureId: string
): Promise<boolean> {
// Context is stored in external automaker directory
const featureDir = await getFeatureDir(projectPath, featureId);
// Context is stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
try {
@@ -782,8 +782,8 @@ Format your response as a structured markdown document.`;
}
}
// Save analysis to external automaker directory
const automakerDir = await getAutomakerDir(projectPath);
// Save analysis to .automaker directory
const automakerDir = getAutomakerDir(projectPath);
const analysisPath = path.join(automakerDir, "project-analysis.md");
await fs.mkdir(automakerDir, { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
@@ -844,7 +844,7 @@ Format your response as a structured markdown document.`;
featureId: string,
branchName: string
): Promise<string> {
// Git worktrees stay in project directory (not external automaker)
// Git worktrees stay in project directory
const worktreesDir = path.join(projectPath, ".worktrees");
const worktreePath = path.join(worktreesDir, featureId);
@@ -883,8 +883,8 @@ Format your response as a structured markdown document.`;
projectPath: string,
featureId: string
): Promise<Feature | null> {
// Features are stored in external automaker directory
const featureDir = await getFeatureDir(projectPath, featureId);
// Features are stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, "feature.json");
try {
@@ -900,8 +900,8 @@ Format your response as a structured markdown document.`;
featureId: string,
status: string
): Promise<void> {
// Features are stored in external automaker directory
const featureDir = await getFeatureDir(projectPath, featureId);
// Features are stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, "feature.json");
try {
@@ -924,8 +924,8 @@ Format your response as a structured markdown document.`;
}
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
// Features are stored in external automaker directory
const featuresDir = await getFeaturesDir(projectPath);
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
@@ -1114,11 +1114,11 @@ When done, summarize what you implemented and any notes for the developer.`;
// Execute via provider
const stream = provider.executeQuery(options);
let responseText = "";
// Agent output goes to external automaker directory
// Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = await getFeatureDir(configProjectPath, featureId);
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
for await (const msg of stream) {

View File

@@ -1,8 +1,6 @@
/**
* Feature Loader - Handles loading and managing features from individual feature folders
* Each feature is stored in external automaker storage: ~/.automaker/projects/{project-id}/features/{featureId}/feature.json
*
* Features are stored outside the git repo to avoid worktree conflicts.
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
import path from "path";
@@ -29,17 +27,14 @@ export class FeatureLoader {
/**
* Get the features directory path
*/
async getFeaturesDir(projectPath: string): Promise<string> {
getFeaturesDir(projectPath: string): string {
return getFeaturesDir(projectPath);
}
/**
* Get the images directory path for a feature
*/
async getFeatureImagesDir(
projectPath: string,
featureId: string
): Promise<string> {
getFeatureImagesDir(projectPath: string, featureId: string): string {
return getFeatureImagesDir(projectPath, featureId);
}
@@ -95,10 +90,7 @@ export class FeatureLoader {
return imagePaths;
}
const featureImagesDir = await this.getFeatureImagesDir(
projectPath,
featureId
);
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await fs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
@@ -166,30 +158,22 @@ export class FeatureLoader {
/**
* Get the path to a specific feature folder
*/
async getFeatureDir(projectPath: string, featureId: string): Promise<string> {
getFeatureDir(projectPath: string, featureId: string): string {
return getFeatureDir(projectPath, featureId);
}
/**
* Get the path to a feature's feature.json file
*/
async getFeatureJsonPath(
projectPath: string,
featureId: string
): Promise<string> {
const featureDir = await this.getFeatureDir(projectPath, featureId);
return path.join(featureDir, "feature.json");
getFeatureJsonPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
}
/**
* Get the path to a feature's agent-output.md file
*/
async getAgentOutputPath(
projectPath: string,
featureId: string
): Promise<string> {
const featureDir = await this.getFeatureDir(projectPath, featureId);
return path.join(featureDir, "agent-output.md");
getAgentOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
}
/**
@@ -204,7 +188,7 @@ export class FeatureLoader {
*/
async getAll(projectPath: string): Promise<Feature[]> {
try {
const featuresDir = await this.getFeaturesDir(projectPath);
const featuresDir = this.getFeaturesDir(projectPath);
// Check if features directory exists
try {
@@ -221,10 +205,7 @@ export class FeatureLoader {
const features: Feature[] = [];
for (const dir of featureDirs) {
const featureId = dir.name;
const featureJsonPath = await this.getFeatureJsonPath(
projectPath,
featureId
);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
@@ -273,10 +254,7 @@ export class FeatureLoader {
*/
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = await this.getFeatureJsonPath(
projectPath,
featureId
);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
return JSON.parse(content);
} catch (error) {
@@ -299,8 +277,8 @@ export class FeatureLoader {
featureData: Partial<Feature>
): Promise<Feature> {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = await this.getFeatureDir(projectPath, featureId);
const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId);
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Ensure automaker directory exists
await ensureAutomakerDir(projectPath);
@@ -376,7 +354,7 @@ export class FeatureLoader {
};
// Write back to file
const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
@@ -392,7 +370,7 @@ export class FeatureLoader {
*/
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = await this.getFeatureDir(projectPath, featureId);
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
@@ -413,10 +391,7 @@ export class FeatureLoader {
featureId: string
): Promise<string | null> {
try {
const agentOutputPath = await this.getAgentOutputPath(
projectPath,
featureId
);
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
return content;
} catch (error) {
@@ -439,10 +414,10 @@ export class FeatureLoader {
featureId: string,
content: string
): Promise<void> {
const featureDir = await this.getFeatureDir(projectPath, featureId);
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
const agentOutputPath = await this.getAgentOutputPath(projectPath, featureId);
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
}
@@ -454,10 +429,7 @@ export class FeatureLoader {
featureId: string
): Promise<void> {
try {
const agentOutputPath = await this.getAgentOutputPath(
projectPath,
featureId
);
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {