Skip to content
306 changes: 306 additions & 0 deletions README-zh.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions cmd/skillshare/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ func parseOptsFromConfig(cfg *config.Config) install.ParseOptions {
return install.ParseOptions{
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
CNBHosts: cfg.EffectiveCNBHosts(),
GiteaHosts: cfg.EffectiveGiteaHosts(),
}
}

Expand All @@ -260,6 +262,8 @@ func parseOptsFromProjectConfig(cfg *config.ProjectConfig) install.ParseOptions
return install.ParseOptions{
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
CNBHosts: cfg.EffectiveCNBHosts(),
GiteaHosts: cfg.EffectiveGiteaHosts(),
}
}

Expand Down
8 changes: 8 additions & 0 deletions cmd/skillshare/install_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ func (g *globalInstallContext) PostInstallSkill(string) error { return nil }
func (g *globalInstallContext) Mode() string { return "global" }
func (g *globalInstallContext) GitLabHosts() []string { return g.cfg.EffectiveGitLabHosts() }
func (g *globalInstallContext) AzureHosts() []string { return g.cfg.EffectiveAzureHosts() }
func (g *globalInstallContext) CNBHosts() []string { return g.cfg.EffectiveCNBHosts() }
func (g *globalInstallContext) GiteaHosts() []string { return g.cfg.EffectiveGiteaHosts() }

