feat(skills): multi-skill ZIP upload with hash-based idempotency (#846)
feat(skills): multi-skill ZIP upload with hash-based idempotency - Multi-skill ZIP detection: upload single ZIP containing multiple skill directories - Hash-based idempotency: SHA-256 of SKILL.md content deduplicates re-uploads - Grouped upload UI: ZIP group headers with per-skill badges (NEW/UNCHANGED/ERROR) - Safety: 50-skill per-ZIP limit, TOCTOU-safe hash check under lock - Performance: cached ZIP parsing O(N) vs O(N*M) - Tests: 17 frontend + 9 backend tests Closes #845
No files matched your search
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 161 KiB |
@@ -56,13 +56,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmp, hasher), file)
|
||||
size, err := io.Copy(tmp, file)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")})
|
||||
return
|
||||
}
|
||||
fileHash := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
|
||||
// Open as zip
|
||||
zr, err := zip.OpenReader(tmp.Name())
|
||||
@@ -126,11 +124,30 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Compute content hash of SKILL.md for idempotency check.
|
||||
// Using SKILL.md content (not ZIP hash) so content-identical uploads are deduplicated
|
||||
// even when packaged into different ZIP files (e.g. multi-skill split upload).
|
||||
skillHash := fmt.Sprintf("%x", sha256.Sum256([]byte(skillContent)))
|
||||
|
||||
tenantSkillsBase := h.tenantSkillsDir(r)
|
||||
uploadLock := h.skillUploadLock(filepath.Join(tenantSkillsBase, slug))
|
||||
uploadLock.Lock()
|
||||
defer uploadLock.Unlock()
|
||||
|
||||
// Check whether content is unchanged from the current stored version.
|
||||
// Performed under lock to avoid TOCTOU race where concurrent uploads
|
||||
// could both pass the hash check before either creates a new version.
|
||||
existingHash, existingVer, skillExists := h.skills.GetSkillHashBySlug(r.Context(), slug)
|
||||
if skillExists && existingHash != "" && existingHash == skillHash {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"slug": slug,
|
||||
"version": existingVer,
|
||||
"name": name,
|
||||
"status": "unchanged",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine version (always increment — includes archived skills so re-upload gets v2+)
|
||||
version := h.skills.GetNextVersion(r.Context(), slug)
|
||||
|
||||
@@ -191,12 +208,14 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
Version: version,
|
||||
FilePath: destDir,
|
||||
FileSize: size,
|
||||
FileHash: &fileHash,
|
||||
FileHash: &skillHash, // SKILL.md content hash for idempotency (not ZIP hash)
|
||||
Frontmatter: frontmatter,
|
||||
}
|
||||
|
||||
// Scan and check dependencies
|
||||
response := map[string]any{"slug": slug, "version": version, "name": name, "status": "active"}
|
||||
// is_new is true only when no previous version of this skill existed (first upload).
|
||||
isNew := !skillExists
|
||||
response := map[string]any{"slug": slug, "version": version, "name": name, "status": "active", "is_new": isNew}
|
||||
depState := uploadSkillDepState{}
|
||||
depsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), uploadDepsInstallTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -365,11 +365,12 @@ func skillMarkdown(name, slug string) string {
|
||||
}
|
||||
|
||||
type skillManageStoreStub struct {
|
||||
baseDir string
|
||||
version int64
|
||||
nextBySlug map[string]int
|
||||
skills map[uuid.UUID]store.SkillInfo
|
||||
systemDirs map[string]string
|
||||
baseDir string
|
||||
version int64
|
||||
nextBySlug map[string]int
|
||||
skills map[uuid.UUID]store.SkillInfo
|
||||
systemDirs map[string]string
|
||||
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
|
||||
}
|
||||
|
||||
func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
|
||||
@@ -378,6 +379,7 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
|
||||
nextBySlug: map[string]int{},
|
||||
skills: map[uuid.UUID]store.SkillInfo{},
|
||||
systemDirs: map[string]string{},
|
||||
hashBySlug: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,9 +446,23 @@ func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.Ski
|
||||
Enabled: true,
|
||||
MissingDeps: append([]string(nil), p.MissingDeps...),
|
||||
}
|
||||
// Track the content hash for idempotency checks (mirrors handler behaviour).
|
||||
if p.FileHash != nil {
|
||||
s.hashBySlug[p.Slug] = *p.FileHash
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *skillManageStoreStub) GetSkillHashBySlug(_ context.Context, slug string) (string, int, bool) {
|
||||
hash, ok := s.hashBySlug[slug]
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
// Find the latest version for this slug.
|
||||
version := s.nextBySlug[slug]
|
||||
return hash, version, true
|
||||
}
|
||||
|
||||
func (s *skillManageStoreStub) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
skill, ok := s.skills[id]
|
||||
if !ok {
|
||||
@@ -529,3 +545,160 @@ func (s *skillManageStoreStub) ListWithGrantStatus(context.Context, uuid.UUID) (
|
||||
func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) {
|
||||
return "", "", 0, false, false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash comparison / idempotency tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleUpload_IdenticalContent_ReturnsUnchanged(t *testing.T) {
|
||||
handler, skillStore, ctx, _ := newTestUploadHandler(t)
|
||||
stubUploadDepFns(t,
|
||||
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(*skills.SkillManifest) (bool, []string) { return true, nil },
|
||||
)
|
||||
|
||||
files := map[string]string{
|
||||
"SKILL.md": skillMarkdown("Hash Skill", "hash-skill"),
|
||||
}
|
||||
|
||||
// First upload — expect 201 Created, version 1.
|
||||
req1 := newZipUploadRequest(t, ctx, files)
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.handleUpload(w1, req1)
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// Second upload with identical content — expect 200 unchanged.
|
||||
req2 := newZipUploadRequest(t, ctx, files)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.handleUpload(w2, req2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "unchanged" {
|
||||
t.Fatalf("status = %q, want unchanged", resp.Status)
|
||||
}
|
||||
if resp.Version != 1 {
|
||||
t.Fatalf("version = %d, want 1 (no new version created)", resp.Version)
|
||||
}
|
||||
if resp.Slug != "hash-skill" {
|
||||
t.Fatalf("slug = %q, want hash-skill", resp.Slug)
|
||||
}
|
||||
|
||||
// Verify no second DB row was created.
|
||||
if got := skillStore.nextBySlug["hash-skill"]; got != 1 {
|
||||
t.Fatalf("nextBySlug[hash-skill] = %d, want 1 (unchanged should not create new version)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpload_ChangedContent_BumpsVersion(t *testing.T) {
|
||||
handler, skillStore, ctx, _ := newTestUploadHandler(t)
|
||||
stubUploadDepFns(t,
|
||||
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(*skills.SkillManifest) (bool, []string) { return true, nil },
|
||||
)
|
||||
|
||||
// First upload.
|
||||
req1 := newZipUploadRequest(t, ctx, map[string]string{
|
||||
"SKILL.md": "---\nname: Change Skill\nslug: change-skill\n---\nOriginal body\n",
|
||||
})
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.handleUpload(w1, req1)
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// Second upload with different SKILL.md content.
|
||||
req2 := newZipUploadRequest(t, ctx, map[string]string{
|
||||
"SKILL.md": "---\nname: Change Skill\nslug: change-skill\n---\nUpdated body with new description\n",
|
||||
})
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.handleUpload(w2, req2)
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "active" {
|
||||
t.Fatalf("status = %q, want active", resp.Status)
|
||||
}
|
||||
if resp.Version != 2 {
|
||||
t.Fatalf("version = %d, want 2", resp.Version)
|
||||
}
|
||||
|
||||
// Verify DB has version 2.
|
||||
if got := skillStore.nextBySlug["change-skill"]; got != 2 {
|
||||
t.Fatalf("nextBySlug[change-skill] = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpload_ResponseIncludesIsNew(t *testing.T) {
|
||||
handler, _, ctx, _ := newTestUploadHandler(t)
|
||||
stubUploadDepFns(t,
|
||||
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(*skills.SkillManifest) (bool, []string) { return true, nil },
|
||||
)
|
||||
|
||||
// Brand-new skill upload should have is_new=true.
|
||||
req1 := newZipUploadRequest(t, ctx, map[string]string{
|
||||
"SKILL.md": skillMarkdown("IsNew Skill", "isnew-skill"),
|
||||
})
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.handleUpload(w1, req1)
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
var resp1 struct {
|
||||
IsNew bool `json:"is_new"`
|
||||
}
|
||||
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
|
||||
t.Fatalf("decode first response: %v", err)
|
||||
}
|
||||
if !resp1.IsNew {
|
||||
t.Fatal("first upload: expected is_new=true")
|
||||
}
|
||||
|
||||
// Changed-content re-upload should have is_new=false.
|
||||
req2 := newZipUploadRequest(t, ctx, map[string]string{
|
||||
"SKILL.md": "---\nname: IsNew Skill\nslug: isnew-skill\n---\nDifferent body content\n",
|
||||
})
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.handleUpload(w2, req2)
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var resp2 struct {
|
||||
IsNew bool `json:"is_new"`
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
t.Fatalf("decode second response: %v", err)
|
||||
}
|
||||
if resp2.IsNew {
|
||||
t.Fatal("second upload (changed content): expected is_new=false")
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,22 @@ func (s *PGSkillStore) GetNextVersion(ctx context.Context, slug string) int {
|
||||
return maxVersion + 1
|
||||
}
|
||||
|
||||
// GetSkillHashBySlug returns the file_hash and version of the latest non-deleted skill
|
||||
// version for the given slug, scoped to the current tenant.
|
||||
// Returns ok=false when no matching row exists.
|
||||
func (s *PGSkillStore) GetSkillHashBySlug(ctx context.Context, slug string) (string, int, bool) {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
var hash string
|
||||
var version int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(file_hash, ''), version FROM skills
|
||||
WHERE slug = $1 AND tenant_id = $2 AND status != 'deleted'
|
||||
ORDER BY version DESC LIMIT 1`,
|
||||
slug, tid,
|
||||
).Scan(&hash, &version)
|
||||
return hash, version, err == nil
|
||||
}
|
||||
|
||||
// GetNextVersionLocked computes the next version atomically using an advisory lock.
|
||||
// Safe for concurrent write paths (patch, create). Returns version and a cleanup func
|
||||
// that MUST be called to release the lock (commits the transaction).
|
||||
|
||||
@@ -108,6 +108,9 @@ type SkillManageStore interface {
|
||||
GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool)
|
||||
GetNextVersion(ctx context.Context, slug string) int
|
||||
GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error)
|
||||
// GetSkillHashBySlug returns the content hash and version of the latest non-deleted skill
|
||||
// version for the given slug (tenant-scoped). Returns ok=false if no skill exists.
|
||||
GetSkillHashBySlug(ctx context.Context, slug string) (hash string, version int, ok bool)
|
||||
IsSystemSkill(slug string) bool
|
||||
// System skill management
|
||||
ListAllSkills(ctx context.Context) []SkillInfo
|
||||
|
||||
@@ -187,6 +187,21 @@ func (s *SQLiteSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (
|
||||
return filePath, slug, version, isSystem, err == nil
|
||||
}
|
||||
|
||||
// GetSkillHashBySlug returns the file_hash and version of the latest non-deleted skill
|
||||
// version for the given slug.
|
||||
// Returns ok=false when no matching row exists.
|
||||
func (s *SQLiteSkillStore) GetSkillHashBySlug(ctx context.Context, slug string) (string, int, bool) {
|
||||
var hash string
|
||||
var version int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(file_hash, ''), version FROM skills
|
||||
WHERE slug = ? AND status != 'deleted'
|
||||
ORDER BY version DESC LIMIT 1`,
|
||||
slug,
|
||||
).Scan(&hash, &version)
|
||||
return hash, version, err == nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSkillStore) GetNextVersion(ctx context.Context, slug string) int {
|
||||
var maxVersion int
|
||||
s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = ?", slug).Scan(&maxVersion)
|
||||
|
||||
@@ -57,10 +57,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"jsdom": "^29.0.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"unified": "^11.0.5",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
|
||||
@@ -46,7 +46,13 @@
|
||||
"validCount": "{{valid}} of {{total}} valid",
|
||||
"uploadCount": "Upload ({{count}})",
|
||||
"successCount": "{{success}} of {{total}} uploaded successfully",
|
||||
"remove": "Remove file"
|
||||
"remove": "Remove file",
|
||||
"tooManySkills": "ZIP contains too many skills (max 50)",
|
||||
"multiDetected": "{{count}} skills detected",
|
||||
"skillUnchanged": "Content unchanged — skipped",
|
||||
"slugConflict": "Slug conflicts with a system skill",
|
||||
"unchanged": "Unchanged",
|
||||
"new": "New"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Skill",
|
||||
|
||||
@@ -46,7 +46,13 @@
|
||||
"validCount": "{{valid}} / {{total}} hợp lệ",
|
||||
"uploadCount": "Tải lên ({{count}})",
|
||||
"successCount": "{{success}} / {{total}} đã tải lên thành công",
|
||||
"remove": "Xóa tệp"
|
||||
"remove": "Xóa tệp",
|
||||
"tooManySkills": "ZIP chứa quá nhiều kỹ năng (tối đa 50)",
|
||||
"multiDetected": "Phát hiện {{count}} kỹ năng",
|
||||
"skillUnchanged": "Nội dung không đổi — đã bỏ qua",
|
||||
"slugConflict": "Slug trùng với kỹ năng hệ thống",
|
||||
"unchanged": "Không đổi",
|
||||
"new": "Mới"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Chỉnh sửa skill",
|
||||
|
||||
@@ -46,7 +46,13 @@
|
||||
"validCount": "{{valid}} / {{total}} 有效",
|
||||
"uploadCount": "上传 ({{count}})",
|
||||
"successCount": "{{success}} / {{total}} 上传成功",
|
||||
"remove": "移除文件"
|
||||
"remove": "移除文件",
|
||||
"tooManySkills": "ZIP 包含过多技能(最多 50 个)",
|
||||
"multiDetected": "检测到 {{count}} 个技能",
|
||||
"skillUnchanged": "内容未更改 — 已跳过",
|
||||
"slugConflict": "Slug 与系统技能冲突",
|
||||
"unchanged": "未更改",
|
||||
"new": "新建"
|
||||
},
|
||||
"edit": {
|
||||
"title": "编辑Skill",
|
||||
|
||||
@@ -12,11 +12,14 @@ import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
|
||||
export type { SkillInfo, SkillFile, SkillVersions };
|
||||
|
||||
export type SkillUploadResponse = {
|
||||
id: string;
|
||||
/** Absent when status is "unchanged" */
|
||||
id?: string;
|
||||
slug: string;
|
||||
version: number;
|
||||
name: string;
|
||||
/** "active" | "unchanged" | "archived" */
|
||||
status?: string;
|
||||
is_new?: boolean;
|
||||
deps_warning?: string;
|
||||
deps_errors?: string[];
|
||||
missing_deps?: string[];
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
import { createSkillSubZip } from "./create-skill-sub-zip";
|
||||
|
||||
describe("createSkillSubZip", () => {
|
||||
it("extracts only files under the specified directory", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("pdf/SKILL.md", "---\nname: PDF\n---\n# PDF");
|
||||
zip.file("pdf/scripts/convert.py", "print('convert')");
|
||||
zip.file("csv/SKILL.md", "---\nname: CSV\n---\n# CSV");
|
||||
|
||||
const subFile = await createSkillSubZip(zip, "pdf");
|
||||
const subZip = await JSZip.loadAsync(subFile);
|
||||
const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
|
||||
|
||||
expect(paths.sort()).toEqual(["SKILL.md", "scripts/convert.py"]);
|
||||
expect(subFile.name).toBe("pdf.zip");
|
||||
});
|
||||
|
||||
it("handles nested subdirectories within skill dir", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("my-skill/SKILL.md", "content");
|
||||
zip.file("my-skill/scripts/helpers/util.py", "util");
|
||||
zip.file("my-skill/references/guide.md", "guide");
|
||||
|
||||
const subFile = await createSkillSubZip(zip, "my-skill");
|
||||
const subZip = await JSZip.loadAsync(subFile);
|
||||
const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
|
||||
|
||||
expect(paths.sort()).toEqual(["SKILL.md", "references/guide.md", "scripts/helpers/util.py"]);
|
||||
});
|
||||
|
||||
it("returns empty ZIP for non-existent directory", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("other/SKILL.md", "content");
|
||||
|
||||
const subFile = await createSkillSubZip(zip, "missing");
|
||||
const subZip = await JSZip.loadAsync(subFile);
|
||||
const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
|
||||
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
|
||||
it("produces a File with correct mime type", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("skill-a/SKILL.md", "content");
|
||||
|
||||
const subFile = await createSkillSubZip(zip, "skill-a");
|
||||
|
||||
expect(subFile).toBeInstanceOf(File);
|
||||
expect(subFile.type).toBe("application/zip");
|
||||
expect(subFile.name).toBe("skill-a.zip");
|
||||
});
|
||||
|
||||
it("does not include directory entries in extracted ZIP", async () => {
|
||||
const zip = new JSZip();
|
||||
// Explicitly add a directory entry alongside a file
|
||||
zip.folder("tool/scripts");
|
||||
zip.file("tool/scripts/run.sh", "#!/bin/bash");
|
||||
|
||||
const subFile = await createSkillSubZip(zip, "tool");
|
||||
const subZip = await JSZip.loadAsync(subFile);
|
||||
const dirEntries = Object.values(subZip.files).filter((f) => f.dir);
|
||||
|
||||
// Directory entries may be created implicitly by JSZip but no explicit
|
||||
// empty dir entries from the source should leak as files
|
||||
const fileEntries = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
|
||||
expect(fileEntries).toEqual(["scripts/run.sh"]);
|
||||
// dirEntries may exist (JSZip auto-creates them) — just ensure files are right
|
||||
expect(dirEntries.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Utility for extracting a single skill's subdirectory into a standalone ZIP file. */
|
||||
import JSZip from "jszip";
|
||||
|
||||
/**
|
||||
* Extract files under `dir/` from a pre-parsed JSZip instance, strip the
|
||||
* directory prefix, and return a new File suitable for upload.
|
||||
*
|
||||
* Accepts a JSZip instance (not a File) so the caller can parse the original
|
||||
* ZIP once and reuse it across multiple skill extractions — O(N) instead of
|
||||
* O(N*M) where M is ZIP parse cost.
|
||||
*/
|
||||
export async function createSkillSubZip(zip: JSZip, dir: string): Promise<File> {
|
||||
const sub = new JSZip();
|
||||
const prefix = dir + "/";
|
||||
|
||||
for (const [path, entry] of Object.entries(zip.files)) {
|
||||
if (path.startsWith(prefix) && !entry.dir) {
|
||||
const relativePath = path.slice(prefix.length);
|
||||
if (relativePath) {
|
||||
sub.file(relativePath, await entry.async("blob"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await sub.generateAsync({ type: "blob" });
|
||||
return new File([blob], `${dir}.zip`, { type: "application/zip" });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Shared types for the skill upload dialog and its sub-components. */
|
||||
|
||||
/** Per-skill lifecycle status */
|
||||
export type SkillStatus =
|
||||
| "validating"
|
||||
| "valid" // new skill, ready to upload
|
||||
| "unchanged" // server returned identical hash — skipped
|
||||
| "invalid"
|
||||
| "uploading"
|
||||
| "success"
|
||||
| "warning" // uploaded but deps_warning present
|
||||
| "error";
|
||||
|
||||
export interface SkillEntry {
|
||||
id: string;
|
||||
dir: string;
|
||||
status: SkillStatus;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
contentHash?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
/** One entry per detected skill in this ZIP */
|
||||
skills: SkillEntry[];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
// Import the functions we'll create/modify
|
||||
import { validateSkillZip, validateMultiSkillZip } from "./validate-skill-zip";
|
||||
|
||||
// Helper: create a ZIP file from an object mapping paths to content
|
||||
async function createTestZip(files: Record<string, string>): Promise<File> {
|
||||
const zip = new JSZip();
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
zip.file(path, content);
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
return new File([blob], "test.zip", { type: "application/zip" });
|
||||
}
|
||||
|
||||
const validFrontmatter = `---
|
||||
name: Test Skill
|
||||
slug: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
# Test Skill Content`;
|
||||
|
||||
const validFrontmatter2 = `---
|
||||
name: Another Skill
|
||||
slug: another-skill
|
||||
description: Another test
|
||||
---
|
||||
# Another Skill`;
|
||||
|
||||
const noNameFrontmatter = `---
|
||||
description: Missing name
|
||||
---
|
||||
# No Name`;
|
||||
|
||||
describe("validateSkillZip (backward compat)", () => {
|
||||
it("validates single-skill ZIP at root", async () => {
|
||||
const file = await createTestZip({ "SKILL.md": validFrontmatter });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.name).toBe("Test Skill");
|
||||
expect(result.slug).toBe("test-skill");
|
||||
});
|
||||
|
||||
it("validates single-skill ZIP in subdirectory", async () => {
|
||||
const file = await createTestZip({ "my-skill/SKILL.md": validFrontmatter });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.name).toBe("Test Skill");
|
||||
});
|
||||
|
||||
it("rejects ZIP without SKILL.md", async () => {
|
||||
const file = await createTestZip({ "readme.txt": "hello" });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("upload.noSkillMd");
|
||||
});
|
||||
|
||||
it("rejects empty SKILL.md", async () => {
|
||||
const file = await createTestZip({ "SKILL.md": " " });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("upload.emptySkillMd");
|
||||
});
|
||||
|
||||
it("rejects missing frontmatter name", async () => {
|
||||
const file = await createTestZip({ "SKILL.md": noNameFrontmatter });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("upload.nameRequired");
|
||||
});
|
||||
|
||||
it("auto-slugifies name when no slug provided", async () => {
|
||||
const fm = `---\nname: My Cool Tool\ndescription: test\n---\n# Content`;
|
||||
const file = await createTestZip({ "SKILL.md": fm });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.slug).toBe("my-cool-tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMultiSkillZip", () => {
|
||||
it("detects multiple skills in ZIP", async () => {
|
||||
const file = await createTestZip({
|
||||
"pdf/SKILL.md": validFrontmatter,
|
||||
"csv/SKILL.md": validFrontmatter2,
|
||||
});
|
||||
const result = await validateMultiSkillZip(file);
|
||||
expect(result.skills).toHaveLength(2);
|
||||
expect(result.skills.map(s => s.slug).sort()).toEqual(["another-skill", "test-skill"]);
|
||||
});
|
||||
|
||||
it("root SKILL.md returns single entry (no subdir scan)", async () => {
|
||||
const file = await createTestZip({
|
||||
"SKILL.md": validFrontmatter,
|
||||
"subdir/SKILL.md": validFrontmatter2,
|
||||
});
|
||||
const result = await validateMultiSkillZip(file);
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0]!.slug).toBe("test-skill");
|
||||
});
|
||||
|
||||
it("computes per-skill SHA-256 contentHash", async () => {
|
||||
const file = await createTestZip({ "skill/SKILL.md": validFrontmatter });
|
||||
const result = await validateMultiSkillZip(file);
|
||||
expect(result.skills[0]!.contentHash).toBeDefined();
|
||||
expect(result.skills[0]!.contentHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("ignores directories without SKILL.md", async () => {
|
||||
const file = await createTestZip({
|
||||
"pdf/SKILL.md": validFrontmatter,
|
||||
"assets/image.png": "binary",
|
||||
});
|
||||
const result = await validateMultiSkillZip(file);
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0]!.dir).toBe("pdf");
|
||||
});
|
||||
|
||||
it("reports per-skill validation errors", async () => {
|
||||
const file = await createTestZip({
|
||||
"good/SKILL.md": validFrontmatter,
|
||||
"bad/SKILL.md": noNameFrontmatter,
|
||||
});
|
||||
const result = await validateMultiSkillZip(file);
|
||||
expect(result.skills).toHaveLength(2);
|
||||
const good = result.skills.find(s => s.dir === "good");
|
||||
const bad = result.skills.find(s => s.dir === "bad");
|
||||
expect(good?.valid).toBe(true);
|
||||
expect(bad?.valid).toBe(false);
|
||||
expect(bad?.error).toBe("upload.nameRequired");
|
||||
});
|
||||
|
||||
it("backward compat: single-skill ZIP returns skills array of 1", async () => {
|
||||
const file = await createTestZip({ "SKILL.md": validFrontmatter });
|
||||
const result = await validateMultiSkillZip(file);
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0]!.valid).toBe(true);
|
||||
expect(result.skills[0]!.name).toBe("Test Skill");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
/** Client-side validation for skill ZIP files before upload.
|
||||
* Mirrors server-side checks in internal/http/skills_upload.go */
|
||||
* Mirrors server-side checks in internal/http/skills_upload.go
|
||||
*
|
||||
* Supports both single-skill ZIPs (SKILL.md at root or one top-level dir)
|
||||
* and multi-skill ZIPs (one SKILL.md per top-level directory). */
|
||||
import JSZip from "jszip";
|
||||
|
||||
export interface SkillZipValidation {
|
||||
@@ -12,70 +15,179 @@ export interface SkillZipValidation {
|
||||
errorDetail?: string;
|
||||
}
|
||||
|
||||
/** Per-skill entry returned by validateMultiSkillZip */
|
||||
export interface SkillValidationEntry {
|
||||
valid: boolean;
|
||||
/** Top-level directory name, or "" for root-level SKILL.md */
|
||||
dir: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
/** SHA-256 hex digest of the SKILL.md content */
|
||||
contentHash?: string;
|
||||
/** i18n key under "upload." namespace */
|
||||
error?: string;
|
||||
errorDetail?: string;
|
||||
}
|
||||
|
||||
export interface MultiSkillZipValidation {
|
||||
skills: SkillValidationEntry[];
|
||||
/** Top-level ZIP error (corrupt file, not a ZIP, too large) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Constants matching server-side (internal/http/skills.go)
|
||||
const MAX_SKILL_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
const MAX_SKILLS_PER_ZIP = 50;
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
/** Validate a skill ZIP file client-side. JSZip is lazy-loaded. */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a skill ZIP file client-side — backward-compatible single-skill path.
|
||||
* Delegates to validateMultiSkillZip and returns the first skill's result.
|
||||
*/
|
||||
export async function validateSkillZip(file: File): Promise<SkillZipValidation> {
|
||||
const multi = await validateMultiSkillZip(file);
|
||||
if (multi.error) return { valid: false, error: multi.error };
|
||||
const first = multi.skills[0];
|
||||
if (!first) return { valid: false, error: "upload.noSkillMd" };
|
||||
return {
|
||||
valid: first.valid,
|
||||
name: first.name,
|
||||
slug: first.slug,
|
||||
description: first.description,
|
||||
error: first.error,
|
||||
errorDetail: first.errorDetail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a ZIP that may contain one or multiple skills.
|
||||
*
|
||||
* Detection logic:
|
||||
* - If SKILL.md exists at root → single-skill mode (root entry, dir="")
|
||||
* - Otherwise → scan each top-level directory for SKILL.md
|
||||
*
|
||||
* Returns one SkillValidationEntry per detected SKILL.md, each independently
|
||||
* validated with a SHA-256 contentHash.
|
||||
*/
|
||||
export async function validateMultiSkillZip(file: File): Promise<MultiSkillZipValidation> {
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return { valid: false, error: "upload.onlyZip" };
|
||||
return { skills: [], error: "upload.onlyZip" };
|
||||
}
|
||||
if (file.size > MAX_SKILL_SIZE) {
|
||||
return { valid: false, error: "upload.tooLarge" };
|
||||
return { skills: [], error: "upload.tooLarge" };
|
||||
}
|
||||
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(file);
|
||||
} catch {
|
||||
return { valid: false, error: "upload.invalidZip" };
|
||||
return { skills: [], error: "upload.invalidZip" };
|
||||
}
|
||||
|
||||
// Find SKILL.md at root or inside single top-level directory
|
||||
const skillMdContent = await findSkillMd(zip);
|
||||
if (skillMdContent === null) {
|
||||
return { valid: false, error: "upload.noSkillMd" };
|
||||
const entries = await findAllSkillMds(zip);
|
||||
if (entries.length === 0) {
|
||||
return { skills: [] };
|
||||
}
|
||||
if (!skillMdContent.trim()) {
|
||||
return { valid: false, error: "upload.emptySkillMd" };
|
||||
if (entries.length > MAX_SKILLS_PER_ZIP) {
|
||||
return { skills: [], error: "upload.tooManySkills" };
|
||||
}
|
||||
|
||||
// Parse frontmatter
|
||||
const match = skillMdContent.match(FRONTMATTER_REGEX);
|
||||
if (!match?.[1]) {
|
||||
return { valid: false, error: "upload.noFrontmatter" };
|
||||
}
|
||||
const fields = parseFrontmatterFields(match[1]);
|
||||
if (!fields.name) {
|
||||
return { valid: false, error: "upload.nameRequired" };
|
||||
}
|
||||
// Validate each skill entry concurrently
|
||||
const skills = await Promise.all(
|
||||
entries.map(({ dir, content }) => validateSkillEntry(dir, content)),
|
||||
);
|
||||
|
||||
const slug = fields.slug || slugify(fields.name);
|
||||
if (!SLUG_REGEX.test(slug)) {
|
||||
return { valid: false, error: "upload.invalidSlug", errorDetail: slug };
|
||||
}
|
||||
|
||||
return { valid: true, name: fields.name, slug, description: fields.description };
|
||||
return { skills };
|
||||
}
|
||||
|
||||
/** Find SKILL.md content — root level or inside a single top-level directory */
|
||||
async function findSkillMd(zip: JSZip): Promise<string | null> {
|
||||
// Try root
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkillMdEntry {
|
||||
dir: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all SKILL.md files in the ZIP.
|
||||
*
|
||||
* Priority rule: if SKILL.md exists at root level, return only that one entry
|
||||
* (single-skill mode). Otherwise collect one per top-level directory.
|
||||
*/
|
||||
async function findAllSkillMds(zip: JSZip): Promise<SkillMdEntry[]> {
|
||||
// Root-level SKILL.md → single-skill mode
|
||||
if (zip.files["SKILL.md"] && !zip.files["SKILL.md"].dir) {
|
||||
return zip.files["SKILL.md"].async("string");
|
||||
const content = await zip.files["SKILL.md"].async("string");
|
||||
return [{ dir: "", content }];
|
||||
}
|
||||
// Try single top-level dir (e.g. "my-skill/SKILL.md")
|
||||
|
||||
// Multi-skill: collect directories that contain a SKILL.md
|
||||
const paths = Object.keys(zip.files);
|
||||
const topDirs = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean));
|
||||
for (const dir of topDirs) {
|
||||
const topDirs = new Set(
|
||||
paths
|
||||
.map((p) => p.split("/")[0])
|
||||
.filter((d): d is string => Boolean(d)),
|
||||
);
|
||||
|
||||
const results: SkillMdEntry[] = [];
|
||||
// Process dirs in stable sorted order for deterministic output
|
||||
for (const dir of [...topDirs].sort()) {
|
||||
const key = dir + "/SKILL.md";
|
||||
if (zip.files[key] && !zip.files[key].dir) {
|
||||
return zip.files[key].async("string");
|
||||
const content = await zip.files[key].async("string");
|
||||
results.push({ dir, content });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Validate a single SKILL.md entry, compute its hash, return SkillValidationEntry */
|
||||
async function validateSkillEntry(dir: string, content: string): Promise<SkillValidationEntry> {
|
||||
if (!content.trim()) {
|
||||
return { valid: false, dir, error: "upload.emptySkillMd" };
|
||||
}
|
||||
|
||||
const match = content.match(FRONTMATTER_REGEX);
|
||||
if (!match?.[1]) {
|
||||
return { valid: false, dir, error: "upload.noFrontmatter" };
|
||||
}
|
||||
|
||||
const fields = parseFrontmatterFields(match[1]);
|
||||
if (!fields.name) {
|
||||
return { valid: false, dir, error: "upload.nameRequired" };
|
||||
}
|
||||
|
||||
const slug = fields.slug ?? slugify(fields.name);
|
||||
if (!SLUG_REGEX.test(slug)) {
|
||||
return { valid: false, dir, error: "upload.invalidSlug", errorDetail: slug };
|
||||
}
|
||||
|
||||
const contentHash = await hashContent(content);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
dir,
|
||||
name: fields.name,
|
||||
slug,
|
||||
description: fields.description,
|
||||
contentHash,
|
||||
};
|
||||
}
|
||||
|
||||
/** SHA-256 hex digest using Web Crypto API */
|
||||
async function hashContent(content: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(content);
|
||||
const buf = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Simple key: value parser matching server's parseSkillFrontmatter() */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Upload, CheckCircle2, XCircle, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,20 +10,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { validateSkillZip } from "./lib/validate-skill-zip";
|
||||
import { validateMultiSkillZip } from "./lib/validate-skill-zip";
|
||||
import { createSkillSubZip } from "./lib/create-skill-sub-zip";
|
||||
import { uniqueId } from "@/lib/utils";
|
||||
import type { SkillUploadResponse } from "./hooks/use-skills";
|
||||
|
||||
type FileStatus = "validating" | "valid" | "invalid" | "uploading" | "success" | "warning" | "error";
|
||||
|
||||
interface FileEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
status: FileStatus;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
error?: string;
|
||||
}
|
||||
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
|
||||
import { FileEntryBlock } from "./skill-upload-entry";
|
||||
import JSZip from "jszip";
|
||||
|
||||
interface SkillUploadDialogProps {
|
||||
open: boolean;
|
||||
@@ -39,43 +32,68 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
const [done, setDone] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addFiles = async (fileList: FileList) => {
|
||||
const newFiles = Array.from(fileList);
|
||||
|
||||
// Build pending list before setState — no side effects inside updater
|
||||
const existingNames = new Set(entries.map((e) => e.file.name));
|
||||
const fresh = newFiles.filter((f) => !existingNames.has(f.name));
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
// Add placeholder entries with validating status
|
||||
const pending: FileEntry[] = fresh.map((f) => ({
|
||||
id: uniqueId(),
|
||||
file: f,
|
||||
status: "validating" as const,
|
||||
skills: [{ id: uniqueId(), dir: "", status: "validating" as const }],
|
||||
}));
|
||||
setEntries((prev) => [...prev, ...pending]);
|
||||
|
||||
// Validate all files concurrently, catch per-file errors
|
||||
// Validate all files concurrently
|
||||
const results = await Promise.all(
|
||||
pending.map(async (entry) => {
|
||||
try {
|
||||
return { id: entry.id, result: await validateSkillZip(entry.file) };
|
||||
const validation = await validateMultiSkillZip(entry.file);
|
||||
const placeholderId = entry.skills[0]?.id ?? uniqueId();
|
||||
if (validation.error) {
|
||||
return {
|
||||
id: entry.id,
|
||||
skills: [{ id: placeholderId, dir: "", status: "invalid" as SkillStatus, error: validation.error }],
|
||||
};
|
||||
}
|
||||
if (validation.skills.length === 0) {
|
||||
return {
|
||||
id: entry.id,
|
||||
skills: [{ id: placeholderId, dir: "", status: "invalid" as SkillStatus, error: "upload.noSkillMd" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
skills: validation.skills.map((s) => ({
|
||||
id: uniqueId(),
|
||||
dir: s.dir,
|
||||
status: s.valid ? ("valid" as SkillStatus) : ("invalid" as SkillStatus),
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
contentHash: s.contentHash,
|
||||
error: s.error,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { id: entry.id, result: { valid: false, error: "upload.invalidZip" } as const };
|
||||
return {
|
||||
id: entry.id,
|
||||
skills: [{ id: entry.skills[0]?.id ?? uniqueId(), dir: "", status: "invalid" as SkillStatus, error: "upload.invalidZip" }],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => {
|
||||
const match = results.find((r) => r.id === e.id);
|
||||
if (!match) return e;
|
||||
const { result } = match;
|
||||
return {
|
||||
...e,
|
||||
status: result.valid ? "valid" : "invalid",
|
||||
name: "name" in result ? result.name : undefined,
|
||||
slug: "slug" in result ? result.slug : undefined,
|
||||
error: result.error,
|
||||
};
|
||||
return match ? { ...e, skills: match.skills } : e;
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -84,43 +102,112 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload — parse each ZIP once, reuse across all skills in that file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validEntries = entries.filter((e) => e.status === "valid");
|
||||
if (validEntries.length === 0) return;
|
||||
const actionable = entries.flatMap((e) =>
|
||||
e.skills
|
||||
.filter((s) => s.status === "valid")
|
||||
.map((s) => ({ fileEntry: e, skill: s })),
|
||||
);
|
||||
if (actionable.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
for (const entry of validEntries) {
|
||||
// Cache parsed JSZip instances keyed by FileEntry id — avoids re-parsing
|
||||
// the same ZIP blob for every skill in a multi-skill archive (O(N) vs O(N*M)).
|
||||
const parsedZips = new Map<string, JSZip>();
|
||||
|
||||
for (const { fileEntry, skill } of actionable) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, status: "uploading" } : e)),
|
||||
prev.map((e) =>
|
||||
e.id === fileEntry.id
|
||||
? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "uploading" as SkillStatus } : s) }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await onUpload(entry.file);
|
||||
const detail = result.deps_warning
|
||||
let uploadFile: File;
|
||||
if (skill.dir && fileEntry.skills.length > 1) {
|
||||
// Parse the ZIP once per FileEntry; reuse on subsequent skills
|
||||
if (!parsedZips.has(fileEntry.id)) {
|
||||
parsedZips.set(fileEntry.id, await JSZip.loadAsync(fileEntry.file));
|
||||
}
|
||||
uploadFile = await createSkillSubZip(parsedZips.get(fileEntry.id)!, skill.dir);
|
||||
} else {
|
||||
uploadFile = fileEntry.file;
|
||||
}
|
||||
|
||||
const result = await onUpload(uploadFile);
|
||||
|
||||
if (result.status === "unchanged") {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === fileEntry.id
|
||||
? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "unchanged" as SkillStatus } : s) }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const depDetail = result.deps_warning
|
||||
? result.deps_errors?.length
|
||||
? `${result.deps_warning}: ${result.deps_errors.join("; ")}`
|
||||
: result.deps_warning
|
||||
: undefined;
|
||||
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === entry.id ? {
|
||||
...e,
|
||||
status: result.deps_warning ? "warning" : "success",
|
||||
error: detail,
|
||||
} : e)),
|
||||
prev.map((e) =>
|
||||
e.id === fileEntry.id
|
||||
? {
|
||||
...e,
|
||||
skills: e.skills.map((s) =>
|
||||
s.id === skill.id
|
||||
? {
|
||||
...s,
|
||||
status: result.deps_warning ? ("warning" as SkillStatus) : ("success" as SkillStatus),
|
||||
error: depDetail,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === entry.id
|
||||
? { ...e, status: "error", error: err instanceof Error ? err.message : t("upload.failed") }
|
||||
e.id === fileEntry.id
|
||||
? {
|
||||
...e,
|
||||
skills: e.skills.map((s) =>
|
||||
s.id === skill.id
|
||||
? {
|
||||
...s,
|
||||
status: "error" as SkillStatus,
|
||||
error: err instanceof Error ? err.message : t("upload.failed"),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog housekeeping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleClose = (v: boolean) => {
|
||||
if (uploading) return;
|
||||
setEntries([]);
|
||||
@@ -132,21 +219,25 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
addFiles(e.target.files);
|
||||
}
|
||||
// Reset so same files can be re-selected
|
||||
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
const validCount = entries.filter((e) => e.status === "valid").length;
|
||||
const successCount = entries.filter((e) => e.status === "success" || e.status === "warning").length;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived counts (skill level, not file level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const allSkills = entries.flatMap((e) => e.skills);
|
||||
const actionableCount = allSkills.filter((s) => s.status === "valid").length;
|
||||
const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -156,7 +247,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
<DialogDescription>{t("upload.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Drop zone — always visible unless uploading/done */}
|
||||
{/* Drop zone — hidden once upload starts or finishes */}
|
||||
{!uploading && !done && (
|
||||
<div
|
||||
role="button"
|
||||
@@ -165,7 +256,12 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
dragging ? "border-primary bg-primary/5" : "hover:border-primary/50"
|
||||
}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
@@ -186,11 +282,11 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{/* File + skill list */}
|
||||
{entries.length > 0 && (
|
||||
<div className="flex flex-col gap-1 overflow-y-auto max-h-[40dvh]">
|
||||
{entries.map((entry) => (
|
||||
<FileEntryRow
|
||||
<FileEntryBlock
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onRemove={() => removeEntry(entry.id)}
|
||||
@@ -201,15 +297,15 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{/* Summary line */}
|
||||
{entries.length > 0 && !done && !uploading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("upload.validCount", { valid: validCount, total: entries.length })}
|
||||
{t("upload.validCount", { valid: actionableCount, total: allSkills.length })}
|
||||
</p>
|
||||
)}
|
||||
{done && (
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("upload.successCount", { success: successCount, total: entries.length })}
|
||||
{t("upload.successCount", { success: successCount, total: allSkills.filter((s) => s.status !== "unchanged" && s.status !== "invalid").length })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -220,10 +316,10 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
{done ? (
|
||||
<Button onClick={() => handleClose(false)}>{t("upload.done")}</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={validCount === 0 || uploading}>
|
||||
<Button onClick={handleSubmit} disabled={actionableCount === 0 || uploading}>
|
||||
{uploading
|
||||
? t("upload.uploading")
|
||||
: t("upload.uploadCount", { count: validCount })}
|
||||
: t("upload.uploadCount", { count: actionableCount })}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -231,69 +327,3 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single file entry row with status icon, name, and remove button */
|
||||
function FileEntryRow({
|
||||
entry,
|
||||
onRemove,
|
||||
uploading,
|
||||
t,
|
||||
}: {
|
||||
entry: FileEntry;
|
||||
onRemove: () => void;
|
||||
uploading: boolean;
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const sizeKB = (entry.file.size / 1024).toFixed(1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<StatusIcon status={entry.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{entry.name || entry.file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{sizeKB} KB</span>
|
||||
</div>
|
||||
{entry.status === "invalid" || entry.status === "error" ? (
|
||||
<p className="text-xs text-destructive truncate">{entry.error ? t(entry.error) : t("upload.failed")}</p>
|
||||
) : entry.status === "warning" ? (
|
||||
<p className="text-xs text-amber-600 truncate">{entry.error ?? t("upload.failed")}</p>
|
||||
) : entry.status === "validating" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("upload.validating")}</p>
|
||||
) : entry.name && entry.status !== "success" ? (
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.file.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Remove button — hidden during upload and for completed entries */}
|
||||
{!uploading && entry.status !== "uploading" && entry.status !== "success" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("upload.remove")}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="shrink-0 rounded-sm p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: FileStatus }) {
|
||||
switch (status) {
|
||||
case "validating":
|
||||
case "uploading":
|
||||
return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||
case "valid":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />;
|
||||
case "warning":
|
||||
return <TriangleAlert className="h-4 w-4 shrink-0 text-amber-600" />;
|
||||
case "invalid":
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/** Sub-components for rendering individual file entries and skill rows in the upload dialog. */
|
||||
import { CheckCircle2, XCircle, Loader2, TriangleAlert, X, Package } from "lucide-react";
|
||||
import type { FileEntry, SkillEntry, SkillStatus } from "./lib/skill-upload-types";
|
||||
|
||||
type TFunc = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileEntryBlock — renders a ZIP file with its skill rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FileEntryBlock({
|
||||
entry,
|
||||
onRemove,
|
||||
uploading,
|
||||
t,
|
||||
}: {
|
||||
entry: FileEntry;
|
||||
onRemove: () => void;
|
||||
uploading: boolean;
|
||||
t: TFunc;
|
||||
}) {
|
||||
const isMulti = entry.skills.length > 1;
|
||||
const sizeKB = (entry.file.size / 1024).toFixed(1);
|
||||
const isValidating = entry.skills.some((s) => s.status === "validating");
|
||||
|
||||
if (!isMulti) {
|
||||
// Single-skill: one row, ZIP filename as subtitle
|
||||
const skill = entry.skills[0]!;
|
||||
return (
|
||||
<SkillRow
|
||||
skill={skill}
|
||||
subtitle={skill.name ? entry.file.name : undefined}
|
||||
primaryLabel={skill.name || entry.file.name}
|
||||
sizeKB={sizeKB}
|
||||
showSize
|
||||
onRemove={onRemove}
|
||||
uploading={uploading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-skill: group header + individual skill rows
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{/* ZIP group header */}
|
||||
<div className="flex items-center gap-2 bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<Package className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate font-medium">{entry.file.name}</span>
|
||||
<span className="shrink-0">{sizeKB} KB</span>
|
||||
{isValidating ? null : (
|
||||
<span className="shrink-0">
|
||||
{t("upload.multiDetected", { count: entry.skills.length })}
|
||||
</span>
|
||||
)}
|
||||
{!uploading && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("upload.remove")}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="shrink-0 rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Individual skill rows — indented */}
|
||||
<div className="flex flex-col divide-y">
|
||||
{entry.skills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
primaryLabel={skill.name || skill.dir || skill.slug || "…"}
|
||||
subtitle={skill.dir || undefined}
|
||||
showSize={false}
|
||||
onRemove={undefined}
|
||||
uploading={uploading}
|
||||
t={t}
|
||||
indent
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillRow — single skill entry row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SkillRow({
|
||||
skill,
|
||||
primaryLabel,
|
||||
subtitle,
|
||||
sizeKB,
|
||||
showSize,
|
||||
onRemove,
|
||||
uploading,
|
||||
t,
|
||||
indent = false,
|
||||
}: {
|
||||
skill: SkillEntry;
|
||||
primaryLabel: string;
|
||||
subtitle?: string;
|
||||
sizeKB?: string;
|
||||
showSize: boolean;
|
||||
onRemove?: () => void;
|
||||
uploading: boolean;
|
||||
t: TFunc;
|
||||
indent?: boolean;
|
||||
}) {
|
||||
const canRemove = !uploading && skill.status !== "uploading" && skill.status !== "success" && onRemove;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-2 text-sm ${indent ? "pl-6" : "rounded-md border"}`}>
|
||||
<SkillStatusIcon status={skill.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{primaryLabel}</span>
|
||||
<SkillBadge status={skill.status} t={t} />
|
||||
{showSize && sizeKB && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{sizeKB} KB</span>
|
||||
)}
|
||||
</div>
|
||||
<SkillSubtitle skill={skill} subtitle={subtitle} t={t} />
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("upload.remove")}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="shrink-0 rounded-sm p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillSubtitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SkillSubtitle({
|
||||
skill,
|
||||
subtitle,
|
||||
t,
|
||||
}: {
|
||||
skill: SkillEntry;
|
||||
subtitle?: string;
|
||||
t: TFunc;
|
||||
}) {
|
||||
if (skill.status === "invalid" || skill.status === "error") {
|
||||
return (
|
||||
<p className="text-xs text-destructive truncate">
|
||||
{skill.error ? t(skill.error) : t("upload.failed")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (skill.status === "warning") {
|
||||
return <p className="text-xs text-amber-600 truncate">{skill.error ?? t("upload.failed")}</p>;
|
||||
}
|
||||
if (skill.status === "validating") {
|
||||
return <p className="text-xs text-muted-foreground">{t("upload.validating")}</p>;
|
||||
}
|
||||
if (skill.status === "unchanged") {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t("upload.skillUnchanged")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (subtitle && skill.status !== "success") {
|
||||
return <p className="text-xs text-muted-foreground truncate">{subtitle}</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillBadge — colored badge for NEW / UNCHANGED / ERROR states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SkillBadge({ status, t }: { status: SkillStatus; t: TFunc }) {
|
||||
const base = "shrink-0 text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded";
|
||||
switch (status) {
|
||||
case "valid":
|
||||
return <span className={`${base} bg-green-50 text-green-700 border border-green-200`}>{t("upload.new")}</span>;
|
||||
case "unchanged":
|
||||
return <span className={`${base} bg-purple-50 text-purple-700 border border-purple-200`}>{t("upload.unchanged")}</span>;
|
||||
case "invalid":
|
||||
case "error":
|
||||
return <span className={`${base} bg-red-50 text-red-700 border border-red-200`}>{t("upload.failed")}</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillStatusIcon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SkillStatusIcon({ status }: { status: SkillStatus }) {
|
||||
switch (status) {
|
||||
case "validating":
|
||||
case "uploading":
|
||||
return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||
case "valid":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />;
|
||||
case "unchanged":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />;
|
||||
case "warning":
|
||||
return <TriangleAlert className="h-4 w-4 shrink-0 text-amber-600" />;
|
||||
case "invalid":
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// jsdom provides browser globals: crypto.subtle, Blob, File, TextEncoder
|
||||
environment: "jsdom",
|
||||
globals: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||