mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 10:35:37 +08:00
refactor: setup project wizards with only three steps
This commit is contained in:
@@ -7,7 +7,8 @@ import crypto from 'crypto';
|
|||||||
import { userDb } from '@/shared/database/repositories/users.js';
|
import { userDb } from '@/shared/database/repositories/users.js';
|
||||||
import { apiKeysDb } from '@/shared/database/repositories/api-keys.js';
|
import { apiKeysDb } from '@/shared/database/repositories/api-keys.js';
|
||||||
import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js';
|
import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js';
|
||||||
import { addProjectManually } from '../../../projects.js';
|
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||||
|
import { getWorkspaceNameFromPath } from '@/modules/projects/projects.utils.js';
|
||||||
import { queryClaudeSDK } from '../../../claude-sdk.js';
|
import { queryClaudeSDK } from '../../../claude-sdk.js';
|
||||||
import { spawnCursor } from '../../../cursor-cli.js';
|
import { spawnCursor } from '../../../cursor-cli.js';
|
||||||
import { queryCodex } from '../../../openai-codex.js';
|
import { queryCodex } from '../../../openai-codex.js';
|
||||||
@@ -902,20 +903,9 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the project (or use existing registration)
|
// Register the workspace path in the refactored metadata table.
|
||||||
let project;
|
workspaceOriginalPathsDb.createWorkspacePath(finalProjectPath, getWorkspaceNameFromPath(finalProjectPath));
|
||||||
try {
|
console.log('📦 Workspace registered:', finalProjectPath);
|
||||||
project = await addProjectManually(finalProjectPath);
|
|
||||||
console.log('📦 Project registered:', project);
|
|
||||||
} catch (error) {
|
|
||||||
// If project already exists, that's fine - continue with the existing registration
|
|
||||||
if (error.message && error.message.includes('Project already configured')) {
|
|
||||||
console.log('📦 Using existing project registration for:', finalProjectPath);
|
|
||||||
project = { path: finalProjectPath };
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up writer based on streaming mode
|
// Set up writer based on streaming mode
|
||||||
if (stream) {
|
if (stream) {
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
renameProject,
|
renameProject,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
addProjectManually,
|
|
||||||
searchConversations
|
searchConversations
|
||||||
} from '../../../projects.js';
|
} from '../../../projects.js';
|
||||||
import { applyCustomSessionNames, sessionNamesDb } from '@/shared/database/repositories/session-names.js';
|
import { applyCustomSessionNames, sessionNamesDb } from '@/shared/database/repositories/session-names.js';
|
||||||
|
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||||
import { authenticateToken } from '../auth/auth.middleware.js';
|
import { authenticateToken } from '../auth/auth.middleware.js';
|
||||||
import { WORKSPACES_ROOT, validateWorkspacePath } from './projects.routes.js';
|
import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -100,8 +100,18 @@ router.post('/api/projects/create', authenticateToken, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Project path is required' });
|
return res.status(400).json({ error: 'Project path is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await addProjectManually(projectPath.trim());
|
const resolvedPath = path.resolve(projectPath.trim());
|
||||||
res.json({ success: true, project });
|
const validation = await validateWorkspacePath(resolvedPath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid workspace path',
|
||||||
|
details: validation.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const safePath = validation.resolvedPath || resolvedPath;
|
||||||
|
workspaceOriginalPathsDb.createWorkspacePath(safePath, getWorkspaceNameFromPath(safePath));
|
||||||
|
res.json({ success: true, message: 'Workspace saved successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating project:', error);
|
console.error('Error creating project:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import express from 'express';
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import spawn from 'cross-spawn';
|
import spawn from 'cross-spawn';
|
||||||
import os from 'os';
|
|
||||||
import { addProjectManually } from '../../../projects.js';
|
|
||||||
import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js';
|
import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js';
|
||||||
|
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||||
|
import { getWorkspaceNameFromPath, validateWorkspacePath } from './projects.utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,177 +13,65 @@ function sanitizeGitError(message, token) {
|
|||||||
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure allowed workspace root (defaults to user's home directory)
|
router.patch('/workspace-name', async (req, res) => {
|
||||||
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
|
||||||
|
|
||||||
// System-critical paths that should never be used as workspace directories
|
|
||||||
export const FORBIDDEN_PATHS = [
|
|
||||||
// Unix
|
|
||||||
'/',
|
|
||||||
'/etc',
|
|
||||||
'/bin',
|
|
||||||
'/sbin',
|
|
||||||
'/usr',
|
|
||||||
'/dev',
|
|
||||||
'/proc',
|
|
||||||
'/sys',
|
|
||||||
'/var',
|
|
||||||
'/boot',
|
|
||||||
'/root',
|
|
||||||
'/lib',
|
|
||||||
'/lib64',
|
|
||||||
'/opt',
|
|
||||||
'/tmp',
|
|
||||||
'/run',
|
|
||||||
// Windows
|
|
||||||
'C:\\Windows',
|
|
||||||
'C:\\Program Files',
|
|
||||||
'C:\\Program Files (x86)',
|
|
||||||
'C:\\ProgramData',
|
|
||||||
'C:\\System Volume Information',
|
|
||||||
'C:\\$Recycle.Bin'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a path is safe for workspace operations
|
|
||||||
* @param {string} requestedPath - The path to validate
|
|
||||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
|
||||||
*/
|
|
||||||
export async function validateWorkspacePath(requestedPath) {
|
|
||||||
try {
|
try {
|
||||||
// Resolve to absolute path
|
const { path: workspacePath, customWorkspaceName } = req.body;
|
||||||
let absolutePath = path.resolve(requestedPath);
|
|
||||||
|
|
||||||
// Check if path is a forbidden system directory
|
if (!workspacePath || !String(workspacePath).trim()) {
|
||||||
const normalizedPath = path.normalize(absolutePath);
|
return res.status(400).json({ error: 'path is required' });
|
||||||
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Cannot use system-critical directories as workspace locations'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional check for paths starting with forbidden directories
|
const normalizedPath = path.resolve(String(workspacePath).trim());
|
||||||
for (const forbidden of FORBIDDEN_PATHS) {
|
const validation = await validateWorkspacePath(normalizedPath);
|
||||||
if (normalizedPath === forbidden ||
|
if (!validation.valid) {
|
||||||
normalizedPath.startsWith(forbidden + path.sep)) {
|
return res.status(400).json({
|
||||||
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
error: 'Invalid workspace path',
|
||||||
// but /var itself and most /var subdirectories should be blocked
|
details: validation.error,
|
||||||
if (forbidden === '/var' &&
|
});
|
||||||
(normalizedPath.startsWith('/var/tmp') ||
|
|
||||||
normalizedPath.startsWith('/var/folders'))) {
|
|
||||||
continue; // Allow these specific cases
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Cannot create workspace in system directory: ${forbidden}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to resolve the real path (following symlinks)
|
const safePath = validation.resolvedPath || normalizedPath;
|
||||||
let realPath;
|
const normalizedCustomName =
|
||||||
try {
|
typeof customWorkspaceName === 'string' && customWorkspaceName.trim()
|
||||||
// Check if path exists to resolve real path
|
? customWorkspaceName.trim()
|
||||||
await fs.access(absolutePath);
|
: null;
|
||||||
realPath = await fs.realpath(absolutePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
// Path doesn't exist yet - check parent directory
|
|
||||||
let parentPath = path.dirname(absolutePath);
|
|
||||||
try {
|
|
||||||
const parentRealPath = await fs.realpath(parentPath);
|
|
||||||
|
|
||||||
// Reconstruct the full path with real parent
|
workspaceOriginalPathsDb.updateCustomWorkspaceName(safePath, normalizedCustomName);
|
||||||
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
|
||||||
} catch (parentError) {
|
|
||||||
if (parentError.code === 'ENOENT') {
|
|
||||||
// Parent doesn't exist either - use the absolute path as-is
|
|
||||||
// We'll validate it's within allowed root
|
|
||||||
realPath = absolutePath;
|
|
||||||
} else {
|
|
||||||
throw parentError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the workspace root to its real path
|
|
||||||
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
|
||||||
|
|
||||||
// Ensure the resolved path is contained within the allowed workspace root
|
|
||||||
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
|
||||||
realPath !== resolvedWorkspaceRoot) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional symlink check for existing paths
|
|
||||||
try {
|
|
||||||
await fs.access(absolutePath);
|
|
||||||
const stats = await fs.lstat(absolutePath);
|
|
||||||
|
|
||||||
if (stats.isSymbolicLink()) {
|
|
||||||
// Verify symlink target is also within allowed root
|
|
||||||
const linkTarget = await fs.readlink(absolutePath);
|
|
||||||
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
|
||||||
const realTarget = await fs.realpath(resolvedTarget);
|
|
||||||
|
|
||||||
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
|
||||||
realTarget !== resolvedWorkspaceRoot) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Symlink target is outside the allowed workspace root'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Path doesn't exist - that's fine for new workspace creation
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
resolvedPath: realPath
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Workspace name updated successfully',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
console.error('Error updating workspace name:', error);
|
||||||
valid: false,
|
return res.status(500).json({
|
||||||
error: `Path validation failed: ${error.message}`
|
error: 'Failed to update workspace name',
|
||||||
};
|
details: process.env.NODE_ENV === 'development' ? error.message : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new workspace
|
* Create or add a workspace
|
||||||
* POST /api/projects/create-workspace
|
* POST /api/projects/create-workspace
|
||||||
*
|
*
|
||||||
* Body:
|
* Body:
|
||||||
* - workspaceType: 'existing' | 'new'
|
|
||||||
* - path: string (workspace path)
|
* - path: string (workspace path)
|
||||||
* - githubUrl?: string (optional, for new workspaces)
|
|
||||||
* - githubTokenId?: number (optional, ID of stored token)
|
|
||||||
* - newGithubToken?: string (optional, one-time token)
|
|
||||||
*/
|
*/
|
||||||
router.post('/create-workspace', async (req, res) => {
|
router.post('/create-workspace', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
|
const { path: workspacePath } = req.body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!workspaceType || !workspacePath) {
|
if (!workspacePath) {
|
||||||
return res.status(400).json({ error: 'workspaceType and path are required' });
|
return res.status(400).json({ error: 'path is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['existing', 'new'].includes(workspaceType)) {
|
// Cloning must go through the SSE clone endpoint.
|
||||||
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
|
if (req.body.githubUrl || req.body.githubTokenId || req.body.newGithubToken) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Git clone options are not supported on /create-workspace. Use /clone-progress instead.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate path safety before any operations
|
// Validate path safety before any operations
|
||||||
@@ -197,107 +85,27 @@ router.post('/create-workspace', async (req, res) => {
|
|||||||
|
|
||||||
const absolutePath = validation.resolvedPath;
|
const absolutePath = validation.resolvedPath;
|
||||||
|
|
||||||
// Handle existing workspace
|
// Add existing workspace or create it if it doesn't exist.
|
||||||
if (workspaceType === 'existing') {
|
let workspaceAlreadyExists = false;
|
||||||
// Check if the path exists
|
try {
|
||||||
try {
|
const stats = await fs.stat(absolutePath);
|
||||||
await fs.access(absolutePath);
|
if (!stats.isDirectory()) {
|
||||||
const stats = await fs.stat(absolutePath);
|
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
||||||
|
}
|
||||||
if (!stats.isDirectory()) {
|
workspaceAlreadyExists = true;
|
||||||
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
} catch (error) {
|
||||||
}
|
if (error.code === 'ENOENT') {
|
||||||
} catch (error) {
|
await fs.mkdir(absolutePath, { recursive: true });
|
||||||
if (error.code === 'ENOENT') {
|
} else {
|
||||||
return res.status(404).json({ error: 'Workspace path does not exist' });
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the existing workspace to the project list
|
|
||||||
const project = await addProjectManually(absolutePath);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
project,
|
|
||||||
message: 'Existing workspace added successfully'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new workspace creation
|
workspaceOriginalPathsDb.createWorkspacePath(absolutePath, getWorkspaceNameFromPath(absolutePath));
|
||||||
if (workspaceType === 'new') {
|
return res.json({
|
||||||
// Create the directory if it doesn't exist
|
success: true,
|
||||||
await fs.mkdir(absolutePath, { recursive: true });
|
message: workspaceAlreadyExists ? 'Workspace added successfully' : 'Workspace created successfully'
|
||||||
|
});
|
||||||
// If GitHub URL is provided, clone the repository
|
|
||||||
if (githubUrl) {
|
|
||||||
let githubToken = null;
|
|
||||||
|
|
||||||
// Get GitHub token if needed
|
|
||||||
if (githubTokenId) {
|
|
||||||
// Fetch token from database
|
|
||||||
const token = await getGithubTokenById(githubTokenId, req.user.id);
|
|
||||||
if (!token) {
|
|
||||||
// Clean up created directory
|
|
||||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
|
||||||
return res.status(404).json({ error: 'GitHub token not found' });
|
|
||||||
}
|
|
||||||
githubToken = token.github_token;
|
|
||||||
} else if (newGithubToken) {
|
|
||||||
githubToken = newGithubToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract repo name from URL for the clone destination
|
|
||||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
|
||||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
|
||||||
const clonePath = path.join(absolutePath, repoName);
|
|
||||||
|
|
||||||
// Check if clone destination already exists to prevent data loss
|
|
||||||
try {
|
|
||||||
await fs.access(clonePath);
|
|
||||||
return res.status(409).json({
|
|
||||||
error: 'Directory already exists',
|
|
||||||
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Directory doesn't exist, which is what we want
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone the repository into a subfolder
|
|
||||||
try {
|
|
||||||
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
|
|
||||||
} catch (error) {
|
|
||||||
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
|
||||||
try {
|
|
||||||
const stats = await fs.stat(clonePath);
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
await fs.rm(clonePath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
// Directory doesn't exist or cleanup failed - ignore
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the cloned repo path to the project list
|
|
||||||
const project = await addProjectManually(clonePath);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
project,
|
|
||||||
message: 'New workspace created and repository cloned successfully'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the new workspace to the project list (no clone)
|
|
||||||
const project = await addProjectManually(absolutePath);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
project,
|
|
||||||
message: 'New workspace created successfully'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating workspace:', error);
|
console.error('Error creating workspace:', error);
|
||||||
@@ -347,13 +155,25 @@ router.get('/clone-progress', async (req, res) => {
|
|||||||
|
|
||||||
const absolutePath = validation.resolvedPath;
|
const absolutePath = validation.resolvedPath;
|
||||||
|
|
||||||
await fs.mkdir(absolutePath, { recursive: true });
|
try {
|
||||||
|
const existingPathStats = await fs.stat(absolutePath);
|
||||||
|
if (!existingPathStats.isDirectory()) {
|
||||||
|
sendEvent('error', { message: 'Path exists but is not a directory' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
await fs.mkdir(absolutePath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let githubToken = null;
|
let githubToken = null;
|
||||||
if (githubTokenId) {
|
if (githubTokenId) {
|
||||||
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
|
||||||
sendEvent('error', { message: 'GitHub token not found' });
|
sendEvent('error', { message: 'GitHub token not found' });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
@@ -419,10 +239,10 @@ router.get('/clone-progress', async (req, res) => {
|
|||||||
gitProcess.on('close', async (code) => {
|
gitProcess.on('close', async (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
try {
|
try {
|
||||||
const project = await addProjectManually(clonePath);
|
workspaceOriginalPathsDb.createWorkspacePath(clonePath, getWorkspaceNameFromPath(clonePath));
|
||||||
sendEvent('complete', { project, message: 'Repository cloned successfully' });
|
sendEvent('complete', { message: 'Repository cloned successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
|
sendEvent('error', { message: `Clone succeeded but failed to register workspace: ${error.message}` });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||||
@@ -465,71 +285,4 @@ router.get('/clone-progress', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to clone a GitHub repository
|
|
||||||
*/
|
|
||||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let cloneUrl = githubUrl;
|
|
||||||
|
|
||||||
if (githubToken) {
|
|
||||||
try {
|
|
||||||
const url = new URL(githubUrl);
|
|
||||||
url.username = githubToken;
|
|
||||||
url.password = '';
|
|
||||||
cloneUrl = url.toString();
|
|
||||||
} catch (error) {
|
|
||||||
// SSH URL - use as-is
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
GIT_TERMINAL_PROMPT: '0'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
gitProcess.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
gitProcess.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
gitProcess.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve({ stdout, stderr });
|
|
||||||
} else {
|
|
||||||
let errorMessage = 'Git clone failed';
|
|
||||||
|
|
||||||
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
|
||||||
errorMessage = 'Authentication failed. Please check your GitHub token.';
|
|
||||||
} else if (stderr.includes('Repository not found')) {
|
|
||||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
|
||||||
} else if (stderr.includes('already exists')) {
|
|
||||||
errorMessage = 'Directory already exists';
|
|
||||||
} else if (stderr) {
|
|
||||||
errorMessage = stderr;
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(new Error(errorMessage));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gitProcess.on('error', (error) => {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
reject(new Error('Git is not installed or not in PATH'));
|
|
||||||
} else {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
159
server/src/modules/projects/projects.utils.ts
Normal file
159
server/src/modules/projects/projects.utils.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Configure allowed workspace root (defaults to user's home directory)
|
||||||
|
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||||
|
|
||||||
|
// System-critical paths that should never be used as workspace directories
|
||||||
|
export const FORBIDDEN_PATHS = [
|
||||||
|
// Unix
|
||||||
|
'/',
|
||||||
|
'/etc',
|
||||||
|
'/bin',
|
||||||
|
'/sbin',
|
||||||
|
'/usr',
|
||||||
|
'/dev',
|
||||||
|
'/proc',
|
||||||
|
'/sys',
|
||||||
|
'/var',
|
||||||
|
'/boot',
|
||||||
|
'/root',
|
||||||
|
'/lib',
|
||||||
|
'/lib64',
|
||||||
|
'/opt',
|
||||||
|
'/tmp',
|
||||||
|
'/run',
|
||||||
|
// Windows
|
||||||
|
'C:\\Windows',
|
||||||
|
'C:\\Program Files',
|
||||||
|
'C:\\Program Files (x86)',
|
||||||
|
'C:\\ProgramData',
|
||||||
|
'C:\\System Volume Information',
|
||||||
|
'C:\\$Recycle.Bin',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getWorkspaceNameFromPath = (workspacePath: string): string => {
|
||||||
|
const trimmed = workspacePath.trim();
|
||||||
|
const normalizedPath = path.normalize(trimmed).replace(/[\\/]+$/, '');
|
||||||
|
const baseName = path.basename(normalizedPath);
|
||||||
|
return baseName || normalizedPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a path is safe for workspace operations.
|
||||||
|
*/
|
||||||
|
export async function validateWorkspacePath(requestedPath: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
resolvedPath?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Resolve to absolute path
|
||||||
|
const absolutePath = path.resolve(requestedPath);
|
||||||
|
|
||||||
|
// Check if path is a forbidden system directory
|
||||||
|
const normalizedPath = path.normalize(absolutePath);
|
||||||
|
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Cannot use system-critical directories as workspace locations',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check for paths starting with forbidden directories
|
||||||
|
for (const forbidden of FORBIDDEN_PATHS) {
|
||||||
|
if (normalizedPath === forbidden || normalizedPath.startsWith(forbidden + path.sep)) {
|
||||||
|
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
||||||
|
// but /var itself and most /var subdirectories should be blocked
|
||||||
|
if (
|
||||||
|
forbidden === '/var' &&
|
||||||
|
(normalizedPath.startsWith('/var/tmp') || normalizedPath.startsWith('/var/folders'))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Cannot create workspace in system directory: ${forbidden}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve the real path (following symlinks)
|
||||||
|
let realPath: string;
|
||||||
|
try {
|
||||||
|
// Check if path exists to resolve real path
|
||||||
|
await fs.access(absolutePath);
|
||||||
|
realPath = await fs.realpath(absolutePath);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
// Path doesn't exist yet - check parent directory
|
||||||
|
const parentPath = path.dirname(absolutePath);
|
||||||
|
try {
|
||||||
|
const parentRealPath = await fs.realpath(parentPath);
|
||||||
|
|
||||||
|
// Reconstruct the full path with real parent
|
||||||
|
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
||||||
|
} catch (parentError: any) {
|
||||||
|
if (parentError.code === 'ENOENT') {
|
||||||
|
// Parent doesn't exist either - use the absolute path as-is
|
||||||
|
// We'll validate it's within allowed root
|
||||||
|
realPath = absolutePath;
|
||||||
|
} else {
|
||||||
|
throw parentError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the workspace root to its real path
|
||||||
|
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
||||||
|
|
||||||
|
// Ensure the resolved path is contained within the allowed workspace root
|
||||||
|
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) && realPath !== resolvedWorkspaceRoot) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional symlink check for existing paths
|
||||||
|
try {
|
||||||
|
await fs.access(absolutePath);
|
||||||
|
const stats = await fs.lstat(absolutePath);
|
||||||
|
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
// Verify symlink target is also within allowed root
|
||||||
|
const linkTarget = await fs.readlink(absolutePath);
|
||||||
|
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
||||||
|
const realTarget = await fs.realpath(resolvedTarget);
|
||||||
|
|
||||||
|
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) && realTarget !== resolvedWorkspaceRoot) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Symlink target is outside the allowed workspace root',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Path doesn't exist - that's fine for new workspace creation
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
resolvedPath: realPath,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Path validation failed: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,13 +2,18 @@ import { getConnection } from '@/shared/database/connection.js';
|
|||||||
import type { WorkspaceOriginalPathRow } from '@/shared/database/types.js';
|
import type { WorkspaceOriginalPathRow } from '@/shared/database/types.js';
|
||||||
|
|
||||||
export const workspaceOriginalPathsDb = {
|
export const workspaceOriginalPathsDb = {
|
||||||
createWorkspacePath(workspacePath: string): void {
|
createWorkspacePath(workspacePath: string, customWorkspaceName: string | null = null): void {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO workspace_original_paths (workspace_path)
|
INSERT INTO workspace_original_paths (workspace_path, custom_workspace_name)
|
||||||
VALUES (?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(workspace_path) DO NOTHING
|
ON CONFLICT(workspace_path) DO UPDATE SET
|
||||||
`).run(workspacePath);
|
custom_workspace_name = CASE
|
||||||
|
WHEN workspace_original_paths.custom_workspace_name IS NULL OR workspace_original_paths.custom_workspace_name = ''
|
||||||
|
THEN excluded.custom_workspace_name
|
||||||
|
ELSE workspace_original_paths.custom_workspace_name
|
||||||
|
END
|
||||||
|
`).run(workspacePath, customWorkspaceName);
|
||||||
},
|
},
|
||||||
|
|
||||||
getCustomWorkspaceName(workspacePath: string): string | null {
|
getCustomWorkspaceName(workspacePath: string): string | null {
|
||||||
@@ -30,4 +35,4 @@ export const workspaceOriginalPathsDb = {
|
|||||||
ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name
|
ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name
|
||||||
`).run(workspacePath, customWorkspaceName);
|
`).run(workspacePath, customWorkspaceName);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -4,21 +4,19 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import ErrorBanner from './components/ErrorBanner';
|
import ErrorBanner from './components/ErrorBanner';
|
||||||
import StepConfiguration from './components/StepConfiguration';
|
import StepConfiguration from './components/StepConfiguration';
|
||||||
import StepReview from './components/StepReview';
|
import StepReview from './components/StepReview';
|
||||||
import StepTypeSelection from './components/StepTypeSelection';
|
|
||||||
import WizardFooter from './components/WizardFooter';
|
import WizardFooter from './components/WizardFooter';
|
||||||
import WizardProgress from './components/WizardProgress';
|
import WizardProgress from './components/WizardProgress';
|
||||||
import { useGithubTokens } from './hooks/useGithubTokens';
|
import { useGithubTokens } from './hooks/useGithubTokens';
|
||||||
import { cloneWorkspaceWithProgress, createWorkspaceRequest } from './data/workspaceApi';
|
import { cloneWorkspaceWithProgress, createWorkspaceRequest } from './data/projectWizardApi';
|
||||||
import { isCloneWorkflow, shouldShowGithubAuthentication } from './utils/pathUtils';
|
import { isCloneWorkflow, shouldShowGithubAuthentication } from './utils/pathUtils';
|
||||||
import type { TokenMode, WizardFormState, WizardStep, WorkspaceType } from './types';
|
import type { TokenMode, WizardFormState, WizardStep } from './types';
|
||||||
|
|
||||||
type ProjectCreationWizardProps = {
|
type ProjectCreationWizardProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onProjectCreated?: (project?: Record<string, unknown>) => void;
|
onProjectCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialFormState: WizardFormState = {
|
const initialFormState: WizardFormState = {
|
||||||
workspaceType: 'existing',
|
|
||||||
workspacePath: '',
|
workspacePath: '',
|
||||||
githubUrl: '',
|
githubUrl: '',
|
||||||
tokenMode: 'stored',
|
tokenMode: 'stored',
|
||||||
@@ -37,8 +35,7 @@ export default function ProjectCreationWizard({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [cloneProgress, setCloneProgress] = useState('');
|
const [cloneProgress, setCloneProgress] = useState('');
|
||||||
|
|
||||||
const shouldLoadTokens =
|
const shouldLoadTokens = step === 1 && shouldShowGithubAuthentication(formState.githubUrl);
|
||||||
step === 2 && shouldShowGithubAuthentication(formState.workspaceType, formState.githubUrl);
|
|
||||||
|
|
||||||
const autoSelectToken = useCallback((tokenId: string) => {
|
const autoSelectToken = useCallback((tokenId: string) => {
|
||||||
setFormState((previous) => ({ ...previous, selectedGithubToken: tokenId }));
|
setFormState((previous) => ({ ...previous, selectedGithubToken: tokenId }));
|
||||||
@@ -60,11 +57,6 @@ export default function ProjectCreationWizard({
|
|||||||
setFormState((previous) => ({ ...previous, [key]: value }));
|
setFormState((previous) => ({ ...previous, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateWorkspaceType = useCallback(
|
|
||||||
(workspaceType: WorkspaceType) => updateField('workspaceType', workspaceType),
|
|
||||||
[updateField],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTokenMode = useCallback(
|
const updateTokenMode = useCallback(
|
||||||
(tokenMode: TokenMode) => updateField('tokenMode', tokenMode),
|
(tokenMode: TokenMode) => updateField('tokenMode', tokenMode),
|
||||||
[updateField],
|
[updateField],
|
||||||
@@ -74,22 +66,13 @@ export default function ProjectCreationWizard({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
if (!formState.workspaceType) {
|
|
||||||
setError(t('projectWizard.errors.selectType'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStep(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === 2) {
|
|
||||||
if (!formState.workspacePath.trim()) {
|
if (!formState.workspacePath.trim()) {
|
||||||
setError(t('projectWizard.errors.providePath'));
|
setError(t('projectWizard.errors.providePath'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStep(3);
|
setStep(2);
|
||||||
}
|
}
|
||||||
}, [formState.workspacePath, formState.workspaceType, step, t]);
|
}, [formState.workspacePath, step, t]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -102,10 +85,10 @@ export default function ProjectCreationWizard({
|
|||||||
setCloneProgress('');
|
setCloneProgress('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shouldCloneRepository = isCloneWorkflow(formState.workspaceType, formState.githubUrl);
|
const shouldCloneRepository = isCloneWorkflow(formState.githubUrl);
|
||||||
|
|
||||||
if (shouldCloneRepository) {
|
if (shouldCloneRepository) {
|
||||||
const project = await cloneWorkspaceWithProgress(
|
await cloneWorkspaceWithProgress(
|
||||||
{
|
{
|
||||||
workspacePath: formState.workspacePath,
|
workspacePath: formState.workspacePath,
|
||||||
githubUrl: formState.githubUrl,
|
githubUrl: formState.githubUrl,
|
||||||
@@ -118,17 +101,16 @@ export default function ProjectCreationWizard({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onProjectCreated?.(project);
|
onProjectCreated?.();
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await createWorkspaceRequest({
|
await createWorkspaceRequest({
|
||||||
workspaceType: formState.workspaceType,
|
|
||||||
path: formState.workspacePath.trim(),
|
path: formState.workspacePath.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
onProjectCreated?.(project);
|
onProjectCreated?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -141,10 +123,7 @@ export default function ProjectCreationWizard({
|
|||||||
}
|
}
|
||||||
}, [formState, onClose, onProjectCreated, t]);
|
}, [formState, onClose, onProjectCreated, t]);
|
||||||
|
|
||||||
const shouldCloneRepository = useMemo(
|
const shouldCloneRepository = useMemo(() => isCloneWorkflow(formState.githubUrl), [formState.githubUrl]);
|
||||||
() => isCloneWorkflow(formState.workspaceType, formState.githubUrl),
|
|
||||||
[formState.githubUrl, formState.workspaceType],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[60] flex items-center justify-center bg-black/50 p-0 backdrop-blur-sm sm:p-4">
|
<div className="fixed bottom-0 left-0 right-0 top-0 z-[60] flex items-center justify-center bg-black/50 p-0 backdrop-blur-sm sm:p-4">
|
||||||
@@ -173,15 +152,7 @@ export default function ProjectCreationWizard({
|
|||||||
{error && <ErrorBanner message={error} />}
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<StepTypeSelection
|
|
||||||
workspaceType={formState.workspaceType}
|
|
||||||
onWorkspaceTypeChange={updateWorkspaceType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<StepConfiguration
|
<StepConfiguration
|
||||||
workspaceType={formState.workspaceType}
|
|
||||||
workspacePath={formState.workspacePath}
|
workspacePath={formState.workspacePath}
|
||||||
githubUrl={formState.githubUrl}
|
githubUrl={formState.githubUrl}
|
||||||
tokenMode={formState.tokenMode}
|
tokenMode={formState.tokenMode}
|
||||||
@@ -200,11 +171,11 @@ export default function ProjectCreationWizard({
|
|||||||
onNewGithubTokenChange={(newGithubToken) =>
|
onNewGithubTokenChange={(newGithubToken) =>
|
||||||
updateField('newGithubToken', newGithubToken)
|
updateField('newGithubToken', newGithubToken)
|
||||||
}
|
}
|
||||||
onAdvanceToConfirm={() => setStep(3)}
|
onAdvanceToConfirm={() => setStep(2)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && (
|
{step === 2 && (
|
||||||
<StepReview
|
<StepReview
|
||||||
formState={formState}
|
formState={formState}
|
||||||
selectedTokenName={selectedTokenName}
|
selectedTokenName={selectedTokenName}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Eye, EyeOff, FolderOpen, FolderPlus, Loader2, Plus, X } from 'lucide-react';
|
import { Eye, EyeOff, FolderOpen, FolderPlus, Loader2, Plus, X } from 'lucide-react';
|
||||||
import { Button, Input } from '../../../shared/view/ui';
|
import { Button, Input } from '../../../shared/view/ui';
|
||||||
import { browseFilesystemFolders, createFolderInFilesystem } from '../data/workspaceApi';
|
import { browseFilesystemFolders, createFolderInFilesystem } from '../data/projectWizardApi';
|
||||||
import { getParentPath, joinFolderPath } from '../utils/pathUtils';
|
import { getParentPath, joinFolderPath } from '../utils/pathUtils';
|
||||||
import type { FolderSuggestion } from '../types';
|
import type { FolderSuggestion } from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input } from '../../../shared/view/ui';
|
import { Input } from '../../../shared/view/ui';
|
||||||
import { shouldShowGithubAuthentication } from '../utils/pathUtils';
|
import { shouldShowGithubAuthentication } from '../utils/pathUtils';
|
||||||
import type { GithubTokenCredential, TokenMode, WorkspaceType } from '../types';
|
import type { GithubTokenCredential, TokenMode } from '../types';
|
||||||
import GithubAuthenticationCard from './GithubAuthenticationCard';
|
import GithubAuthenticationCard from './GithubAuthenticationCard';
|
||||||
import WorkspacePathField from './WorkspacePathField';
|
import WorkspacePathField from './WorkspacePathField';
|
||||||
|
|
||||||
type StepConfigurationProps = {
|
type StepConfigurationProps = {
|
||||||
workspaceType: WorkspaceType;
|
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
githubUrl: string;
|
githubUrl: string;
|
||||||
tokenMode: TokenMode;
|
tokenMode: TokenMode;
|
||||||
@@ -25,7 +24,6 @@ type StepConfigurationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StepConfiguration({
|
export default function StepConfiguration({
|
||||||
workspaceType,
|
|
||||||
workspacePath,
|
workspacePath,
|
||||||
githubUrl,
|
githubUrl,
|
||||||
tokenMode,
|
tokenMode,
|
||||||
@@ -43,19 +41,16 @@ export default function StepConfiguration({
|
|||||||
onAdvanceToConfirm,
|
onAdvanceToConfirm,
|
||||||
}: StepConfigurationProps) {
|
}: StepConfigurationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const showGithubAuth = shouldShowGithubAuthentication(workspaceType, githubUrl);
|
const showGithubAuth = shouldShowGithubAuthentication(githubUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{workspaceType === 'existing'
|
{t('projectWizard.step2.newPath')}
|
||||||
? t('projectWizard.step2.existingPath')
|
|
||||||
: t('projectWizard.step2.newPath')}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<WorkspacePathField
|
<WorkspacePathField
|
||||||
workspaceType={workspaceType}
|
|
||||||
value={workspacePath}
|
value={workspacePath}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
onChange={onWorkspacePathChange}
|
onChange={onWorkspacePathChange}
|
||||||
@@ -63,45 +58,39 @@ export default function StepConfiguration({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{workspaceType === 'existing'
|
{t('projectWizard.step2.newHelp')}
|
||||||
? t('projectWizard.step2.existingHelp')
|
|
||||||
: t('projectWizard.step2.newHelp')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{workspaceType === 'new' && (
|
<div>
|
||||||
<>
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
<div>
|
{t('projectWizard.step2.githubUrl')}
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
</label>
|
||||||
{t('projectWizard.step2.githubUrl')}
|
<Input
|
||||||
</label>
|
type="text"
|
||||||
<Input
|
value={githubUrl}
|
||||||
type="text"
|
onChange={(event) => onGithubUrlChange(event.target.value)}
|
||||||
value={githubUrl}
|
placeholder="https://github.com/username/repository"
|
||||||
onChange={(event) => onGithubUrlChange(event.target.value)}
|
className="w-full"
|
||||||
placeholder="https://github.com/username/repository"
|
disabled={isCreating}
|
||||||
className="w-full"
|
/>
|
||||||
disabled={isCreating}
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
/>
|
{t('projectWizard.step2.githubHelp')}
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
</p>
|
||||||
{t('projectWizard.step2.githubHelp')}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showGithubAuth && (
|
{showGithubAuth && (
|
||||||
<GithubAuthenticationCard
|
<GithubAuthenticationCard
|
||||||
tokenMode={tokenMode}
|
tokenMode={tokenMode}
|
||||||
selectedGithubToken={selectedGithubToken}
|
selectedGithubToken={selectedGithubToken}
|
||||||
newGithubToken={newGithubToken}
|
newGithubToken={newGithubToken}
|
||||||
availableTokens={availableTokens}
|
availableTokens={availableTokens}
|
||||||
loadingTokens={loadingTokens}
|
loadingTokens={loadingTokens}
|
||||||
tokenLoadError={tokenLoadError}
|
tokenLoadError={tokenLoadError}
|
||||||
onTokenModeChange={onTokenModeChange}
|
onTokenModeChange={onTokenModeChange}
|
||||||
onSelectedGithubTokenChange={onSelectedGithubTokenChange}
|
onSelectedGithubTokenChange={onSelectedGithubTokenChange}
|
||||||
onNewGithubTokenChange={onNewGithubTokenChange}
|
onNewGithubTokenChange={onNewGithubTokenChange}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,17 +42,6 @@ export default function StepReview({
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
|
||||||
{t('projectWizard.step3.workspaceType')}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{formState.workspaceType === 'existing'
|
|
||||||
? t('projectWizard.step3.existingWorkspace')
|
|
||||||
: t('projectWizard.step3.newWorkspace')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
|
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
|
||||||
<span className="break-all font-mono text-xs text-gray-900 dark:text-white">
|
<span className="break-all font-mono text-xs text-gray-900 dark:text-white">
|
||||||
@@ -60,7 +49,7 @@ export default function StepReview({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formState.workspaceType === 'new' && formState.githubUrl && (
|
{formState.githubUrl && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
@@ -94,11 +83,7 @@ export default function StepReview({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
{formState.workspaceType === 'existing'
|
{formState.githubUrl ? t('projectWizard.step3.newWithClone') : t('projectWizard.step3.newEmpty')}
|
||||||
? t('projectWizard.step3.existingInfo')
|
|
||||||
: formState.githubUrl
|
|
||||||
? t('projectWizard.step3.newWithClone')
|
|
||||||
: t('projectWizard.step3.newEmpty')}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { FolderPlus, GitBranch } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { WorkspaceType } from '../types';
|
|
||||||
|
|
||||||
type StepTypeSelectionProps = {
|
|
||||||
workspaceType: WorkspaceType;
|
|
||||||
onWorkspaceTypeChange: (workspaceType: WorkspaceType) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StepTypeSelection({
|
|
||||||
workspaceType,
|
|
||||||
onWorkspaceTypeChange,
|
|
||||||
}: StepTypeSelectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{t('projectWizard.step1.question')}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onWorkspaceTypeChange('existing')}
|
|
||||||
className={`rounded-lg border-2 p-4 text-left transition-all ${
|
|
||||||
workspaceType === 'existing'
|
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
||||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/50">
|
|
||||||
<FolderPlus className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h5 className="mb-1 font-semibold text-gray-900 dark:text-white">
|
|
||||||
{t('projectWizard.step1.existing.title')}
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{t('projectWizard.step1.existing.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onWorkspaceTypeChange('new')}
|
|
||||||
className={`rounded-lg border-2 p-4 text-left transition-all ${
|
|
||||||
workspaceType === 'new'
|
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
||||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/50">
|
|
||||||
<GitBranch className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h5 className="mb-1 font-semibold text-gray-900 dark:text-white">
|
|
||||||
{t('projectWizard.step1.new.title')}
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{t('projectWizard.step1.new.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ export default function WizardFooter({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button onClick={step === 3 ? onCreate : onNext} disabled={isCreating}>
|
<Button onClick={step === 2 ? onCreate : onNext} disabled={isCreating}>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -45,7 +45,7 @@ export default function WizardFooter({
|
|||||||
? t('projectWizard.buttons.cloning', { defaultValue: 'Cloning...' })
|
? t('projectWizard.buttons.cloning', { defaultValue: 'Cloning...' })
|
||||||
: t('projectWizard.buttons.creating')}
|
: t('projectWizard.buttons.creating')}
|
||||||
</>
|
</>
|
||||||
) : step === 3 ? (
|
) : step === 2 ? (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-1 h-4 w-4" />
|
<Check className="mr-1 h-4 w-4" />
|
||||||
{t('projectWizard.buttons.createProject')}
|
{t('projectWizard.buttons.createProject')}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type WizardProgressProps = {
|
|||||||
|
|
||||||
export default function WizardProgress({ step }: WizardProgressProps) {
|
export default function WizardProgress({ step }: WizardProgressProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const steps: WizardStep[] = [1, 2, 3];
|
const steps: WizardStep[] = [1, 2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 pb-2 pt-4">
|
<div className="px-6 pb-2 pt-4">
|
||||||
@@ -29,15 +29,11 @@ export default function WizardProgress({ step }: WizardProgressProps) {
|
|||||||
{currentStep < step ? <Check className="h-4 w-4" /> : currentStep}
|
{currentStep < step ? <Check className="h-4 w-4" /> : currentStep}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden text-sm font-medium text-gray-700 dark:text-gray-300 sm:inline">
|
<span className="hidden text-sm font-medium text-gray-700 dark:text-gray-300 sm:inline">
|
||||||
{currentStep === 1
|
{currentStep === 1 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
|
||||||
? t('projectWizard.steps.type')
|
|
||||||
: currentStep === 2
|
|
||||||
? t('projectWizard.steps.configure')
|
|
||||||
: t('projectWizard.steps.confirm')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentStep < 3 && (
|
{currentStep < 2 && (
|
||||||
<div
|
<div
|
||||||
className={`mx-2 h-1 flex-1 rounded ${
|
className={`mx-2 h-1 flex-1 rounded ${
|
||||||
currentStep < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
currentStep < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { FolderOpen } from 'lucide-react';
|
import { FolderOpen } from 'lucide-react';
|
||||||
import { Button, Input } from '../../../shared/view/ui';
|
import { Button, Input } from '../../../shared/view/ui';
|
||||||
import { browseFilesystemFolders } from '../data/workspaceApi';
|
import { browseFilesystemFolders } from '../data/projectWizardApi';
|
||||||
import { getSuggestionRootPath } from '../utils/pathUtils';
|
import { getSuggestionRootPath } from '../utils/pathUtils';
|
||||||
import type { FolderSuggestion, WorkspaceType } from '../types';
|
import type { FolderSuggestion } from '../types';
|
||||||
import FolderBrowserModal from './FolderBrowserModal';
|
import FolderBrowserModal from './FolderBrowserModal';
|
||||||
|
|
||||||
type WorkspacePathFieldProps = {
|
type WorkspacePathFieldProps = {
|
||||||
workspaceType: WorkspaceType;
|
|
||||||
value: string;
|
value: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (path: string) => void;
|
onChange: (path: string) => void;
|
||||||
@@ -15,7 +14,6 @@ type WorkspacePathFieldProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WorkspacePathField({
|
export default function WorkspacePathField({
|
||||||
workspaceType,
|
|
||||||
value,
|
value,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -88,11 +86,7 @@ export default function WorkspacePathField({
|
|||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
placeholder={
|
placeholder="/path/to/workspace"
|
||||||
workspaceType === 'existing'
|
|
||||||
? '/path/to/existing/workspace'
|
|
||||||
: '/path/to/new/workspace'
|
|
||||||
}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
@@ -127,7 +121,7 @@ export default function WorkspacePathField({
|
|||||||
|
|
||||||
<FolderBrowserModal
|
<FolderBrowserModal
|
||||||
isOpen={showFolderBrowser}
|
isOpen={showFolderBrowser}
|
||||||
autoAdvanceOnSelect={workspaceType === 'existing'}
|
autoAdvanceOnSelect={false}
|
||||||
onClose={() => setShowFolderBrowser(false)}
|
onClose={() => setShowFolderBrowser(false)}
|
||||||
onFolderSelected={handleFolderSelected}
|
onFolderSelected={handleFolderSelected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -71,8 +71,6 @@ export const createWorkspaceRequest = async (payload: CreateWorkspacePayload) =>
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.details || data.error || 'Failed to create workspace');
|
throw new Error(data.details || data.error || 'Failed to create workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.project;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCloneProgressQuery = ({
|
const buildCloneProgressQuery = ({
|
||||||
@@ -108,7 +106,7 @@ export const cloneWorkspaceWithProgress = (
|
|||||||
params: CloneWorkspaceParams,
|
params: CloneWorkspaceParams,
|
||||||
handlers: CloneProgressHandlers,
|
handlers: CloneProgressHandlers,
|
||||||
) =>
|
) =>
|
||||||
new Promise<Record<string, unknown> | undefined>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
const query = buildCloneProgressQuery(params);
|
const query = buildCloneProgressQuery(params);
|
||||||
const eventSource = new EventSource(`/api/projects/clone-progress?${query}`);
|
const eventSource = new EventSource(`/api/projects/clone-progress?${query}`);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -132,7 +130,7 @@ export const cloneWorkspaceWithProgress = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'complete') {
|
if (payload.type === 'complete') {
|
||||||
settle(() => resolve(payload.project));
|
settle(() => resolve());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { fetchGithubTokenCredentials } from '../data/workspaceApi';
|
import { fetchGithubTokenCredentials } from '../data/projectWizardApi';
|
||||||
import type { GithubTokenCredential } from '../types';
|
import type { GithubTokenCredential } from '../types';
|
||||||
|
|
||||||
type UseGithubTokensParams = {
|
type UseGithubTokensParams = {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export type WizardStep = 1 | 2 | 3;
|
export type WizardStep = 1 | 2;
|
||||||
|
|
||||||
export type WorkspaceType = 'existing' | 'new';
|
|
||||||
|
|
||||||
export type TokenMode = 'stored' | 'new' | 'none';
|
export type TokenMode = 'stored' | 'new' | 'none';
|
||||||
|
|
||||||
@@ -35,13 +33,12 @@ export type CreateFolderResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CreateWorkspacePayload = {
|
export type CreateWorkspacePayload = {
|
||||||
workspaceType: WorkspaceType;
|
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateWorkspaceResponse = {
|
export type CreateWorkspaceResponse = {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
project?: Record<string, unknown>;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
};
|
};
|
||||||
@@ -49,11 +46,9 @@ export type CreateWorkspaceResponse = {
|
|||||||
export type CloneProgressEvent = {
|
export type CloneProgressEvent = {
|
||||||
type?: string;
|
type?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
project?: Record<string, unknown>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WizardFormState = {
|
export type WizardFormState = {
|
||||||
workspaceType: WorkspaceType;
|
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
githubUrl: string;
|
githubUrl: string;
|
||||||
tokenMode: TokenMode;
|
tokenMode: TokenMode;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { WorkspaceType } from '../types';
|
|
||||||
|
|
||||||
const SSH_PREFIXES = ['git@', 'ssh://'];
|
const SSH_PREFIXES = ['git@', 'ssh://'];
|
||||||
const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:\\?$/;
|
const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:\\?$/;
|
||||||
|
|
||||||
@@ -8,13 +6,10 @@ export const isSshGitUrl = (url: string): boolean => {
|
|||||||
return SSH_PREFIXES.some((prefix) => trimmedUrl.startsWith(prefix));
|
return SSH_PREFIXES.some((prefix) => trimmedUrl.startsWith(prefix));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldShowGithubAuthentication = (
|
export const shouldShowGithubAuthentication = (githubUrl: string): boolean =>
|
||||||
workspaceType: WorkspaceType,
|
githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);
|
||||||
githubUrl: string,
|
|
||||||
): boolean => workspaceType === 'new' && githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);
|
|
||||||
|
|
||||||
export const isCloneWorkflow = (workspaceType: WorkspaceType, githubUrl: string): boolean =>
|
export const isCloneWorkflow = (githubUrl: string): boolean => githubUrl.trim().length > 0;
|
||||||
workspaceType === 'new' && githubUrl.trim().length > 0;
|
|
||||||
|
|
||||||
export const getSuggestionRootPath = (inputPath: string): string => {
|
export const getSuggestionRootPath = (inputPath: string): string => {
|
||||||
const trimmedPath = inputPath.trim();
|
const trimmedPath = inputPath.trim();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Project } from '@/types/app';
|
|||||||
*/
|
*/
|
||||||
export const fetchWorkspaces = async (): Promise<Project[]> => {
|
export const fetchWorkspaces = async (): Promise<Project[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch('/api/get-workspaces');
|
const response = await authenticatedFetch('/api/projects');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch workspaces: ${response.statusText}`);
|
throw new Error(`Failed to fetch workspaces: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,8 +154,8 @@
|
|||||||
"newPath": "Workspace Path",
|
"newPath": "Workspace Path",
|
||||||
"existingPlaceholder": "/path/to/existing/workspace",
|
"existingPlaceholder": "/path/to/existing/workspace",
|
||||||
"newPlaceholder": "/path/to/new/workspace",
|
"newPlaceholder": "/path/to/new/workspace",
|
||||||
"existingHelp": "Full path to your existing workspace directory",
|
"existingHelp": "You can also paste your chosen workspace directory path above",
|
||||||
"newHelp": "Full path to your workspace directory",
|
"newHelp": "You can also paste your chosen workspace directory path above",
|
||||||
"githubUrl": "GitHub URL (Optional)",
|
"githubUrl": "GitHub URL (Optional)",
|
||||||
"githubPlaceholder": "https://github.com/username/repository",
|
"githubPlaceholder": "https://github.com/username/repository",
|
||||||
"githubHelp": "Optional: provide a GitHub URL to clone a repository",
|
"githubHelp": "Optional: provide a GitHub URL to clone a repository",
|
||||||
@@ -186,9 +186,9 @@
|
|||||||
"usingProvidedToken": "Using provided token",
|
"usingProvidedToken": "Using provided token",
|
||||||
"noAuthentication": "No authentication",
|
"noAuthentication": "No authentication",
|
||||||
"sshKey": "SSH Key",
|
"sshKey": "SSH Key",
|
||||||
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
"existingInfo": "This workspace will be available to your chosen ai providers.",
|
||||||
"newWithClone": "The repository will be cloned from this folder.",
|
"newWithClone": "The repository will be cloned from this folder.",
|
||||||
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
"newEmpty": "This workspace will be available to your chosen ai providers.",
|
||||||
"cloningRepository": "Cloning repository..."
|
"cloningRepository": "Cloning repository..."
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
|
|||||||
Reference in New Issue
Block a user