diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..55fa321 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,290 @@ +package profile + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// 格式版本号,用于向后兼容检查 +const CurrentVersion = 1 + +// 名称合法性正则:1-64 字符,仅允许字母、数字、下划线、连字符 +var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +// Profile 环境配置文件,控制哪些采集器启用及其过滤规则 +type Profile struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Version int `yaml:"version"` + Collectors CollectorConfig `yaml:"collectors"` + Settings map[string]SettingMap `yaml:"settings,omitempty"` +} + +// CollectorConfig 采集器启用/禁用配置 +type CollectorConfig struct { + Enabled []string `yaml:"enabled,omitempty"` // 启用列表(空=全部启用) + Disabled []string `yaml:"disabled,omitempty"` // 显式禁用列表 +} + +// SettingMap 采集器自定义选项(键值对) +type SettingMap map[string]interface{} + +// ProfileMeta 列出 Profile 时的摘要信息 +type ProfileMeta struct { + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` + ModTime time.Time `json:"mod_time"` + Collectors int `json:"collectors"` // 启用的采集器数量 +} + +// ---------------------------------------------------------------- +// 验证 +// ---------------------------------------------------------------- + +// Validate 校验 Profile 的完整性和合法性 +func (p *Profile) Validate() error { + // 名称非空且合法 + if !nameRegex.MatchString(p.Name) { + return fmt.Errorf("E1010: Profile 名称 %q 非法,仅允许 1-64 个字母、数字、下划线或连字符", p.Name) + } + + // 版本兼容 + if p.Version < 1 || p.Version > CurrentVersion { + return fmt.Errorf("E1016: Profile 版本 %d 不兼容,当前支持最高版本 %d", p.Version, CurrentVersion) + } + + // enabled 与 disabled 不能有交集 + if len(p.Collectors.Enabled) > 0 && len(p.Collectors.Disabled) > 0 { + enabledSet := make(map[string]struct{}, len(p.Collectors.Enabled)) + for _, name := range p.Collectors.Enabled { + enabledSet[name] = struct{}{} + } + for _, name := range p.Collectors.Disabled { + if _, ok := enabledSet[name]; ok { + return fmt.Errorf("E1013: 采集器 %q 同时出现在 enabled 和 disabled 列表中", name) + } + } + } + + return nil +} + +// IsCollectorEnabled 判断指定采集器在此 Profile 下是否启用 +func (p *Profile) IsCollectorEnabled(name string) bool { + // 检查是否显式禁用 + for _, d := range p.Collectors.Disabled { + if d == name { + return false + } + } + // 如果 enabled 列表非空,只有列表中的才启用 + if len(p.Collectors.Enabled) > 0 { + for _, e := range p.Collectors.Enabled { + if e == name { + return true + } + } + return false + } + // enabled 为空表示全部启用 + return true +} + +// GetSetting 获取指定采集器的某个设置项 +func (p *Profile) GetSetting(collector, key string) (interface{}, bool) { + sm, ok := p.Settings[collector] + if !ok { + return nil, false + } + val, ok := sm[key] + return val, ok +} + +// GetSettingBool 获取布尔类型设置项,不存在时返回 defaultVal +func (p *Profile) GetSettingBool(collector, key string, defaultVal bool) bool { + val, ok := p.GetSetting(collector, key) + if !ok { + return defaultVal + } + if b, ok := val.(bool); ok { + return b + } + return defaultVal +} + +// GetSettingStrings 获取字符串数组类型设置项 +func (p *Profile) GetSettingStrings(collector, key string) []string { + val, ok := p.GetSetting(collector, key) + if !ok { + return nil + } + switch v := val.(type) { + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + case []string: + return v + default: + return nil + } +} + +// ---------------------------------------------------------------- +// CRUD 操作 +// ---------------------------------------------------------------- + +// Load 从指定路径加载 Profile 文件 +func Load(path string) (*Profile, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("打开 Profile 失败: %w", err) + } + defer f.Close() + + p := &Profile{} + if err := yaml.NewDecoder(f).Decode(p); err != nil { + return nil, fmt.Errorf("解析 Profile 失败: %w", err) + } + + if err := p.Validate(); err != nil { + return nil, err + } + + return p, nil +} + +// LoadByName 根据名称从 profiles 目录加载 Profile +func LoadByName(profilesDir, name string) (*Profile, error) { + path := filepath.Join(profilesDir, name+".yaml") + return Load(path) +} + +// Save 将 Profile 保存到指定路径(原子写入:先写临时文件再重命名) +func Save(path string, p *Profile) error { + if err := p.Validate(); err != nil { + return err + } + + // 确保目录存在 + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建目录失败: %w", err) + } + + // 写入临时文件 + tmpPath := path + ".tmp" + f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("创建临时文件失败: %w", err) + } + + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + if err := enc.Encode(p); err != nil { + f.Close() + os.Remove(tmpPath) + return fmt.Errorf("序列化 Profile 失败: %w", err) + } + if err := enc.Close(); err != nil { + f.Close() + os.Remove(tmpPath) + return fmt.Errorf("关闭编码器失败: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("关闭文件失败: %w", err) + } + + // 原子重命名 + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("重命名文件失败: %w", err) + } + + return nil +} + +// SaveByName 根据名称保存到 profiles 目录 +func SaveByName(profilesDir string, p *Profile) error { + path := filepath.Join(profilesDir, p.Name+".yaml") + return Save(path, p) +} + +// Delete 删除指定名称的 Profile 文件 +func Delete(profilesDir, name string) error { + path := filepath.Join(profilesDir, name+".yaml") + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("Profile %q 不存在", name) + } + return os.Remove(path) +} + +// Exists 检查指定名称的 Profile 是否存在 +func Exists(profilesDir, name string) bool { + path := filepath.Join(profilesDir, name+".yaml") + _, err := os.Stat(path) + return err == nil +} + +// List 列出目录下所有 Profile 摘要信息 +func List(profilesDir string) ([]ProfileMeta, error) { + entries, err := os.ReadDir(profilesDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("读取 profiles 目录失败: %w", err) + } + + var metas []ProfileMeta + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + + path := filepath.Join(profilesDir, entry.Name()) + p, err := Load(path) + if err != nil { + continue // 跳过无效文件 + } + + info, _ := entry.Info() + collectorCount := len(p.Collectors.Enabled) + if collectorCount == 0 { + collectorCount = -1 // -1 表示全部启用 + } + + metas = append(metas, ProfileMeta{ + Name: p.Name, + Description: p.Description, + Path: path, + ModTime: info.ModTime(), + Collectors: collectorCount, + }) + } + + return metas, nil +} + +// Export 将 Profile 以格式化 YAML 写入 Writer(用于 profile show 命令) +func Export(w io.Writer, p *Profile) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + if err := enc.Encode(p); err != nil { + return fmt.Errorf("导出 Profile 失败: %w", err) + } + return enc.Close() +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..838eb1e --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,427 @@ +package profile + +import ( + "os" + "path/filepath" + "testing" +) + +// ---------------------------------------------------------------- +// 模板与构造 +// ---------------------------------------------------------------- + +func TestNewFromTemplate_Minimal(t *testing.T) { + p, err := NewFromTemplate("test-min", TemplateMinimal) + if err != nil { + t.Fatalf("创建 minimal 模板失败: %v", err) + } + if p.Name != "test-min" { + t.Errorf("Name = %q, want %q", p.Name, "test-min") + } + if p.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", p.Version, CurrentVersion) + } + if len(p.Collectors.Enabled) != 4 { + t.Errorf("Enabled 采集器数量 = %d, want 4", len(p.Collectors.Enabled)) + } +} + +func TestNewFromTemplate_Standard(t *testing.T) { + p, err := NewFromTemplate("test-std", TemplateStandard) + if err != nil { + t.Fatalf("创建 standard 模板失败: %v", err) + } + if len(p.Collectors.Enabled) != 7 { + t.Errorf("Enabled 采集器数量 = %d, want 7", len(p.Collectors.Enabled)) + } + // standard 应包含 vscode + found := false + for _, name := range p.Collectors.Enabled { + if name == "vscode" { + found = true + break + } + } + if !found { + t.Error("standard 模板应包含 vscode 采集器") + } +} + +func TestNewFromTemplate_Full(t *testing.T) { + p, err := NewFromTemplate("test-full", TemplateFull) + if err != nil { + t.Fatalf("创建 full 模板失败: %v", err) + } + // full 模板 enabled 列表为空(表示全部启用) + if len(p.Collectors.Enabled) != 0 { + t.Errorf("full 模板 Enabled 应为空, got %d", len(p.Collectors.Enabled)) + } + if len(p.Collectors.Disabled) != 0 { + t.Errorf("full 模板 Disabled 应为空, got %d", len(p.Collectors.Disabled)) + } +} + +func TestNewFromTemplate_Invalid(t *testing.T) { + _, err := NewFromTemplate("test", "invalid") + if err == nil { + t.Error("无效模板类型应返回错误") + } +} + +// ---------------------------------------------------------------- +// 验证 +// ---------------------------------------------------------------- + +func TestValidate_ValidProfile(t *testing.T) { + p, _ := NewFromTemplate("valid-profile", TemplateStandard) + if err := p.Validate(); err != nil { + t.Errorf("合法 Profile 验证失败: %v", err) + } +} + +func TestValidate_EmptyName(t *testing.T) { + p := &Profile{Name: "", Version: 1} + if err := p.Validate(); err == nil { + t.Error("空名称应验证失败") + } +} + +func TestValidate_InvalidNameChars(t *testing.T) { + p := &Profile{Name: "has space", Version: 1} + if err := p.Validate(); err == nil { + t.Error("含空格的名称应验证失败") + } +} + +func TestValidate_TooLongName(t *testing.T) { + longName := "" + for i := 0; i < 65; i++ { + longName += "a" + } + p := &Profile{Name: longName, Version: 1} + if err := p.Validate(); err == nil { + t.Error("超过 64 字符的名称应验证失败") + } +} + +func TestValidate_InvalidVersion(t *testing.T) { + p := &Profile{Name: "test", Version: 0} + if err := p.Validate(); err == nil { + t.Error("版本 0 应验证失败") + } + p.Version = 999 + if err := p.Validate(); err == nil { + t.Error("超出当前版本应验证失败") + } +} + +func TestValidate_EnabledDisabledConflict(t *testing.T) { + p := &Profile{ + Name: "test", + Version: 1, + Collectors: CollectorConfig{ + Enabled: []string{"go", "node"}, + Disabled: []string{"node"}, // 冲突 + }, + } + if err := p.Validate(); err == nil { + t.Error("enabled 和 disabled 有交集应验证失败") + } +} + +// ---------------------------------------------------------------- +// IsCollectorEnabled +// ---------------------------------------------------------------- + +func TestIsCollectorEnabled_AllEnabled(t *testing.T) { + p := &Profile{Collectors: CollectorConfig{}} + if !p.IsCollectorEnabled("go") { + t.Error("enabled 为空时应全部启用") + } +} + +func TestIsCollectorEnabled_ExplicitEnabled(t *testing.T) { + p := &Profile{ + Collectors: CollectorConfig{ + Enabled: []string{"go", "node"}, + }, + } + if !p.IsCollectorEnabled("go") { + t.Error("go 应启用") + } + if p.IsCollectorEnabled("vscode") { + t.Error("vscode 不在 enabled 列表中,应禁用") + } +} + +func TestIsCollectorEnabled_ExplicitDisabled(t *testing.T) { + p := &Profile{ + Collectors: CollectorConfig{ + Disabled: []string{"ssh"}, + }, + } + if p.IsCollectorEnabled("ssh") { + t.Error("ssh 在 disabled 列表中,应禁用") + } + if !p.IsCollectorEnabled("go") { + t.Error("go 不在 disabled 中,应启用") + } +} + +// ---------------------------------------------------------------- +// GetSetting / GetSettingBool / GetSettingStrings +// ---------------------------------------------------------------- + +func TestGetSetting(t *testing.T) { + p, _ := NewFromTemplate("test", TemplateStandard) + val, ok := p.GetSetting("go", "include_tools") + if !ok { + t.Fatal("go.include_tools 应存在") + } + if val != true { + t.Errorf("go.include_tools = %v, want true", val) + } + + _, ok = p.GetSetting("go", "nonexistent") + if ok { + t.Error("不存在的 key 应返回 false") + } + + _, ok = p.GetSetting("nonexistent", "key") + if ok { + t.Error("不存在的采集器应返回 false") + } +} + +func TestGetSettingBool(t *testing.T) { + p, _ := NewFromTemplate("test", TemplateStandard) + if !p.GetSettingBool("go", "include_tools", false) { + t.Error("go.include_tools 应为 true") + } + if p.GetSettingBool("go", "nonexistent", false) { + t.Error("不存在的 key 应返回默认值 false") + } + if !p.GetSettingBool("go", "nonexistent", true) { + t.Error("不存在的 key 应返回默认值 true") + } +} + +func TestGetSettingStrings(t *testing.T) { + p, _ := NewFromTemplate("test", TemplateStandard) + filters := p.GetSettingStrings("go", "tools_filter") + if len(filters) == 0 { + t.Fatal("go.tools_filter 应有内容") + } + found := false + for _, f := range filters { + if f == "gopls" { + found = true + } + } + if !found { + t.Error("tools_filter 应包含 gopls") + } + + result := p.GetSettingStrings("go", "nonexistent") + if result != nil { + t.Error("不存在的 key 应返回 nil") + } +} + +// ---------------------------------------------------------------- +// CRUD 操作 +// ---------------------------------------------------------------- + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + p, _ := NewFromTemplate("round-trip", TemplateStandard) + + path := filepath.Join(dir, "round-trip.yaml") + if err := Save(path, p); err != nil { + t.Fatalf("Save 失败: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load 失败: %v", err) + } + + if loaded.Name != p.Name { + t.Errorf("Name = %q, want %q", loaded.Name, p.Name) + } + if loaded.Description != p.Description { + t.Errorf("Description 不匹配") + } + if len(loaded.Collectors.Enabled) != len(p.Collectors.Enabled) { + t.Errorf("Enabled 数量不匹配: got %d, want %d", len(loaded.Collectors.Enabled), len(p.Collectors.Enabled)) + } +} + +func TestSaveByName_LoadByName(t *testing.T) { + dir := t.TempDir() + p, _ := NewFromTemplate("my-profile", TemplateMinimal) + + if err := SaveByName(dir, p); err != nil { + t.Fatalf("SaveByName 失败: %v", err) + } + + loaded, err := LoadByName(dir, "my-profile") + if err != nil { + t.Fatalf("LoadByName 失败: %v", err) + } + if loaded.Name != "my-profile" { + t.Errorf("Name = %q, want %q", loaded.Name, "my-profile") + } +} + +func TestLoad_FileNotExist(t *testing.T) { + _, err := Load("/nonexistent/path.yaml") + if err == nil { + t.Error("加载不存在的文件应报错") + } +} + +func TestSave_InvalidProfile(t *testing.T) { + dir := t.TempDir() + p := &Profile{Name: "", Version: 1} // 名称为空,验证不通过 + path := filepath.Join(dir, "invalid.yaml") + if err := Save(path, p); err == nil { + t.Error("保存验证不通过的 Profile 应报错") + } +} + +func TestDelete(t *testing.T) { + dir := t.TempDir() + p, _ := NewFromTemplate("to-delete", TemplateMinimal) + SaveByName(dir, p) + + if err := Delete(dir, "to-delete"); err != nil { + t.Fatalf("Delete 失败: %v", err) + } + + if Exists(dir, "to-delete") { + t.Error("删除后文件应不存在") + } +} + +func TestDelete_NotExist(t *testing.T) { + dir := t.TempDir() + if err := Delete(dir, "nonexistent"); err == nil { + t.Error("删除不存在的 Profile 应报错") + } +} + +func TestExists(t *testing.T) { + dir := t.TempDir() + if Exists(dir, "nope") { + t.Error("不存在的 Profile 应返回 false") + } + + p, _ := NewFromTemplate("exists", TemplateMinimal) + SaveByName(dir, p) + if !Exists(dir, "exists") { + t.Error("已保存的 Profile 应返回 true") + } +} + +func TestList(t *testing.T) { + dir := t.TempDir() + + // 空目录 + metas, err := List(dir) + if err != nil { + t.Fatalf("List 空目录失败: %v", err) + } + if len(metas) != 0 { + t.Errorf("空目录应返回 0 个 Profile, got %d", len(metas)) + } + + // 添加两个 Profile + p1, _ := NewFromTemplate("alpha", TemplateMinimal) + p2, _ := NewFromTemplate("beta", TemplateStandard) + SaveByName(dir, p1) + SaveByName(dir, p2) + + metas, err = List(dir) + if err != nil { + t.Fatalf("List 失败: %v", err) + } + if len(metas) != 2 { + t.Errorf("应有 2 个 Profile, got %d", len(metas)) + } +} + +func TestList_NonExistentDir(t *testing.T) { + metas, err := List("/nonexistent/dir") + if err != nil { + t.Fatalf("不存在的目录应返回 nil 而非错误: %v", err) + } + if metas != nil { + t.Error("不存在的目录应返回 nil") + } +} + +// ---------------------------------------------------------------- +// 原子写入验证 +// ---------------------------------------------------------------- + +func TestSave_AtomicWrite(t *testing.T) { + dir := t.TempDir() + p, _ := NewFromTemplate("atomic-test", TemplateMinimal) + path := filepath.Join(dir, "atomic-test.yaml") + + if err := Save(path, p); err != nil { + t.Fatalf("Save 失败: %v", err) + } + + // 临时文件不应残留 + tmpPath := path + ".tmp" + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Error("临时文件应在保存后被清理") + } + + // 真实文件应存在 + if _, err := os.Stat(path); err != nil { + t.Errorf("目标文件应存在: %v", err) + } +} + +// ---------------------------------------------------------------- +// 模板辅助 +// ---------------------------------------------------------------- + +func TestIsValidTemplateName(t *testing.T) { + if !IsValidTemplateName("minimal") { + t.Error("minimal 应是合法模板名") + } + if !IsValidTemplateName("standard") { + t.Error("standard 应是合法模板名") + } + if !IsValidTemplateName("full") { + t.Error("full 应是合法模板名") + } + if IsValidTemplateName("unknown") { + t.Error("unknown 不应是合法模板名") + } +} + +func TestTemplateDescription(t *testing.T) { + for _, tmpl := range ValidTemplateNames() { + desc := TemplateDescription(tmpl) + if desc == "" || desc == "未知模板" { + t.Errorf("模板 %q 应有有效描述", tmpl) + } + } +} + +func TestAllTemplatesValidate(t *testing.T) { + for _, tmpl := range ValidTemplateNames() { + p, err := NewFromTemplate("test-"+string(tmpl), tmpl) + if err != nil { + t.Fatalf("创建模板 %q 失败: %v", tmpl, err) + } + if err := p.Validate(); err != nil { + t.Errorf("模板 %q 创建的 Profile 验证失败: %v", tmpl, err) + } + } +} diff --git a/internal/profile/templates.go b/internal/profile/templates.go new file mode 100644 index 0000000..e5c0c24 --- /dev/null +++ b/internal/profile/templates.go @@ -0,0 +1,206 @@ +package profile + +import "fmt" + +// TemplateName 模板类型 +type TemplateName string + +const ( + TemplateMinimal TemplateName = "minimal" // 最小环境:仅运行时 + 包管理器 + TemplateStandard TemplateName = "standard" // 标准环境:常用开发工具 + TemplateFull TemplateName = "full" // 完整环境:含敏感数据和字体 +) + +// ValidTemplateNames 返回所有合法的模板名称 +func ValidTemplateNames() []TemplateName { + return []TemplateName{TemplateMinimal, TemplateStandard, TemplateFull} +} + +// IsValidTemplateName 检查模板名是否合法 +func IsValidTemplateName(name string) bool { + switch TemplateName(name) { + case TemplateMinimal, TemplateStandard, TemplateFull: + return true + } + return false +} + +// NewFromTemplate 根据模板类型创建 Profile +// name 为 Profile 名称,tmpl 为模板类型 +func NewFromTemplate(name string, tmpl TemplateName) (*Profile, error) { + switch tmpl { + case TemplateMinimal: + return newMinimal(name), nil + case TemplateStandard: + return newStandard(name), nil + case TemplateFull: + return newFull(name), nil + default: + return nil, fmt.Errorf("未知模板类型: %q,可选值: minimal, standard, full", tmpl) + } +} + +// ---------------------------------------------------------------- +// minimal 模板 — 仅运行时 + 包管理器 +// ---------------------------------------------------------------- + +func newMinimal(name string) *Profile { + return &Profile{ + Name: name, + Description: "最小环境配置 — 仅包含编程语言运行时和包管理器", + Version: CurrentVersion, + Collectors: CollectorConfig{ + Enabled: []string{ + "go", + "node", + "python", + "scoop", + }, + Disabled: []string{ + "ssh", + "font", + }, + }, + Settings: map[string]SettingMap{ + "go": { + "include_tools": true, + }, + "node": { + "include_global_packages": true, + "exclude_packages": []string{"npm"}, + }, + "scoop": { + "include_buckets": true, + }, + }, + } +} + +// ---------------------------------------------------------------- +// standard 模板 — 常用开发工具 +// ---------------------------------------------------------------- + +func newStandard(name string) *Profile { + return &Profile{ + Name: name, + Description: "标准开发环境配置 — 包含常用运行时、编辑器、Shell 和 Git", + Version: CurrentVersion, + Collectors: CollectorConfig{ + Enabled: []string{ + "go", + "node", + "python", + "vscode", + "powershell", + "scoop", + "git", + }, + Disabled: []string{ + "ssh", + "font", + "env", + }, + }, + Settings: map[string]SettingMap{ + "go": { + "include_tools": true, + "tools_filter": []string{"gopls", "dlv", "staticcheck", "goimports"}, + }, + "node": { + "include_global_packages": true, + "exclude_packages": []string{"npm"}, + }, + "vscode": { + "include_settings": true, + "include_keybindings": true, + "include_snippets": true, + "exclude_extensions": []string{"ms-vscode.remote-*"}, + }, + "powershell": { + "include_profile": true, + "include_modules": true, + }, + "scoop": { + "include_buckets": true, + }, + "git": { + "include_aliases": true, + "skip_credentials": true, + }, + }, + } +} + +// ---------------------------------------------------------------- +// full 模板 — 完整环境(含敏感数据和字体) +// ---------------------------------------------------------------- + +func newFull(name string) *Profile { + return &Profile{ + Name: name, + Description: "完整环境配置 — 包含所有采集器,含环境变量、加密密钥和字体", + Version: CurrentVersion, + Collectors: CollectorConfig{ + Enabled: []string{}, // 空列表 = 启用全部 + Disabled: []string{}, // 无禁用 + }, + Settings: map[string]SettingMap{ + "go": { + "include_tools": true, + }, + "node": { + "include_global_packages": true, + "exclude_packages": []string{"npm"}, + }, + "vscode": { + "include_settings": true, + "include_keybindings": true, + "include_snippets": true, + }, + "powershell": { + "include_profile": true, + "include_modules": true, + }, + "scoop": { + "include_buckets": true, + "exclude_packages": []string{}, + }, + "git": { + "include_aliases": true, + "skip_credentials": true, + }, + "env": { + "include_patterns": []string{ + "GOPATH", "GOROOT", "GOPROXY", + "JAVA_HOME", + "NODE_*", + "PYTHON*", + "CARGO_HOME", "RUSTUP_HOME", + }, + "exclude_patterns": []string{ + "*SECRET*", "*TOKEN*", "*PASSWORD*", "*KEY*", "*CREDENTIAL*", + }, + }, + "ssh": { + "encrypt": true, + }, + "font": { + "formats": []string{".ttf", ".otf"}, + }, + }, + } +} + +// TemplateDescription 返回模板的简短描述 +func TemplateDescription(tmpl TemplateName) string { + switch tmpl { + case TemplateMinimal: + return "最小环境 — 仅运行时和包管理器 (go, node, python, scoop)" + case TemplateStandard: + return "标准环境 — 常用开发工具 (+ vscode, powershell, git)" + case TemplateFull: + return "完整环境 — 所有采集器 (+ env, ssh, font)" + default: + return "未知模板" + } +}