Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 84 additions & 59 deletions packages/cli/src/utils/clone-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import util from 'node:util';
import shellQuote from 'shell-quote';
import yoctoSpinner from 'yocto-spinner';

import type { LLMProvider } from '../commands/init/utils';
Expand All @@ -12,7 +11,7 @@
import { logger } from './logger';
import type { Template } from './template-utils';

const exec = util.promisify(child_process.exec);
const execFile = util.promisify(child_process.execFile);

export interface CloneTemplateOptions {
template: Template;
Expand All @@ -24,39 +23,45 @@

export async function cloneTemplate(options: CloneTemplateOptions): Promise<string> {
const { template, projectName, targetDir, branch, llmProvider } = options;
const projectPath = targetDir ? path.resolve(targetDir, projectName) : path.resolve(projectName);
const projectPath = targetDir
? path.resolve(targetDir, projectName)
: path.resolve(projectName);

const spinner = yoctoSpinner({ text: `Cloning template "${template.title}"...` }).start();
const spinner = yoctoSpinner({
text: `Cloning template "${template.title}"...`,
}).start();

try {
// Check if directory already exists
if (await directoryExists(projectPath)) {
spinner.error(`Directory ${projectName} already exists`);
throw new Error(`Directory ${projectName} already exists`);
Comment on lines 35 to 37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clean up targetPath when cloning fails.

Because Line 92 creates targetPath before either clone command runs, any failure path leaves that directory behind. The next retry then immediately trips the existing-directory guard at Lines 35-37, and the git fallback is also fragile if degit wrote anything before throwing. Remove targetPath on failure, or clone into a temp directory and rename only after success.

Also applies to: 92-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/utils/clone-template.ts` around lines 35 - 37, The clone
flow leaves targetPath behind on failure causing the directoryExists guard in
the projectPath/projectName check to fail on retries; update the cloning logic
in clone-template.ts to either (a) perform the clone into a temporary directory
(e.g., tmpTarget) and rename/move it to targetPath only after the clone
succeeds, or (b) ensure any created targetPath is removed in all failure paths
(catch blocks and both degit and git fallback code paths) before rethrowing;
reference the projectPath/projectName checks, targetPath creation, and the
degit/git fallback branches so cleanup is executed consistently on errors and
spinner.error is still called.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DawitMengistu can you address this comment? Thanks!

}

// Clone the repository without git history
await cloneRepositoryWithoutGit(template.githubUrl, projectPath, branch);

// Update package.json with new project name
await updatePackageJson(projectPath, projectName);

// Copy .env.example to .env if it exists, and update MODEL if llmProvider is specified
const envExamplePath = path.join(projectPath, '.env.example');
if (await fileExists(envExamplePath)) {
const envPath = path.join(projectPath, '.env');
await fs.copyFile(envExamplePath, envPath);

// Update MODEL in .env if llmProvider is specified
if (llmProvider) {
await updateEnvFile(envPath, llmProvider);
}
}

spinner.success(`Template "${template.title}" cloned successfully to ${projectName}`);
spinner.success(
`Template "${template.title}" cloned successfully to ${projectName}`,
);

return projectPath;
} catch (error) {
spinner.error(`Failed to clone template: ${error instanceof Error ? error.message : 'Unknown error'}`);
spinner.error(
`Failed to clone template: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
);
throw error;
}
}
Expand All @@ -79,103 +84,123 @@
}
}