// ---------------------------------------------------------------------------
// projectInstallContext
Expand Down Expand Up @@ -107,3 +109,9 @@ func (p *projectInstallContext) GitLabHosts() []string {
func (p *projectInstallContext) AzureHosts() []string {
return p.runtime.config.EffectiveAzureHosts()
}
func (p *projectInstallContext) CNBHosts() []string {
return p.runtime.config.EffectiveCNBHosts()
}
func (p *projectInstallContext) GiteaHosts() []string {
return p.runtime.config.EffectiveGiteaHosts()
}
42 changes: 42 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ type Config struct {
TUI *bool `yaml:"tui,omitempty"` // nil = default true
GitLabHosts []string `yaml:"gitlab_hosts,omitempty"`
AzureHosts []string `yaml:"azure_hosts,omitempty"`
CNBHosts []string `yaml:"cnb_hosts,omitempty"`
GiteaHosts []string `yaml:"gitea_hosts,omitempty"`

// PreserveTildeOnSave folds $HOME prefixes back to ~ when serializing the
// config to YAML. Useful when the config is shared via dotfiles across
Expand Down Expand Up @@ -430,6 +432,16 @@ func (c *Config) EffectiveAzureHosts() []string {
return mergeAzureHostsFromEnv(c.AzureHosts)
}

// EffectiveCNBHosts returns CNBHosts merged with SKILLSHARE_CNB_HOSTS env var.
func (c *Config) EffectiveCNBHosts() []string {
return mergeCNBHostsFromEnv(c.CNBHosts)
}

// EffectiveGiteaHosts returns GiteaHosts merged with SKILLSHARE_GITEA_HOSTS env var.
func (c *Config) EffectiveGiteaHosts() []string {
return mergeGiteaHostsFromEnv(c.GiteaHosts)
}

// IsTUIEnabled reports whether interactive TUI is enabled.
// nil (absent from config) is treated as true for backward compatibility.
func (c *Config) IsTUIEnabled() bool {
Expand Down Expand Up @@ -571,6 +583,20 @@ func Load() (*Config, error) {
}
cfg.AzureHosts = azureHosts

// Validate and normalize cnb_hosts
cnbHosts, err := normalizeCNBHosts(cfg.CNBHosts)
if err != nil {
return nil, err
}
cfg.CNBHosts = cnbHosts

// Validate and normalize gitea_hosts
giteaHosts, err := normalizeGiteaHosts(cfg.GiteaHosts)
if err != nil {
return nil, err
}
cfg.GiteaHosts = giteaHosts

// Migrate legacy flat target fields to skills: sub-key (one-time, persisted immediately)
if migrateTargetConfigs(cfg.Targets) {
if data, err := marshalYAML(&cfg); err == nil {
Expand Down Expand Up @@ -830,6 +856,14 @@ func normalizeAzureHosts(hosts []string) ([]string, error) {
return normalizeHostList(hosts, "azure_hosts")
}

func normalizeCNBHosts(hosts []string) ([]string, error) {
return normalizeHostList(hosts, "cnb_hosts")
}

func normalizeGiteaHosts(hosts []string) ([]string, error) {
return normalizeHostList(hosts, "gitea_hosts")
}

// mergeHostsFromEnv merges comma-separated env var entries with config file hosts.
// Invalid entries in the env var are silently skipped.
func mergeHostsFromEnv(configHosts []string, envKey string) []string {
Expand Down Expand Up @@ -863,6 +897,14 @@ func mergeAzureHostsFromEnv(configHosts []string) []string {
return mergeHostsFromEnv(configHosts, "SKILLSHARE_AZURE_HOSTS")
}

func mergeCNBHostsFromEnv(configHosts []string) []string {
return mergeHostsFromEnv(configHosts, "SKILLSHARE_CNB_HOSTS")
}

func mergeGiteaHostsFromEnv(configHosts []string) []string {
return mergeHostsFromEnv(configHosts, "SKILLSHARE_GITEA_HOSTS")
}

func normalizeAuditBlockThreshold(v string) (string, error) {
threshold := strings.ToUpper(strings.TrimSpace(v))
if threshold == "" {
Expand Down
26 changes: 26 additions & 0 deletions internal/config/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ type ProjectConfig struct {
Hub HubConfig `yaml:"hub,omitempty"`
GitLabHosts []string `yaml:"gitlab_hosts,omitempty"`
AzureHosts []string `yaml:"azure_hosts,omitempty"`
CNBHosts []string `yaml:"cnb_hosts,omitempty"`
GiteaHosts []string `yaml:"gitea_hosts,omitempty"`
}

// EffectiveSkillsSource returns the resolved skills source directory.
Expand Down Expand Up @@ -339,6 +341,16 @@ func (c *ProjectConfig) EffectiveAzureHosts() []string {
return mergeAzureHostsFromEnv(c.AzureHosts)
}

// EffectiveCNBHosts returns CNBHosts merged with SKILLSHARE_CNB_HOSTS env var.
func (c *ProjectConfig) EffectiveCNBHosts() []string {
return mergeCNBHostsFromEnv(c.CNBHosts)
}

// EffectiveGiteaHosts returns GiteaHosts merged with SKILLSHARE_GITEA_HOSTS env var.
func (c *ProjectConfig) EffectiveGiteaHosts() []string {
return mergeGiteaHostsFromEnv(c.GiteaHosts)
}

// ProjectConfigPath returns the project config path for the given root.
func ProjectConfigPath(projectRoot string) string {
return filepath.Join(projectRoot, ".skillshare", "config.yaml")
Expand Down Expand Up @@ -381,6 +393,20 @@ func LoadProject(projectRoot string) (*ProjectConfig, error) {
}
cfg.AzureHosts = azureHosts

// Validate and normalize cnb_hosts
cnbHosts, err := normalizeCNBHosts(cfg.CNBHosts)
if err != nil {
return nil, fmt.Errorf("project config: %w", err)
}
cfg.CNBHosts = cnbHosts

// Validate and normalize gitea_hosts
giteaHosts, err := normalizeGiteaHosts(cfg.GiteaHosts)
if err != nil {
return nil, fmt.Errorf("project config: %w", err)
}
cfg.GiteaHosts = giteaHosts

for _, target := range cfg.Targets {
if strings.TrimSpace(target.Name) == "" {
return nil, fmt.Errorf("project config has target with empty name")
Expand Down
51 changes: 49 additions & 2 deletions internal/install/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const (
PlatformGitLab // gitlab.com and self-hosted GitLab
PlatformBitbucket // bitbucket.org
PlatformAzureDevOps // dev.azure.com and visualstudio.com
PlatformCNB // cnb.cool and self-hosted CNB instances
PlatformGitea // gitea.com and self-hosted Gitea instances
)

// extractHost returns the hostname from a clone URL.
Expand Down Expand Up @@ -62,6 +64,12 @@ func detectPlatform(cloneURL string) Platform {
if host == "dev.azure.com" || host == "ssh.dev.azure.com" || strings.HasSuffix(host, ".visualstudio.com") {
return PlatformAzureDevOps
}
if strings.Contains(host, "cnb.cool") {
return PlatformCNB
}
if strings.Contains(host, "gitea") {
return PlatformGitea
}
return PlatformUnknown
}

Expand All @@ -70,11 +78,15 @@ func detectPlatform(cloneURL string) Platform {
// SKILLSHARE_GIT_TOKEN. Returns empty strings if no token is available or
// the URL is not HTTPS.
func resolveToken(cloneURL string) (token, username string) {
return resolveTokenWithOptions(cloneURL, nil, nil)
}

func resolveTokenWithOptions(cloneURL string, cnbHosts, giteaHosts []string) (token, username string) {
if !isHTTPS(cloneURL) {
return "", ""
}

platform := detectPlatform(cloneURL)
platform := detectPlatformWithOptions(cloneURL, cnbHosts, giteaHosts)
switch platform {
case PlatformGitHub:
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
Expand All @@ -98,6 +110,14 @@ func resolveToken(cloneURL string) (token, username string) {
if t := os.Getenv("AZURE_DEVOPS_TOKEN"); t != "" {
return t, "x-access-token"
}
case PlatformCNB:
if t := os.Getenv("CNB_TOKEN"); t != "" {
return t, "cnb"
}
case PlatformGitea:
if t := os.Getenv("GITEA_TOKEN"); t != "" {
return t, "x-access-token"
}
}

// Generic fallback — use platform-appropriate username, or preserve
Expand Down Expand Up @@ -126,7 +146,11 @@ func resolveToken(cloneURL string) (token, username string) {
// git config entries (e.g. from CI pipelines).
// Returns nil for SSH/file URLs or when no token is available.
func authEnv(cloneURL string) []string {
token, username := resolveToken(cloneURL)
return authEnvWithOptions(cloneURL, nil, nil)
}

func authEnvWithOptions(cloneURL string, cnbHosts, giteaHosts []string) []string {
token, username := resolveTokenWithOptions(cloneURL, cnbHosts, giteaHosts)
if token == "" {
return nil
}
Expand Down Expand Up @@ -163,6 +187,28 @@ func DetectPlatformForURL(cloneURL string) Platform {
return detectPlatform(cloneURL)
}

func detectPlatformWithOptions(cloneURL string, cnbHosts, giteaHosts []string) Platform {
host := strings.ToLower(extractHost(cloneURL))
if host == "" {
return PlatformUnknown
}
return detectPlatformFromHost(host, cnbHosts, giteaHosts)
}

func (s *Source) authEnv() []string {
if s == nil {
return nil
}
return authEnvWithOptions(s.CloneURL, s.CNBHosts, s.GiteaHosts)
}

func (s *Source) platform() Platform {
if s == nil {
return PlatformUnknown
}
return detectPlatformWithOptions(s.CloneURL, s.CNBHosts, s.GiteaHosts)
}

// existingConfigCount returns the current GIT_CONFIG_COUNT from the
// environment, or 0 if unset/invalid.
func existingConfigCount() int {
Expand Down Expand Up @@ -195,6 +241,7 @@ func sanitizeTokens(text string) string {
vars := []string{
"GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "BITBUCKET_TOKEN",
"AZURE_DEVOPS_TOKEN", "SKILLSHARE_GIT_TOKEN", "BITBUCKET_USERNAME",
"CNB_TOKEN", "GITEA_TOKEN",
}
for _, v := range vars {
if t := os.Getenv(v); t != "" {
Expand Down
72 changes: 72 additions & 0 deletions internal/install/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ var authTestEnvKeys = []string{
"BITBUCKET_TOKEN",
"BITBUCKET_USERNAME",
"AZURE_DEVOPS_TOKEN",
"CNB_TOKEN",
"GITEA_TOKEN",
"SKILLSHARE_GIT_TOKEN",
"GIT_CONFIG_COUNT",
}
Expand Down Expand Up @@ -164,6 +166,21 @@ func TestResolveToken(t *testing.T) {
wantTok: "tok",
wantUser: "oauth2",
},

{
name: "cnb token",
url: "https://cnb.cool/org/repo.git",
envVars: map[string]string{"CNB_TOKEN": "cnb_pat"},
wantTok: "cnb_pat",
wantUser: "cnb",
},
{
name: "gitea token",
url: "https://gitea.example.com/org/repo.git",
envVars: map[string]string{"GITEA_TOKEN": "gitea_pat"},
wantTok: "gitea_pat",
wantUser: "x-access-token",
},
{
name: "azure devops token",
url: "https://dev.azure.com/org/proj/_git/repo",
Expand Down Expand Up @@ -231,6 +248,61 @@ func TestResolveToken(t *testing.T) {
}
}

func TestSourceAuthEnv_ConfiguredCNBGiteaHosts(t *testing.T) {
tests := []struct {
name string
source *Source
envVars map[string]string
wantKey string
}{
{
name: "configured CNB host uses CNB_TOKEN",
source: &Source{
CloneURL: "https://git.corp.example/org/repo.git",
CNBHosts: []string{"git.corp.example"},
},
envVars: map[string]string{"CNB_TOKEN": "cnb_private"},
wantKey: "url.https://cnb:cnb_private@git.corp.example/.insteadOf",
},
{
name: "configured Gitea host uses GITEA_TOKEN",
source: &Source{
CloneURL: "https://git.example.com/org/repo.git",
GiteaHosts: []string{"git.example.com"},
},
envVars: map[string]string{"GITEA_TOKEN": "gitea_private"},
wantKey: "url.https://x-access-token:gitea_private@git.example.com/.insteadOf",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetAuthTestEnv(t)
for k, v := range tt.envVars {
t.Setenv(k, v)
}
got := tt.source.authEnv()
if len(got) < 2 {
t.Fatalf("authEnv() = %v, want git config env", got)
}
if got[1] != "GIT_CONFIG_KEY_0="+tt.wantKey {
t.Errorf("auth key = %q, want %q", got[1], "GIT_CONFIG_KEY_0="+tt.wantKey)
}
})
}
}

func TestAPISourceDetection_UsesConfiguredHosts(t *testing.T) {
cnb := &Source{CloneURL: "https://git.corp.example/org/repo.git", CNBHosts: []string{"git.corp.example"}}
if !isCNBAPISource(cnb) {
t.Fatalf("configured CNB host was not treated as CNB API source")
}
gitea := &Source{CloneURL: "https://git.example.com/org/repo.git", GiteaHosts: []string{"git.example.com"}}
if !isGiteaAPISource(gitea) {
t.Fatalf("configured Gitea host was not treated as Gitea API source")
}
}

func TestAuthEnv(t *testing.T) {
tests := []struct {
name string
Expand Down
6 changes: 6 additions & 0 deletions internal/install/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ type InstallContext interface {

// AzureHosts returns extra hostnames to treat as Azure DevOps on-premises instances.
AzureHosts() []string

// CNBHosts returns extra hostnames to treat as CNB instances.
CNBHosts() []string

// GiteaHosts returns extra hostnames to treat as Gitea instances.
GiteaHosts() []string
}

// ConfigInstallResult summarises the outcome of InstallFromConfig.
Expand Down
Loading
Loading