fix install preset error

This commit is contained in:
musistudio
2026-01-01 17:53:26 +08:00
parent ec7ac8cc9f
commit 5ac38d3d0f
14 changed files with 1665 additions and 94 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ import fs, { existsSync, readFileSync } from "fs";
import { join } from "path";
import { parseStatusLineData, StatusLineInput } from "./utils/statusline";
import {handlePresetCommand} from "./utils/preset";
import { handleInstallCommand } from "./utils/installCommand";
const command = process.argv[2];
@@ -31,6 +32,7 @@ const KNOWN_COMMANDS = [
"code",
"model",
"preset",
"install",
"activate",
"env",
"ui",
@@ -52,6 +54,7 @@ Commands:
code Execute claude command
model Interactive model selection and configuration
preset Manage presets (export, install, list, delete)
install Install preset from GitHub marketplace
activate Output environment variables for shell integration
ui Open the web UI in browser
-v, version Show version information
@@ -68,6 +71,7 @@ Examples:
ccr preset export my-config # Export current config as preset
ccr preset install /path/to/preset # Install a preset from directory
ccr preset list # List all presets
ccr install my-preset # Install preset from marketplace
eval "$(ccr activate)" # Set environment variables globally
ccr ui
`;
@@ -264,6 +268,10 @@ async function main() {
case "preset":
await handlePresetCommand(process.argv.slice(3));
break;
case "install":
const presetName = process.argv[3];
await handleInstallCommand(presetName);
break;
case "activate":
case "env":
await activateCommand();

View File

@@ -0,0 +1,50 @@
/**
* Install preset from GitHub marketplace
* ccr install {presetname}
*/
import { installPresetFromMarket } from './preset/install-github';
import { applyPresetCli } from './preset/install';
// ANSI color codes
const RESET = "\x1B[0m";
const GREEN = "\x1B[32m";
const YELLOW = "\x1B[33m";
const BOLDGREEN = "\x1B[1m\x1B[32m";
const BOLDYELLOW = "\x1B[1m\x1B[33m";
const BOLDCYAN = "\x1B[1m\x1B[36m";
const DIM = "\x1B[2m";
/**
* Install preset from marketplace by preset name
* @param presetName Preset name (must exist in marketplace)
*/
export async function handleInstallCommand(presetName: string): Promise<void> {
try {
if (!presetName) {
console.error(`\n${BOLDYELLOW}Error:${RESET} Preset name is required\n`);
console.error('Usage: ccr install <preset-name>\n');
console.error('Examples:');
console.error(' ccr install my-preset');
console.error(' ccr install awesome-preset\n');
console.error(`${DIM}Note: Preset must exist in the official marketplace.${RESET}\n`);
process.exit(1);
}
console.log(`${BOLDCYAN}Installing preset:${RESET} ${presetName}\n`);
// Install preset (download and extract)
const { name: installedName, preset } = await installPresetFromMarket(presetName);
if (installedName && preset) {
// Apply preset configuration (interactive setup)
await applyPresetCli(installedName, preset);
console.log(`\n${BOLDGREEN}✓ Preset installation completed!${RESET}\n`);
}
} catch (error: any) {
console.error(`\n${BOLDYELLOW}Failed to install preset:${RESET} ${error.message}\n`);
process.exit(1);
}
}

View File

@@ -0,0 +1,163 @@
/**
* Install preset from GitHub marketplace by preset name
*/
import * as fs from 'fs/promises';
import {
findMarketPresetByName,
getPresetDir,
readManifestFromDir,
saveManifest,
isPresetInstalled,
downloadPresetToTemp,
extractPreset,
manifestToPresetFile,
type PresetFile,
} from '@CCR/shared';
import AdmZip from 'adm-zip';
// ANSI color codes
const RESET = "\x1B[0m";
const GREEN = "\x1B[32m";
const BOLDCYAN = "\x1B[1m\x1B[36m";
const BOLDYELLOW = "\x1B[1m\x1B[33m";
/**
* Parse GitHub repository URL or name
* Supports:
* - owner/repo (short format)
* - github.com/owner/repo
* - https://github.com/owner/repo
* - https://github.com/owner/repo.git
* - git@github.com:owner/repo.git
*/
function parseGitHubRepo(input: string): { owner: string; repoName: string } | null {
const match = input.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/);
if (!match) {
return null;
}
const [, owner, repoName] = match;
return { owner, repoName };
}
/**
* Load preset from ZIP file
*/
async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
const zip = new AdmZip(zipFile);
// First try to find manifest.json in root directory
let entry = zip.getEntry('manifest.json');
// If not in root, try to find in subdirectories (handle GitHub repo archive structure)
if (!entry) {
const entries = zip.getEntries();
// Find any manifest.json file
entry = entries.find(e => e.entryName.includes('manifest.json')) || null;
}
if (!entry) {
throw new Error('Invalid preset file: manifest.json not found');
}
const manifest = JSON.parse(entry.getData().toString('utf-8'));
return manifestToPresetFile(manifest);
}
/**
* Install preset from marketplace by preset name
* @param presetName Preset name (must exist in marketplace)
* @returns Object containing installed preset name and PresetFile
*/
export async function installPresetFromMarket(presetName: string): Promise<{ name: string; preset: PresetFile }> {
// Check if preset is in the marketplace
console.log(`${BOLDCYAN}Checking marketplace...${RESET}`);
const marketPreset = await findMarketPresetByName(presetName);
if (!marketPreset) {
throw new Error(
`Preset '${presetName}' not found in marketplace. ` +
`Please check the available presets at: https://github.com/claude-code-router/presets`
);
}
console.log(`${GREEN}${RESET} Found in marketplace\n`);
// Get repository from market preset
if (!marketPreset.repo) {
throw new Error(`Preset '${presetName}' does not have repository information`);
}
// Parse GitHub repository URL
const githubRepo = parseGitHubRepo(marketPreset.repo);
if (!githubRepo) {
throw new Error(`Invalid repository format: ${marketPreset.repo}`);
}
const { owner, repoName } = githubRepo;
// Use preset name from market (or the preset's id)
const installedPresetName = marketPreset.name || presetName;
// Check if already installed BEFORE downloading
if (await isPresetInstalled(installedPresetName)) {
throw new Error(
`Preset '${installedPresetName}' is already installed.\n` +
`To delete and reinstall, use: ccr preset delete ${installedPresetName}\n` +
`To reconfigure without deleting, use: ccr preset install ${installedPresetName}`
);
}
// Download GitHub repository ZIP file
console.log(`${BOLDCYAN}Downloading preset...${RESET}`);
const downloadUrl = `https://github.com/${owner}/${repoName}/archive/refs/heads/main.zip`;
const tempFile = await downloadPresetToTemp(downloadUrl);
console.log(`${GREEN}${RESET} Downloaded\n`);
try {
// Load preset to validate structure
console.log(`${BOLDCYAN}Validating preset...${RESET}`);
const preset = await loadPresetFromZip(tempFile);
console.log(`${GREEN}${RESET} Valid\n`);
// Double-check if already installed (in case of race condition)
if (await isPresetInstalled(installedPresetName)) {
throw new Error(
`Preset '${installedPresetName}' was installed while downloading. ` +
`Please try again.`
);
}
// Extract to target directory
console.log(`${BOLDCYAN}Installing preset...${RESET}`);
const targetDir = getPresetDir(installedPresetName);
await extractPreset(tempFile, targetDir);
console.log(`${GREEN}${RESET} Installed\n`);
// Read manifest and add repo information
const manifest = await readManifestFromDir(targetDir);
// Add repo information to manifest
manifest.repository = marketPreset.repository;
if (marketPreset.url) {
manifest.source = marketPreset.url;
}
// Save updated manifest
await saveManifest(installedPresetName, manifest);
// Return preset name and PresetFile for further configuration
return { name: installedPresetName, preset };
} finally {
// Clean up temp file
try {
await fs.unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}

View File

@@ -78,7 +78,17 @@ export async function applyPresetCli(
userInputs = await collectUserInputs(preset.schema, preset.config);
}
// Build manifest, keep original config, store user values in userValues
// Read existing manifest to preserve fields like repository, source, etc.
const presetDir = getPresetDir(presetName);
let existingManifest: ManifestFile | null = null;
try {
existingManifest = await readManifestFromDir(presetDir);
} catch {
// Manifest doesn't exist yet, this is a new installation
}
// Build manifest, preserve existing fields
const manifest: ManifestFile = {
name: presetName,
version: preset.metadata?.version || '1.0.0',
@@ -86,6 +96,22 @@ export async function applyPresetCli(
...preset.config, // Keep original config (may contain placeholders)
};
// Preserve fields from existing manifest (repository, source, etc.)
if (existingManifest) {
if (existingManifest.repository) {
manifest.repository = existingManifest.repository;
}
if (existingManifest.source) {
manifest.source = existingManifest.source;
}
if (existingManifest.sourceType) {
manifest.sourceType = existingManifest.sourceType;
}
if (existingManifest.checksum) {
manifest.checksum = existingManifest.checksum;
}
}
// Save schema (if exists)
if (preset.schema) {
manifest.schema = preset.schema;
@@ -107,8 +133,6 @@ export async function applyPresetCli(
// Save to manifest.json in extracted directory
await saveManifest(presetName, manifest);
const presetDir = getPresetDir(presetName);
// Display summary
console.log(`\n${BOLDGREEN}✓ Preset configured successfully!${RESET}\n`);
console.log(`${BOLDCYAN}Preset directory:${RESET} ${presetDir}`);
@@ -171,6 +195,11 @@ export async function installPresetCli(
throw new Error(`Preset directory not found: ${source}`);
}
sourceDir = source;
// Check if preset with this name already exists BEFORE installing
if (await isPresetInstalled(presetName)) {
throw new Error(`Preset '${presetName}' is already installed. To reconfigure, use: ccr preset install ${presetName}\nTo delete and reinstall, use: ccr preset delete ${presetName}`);
}
} else {
// Preset name (without path)
presetName = source;

View File

@@ -16,6 +16,8 @@ import {
loadConfigFromManifest,
downloadPresetToTemp,
getTempDir,
findMarketPresetByName,
getMarketPresets,
type PresetFile,
type ManifestFile,
type PresetMetadata,
@@ -349,14 +351,8 @@ export const createServer = async (config: any): Promise<any> => {
// Get preset market list
app.get("/api/presets/market", async (req: any, reply: any) => {
try {
const marketUrl = "https://pub-0dc3e1677e894f07bbea11b17a29e032.r2.dev/presets.json";
const response = await fetch(marketUrl);
if (!response.ok) {
throw new Error(`Failed to fetch market presets: ${response.status} ${response.statusText}`);
}
const marketPresets = await response.json();
// Use market presets function
const marketPresets = await getMarketPresets();
return { presets: marketPresets };
} catch (error: any) {
console.error("Failed to get market presets:", error);
@@ -364,24 +360,37 @@ export const createServer = async (config: any): Promise<any> => {
}
});
// Install preset from GitHub repository
// Install preset from GitHub repository by preset name
app.post("/api/presets/install/github", async (req: any, reply: any) => {
try {
const { repo, name } = req.body;
const { presetName } = req.body;
if (!repo) {
reply.status(400).send({ error: "Repository URL is required" });
if (!presetName) {
reply.status(400).send({ error: "Preset name is required" });
return;
}
// Check if preset is in the marketplace
const marketPreset = await findMarketPresetByName(presetName);
if (!marketPreset) {
reply.status(400).send({
error: "Preset not found in marketplace",
message: `Preset '${presetName}' is not available in the official marketplace. Please check the available presets.`
});
return;
}
// Get repository from market preset
if (!marketPreset.repo) {
reply.status(400).send({
error: "Invalid preset data",
message: `Preset '${presetName}' does not have repository information`
});
return;
}
// Parse GitHub repository URL
// Supported formats:
// - owner/repo (short format, from market)
// - github.com/owner/repo
// - https://github.com/owner/repo
// - https://github.com/owner/repo.git
// - git@github.com:owner/repo.git
const githubRepoMatch = repo.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/);
const githubRepoMatch = marketPreset.repo.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/);
if (!githubRepoMatch) {
reply.status(400).send({ error: "Invalid GitHub repository URL" });
return;
@@ -389,33 +398,59 @@ export const createServer = async (config: any): Promise<any> => {
const [, owner, repoName] = githubRepoMatch;
// Use preset name from market
const installedPresetName = marketPreset.name || presetName;
// Check if already installed BEFORE downloading
if (await isPresetInstalled(installedPresetName)) {
reply.status(409).send({
error: "Preset already installed",
message: `Preset '${installedPresetName}' is already installed. To update or reconfigure, please delete it first using the delete button.`,
presetName: installedPresetName
});
return;
}
// Download GitHub repository ZIP file
const downloadUrl = `https://github.com/${owner}/${repoName}/archive/refs/heads/main.zip`;
const tempFile = await downloadPresetToTemp(downloadUrl);
// Load preset
// Load preset to validate structure
const preset = await loadPresetFromZip(tempFile);
// Determine preset name
const presetName = name || preset.metadata?.name || repoName;
// Check if already installed
if (await isPresetInstalled(presetName)) {
// Double-check if already installed (in case of race condition)
if (await isPresetInstalled(installedPresetName)) {
unlinkSync(tempFile);
reply.status(409).send({ error: "Preset already installed" });
reply.status(409).send({
error: "Preset already installed",
message: `Preset '${installedPresetName}' was installed while downloading. Please try again.`,
presetName: installedPresetName
});
return;
}
// Extract to target directory
const targetDir = getPresetDir(presetName);
const targetDir = getPresetDir(installedPresetName);
await extractPreset(tempFile, targetDir);
// Read manifest and add repo information
const manifest = await readManifestFromDir(targetDir);
// Add repo information to manifest from market data
manifest.repository = marketPreset.repo;
if (marketPreset.url) {
manifest.source = marketPreset.url;
}
// Save updated manifest
await saveManifest(installedPresetName, manifest);
// Clean up temp file
unlinkSync(tempFile);
return {
success: true,
presetName,
presetName: installedPresetName,
preset: {
...preset.metadata,
installed: true,

View File

@@ -8,4 +8,5 @@ export * from './preset/install';
export * from './preset/export';
export * from './preset/readPreset';
export * from './preset/schema';
export * from './preset/marketplace';

View File

@@ -0,0 +1,56 @@
/**
* Preset marketplace management
* Fetches preset market data directly from remote without caching
*/
import { PresetIndexEntry } from './types';
// Preset market URL
const MARKET_URL = 'https://pub-0dc3e1677e894f07bbea11b17a29e032.r2.dev/presets.json';
/**
* Fetch preset market data from remote URL
*/
async function fetchMarketData(): Promise<PresetIndexEntry[]> {
const response = await fetch(MARKET_URL);
if (!response.ok) {
throw new Error(`Failed to fetch preset market: ${response.status} ${response.statusText}`);
}
const data = await response.json() as PresetIndexEntry[];
return data;
}
/**
* Get preset market data (always fetches from remote)
* @returns Array of preset market entries
*/
export async function getMarketPresets(): Promise<PresetIndexEntry[]> {
return await fetchMarketData();
}
/**
* Find a preset in the market by preset name (id or name field)
* @param presetName Preset name to search for
* @returns Preset entry if found, null otherwise
*/
export async function findMarketPresetByName(presetName: string): Promise<PresetIndexEntry | null> {
const marketPresets = await getMarketPresets();
// First try exact match by id
let preset = marketPresets.find(p => p.id === presetName);
// If not found, try exact match by name
if (!preset) {
preset = marketPresets.find(p => p.name === presetName);
}
// If still not found, try case-insensitive match by name
if (!preset) {
const lowerName = presetName.toLowerCase();
preset = marketPresets.find(p => p.name.toLowerCase() === lowerName);
}
return preset || null;
}

View File

@@ -214,6 +214,7 @@ export interface PresetIndexEntry {
stars?: number; // Star count
tags?: string[]; // Tags
url: string; // Download address
repo?: string; // Repository (e.g., 'owner/repo')
checksum?: string; // SHA256 checksum
ccrVersion?: string; // Compatible version
}

View File

@@ -14,7 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react";
import { Upload, Link, Trash2, Info, Download, Check, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react";
import { Toast } from "@/components/ui/toast";
import { DynamicConfigForm } from "./preset/DynamicConfigForm";
@@ -193,7 +193,13 @@ export function Presets() {
}
} catch (error: any) {
console.error('Failed to install preset:', error);
setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' });
// Check if it's an "already installed" error
const errorMessage = error.message || '';
if (errorMessage.includes('already installed') || errorMessage.includes('已安装')) {
setToast({ message: t('presets.preset_already_installed'), type: 'warning' });
} else {
setToast({ message: t('presets.preset_install_failed', { error: errorMessage }), type: 'error' });
}
} finally {
setInstallingFromMarket(null);
}
@@ -345,7 +351,13 @@ export function Presets() {
}
} catch (error: any) {
console.error('Failed to install preset:', error);
setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' });
// Check if it's an "already installed" error
const errorMessage = error.message || '';
if (errorMessage.includes('already installed') || errorMessage.includes('已安装')) {
setToast({ message: t('presets.preset_already_installed'), type: 'warning' });
} else {
setToast({ message: t('presets.preset_install_failed', { error: errorMessage }), type: 'error' });
}
} finally {
setIsInstalling(false);
}
@@ -636,7 +648,20 @@ export function Presets() {
</div>
) : (
<div className="space-y-3">
{filteredMarketPresets.map((preset) => (
{filteredMarketPresets.map((preset) => {
// Check if this preset is already installed by repo
const isInstalled = presets.some(p => {
// Extract repo from repository field (handle both formats)
let installedRepo = '';
if (p.repository) {
// Remove GitHub URL prefix if present
installedRepo = p.repository.replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, '');
}
// Match by repo (preferred), or name as fallback
return installedRepo === preset.repo || p.name === preset.name;
});
return (
<div
key={preset.id}
className="p-4 border rounded-lg hover:bg-gray-50 transition-colors"
@@ -668,7 +693,8 @@ export function Presets() {
</div>
<Button
onClick={() => handleInstallFromMarket(preset)}
disabled={installingFromMarket === preset.id}
disabled={installingFromMarket === preset.id || isInstalled}
variant={isInstalled ? "secondary" : "default"}
className="shrink-0"
>
{installingFromMarket === preset.id ? (
@@ -676,6 +702,11 @@ export function Presets() {
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('presets.installing')}
</>
) : isInstalled ? (
<>
<Check className="mr-2 h-4 w-4" />
{t('presets.installed_label')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
@@ -685,7 +716,8 @@ export function Presets() {
</Button>
</div>
</div>
))}
);
})}
</div>
)}
</div>

View File

@@ -99,7 +99,17 @@ class ApiClient {
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
// Try to get detailed error message from response body
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error || errorData.message) {
errorMessage = errorData.message || errorData.error || errorMessage;
}
} catch {
// If parsing fails, use default error message
}
throw new Error(errorMessage);
}
if (response.status === 204) {

View File

@@ -255,6 +255,7 @@
"view_details": "View Details",
"install": "Install",
"installing": "Installing...",
"installed_label": "Installed",
"apply": "Apply Preset",
"applying": "Applying...",
"close": "Close",
@@ -274,6 +275,7 @@
"delete_dialog_description": "Are you sure you want to delete preset \"{{name}}\"? This action cannot be undone.",
"preset_installed": "Preset installed successfully",
"preset_install_failed": "Failed to install preset: {{error}}",
"preset_already_installed": "Preset already installed. Please delete it first if you want to reinstall.",
"preset_applied": "Preset applied successfully",
"preset_apply_failed": "Failed to apply preset: {{error}}",
"preset_deleted": "Preset deleted successfully",

View File

@@ -255,6 +255,7 @@
"view_details": "查看详情",
"install": "安装",
"installing": "安装中...",
"installed_label": "已安装",
"apply": "应用预设",
"applying": "应用中...",
"close": "关闭",
@@ -262,7 +263,6 @@
"install_dialog_title": "安装预设",
"install_dialog_description": "从 GitHub 仓库安装预设",
"from_url": "从 GitHub",
"github_repository": "GitHub 仓库",
"preset_url": "仓库 URL",
"preset_url_placeholder": "https://github.com/owner/repo",
"preset_name": "预设名称 (可选)",
@@ -274,6 +274,7 @@
"delete_dialog_description": "您确定要删除预设 \"{{name}}\" 吗?此操作无法撤销。",
"preset_installed": "预设安装成功",
"preset_install_failed": "预设安装失败:{{error}}",
"preset_already_installed": "预设已经安装。如需重新安装,请先删除现有预设。",
"preset_applied": "预设应用成功",
"preset_apply_failed": "预设应用失败:{{error}}",
"preset_deleted": "预设删除成功",

21
pnpm-lock.yaml generated
View File

@@ -65,7 +65,7 @@ importers:
version: 3.4.19(tsx@4.21.0)
packages/cli:
dependencies:
devDependencies:
'@CCR/server':
specifier: workspace:*
version: link:../server
@@ -75,12 +75,21 @@ importers:
'@inquirer/prompts':
specifier: ^5.0.0
version: 5.5.0
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@types/node':
specifier: ^24.0.15
version: 24.7.0
adm-zip:
specifier: ^0.5.16
version: 0.5.16
archiver:
specifier: ^7.0.1
version: 7.0.1
esbuild:
specifier: ^0.25.1
version: 0.25.10
find-process:
specifier: ^2.0.0
version: 2.0.0
@@ -90,16 +99,6 @@ importers:
openurl:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@types/node':
specifier: ^24.0.15
version: 24.7.0
esbuild:
specifier: ^0.25.1
version: 0.25.10
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@24.7.0)(typescript@5.8.3)