mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
e1caee99f2
- MCP: editable JSON config in the detail drawer with secret masking and mask-preserving writes; create/update/delete across claude-code, qwen-code, opencode, cursor, windsurf, gemini, qoderwork, openclaw and Claude Desktop - Skills: upload a .zip and install into any agent's skills root (self-contained ZIP reader, zip-slip safe); scan more roots (openclaw workspace, qoderwork, windsurf/codeium, gemini antigravity, workbuddy) - Markdown: GFM table rendering in the skill detail drawer - Layout: collapsible grouped sidebar with icons + persistent state, responsive breakpoint, wider main, single-line tile titles, 2-line description clamp, round icon run buttons, custom file picker, modal spacing - Server: /api/mcp POST/DELETE, /api/skill/install, binary upload reader, constant-time token compare, CSP/no-store headers, error logging
97 lines
3.7 KiB
TypeScript
97 lines
3.7 KiB
TypeScript
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, sep } from "node:path";
|
|
import { expect, test } from "vite-plus/test";
|
|
import { listAssets, resolveAssetPath, contentType } from "../src/commands/config/assets.ts";
|
|
|
|
/** Build an isolated temp output base and clean it up afterwards. */
|
|
function withBase(fn: (base: string) => void): void {
|
|
const base = mkdtempSync(join(tmpdir(), "bl-assets-"));
|
|
try {
|
|
fn(base);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function put(base: string, rel: string, content = "x"): string {
|
|
const path = join(base, rel);
|
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
writeFileSync(path, content);
|
|
return path;
|
|
}
|
|
|
|
test("listAssets 按分类归类并识别类型", () => {
|
|
withBase((base) => {
|
|
put(base, "images/a.png");
|
|
put(base, "videos/clip.mp4");
|
|
put(base, "speech/voice.mp3");
|
|
put(base, "notes.txt"); // loose file -> other
|
|
|
|
const { base: reported, assets } = listAssets(base);
|
|
expect(reported).toBe(base);
|
|
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
|
|
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image", ext: "png" });
|
|
expect(byName["clip.mp4"]).toMatchObject({ category: "videos", kind: "video", ext: "mp4" });
|
|
expect(byName["voice.mp3"]).toMatchObject({ category: "speech", kind: "audio", ext: "mp3" });
|
|
expect(byName["notes.txt"]).toMatchObject({ category: "other", kind: "other" });
|
|
});
|
|
});
|
|
|
|
test("listAssets 递归扫描任意子文件夹(非预设分类目录)", () => {
|
|
withBase((base) => {
|
|
put(base, "news-articles/report.md");
|
|
put(base, "custom/deep/nested/pic.png");
|
|
put(base, "images/a.png");
|
|
|
|
const { assets } = listAssets(base);
|
|
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
|
|
// Arbitrary top-level folder becomes the category.
|
|
expect(byName["report.md"]).toMatchObject({
|
|
category: "news-articles",
|
|
kind: "other",
|
|
ext: "md",
|
|
});
|
|
// Deeply nested file is discovered; category is its top-level folder.
|
|
expect(byName["pic.png"]).toMatchObject({ category: "custom", kind: "image" });
|
|
expect(byName["pic.png"]!.relPath).toBe(join("custom", "deep", "nested", "pic.png"));
|
|
// Known category dirs still work.
|
|
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image" });
|
|
});
|
|
});
|
|
|
|
test("listAssets 按生成时间倒序排列", () => {
|
|
withBase((base) => {
|
|
const older = put(base, "images/old.png");
|
|
const newer = put(base, "images/new.png");
|
|
// Force a stable ordering by stamping mtimes.
|
|
utimesSync(older, new Date(1000), new Date(1000));
|
|
utimesSync(newer, new Date(2000), new Date(2000));
|
|
|
|
const { assets } = listAssets(base);
|
|
expect(assets.map((a) => a.name)).toEqual(["new.png", "old.png"]);
|
|
});
|
|
});
|
|
|
|
test("listAssets 目录不存在时返回空", () => {
|
|
const { assets } = listAssets(join(tmpdir(), "bl-assets-does-not-exist-xyz"));
|
|
expect(assets).toEqual([]);
|
|
});
|
|
|
|
test("resolveAssetPath 阻止目录穿越", () => {
|
|
withBase((base) => {
|
|
put(base, "images/a.png");
|
|
expect(resolveAssetPath(base, "images/a.png")).toBe(join(base, "images/a.png"));
|
|
expect(resolveAssetPath(base, "../../etc/passwd")).toBeNull();
|
|
expect(resolveAssetPath(base, "")).toBeNull();
|
|
expect(resolveAssetPath(base, "images" + sep + ".." + sep + ".." + sep + "outside")).toBeNull();
|
|
});
|
|
});
|
|
|
|
test("contentType 映射常见扩展名", () => {
|
|
expect(contentType(".png")).toBe("image/png");
|
|
expect(contentType(".MP4")).toBe("video/mp4");
|
|
expect(contentType(".mp3")).toBe("audio/mpeg");
|
|
expect(contentType(".xyz")).toBe("application/octet-stream");
|
|
});
|