async function cloneRepositoryWithoutGit(repoUrl: string, targetPath: string, branch?: string): Promise<void> {
// Create target directory
async function cloneRepositoryWithoutGit(
repoUrl: string,
targetPath: string,
branch?: string,
): Promise<void> {
await fs.mkdir(targetPath, { recursive: true });

// 1. Try degit first
try {
// First try using degit if available (similar to Next.js)
const degitRepo = repoUrl.replace('https://github.com/', '');
// If branch is specified, append it to the degit repo (format: owner/repo#branch)
const degitRepoWithBranch = branch ? `${degitRepo}#${branch}` : degitRepo;
const degitCommand = shellQuote.quote(['npx', 'degit', degitRepoWithBranch, targetPath]);
await exec(degitCommand, {
const repo = repoUrl.replace('https://github.com/', '');
const repoWithBranch = branch ? `${repo}#${branch}` : repo;

await execFile('npx', ['degit', repoWithBranch, targetPath], {
cwd: process.cwd(),
});

return;
} catch {
// Fallback to git clone + remove .git
try {
const gitArgs = ['git', 'clone'];
// Add branch flag if specified
if (branch) {
gitArgs.push('--branch', branch);
}
gitArgs.push(repoUrl, targetPath);
// fallback to git
}

const gitCommand = shellQuote.quote(gitArgs);
await exec(gitCommand, {
cwd: process.cwd(),
});
// 2. Fallback git clone (SAFE VERSION)
const args: string[] = ['clone'];

// Remove .git directory
const gitDir = path.join(targetPath, '.git');
if (await directoryExists(gitDir)) {
await fs.rm(gitDir, { recursive: true, force: true });
}
} catch (gitError) {
throw new Error(`Failed to clone repository: ${gitError instanceof Error ? gitError.message : 'Unknown error'}`);
}
if (branch) {
args.push('--branch', branch);
}

args.push(repoUrl, targetPath);

await execFile('git', args, {

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should update MODEL in .env when llmProvider is specified

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:311:7

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should not include branch in git clone when branch is not specified

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:286:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should clone from beta branch with git clone when degit fails

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:259:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should clone from beta branch when branch is specified with degit

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:238:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should use custom target directory when provided

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:221:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should handle missing package.json gracefully

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:174:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should update package.json with new project name

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:154:7

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should fallback to git clone when degit fails

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:124:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / UNIT Test (Shard 3)

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should clone template successfully using degit

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:102:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should update MODEL in .env when llmProvider is specified

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:311:7

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should not include branch in git clone when branch is not specified

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:286:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should clone from beta branch with git clone when degit fails

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:259:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should clone from beta branch when branch is specified with degit

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:238:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should use custom target directory when provided

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:221:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should handle missing package.json gracefully

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:174:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should update package.json with new project name

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:154:7

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should fallback to git clone when degit fails

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:124:22

Check failure on line 117 in packages/cli/src/utils/clone-template.ts

View workflow job for this annotation

GitHub Actions / Unit and E2E Tests / Merge Test Reports

[unit:packages/cli] src/utils/clone-template.test.ts > clone-template > cloneTemplate > should clone template successfully using degit

TypeError: execFile is not a function ❯ cloneRepositoryWithoutGit src/utils/clone-template.ts:117:9 ❯ cloneTemplate src/utils/clone-template.ts:40:5 ❯ src/utils/clone-template.test.ts:102:22
cwd: process.cwd(),
});

// remove .git
const gitDir = path.join(targetPath, '.git');
if (await directoryExists(gitDir)) {
await fs.rm(gitDir, { recursive: true, force: true });
}
}

async function updatePackageJson(projectPath: string, projectName: string): Promise<void> {
async function updatePackageJson(
projectPath: string,
projectName: string,
): Promise<void> {
const packageJsonPath = path.join(projectPath, 'package.json');

try {
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);

// Update the name field
packageJson.name = projectName;

// Write back the updated package.json
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8');
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf-8',
);
} catch (error) {
// It's okay if package.json doesn't exist or can't be updated
logger.warn('Could not update package.json', { error: error instanceof Error ? error.message : 'Unknown error' });
logger.warn('Could not update package.json', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}

async function updateEnvFile(envPath: string, llmProvider: LLMProvider): Promise<void> {
async function updateEnvFile(
envPath: string,
llmProvider: LLMProvider,
): Promise<void> {
try {
const envContent = await fs.readFile(envPath, 'utf-8');
const modelString = getModelIdentifier(llmProvider);

if (!modelString) {
logger.warn('Could not get model identifier for provider', { provider: llmProvider });
logger.warn('Could not get model identifier', { provider: llmProvider });
return;
}

// Remove quotes from modelString (it comes as 'provider/model')
const modelValue = modelString.replace(/'/g, '');

// Replace the MODEL line with the selected provider's model
const updatedContent = envContent.replace(/^MODEL=.*/m, `MODEL=${modelValue}`);
const updatedContent = envContent.replace(
/^MODEL=.*/m,
`MODEL=${modelValue}`,
);

await fs.writeFile(envPath, updatedContent, 'utf-8');

logger.info('Updated MODEL in .env', { model: modelValue });
} catch (error) {
// It's okay if .env can't be updated
logger.warn('Could not update .env file', { error: error instanceof Error ? error.message : 'Unknown error' });
logger.warn('Could not update .env file', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}

export async function installDependencies(projectPath: string, packageManager?: string): Promise<void> {
const spinner = yoctoSpinner({ text: 'Installing dependencies...' }).start();
export async function installDependencies(
projectPath: string,
packageManager?: string,
): Promise<void> {
const spinner = yoctoSpinner({
text: 'Installing dependencies...',
}).start();

try {
// Use provided package manager or detect from environment/globally
const pm = packageManager || getPackageManager();

const installCommand = shellQuote.quote([pm, 'install']);

await exec(installCommand, {
await execFile(pm, ['install'], {
cwd: projectPath,
});

spinner.success('Dependencies installed successfully');
} catch (error) {
spinner.error(`Failed to install dependencies: ${error instanceof Error ? error.message : 'Unknown error'}`);
spinner.error(
`Failed to install dependencies: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
);
throw error;
}
}
Loading