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