mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 16:21:23 +00:00
584 lines
18 KiB
Go
584 lines
18 KiB
Go
package http
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
var (
|
|
errSkillEvolutionSkillNotFound = errors.New("skill not found")
|
|
errSkillEvolutionSystemMutation = errors.New("system skill mutation is blocked")
|
|
errSkillEvolutionInvalidDraftPatch = errors.New("invalid draft_patch")
|
|
errSkillEvolutionDraftPatchRequired = errors.New("draft_patch requires content or find/replace")
|
|
errSkillEvolutionFindTextNotFound = errors.New("find text not found in target file")
|
|
errSkillEvolutionGuardPolicyViolation = errors.New("skill guard policy violation")
|
|
)
|
|
|
|
type skillEvolutionPatchRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
|
|
type skillSuggestionCreateRequest struct {
|
|
SuggestionType string `json:"suggestion_type"`
|
|
Reason string `json:"reason"`
|
|
Evidence json.RawMessage `json:"evidence"`
|
|
DraftPatch json.RawMessage `json:"draft_patch"`
|
|
TargetFile string `json:"target_file"`
|
|
}
|
|
|
|
type skillSuggestionApplyRequest struct {
|
|
Approve bool `json:"approve"`
|
|
}
|
|
|
|
type skillDraftPatch struct {
|
|
Find string `json:"find"`
|
|
Replace string `json:"replace"`
|
|
Content *string `json:"content"`
|
|
}
|
|
|
|
func (h *SkillsHandler) evolutionConfigured(w http.ResponseWriter, r *http.Request) bool {
|
|
if h.evolutionStore == nil {
|
|
writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgSkillEvolutionNotConfigured)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeSkillEvolutionError(w http.ResponseWriter, r *http.Request, status int, key string, args ...any) {
|
|
writeJSON(w, status, map[string]string{"error": i18n.T(store.LocaleFromContext(r.Context()), key, args...)})
|
|
}
|
|
|
|
func (h *SkillsHandler) skillIDFromRequest(w http.ResponseWriter, r *http.Request) (uuid.UUID, store.SkillInfo, bool) {
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "skill")
|
|
return uuid.Nil, store.SkillInfo{}, false
|
|
}
|
|
info, ok := h.skills.GetSkillByID(r.Context(), id)
|
|
if !ok {
|
|
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "skill", id.String())
|
|
return uuid.Nil, store.SkillInfo{}, false
|
|
}
|
|
return id, info, true
|
|
}
|
|
|
|
func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Request) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
settings, err := h.evolutionStore.GetSettings(r.Context(), skillID)
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Request) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req skillEvolutionPatchRequest
|
|
if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) {
|
|
return
|
|
}
|
|
current, err := h.evolutionStore.GetSettings(r.Context(), skillID)
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
enabled := current.Enabled
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
mode := strings.TrimSpace(req.Mode)
|
|
if mode == "" {
|
|
mode = current.Mode
|
|
}
|
|
if mode == "" {
|
|
mode = store.SkillEvolutionModeSuggestOnly
|
|
}
|
|
if mode != store.SkillEvolutionModeSuggestOnly && mode != store.SkillEvolutionModeAutoAnalyze {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidEvolutionMode)
|
|
return
|
|
}
|
|
updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{
|
|
SkillID: skillID,
|
|
Enabled: enabled,
|
|
Mode: mode,
|
|
})
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
action := "skill.evolve.mode_updated"
|
|
if req.Enabled != nil {
|
|
if enabled {
|
|
action = "skill.evolve.enabled"
|
|
} else {
|
|
action = "skill.evolve.disabled"
|
|
}
|
|
}
|
|
h.logSkillActivity(r, action, skillID, map[string]any{"enabled": enabled, "mode": mode})
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Request) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var since *time.Time
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
|
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
since = &t
|
|
}
|
|
}
|
|
stats, err := h.evolutionStore.AggregateUsage(r.Context(), skillID, since)
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) {
|
|
stats.TopFailureReasons = nil
|
|
}
|
|
writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
func (h *SkillsHandler) handleGetSkillActivity(w http.ResponseWriter, r *http.Request) {
|
|
if h.activityStore == nil {
|
|
writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgActivityStoreNotConfigured)
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := h.activityStore.List(r.Context(), store.ActivityListOpts{
|
|
EntityType: "skill",
|
|
EntityID: skillID.String(),
|
|
Limit: clampLimit(r.URL.Query().Get("limit"), 50, 200),
|
|
})
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"activity": items})
|
|
}
|
|
|
|
func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *http.Request) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := h.evolutionStore.ListSuggestions(r.Context(), skillID, r.URL.Query().Get("status"), clampLimit(r.URL.Query().Get("limit"), 50, 200))
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) {
|
|
for i := range items {
|
|
items[i].Evidence = nil
|
|
items[i].DraftPatch = nil
|
|
items[i].CreatedByActorID = ""
|
|
items[i].ReviewedByActorID = ""
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": items})
|
|
}
|
|
|
|
func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req skillSuggestionCreateRequest
|
|
if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.SuggestionType) == "" {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgRequired, "suggestion_type")
|
|
return
|
|
}
|
|
target := strings.TrimSpace(req.TargetFile)
|
|
if target != "" {
|
|
clean, err := skills.ValidateSkillTargetPath(target, true)
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
target = clean
|
|
}
|
|
created, err := h.evolutionStore.CreateSuggestion(r.Context(), store.SkillImprovementSuggestion{
|
|
SkillID: skillID,
|
|
SuggestionType: req.SuggestionType,
|
|
Reason: req.Reason,
|
|
Evidence: req.Evidence,
|
|
DraftPatch: req.DraftPatch,
|
|
TargetFile: target,
|
|
CreatedByActorType: "user",
|
|
CreatedByActorID: store.ActorIDFromContext(r.Context()),
|
|
})
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
h.logSkillActivity(r, "skill.suggestion.created", skillID, map[string]any{"suggestion_id": created.ID.String(), "type": created.SuggestionType})
|
|
writeJSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
func (h *SkillsHandler) handleApproveSkillSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusApproved, "skill.suggestion.approved")
|
|
}
|
|
|
|
func (h *SkillsHandler) handleRejectSkillSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusRejected, "skill.suggestion.rejected")
|
|
}
|
|
|
|
func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Request, status, action string) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, _, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion")
|
|
return
|
|
}
|
|
sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID)
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
if sg == nil || sg.SkillID != skillID {
|
|
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String())
|
|
return
|
|
}
|
|
updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context()))
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
h.logSkillActivity(r, action, skillID, map[string]any{"suggestion_id": suggestionID.String()})
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
if !h.evolutionConfigured(w, r) {
|
|
return
|
|
}
|
|
skillID, info, ok := h.skillIDFromRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if info.IsSystem {
|
|
writeSkillEvolutionError(w, r, http.StatusForbidden, i18n.MsgSystemSkillMutationBlocked)
|
|
return
|
|
}
|
|
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion")
|
|
return
|
|
}
|
|
var req skillSuggestionApplyRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID)
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
if sg == nil || sg.SkillID != skillID {
|
|
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String())
|
|
return
|
|
}
|
|
if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil {
|
|
writeJSON(w, http.StatusOK, sg)
|
|
return
|
|
}
|
|
if sg.Status != store.SkillSuggestionStatusApproved {
|
|
if !req.Approve {
|
|
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgSuggestionMustBeApproved)
|
|
return
|
|
}
|
|
sg, err = h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, store.SkillSuggestionStatusApproved, "user", store.ActorIDFromContext(r.Context()))
|
|
if err != nil {
|
|
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
|
|
return
|
|
}
|
|
}
|
|
applied, err := h.applySkillSuggestionPatch(r, skillID, sg)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": h.skillEvolutionApplyErrorMessage(r, skillID, err)})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, applied)
|
|
}
|
|
|
|
func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid.UUID, sg *store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) {
|
|
currentDir, slug, oldVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), skillID)
|
|
if !ok {
|
|
return nil, errSkillEvolutionSkillNotFound
|
|
}
|
|
if isSystem {
|
|
return nil, errSkillEvolutionSystemMutation
|
|
}
|
|
target := sg.TargetFile
|
|
if strings.TrimSpace(target) == "" {
|
|
target = "SKILL.md"
|
|
}
|
|
cleanTarget, err := skills.ValidateSkillTargetPath(target, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var patch skillDraftPatch
|
|
if len(sg.DraftPatch) > 0 {
|
|
if err := json.Unmarshal(sg.DraftPatch, &patch); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", errSkillEvolutionInvalidDraftPatch, err)
|
|
}
|
|
}
|
|
if patch.Content == nil && patch.Find == "" {
|
|
return nil, errSkillEvolutionDraftPatchRequired
|
|
}
|
|
newVersion, commitLock, err := h.skills.GetNextVersionLocked(r.Context(), slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer commitLock() //nolint:errcheck
|
|
|
|
destDir := filepath.Join(h.tenantSkillsDir(r), slug, fmt.Sprintf("%d", newVersion))
|
|
tmpDir := destDir + ".tmp-" + uuid.NewString()
|
|
if err := copyDir(currentDir, tmpDir); err != nil {
|
|
return nil, fmt.Errorf("stage version: %w", err)
|
|
}
|
|
removeDestOnError := true
|
|
defer func() {
|
|
_ = os.RemoveAll(tmpDir)
|
|
if removeDestOnError {
|
|
_ = os.RemoveAll(destDir)
|
|
}
|
|
}()
|
|
|
|
targetPath := filepath.Join(tmpDir, filepath.FromSlash(cleanTarget))
|
|
var nextContent string
|
|
if patch.Content != nil {
|
|
nextContent = *patch.Content
|
|
} else {
|
|
currentBytes, err := os.ReadFile(targetPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read target file: %w", err)
|
|
}
|
|
nextContent = string(currentBytes)
|
|
replaced := strings.Replace(nextContent, patch.Find, patch.Replace, 1)
|
|
if replaced == nextContent {
|
|
return nil, errSkillEvolutionFindTextNotFound
|
|
}
|
|
nextContent = replaced
|
|
}
|
|
if cleanTarget == "SKILL.md" {
|
|
violations, safe := skills.GuardSkillContent(nextContent)
|
|
if !safe {
|
|
return nil, fmt.Errorf("%w: %s", errSkillEvolutionGuardPolicyViolation, skills.FormatGuardViolations(violations))
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("create target directory: %w", err)
|
|
}
|
|
if err := os.WriteFile(targetPath, []byte(nextContent), 0644); err != nil {
|
|
return nil, fmt.Errorf("write target file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpDir, destDir); err != nil {
|
|
return nil, fmt.Errorf("commit version files: %w", err)
|
|
}
|
|
|
|
hash, size, err := hashSkillDir(destDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := h.skills.UpdateSkill(r.Context(), skillID, map[string]any{
|
|
"version": newVersion,
|
|
"file_path": destDir,
|
|
"file_size": size,
|
|
"file_hash": &hash,
|
|
"updated_at": time.Now(),
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("update skill: %w", err)
|
|
}
|
|
// The active skill row now points at destDir; keep files even if
|
|
// version history or suggestion metadata fails after this point.
|
|
removeDestOnError = false
|
|
changedFiles, _ := json.Marshal([]string{cleanTarget})
|
|
if _, err := h.evolutionStore.CreateSkillVersion(r.Context(), store.SkillVersion{
|
|
SkillID: skillID,
|
|
Version: newVersion,
|
|
ContentHash: hash,
|
|
ChangedFiles: changedFiles,
|
|
CreatedByActorType: "user",
|
|
CreatedByActorID: store.ActorIDFromContext(r.Context()),
|
|
CreatedFromSuggestionID: &sg.ID,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("record skill version: %w", err)
|
|
}
|
|
applied, err := h.evolutionStore.MarkSuggestionApplied(r.Context(), sg.ID, newVersion, "user", store.ActorIDFromContext(r.Context()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.logSkillActivity(r, "skill.suggestion.applied", skillID, map[string]any{
|
|
"suggestion_id": sg.ID.String(),
|
|
"changed_files": []string{cleanTarget},
|
|
"old_version": oldVersion,
|
|
"new_version": newVersion,
|
|
"content_hash": hash,
|
|
})
|
|
return applied, nil
|
|
}
|
|
|
|
func (h *SkillsHandler) skillEvolutionApplyErrorMessage(r *http.Request, skillID uuid.UUID, err error) string {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
switch {
|
|
case errors.Is(err, errSkillEvolutionSkillNotFound):
|
|
return i18n.T(locale, i18n.MsgNotFound, "skill", skillID.String())
|
|
case errors.Is(err, errSkillEvolutionSystemMutation):
|
|
return i18n.T(locale, i18n.MsgSystemSkillMutationBlocked)
|
|
case errors.Is(err, errSkillEvolutionInvalidDraftPatch):
|
|
return i18n.T(locale, i18n.MsgInvalidDraftPatch, strings.TrimPrefix(err.Error(), errSkillEvolutionInvalidDraftPatch.Error()+": "))
|
|
case errors.Is(err, errSkillEvolutionDraftPatchRequired):
|
|
return i18n.T(locale, i18n.MsgDraftPatchRequired)
|
|
case errors.Is(err, errSkillEvolutionFindTextNotFound):
|
|
return i18n.T(locale, i18n.MsgFindTextNotFound)
|
|
case errors.Is(err, errSkillEvolutionGuardPolicyViolation):
|
|
return i18n.T(locale, i18n.MsgInvalidRequest, strings.TrimPrefix(err.Error(), errSkillEvolutionGuardPolicyViolation.Error()+": "))
|
|
default:
|
|
return i18n.T(locale, i18n.MsgInvalidRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
func (h *SkillsHandler) logSkillActivity(r *http.Request, action string, skillID uuid.UUID, details map[string]any) {
|
|
if h.activityStore == nil {
|
|
return
|
|
}
|
|
raw, _ := json.Marshal(details)
|
|
_ = h.activityStore.Log(r.Context(), &store.ActivityLog{
|
|
ActorType: "user",
|
|
ActorID: store.ActorIDFromContext(r.Context()),
|
|
Action: action,
|
|
EntityType: "skill",
|
|
EntityID: skillID.String(),
|
|
Details: raw,
|
|
IPAddress: r.RemoteAddr,
|
|
})
|
|
}
|
|
|
|
func clampLimit(raw string, fallback, max int) int {
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
|
|
return fallback
|
|
}
|
|
if n > max {
|
|
return max
|
|
}
|
|
return n
|
|
}
|
|
|
|
func copyDir(src, dst string) error {
|
|
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if d.IsDir() {
|
|
return os.MkdirAll(target, 0755)
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
in, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func hashSkillDir(dir string) (string, int64, error) {
|
|
var size int64
|
|
h := sha256.New()
|
|
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || d.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
size += info.Size()
|
|
rel, _ := filepath.Rel(dir, path)
|
|
h.Write([]byte(filepath.ToSlash(rel)))
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.Write(data)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), size, nil
|
|
}
|