mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-15 23:31:10 +00:00
feat(cli): 优化 download 和 update 命令,添加友好错误提示
- download: 添加智能搜索 namespace 和交互式版本选择 - download: 修复下载失败仍创建文件的问题 - update: 重写为带版本对比的更新流程 - update: 添加 -y/--yes 选项跳过确认 - star/rating/rate/report/delete/archive/hide/unhide: 添加 404 友好错误提示 - 提示用户使用 namespace/skill-name 格式
This commit is contained in:
parent
273e14a26f
commit
f4752f2d9b
8 changed files with 308 additions and 39 deletions
|
|
@ -33,7 +33,15 @@ export function registerArchive(program: Command) {
|
|||
await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`);
|
||||
success(`Archived ${skillSlug}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,15 @@ export function registerDelete(program: Command) {
|
|||
await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`);
|
||||
success(`Deleted ${skillSlug} from ${namespace}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { createWriteStream } from "node:fs";
|
|||
import { resolve } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { ApiClient } from "../core/api-client.js";
|
||||
import { ApiRoutes } from "../schema/routes.js";
|
||||
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
|
||||
import { loadConfigFromProgram } from "../core/config.js";
|
||||
import { readToken } from "../core/auth-token.js";
|
||||
import { success, error } from "../utils/logger.js";
|
||||
import * as p from "@clack/prompts";
|
||||
|
||||
import ora from "ora";
|
||||
|
||||
|
|
@ -57,8 +57,7 @@ export function registerDownload(program: Command) {
|
|||
downloadCmd.helpInformation = () => buildDownloadHelp(downloadCmd);
|
||||
|
||||
downloadCmd.action(async (slug: string, opts: Record<string, string>) => {
|
||||
const { parseSkillNamespace } = await import("../core/skill-resolver.js");
|
||||
const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace);
|
||||
const { resolveSkillNamespace, parseSkillNamespace } = await import("../core/skill-resolver.js");
|
||||
const config = loadConfigFromProgram(program);
|
||||
const token = await readToken();
|
||||
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
|
||||
|
|
@ -66,15 +65,91 @@ export function registerDownload(program: Command) {
|
|||
const outputDir = opts.output ? resolve(process.cwd(), opts.output) : process.cwd();
|
||||
|
||||
try {
|
||||
let namespace: string;
|
||||
let skillSlug: string;
|
||||
|
||||
if (opts.namespace || slug.includes("/")) {
|
||||
const parsed = parseSkillNamespace(slug, opts.namespace);
|
||||
namespace = parsed.namespace;
|
||||
skillSlug = parsed.slug;
|
||||
} else {
|
||||
const spinner = ora(`Searching for ${slug}`).start();
|
||||
try {
|
||||
const resolved = await resolveSkillNamespace(client, slug);
|
||||
namespace = resolved.namespace;
|
||||
skillSlug = resolved.slug;
|
||||
spinner.succeed(`Found ${namespace}/${skillSlug}`);
|
||||
} catch (e: any) {
|
||||
spinner.fail(e.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora(`Downloading ${skillSlug} from ${namespace}`).start();
|
||||
|
||||
let downloadUrl = `${ApiRoutes.skillDownload.replace("{namespace}", namespace).replace("{slug}", skillSlug)}`;
|
||||
let selectedVersion: string;
|
||||
if (opts.skillVersion) {
|
||||
downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/versions/${opts.skillVersion}/download`;
|
||||
} else if (opts.tag) {
|
||||
downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/tags/${opts.tag}/download`;
|
||||
selectedVersion = opts.skillVersion;
|
||||
} else if (opts.tag && opts.tag !== "latest") {
|
||||
spinner.text = `Resolving tag ${opts.tag}`;
|
||||
try {
|
||||
const tagsResp = await client.get<Array<{ tagName: string; version: string }>>(
|
||||
`/api/v1/skills/${namespace}/${skillSlug}/tags`
|
||||
);
|
||||
const tags = tagsResp || [];
|
||||
const matchedTag = tags.find((t) => t.tagName === opts.tag);
|
||||
if (matchedTag) {
|
||||
selectedVersion = matchedTag.version;
|
||||
} else {
|
||||
spinner.fail(`Tag not found: ${opts.tag}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
spinner.fail(`Failed to fetch tags: ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
spinner.stop();
|
||||
try {
|
||||
const versionsResp = await client.get<{ items: Array<{ version: string; publishedAt: string }> }>(
|
||||
`/api/v1/skills/${namespace}/${skillSlug}/versions`
|
||||
);
|
||||
const versions = versionsResp.items || [];
|
||||
if (versions.length === 0) {
|
||||
error(`No versions found for ${namespace}/${skillSlug}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (versions.length === 1) {
|
||||
selectedVersion = versions[0].version;
|
||||
} else {
|
||||
const picked = await p.select({
|
||||
message: "Select version to download",
|
||||
options: versions.map((v) => ({
|
||||
value: v.version,
|
||||
label: `v${v.version}`,
|
||||
hint: new Date(v.publishedAt).toLocaleDateString(),
|
||||
})),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
console.log("Cancelled.");
|
||||
return;
|
||||
}
|
||||
selectedVersion = picked as string;
|
||||
}
|
||||
spinner.start(`Downloading ${skillSlug}@${selectedVersion}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed to fetch versions: ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadUrl = `${config.registry.replace(/\/$/, "")}/api/v1/skills/${namespace}/${skillSlug}/versions/${selectedVersion}/download`;
|
||||
|
||||
const { request } = await import("undici");
|
||||
const url = new URL(downloadUrl, config.registry);
|
||||
let response = await request(url.toString(), {
|
||||
|
|
@ -86,6 +161,7 @@ export function registerDownload(program: Command) {
|
|||
if (!location) {
|
||||
spinner.fail(`Redirect response has no Location header`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
response = await request(location as string, { method: "GET" });
|
||||
}
|
||||
|
|
@ -94,6 +170,7 @@ export function registerDownload(program: Command) {
|
|||
if (statusCode >= 400) {
|
||||
spinner.fail(`Download failed: HTTP ${statusCode}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const outPath = resolve(outputDir, `${skillSlug}.zip`);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,15 @@ export function registerHide(program: Command) {
|
|||
|
||||
success(`Hidden ${skillSlug}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
@ -87,7 +95,15 @@ export function registerHide(program: Command) {
|
|||
|
||||
success(`Unhidden ${skillSlug}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,15 @@ export function registerRating(program: Command) {
|
|||
dim("Use: skillhub rate <skill> <score>");
|
||||
}
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
@ -69,7 +77,15 @@ export function registerRate(program: Command) {
|
|||
});
|
||||
success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,15 @@ export function registerReport(program: Command) {
|
|||
});
|
||||
success(`Report submitted for ${skillSlug}`);
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js";
|
|||
import { ApiRoutes } from "../schema/routes.js";
|
||||
import { requireToken } from "../core/auth-token.js";
|
||||
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
|
||||
import { success, error } from "../utils/logger.js";
|
||||
import { success, error, dim } from "../utils/logger.js";
|
||||
|
||||
|
||||
export function registerStar(program: Command) {
|
||||
|
|
@ -33,7 +33,15 @@ export function registerStar(program: Command) {
|
|||
success(`Starred ${skillSlug}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
error(`Failed: ${e.message}`);
|
||||
const status = e.status || e.statusCode;
|
||||
if (status === 404) {
|
||||
error(`Skill not found: ${namespace}/${skillSlug}`);
|
||||
if (!slug.includes("/")) {
|
||||
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
|
||||
}
|
||||
} else {
|
||||
error(`Failed: ${e.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,45 @@
|
|||
import { Command } from "commander";
|
||||
import { success, error, info, warn } from "../utils/logger.js";
|
||||
import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js";
|
||||
import { success, error, info, warn, dim } from "../utils/logger.js";
|
||||
import { getAllLockedSkills, getSkillLockPath, type SkillLockEntry } from "../core/skill-lock.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
|
||||
import { ApiClient } from "../core/api-client.js";
|
||||
import { loadConfigFromProgram } from "../core/config.js";
|
||||
import { readToken } from "../core/auth-token.js";
|
||||
import * as p from "@clack/prompts";
|
||||
import ora from "ora";
|
||||
|
||||
function getCliCommand(): string {
|
||||
const cliPath = process.argv[1];
|
||||
return `node "${cliPath}"`;
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
name: string;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
namespace: string;
|
||||
slug: string;
|
||||
source: string;
|
||||
sourceType: string;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
export function registerUpdate(program: Command) {
|
||||
program
|
||||
.command("update [skill]")
|
||||
.description("Update installed skills from their source")
|
||||
.option("-a, --all", "Update all installed skills")
|
||||
.option("-g, --global", "Update global scope skills")
|
||||
.option("-y, --yes", "Skip confirmation and update all outdated skills")
|
||||
.action(async (slug: string | undefined, opts: Record<string, boolean>) => {
|
||||
const lockPath = getSkillLockPath();
|
||||
|
||||
if (!existsSync(lockPath)) {
|
||||
error("No skillhub.lock found. Have you installed any skills?");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const lockedSkills = await getAllLockedSkills();
|
||||
|
|
@ -30,21 +48,32 @@ export function registerUpdate(program: Command) {
|
|||
if (allSkillNames.length === 0) {
|
||||
error("No skills in lock file.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let skillsToUpdate: string[] = [];
|
||||
const config = loadConfigFromProgram(program);
|
||||
const token = await readToken();
|
||||
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
|
||||
|
||||
let skillsToCheck: string[] = [];
|
||||
|
||||
if (opts.all) {
|
||||
skillsToUpdate = allSkillNames;
|
||||
skillsToCheck = allSkillNames;
|
||||
} else if (slug) {
|
||||
skillsToUpdate = [slug];
|
||||
if (!lockedSkills[slug]) {
|
||||
error(`Skill not found in lock file: ${slug}`);
|
||||
error(`Installed skills: ${allSkillNames.join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
skillsToCheck = [slug];
|
||||
} else {
|
||||
const selected = await searchMultiselect({
|
||||
message: "Select skills to update",
|
||||
message: "Select skills to check for updates",
|
||||
items: allSkillNames.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
hint: lockedSkills[name].sourceType,
|
||||
hint: `${lockedSkills[name].namespace}/${lockedSkills[name].slug} @ ${lockedSkills[name].version}`,
|
||||
})),
|
||||
required: true,
|
||||
});
|
||||
|
|
@ -54,7 +83,116 @@ export function registerUpdate(program: Command) {
|
|||
return;
|
||||
}
|
||||
|
||||
skillsToUpdate = selected as string[];
|
||||
skillsToCheck = selected as string[];
|
||||
}
|
||||
|
||||
const spinner = ora("Checking for updates...").start();
|
||||
const updates: UpdateInfo[] = [];
|
||||
const upToDate: string[] = [];
|
||||
const checkFailed: string[] = [];
|
||||
|
||||
for (const name of skillsToCheck) {
|
||||
const entry = lockedSkills[name];
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.sourceType !== "registry") {
|
||||
checkFailed.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const versionsResp = await client.get<{ items: Array<{ version: string }> }>(
|
||||
`/api/v1/skills/${entry.namespace}/${entry.slug}/versions`
|
||||
);
|
||||
const versions = versionsResp.items || [];
|
||||
|
||||
if (versions.length === 0) {
|
||||
checkFailed.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestVersion = versions[0].version;
|
||||
const currentVersion = entry.version;
|
||||
|
||||
if (latestVersion === currentVersion) {
|
||||
upToDate.push(name);
|
||||
} else {
|
||||
updates.push({
|
||||
name,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
namespace: entry.namespace,
|
||||
slug: entry.slug,
|
||||
source: entry.source,
|
||||
sourceType: entry.sourceType,
|
||||
hasUpdate: true,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
checkFailed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (upToDate.length > 0) {
|
||||
console.log("");
|
||||
info(`Up to date (${upToDate.length}):`);
|
||||
for (const name of upToDate) {
|
||||
dim(` ✓ ${name} @ ${lockedSkills[name].version}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkFailed.length > 0) {
|
||||
console.log("");
|
||||
warn(`Check failed (${checkFailed.length}):`);
|
||||
for (const name of checkFailed) {
|
||||
dim(` ✗ ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log("");
|
||||
success("All skills are up to date!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
info(`Updates available (${updates.length}):`);
|
||||
for (const u of updates) {
|
||||
console.log(` ↑ ${u.name}: ${u.currentVersion} → ${u.latestVersion}`);
|
||||
}
|
||||
|
||||
let skillsToUpdate = updates;
|
||||
|
||||
if (!opts.yes && !opts.all && !slug) {
|
||||
const selected = await searchMultiselect({
|
||||
message: "Select skills to update",
|
||||
items: updates.map((u) => ({
|
||||
value: u.name,
|
||||
label: u.name,
|
||||
hint: `${u.currentVersion} → ${u.latestVersion}`,
|
||||
})),
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (selected === cancelSymbol) {
|
||||
console.log("Cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
skillsToUpdate = updates.filter((u) => (selected as string[]).includes(u.name));
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const confirmed = await p.confirm({
|
||||
message: `Update ${skillsToUpdate.length} skill(s)?`,
|
||||
});
|
||||
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
console.log("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const scope = opts.global ? "--global" : "";
|
||||
|
|
@ -63,24 +201,14 @@ export function registerUpdate(program: Command) {
|
|||
let updated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const name of skillsToUpdate) {
|
||||
const entry = lockedSkills[name];
|
||||
if (!entry) {
|
||||
warn(`Skill not found in lock: ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const info of skillsToUpdate) {
|
||||
try {
|
||||
info(`Updating ${name} from ${entry.source}...`);
|
||||
const source = entry.sourceType === "registry"
|
||||
? entry.source
|
||||
: entry.sourceUrl;
|
||||
|
||||
const cmd = `${cliCmd} install ${source} ${scope}`.trim();
|
||||
info(`Updating ${info.name} from ${info.currentVersion} to ${info.latestVersion}...`);
|
||||
const cmd = `${cliCmd} install ${info.namespace}/${info.slug} --skill-version ${info.latestVersion} ${scope}`.trim();
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
updated++;
|
||||
} catch (e: any) {
|
||||
error(`Failed to update ${name}: ${e.message}`);
|
||||
error(`Failed to update ${info.name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue