repo clone: derive a git URL the host actually servesUnverified
The web host does not speak smart HTTP, so <host>/<owner>/<repo>.git 404s for every repo. rickub serves git over SSH on the site hostname and over HTTPS on git.<host>; clone now defaults to the SSH form and takes --protocol https, in which case the token authenticates the clone through GIT_CONFIG_* (never argv, never .git/config). Fixes #1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgkcT3UPxwxukBKojB5eg4
d68b124 parent: 900561e modified
cmd/repo.go +62 -2 | @@ -4,7 +4,10 @@ import ( | ||
| 4 | 4 | "encoding/base64" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | + "net/url" | |
| 8 | + "os" | |
| 7 | 9 | "os/exec" |
| 10 | + "strconv" | |
| 8 | 11 | "strings" |
| 9 | 12 | |
| 10 | 13 | "rickub.com/rickub/cli/internal/api" |
| @@ -96,11 +99,14 @@ By default repositories are private; pass --public to create a public one.`, | ||
| 96 | 99 | Short: "Clone a repository with git", |
| 97 | 100 | Long: `Clone a repository by shelling out to git. |
| 98 | 101 | |
| 99 | -The clone URL is derived from the configured host as <host>/<owner>/<repo>.git. | |
| 102 | +The clone URL is derived from the configured host: ssh://git@<host>/<owner>/<repo>.git | |
| 103 | +by default (your SSH keys authenticate), or https://git.<host>/<owner>/<repo>.git | |
| 104 | +with --protocol https, where your token authenticates this clone only. | |
| 100 | 105 | Extra arguments after -- are passed through to git clone.`, |
| 101 | 106 | Args: cobra.MinimumNArgs(1), |
| 102 | 107 | RunE: runRepoClone, |
| 103 | 108 | } |
| 109 | + cloneCmd.Flags().StringVar(&repoCloneProtocol, "protocol", "ssh", "ssh | https") | |
| 104 | 110 | |
| 105 | 111 | // files (list dir) |
| 106 | 112 | filesCmd := &cobra.Command{ |
| @@ -311,7 +317,10 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | ||
| 311 | 317 | return err |
| 312 | 318 | } |
| 313 | 319 | host := hostFor(cfg) |
| 314 | - cloneURL := fmt.Sprintf("%s/%s/%s.git", host, owner, repo) | |
| 320 | + cloneURL, err := cloneURLFor(host, owner, repo, repoCloneProtocol) | |
| 321 | + if err != nil { | |
| 322 | + return err | |
| 323 | + } | |
| 315 | 324 | gitArgs := []string{"clone", cloneURL} |
| 316 | 325 | gitArgs = append(gitArgs, args[1:]...) |
| 317 | 326 | fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) |
| @@ -319,9 +328,58 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | ||
| 319 | 328 | g.Stdout = cmd.OutOrStdout() |
| 320 | 329 | g.Stderr = cmd.ErrOrStderr() |
| 321 | 330 | g.Stdin = cmd.InOrStdin() |
| 331 | + g.Env = os.Environ() | |
| 332 | + if repoCloneProtocol == "https" { | |
| 333 | + // Authenticate this clone with the token, handed to git through its | |
| 334 | + // environment-config mechanism: never on the command line (visible in | |
| 335 | + // ps) and never written into the clone's .git/config. | |
| 336 | + if token := config.ResolveToken(flagToken, cfg, host); token != "" { | |
| 337 | + config.WarnIfInsecure(cmd.ErrOrStderr(), cloneURL) | |
| 338 | + g.Env = append(g.Env, gitAuthEnv(token)...) | |
| 339 | + } | |
| 340 | + } | |
| 322 | 341 | return g.Run() |
| 323 | 342 | } |
| 324 | 343 | |
| 344 | +// cloneURLFor builds the git URL for owner/repo on the API host. rickub serves | |
| 345 | +// git over SSH on the site's own hostname and over HTTPS on the git.<host> | |
| 346 | +// subdomain; the web host itself does not speak smart HTTP, so the API host is | |
| 347 | +// never used verbatim. | |
| 348 | +func cloneURLFor(host, owner, repo, protocol string) (string, error) { | |
| 349 | + u, err := url.Parse(config.NormalizeHost(host)) | |
| 350 | + if err != nil || u.Hostname() == "" { | |
| 351 | + return "", fmt.Errorf("cannot derive a clone URL from host %q", host) | |
| 352 | + } | |
| 353 | + switch protocol { | |
| 354 | + case "", "ssh": | |
| 355 | + return fmt.Sprintf("ssh://git@%s/%s/%s.git", u.Hostname(), owner, repo), nil | |
| 356 | + case "https": | |
| 357 | + scheme := u.Scheme | |
| 358 | + if scheme == "" { | |
| 359 | + scheme = "https" | |
| 360 | + } | |
| 361 | + return fmt.Sprintf("%s://git.%s/%s/%s.git", scheme, u.Host, owner, repo), nil | |
| 362 | + default: | |
| 363 | + return "", fmt.Errorf("unknown --protocol %q (want ssh or https)", protocol) | |
| 364 | + } | |
| 365 | +} | |
| 366 | + | |
| 367 | +// gitAuthEnv returns the GIT_CONFIG_* variables that make git send the token as | |
| 368 | +// a bearer header, appended after any GIT_CONFIG_COUNT entries already present. | |
| 369 | +func gitAuthEnv(token string) []string { | |
| 370 | + n := 0 | |
| 371 | + if v := os.Getenv("GIT_CONFIG_COUNT"); v != "" { | |
| 372 | + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { | |
| 373 | + n = parsed | |
| 374 | + } | |
| 375 | + } | |
| 376 | + return []string{ | |
| 377 | + fmt.Sprintf("GIT_CONFIG_COUNT=%d", n+1), | |
| 378 | + fmt.Sprintf("GIT_CONFIG_KEY_%d=http.extraHeader", n), | |
| 379 | + fmt.Sprintf("GIT_CONFIG_VALUE_%d=Authorization: Bearer %s", n, token), | |
| 380 | + } | |
| 381 | +} | |
| 382 | + | |
| 325 | 383 | func runRepoFiles(cmd *cobra.Command, args []string) error { |
| 326 | 384 | client, err := newClient() |
| 327 | 385 | if err != nil { |
| @@ -514,6 +572,8 @@ func defaultBranch(cmd *cobra.Command, client *api.Client, owner, repo string) ( | ||
| 514 | 572 | } |
| 515 | 573 | |
| 516 | 574 | // hostFor returns the effective host from flags/env/config. |
| 575 | +var repoCloneProtocol string | |
| 576 | + | |
| 517 | 577 | func hostFor(cfg *config.Config) string { |
| 518 | 578 | return config.ResolveHost(flagHost, cfg) |
| 519 | 579 | } |
| @@ -4,7 +4,10 @@ import ( | |||
| 4 | "encoding/base64" | 4 | "encoding/base64" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | + "net/url" | ||
| 8 | + "os" | ||
| 7 | "os/exec" | 9 | "os/exec" |
| 10 | + "strconv" | ||
| 8 | "strings" | 11 | "strings" |
| 9 | 12 | ||
| 10 | "rickub.com/rickub/cli/internal/api" | 13 | "rickub.com/rickub/cli/internal/api" |
| @@ -96,11 +99,14 @@ By default repositories are private; pass --public to create a public one.`, | |||
| 96 | Short: "Clone a repository with git", | 99 | Short: "Clone a repository with git", |
| 97 | Long: `Clone a repository by shelling out to git. | 100 | Long: `Clone a repository by shelling out to git. |
| 98 | 101 | ||
| 99 | -The clone URL is derived from the configured host as <host>/<owner>/<repo>.git. | 102 | +The clone URL is derived from the configured host: ssh://git@<host>/<owner>/<repo>.git |
| 103 | +by default (your SSH keys authenticate), or https://git.<host>/<owner>/<repo>.git | ||
| 104 | +with --protocol https, where your token authenticates this clone only. | ||
| 100 | Extra arguments after -- are passed through to git clone.`, | 105 | Extra arguments after -- are passed through to git clone.`, |
| 101 | Args: cobra.MinimumNArgs(1), | 106 | Args: cobra.MinimumNArgs(1), |
| 102 | RunE: runRepoClone, | 107 | RunE: runRepoClone, |
| 103 | } | 108 | } |
| 109 | + cloneCmd.Flags().StringVar(&repoCloneProtocol, "protocol", "ssh", "ssh | https") | ||
| 104 | 110 | ||
| 105 | // files (list dir) | 111 | // files (list dir) |
| 106 | filesCmd := &cobra.Command{ | 112 | filesCmd := &cobra.Command{ |
| @@ -311,7 +317,10 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | |||
| 311 | return err | 317 | return err |
| 312 | } | 318 | } |
| 313 | host := hostFor(cfg) | 319 | host := hostFor(cfg) |
| 314 | - cloneURL := fmt.Sprintf("%s/%s/%s.git", host, owner, repo) | 320 | + cloneURL, err := cloneURLFor(host, owner, repo, repoCloneProtocol) |
| 321 | + if err != nil { | ||
| 322 | + return err | ||
| 323 | + } | ||
| 315 | gitArgs := []string{"clone", cloneURL} | 324 | gitArgs := []string{"clone", cloneURL} |
| 316 | gitArgs = append(gitArgs, args[1:]...) | 325 | gitArgs = append(gitArgs, args[1:]...) |
| 317 | fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) | 326 | fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) |
| @@ -319,9 +328,58 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | |||
| 319 | g.Stdout = cmd.OutOrStdout() | 328 | g.Stdout = cmd.OutOrStdout() |
| 320 | g.Stderr = cmd.ErrOrStderr() | 329 | g.Stderr = cmd.ErrOrStderr() |
| 321 | g.Stdin = cmd.InOrStdin() | 330 | g.Stdin = cmd.InOrStdin() |
| 331 | + g.Env = os.Environ() | ||
| 332 | + if repoCloneProtocol == "https" { | ||
| 333 | + // Authenticate this clone with the token, handed to git through its | ||
| 334 | + // environment-config mechanism: never on the command line (visible in | ||
| 335 | + // ps) and never written into the clone's .git/config. | ||
| 336 | + if token := config.ResolveToken(flagToken, cfg, host); token != "" { | ||
| 337 | + config.WarnIfInsecure(cmd.ErrOrStderr(), cloneURL) | ||
| 338 | + g.Env = append(g.Env, gitAuthEnv(token)...) | ||
| 339 | + } | ||
| 340 | + } | ||
| 322 | return g.Run() | 341 | return g.Run() |
| 323 | } | 342 | } |
| 324 | 343 | ||
| 344 | +// cloneURLFor builds the git URL for owner/repo on the API host. rickub serves | ||
| 345 | +// git over SSH on the site's own hostname and over HTTPS on the git.<host> | ||
| 346 | +// subdomain; the web host itself does not speak smart HTTP, so the API host is | ||
| 347 | +// never used verbatim. | ||
| 348 | +func cloneURLFor(host, owner, repo, protocol string) (string, error) { | ||
| 349 | + u, err := url.Parse(config.NormalizeHost(host)) | ||
| 350 | + if err != nil || u.Hostname() == "" { | ||
| 351 | + return "", fmt.Errorf("cannot derive a clone URL from host %q", host) | ||
| 352 | + } | ||
| 353 | + switch protocol { | ||
| 354 | + case "", "ssh": | ||
| 355 | + return fmt.Sprintf("ssh://git@%s/%s/%s.git", u.Hostname(), owner, repo), nil | ||
| 356 | + case "https": | ||
| 357 | + scheme := u.Scheme | ||
| 358 | + if scheme == "" { | ||
| 359 | + scheme = "https" | ||
| 360 | + } | ||
| 361 | + return fmt.Sprintf("%s://git.%s/%s/%s.git", scheme, u.Host, owner, repo), nil | ||
| 362 | + default: | ||
| 363 | + return "", fmt.Errorf("unknown --protocol %q (want ssh or https)", protocol) | ||
| 364 | + } | ||
| 365 | +} | ||
| 366 | + | ||
| 367 | +// gitAuthEnv returns the GIT_CONFIG_* variables that make git send the token as | ||
| 368 | +// a bearer header, appended after any GIT_CONFIG_COUNT entries already present. | ||
| 369 | +func gitAuthEnv(token string) []string { | ||
| 370 | + n := 0 | ||
| 371 | + if v := os.Getenv("GIT_CONFIG_COUNT"); v != "" { | ||
| 372 | + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { | ||
| 373 | + n = parsed | ||
| 374 | + } | ||
| 375 | + } | ||
| 376 | + return []string{ | ||
| 377 | + fmt.Sprintf("GIT_CONFIG_COUNT=%d", n+1), | ||
| 378 | + fmt.Sprintf("GIT_CONFIG_KEY_%d=http.extraHeader", n), | ||
| 379 | + fmt.Sprintf("GIT_CONFIG_VALUE_%d=Authorization: Bearer %s", n, token), | ||
| 380 | + } | ||
| 381 | +} | ||
| 382 | + | ||
| 325 | func runRepoFiles(cmd *cobra.Command, args []string) error { | 383 | func runRepoFiles(cmd *cobra.Command, args []string) error { |
| 326 | client, err := newClient() | 384 | client, err := newClient() |
| 327 | if err != nil { | 385 | if err != nil { |
| @@ -514,6 +572,8 @@ func defaultBranch(cmd *cobra.Command, client *api.Client, owner, repo string) ( | |||
| 514 | } | 572 | } |
| 515 | 573 | ||
| 516 | // hostFor returns the effective host from flags/env/config. | 574 | // hostFor returns the effective host from flags/env/config. |
| 575 | +var repoCloneProtocol string | ||
| 576 | + | ||
| 517 | func hostFor(cfg *config.Config) string { | 577 | func hostFor(cfg *config.Config) string { |
| 518 | return config.ResolveHost(flagHost, cfg) | 578 | return config.ResolveHost(flagHost, cfg) |
| 519 | } | 579 | } |