diff --git a/docs/configuration.md b/docs/configuration.md index 26c1db9..9c9a60f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,7 +3,7 @@ `tg` resolves configuration values from the following sources, in increasing precedence (later sources override earlier ones): -1. **Defaults** — `appview` is `https://bobbin.klbr.net` +1. **Defaults** — `appview` is `https://bobbin.klbr.net`; `knot` is `knot1.tangled.sh`; `ssh-port` is `22` 2. **Config file** — `$XDG_CONFIG_HOME/tg/config.toml` (or `~/.config/tg/config.toml`) 3. **Environment variables** — prefixed `TG_` (e.g. `TG_APPVIEW`) 4. **Command-line flags** — e.g. `--appview` @@ -15,16 +15,25 @@ The config file is optional; a missing file is not an error. ```toml # ~/.config/tg/config.toml appview = "https://bobbin.klbr.net" +knot = "knot.example.com" +ssh-port = 2222 ``` +`knot` and `ssh-port` are defaults for `tg repo create`. They select where a +repository is provisioned and the SSH port used when constructing its initial +clone or push remote. Existing repositories continue to use their configured +Git remotes. + Override the config file location with `--config /path/to/config.toml`. ## Environment variables -| Variable | Config key | Purpose | -| ------------ | ---------- | --------------------- | -| `TG_APPVIEW` | `appview` | Appview host URL | -| `TG_ACCOUNT` | `account` | Account handle or DID | +| Variable | Config key | Purpose | +| ------------- | ---------- | ---------------------------------- | +| `TG_APPVIEW` | `appview` | Appview host URL | +| `TG_ACCOUNT` | `account` | Account handle or DID | +| `TG_KNOT` | `knot` | Knot host for repo creation | +| `TG_SSH_PORT` | `ssh-port` | SSH port used during repo creation | Keys containing `.` or `-` map to `TG_`-prefixed underscore-separated names (e.g. `foo.bar` → `TG_FOO_BAR`). diff --git a/internal/cli/config.go b/internal/cli/config.go index 00282e0..5e084a4 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/alyraffauf/tg/internal/app" "github.com/spf13/viper" ) @@ -22,6 +23,8 @@ const ( type settings struct { Appview string Account string + Knot string + SSHPort string } type flagSettings struct { @@ -55,6 +58,8 @@ func loadConfig(flags flagSettings, errorWriter io.Writer) settings { config.AutomaticEnv() config.SetDefault("appview", defaultAppview) config.SetDefault("account", "") + config.SetDefault("knot", app.DefaultKnot) + config.SetDefault("ssh-port", "22") if err := config.ReadInConfig(); err != nil { if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok { @@ -62,12 +67,19 @@ func loadConfig(flags flagSettings, errorWriter io.Writer) settings { return applyFlagSettings(settings{ Appview: config.GetString("appview"), Account: config.GetString("account"), + Knot: config.GetString("knot"), + SSHPort: config.GetString("ssh-port"), }, flags) } // Surface parse/permission errors but keep running with defaults. fmt.Fprintln(errorWriter, "warning: failed to read config:", err) } - resolved := settings{Appview: config.GetString("appview"), Account: config.GetString("account")} + resolved := settings{ + Appview: config.GetString("appview"), + Account: config.GetString("account"), + Knot: config.GetString("knot"), + SSHPort: config.GetString("ssh-port"), + } return applyFlagSettings(resolved, flags) } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index d573782..6260f47 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -1,6 +1,8 @@ package cli import ( + "io" + "os" "path/filepath" "reflect" "testing" @@ -38,3 +40,75 @@ func TestConfigSearchDirs(t *testing.T) { }) } } + +func TestLoadConfigKnotPrecedence(t *testing.T) { + tests := []struct { + name string + config string + env string + wantKnot string + }{ + {name: "hosted Knot default", wantKnot: "knot1.tangled.sh"}, + {name: "config", config: "config.example", wantKnot: "config.example"}, + {name: "environment over config", config: "config.example", env: "env.example", wantKnot: "env.example"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + t.Setenv("TG_KNOT", tt.env) + if tt.config != "" { + configDir := filepath.Join(xdg, "tg") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("create config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("knot = \""+tt.config+"\"\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + } + + got := loadConfig(flagSettings{}, io.Discard) + if got.Knot != tt.wantKnot { + t.Fatalf("Knot = %q, want %q", got.Knot, tt.wantKnot) + } + }) + } +} + +func TestLoadConfigSSHPortPrecedence(t *testing.T) { + tests := []struct { + name string + config string + env string + wantPort string + }{ + {name: "default", wantPort: "22"}, + {name: "config", config: "ssh-port = 2200\n", wantPort: "2200"}, + {name: "environment over config", config: "ssh-port = 2200\n", env: "2222", wantPort: "2222"}, + {name: "malformed config is preserved", config: "ssh-port = true\n", wantPort: "true"}, + {name: "malformed environment is preserved", env: "not-a-port", wantPort: "not-a-port"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + t.Setenv("TG_SSH_PORT", tt.env) + if tt.config != "" { + configDir := filepath.Join(xdg, "tg") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("create config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(tt.config), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + } + + got := loadConfig(flagSettings{}, io.Discard) + if got.SSHPort != tt.wantPort { + t.Fatalf("SSH port = %q, want %q", got.SSHPort, tt.wantPort) + } + }) + } +} diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go index e759740..b02bc28 100644 --- a/internal/cli/repo_create.go +++ b/internal/cli/repo_create.go @@ -2,14 +2,14 @@ package cli import ( "fmt" + "strconv" "github.com/alyraffauf/tg/internal/app" "github.com/spf13/cobra" ) -func newRepoCreateCommand(service *app.Service) *cobra.Command { - var description, knotHost, pushPath, remote string - var sshPort int +func newRepoCreateCommand(service *app.Service, defaultKnot, defaultSSHPort string) *cobra.Command { + var description, knotHost, pushPath, remote, sshPort string var clone bool command := &cobra.Command{ @@ -17,7 +17,7 @@ func newRepoCreateCommand(service *app.Service) *cobra.Command { Short: "Create a repository on Tangled", Long: `Create a repository on Tangled. -The repository is provisioned on a knot (default ` + app.DefaultKnot + `) and a +The repository is provisioned on the selected Knot and a sh.tangled.repo record is written to your PDS. The repository name is used as the record key, matching the current Tangled schema. @@ -29,6 +29,10 @@ Requires authentication (run "tg auth login" first).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + parsedSSHPort, err := strconv.Atoi(sshPort) + if err != nil { + return fmt.Errorf("invalid SSH port %q: %w", sshPort, err) + } selectedKnot := knotHost if selectedKnot == "" { @@ -36,7 +40,7 @@ Requires authentication (run "tg auth login" first).`, } result, err := service.CreateRepo(ctx, app.CreateRepoInput{ - KnotHost: selectedKnot, SSHPort: sshPort, Name: args[0], Description: description, + KnotHost: selectedKnot, SSHPort: parsedSSHPort, Name: args[0], Description: description, Clone: clone, PushPath: pushPath, RemoteName: remote, }) if err != nil { @@ -46,8 +50,8 @@ Requires authentication (run "tg auth login" first).`, }, } command.Flags().StringVar(&description, "description", "", "Repository description") - command.Flags().StringVar(&knotHost, "knot", "", "knot host to create on (default "+app.DefaultKnot+")") - command.Flags().IntVar(&sshPort, "ssh-port", 22, "SSH port for cloning from or pushing to the selected Knot") + command.Flags().StringVar(&knotHost, "knot", defaultKnot, "Knot host to provision and optionally push to (overrides config file and TG_KNOT)") + command.Flags().StringVar(&sshPort, "ssh-port", defaultSSHPort, "SSH port for cloning from or pushing to the selected Knot (overrides config file and TG_SSH_PORT)") command.Flags().BoolVar(&clone, "clone", false, "Clone the new repository into the current directory") command.Flags().StringVar(&pushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)") command.Flags().StringVar(&remote, "remote", "origin", "Remote name to use with --push") diff --git a/internal/cli/root.go b/internal/cli/root.go index 817028d..0f5a02f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,6 +20,10 @@ const ( ) func NewRoot(service *app.Service) *cobra.Command { + return newRoot(service, app.DefaultKnot, "22") +} + +func newRoot(service *app.Service, defaultKnot, defaultSSHPort string) *cobra.Command { rootCmd := &cobra.Command{ Use: "tg", Short: "A CLI for Tangled", @@ -41,7 +45,7 @@ func NewRoot(service *app.Service) *cobra.Command { rootCmd.AddCommand(pull) repo := newRepoCommand(service) - repo.AddCommand(newRepoViewCommand(service), newRepoCloneCommand(service), newRepoCreateCommand(service), newRepoListCommand(service), newRepoEditCommand(service), newRepoSetDefaultBranchCommand(service), newRepoDeleteCommand(service), newRepoForkCommand(service)) + repo.AddCommand(newRepoViewCommand(service), newRepoCloneCommand(service), newRepoCreateCommand(service, defaultKnot, defaultSSHPort), newRepoListCommand(service), newRepoEditCommand(service), newRepoSetDefaultBranchCommand(service), newRepoDeleteCommand(service), newRepoForkCommand(service)) rootCmd.AddCommand(repo) keys := newSSHKeyCommand(service) @@ -68,7 +72,7 @@ func ExecuteWith(arguments []string, input io.Reader, output, errorOutput io.Wri settings := loadConfig(flags, errorOutput) service := app.NewWithStreams(settings.Appview, output, errorOutput) service.SetAccount(settings.Account) - root := NewRoot(service) + root := newRoot(service, settings.Knot, settings.SSHPort) root.SetArgs(arguments) root.SetIn(input) root.SetOut(output) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 4473efb..8c85a88 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -58,22 +58,53 @@ func TestExecuteWithRendersPreCommandErrors(t *testing.T) { } func TestRepoCreateSSHPortHelp(t *testing.T) { - create, _, err := NewRoot(&app.Service{}).Find([]string{"repo", "create"}) + create, _, err := newRoot(&app.Service{}, "configured.example", "2200").Find([]string{"repo", "create"}) if err != nil { t.Fatalf("find repo create command: %v", err) } flag := create.Flags().Lookup("ssh-port") - if flag == nil || flag.Usage != "SSH port for cloning from or pushing to the selected Knot" { + if flag == nil || flag.Usage != "SSH port for cloning from or pushing to the selected Knot (overrides config file and TG_SSH_PORT)" { t.Fatalf("ssh-port flag = %+v", flag) } - if flag.DefValue != "22" { - t.Fatalf("ssh-port default = %q, want 22", flag.DefValue) + if flag.DefValue != "2200" { + t.Fatalf("ssh-port default = %q, want 2200", flag.DefValue) } - if err := create.Flags().Set("ssh-port", "2200"); err != nil { + if err := create.Flags().Set("ssh-port", "2222"); err != nil { t.Fatalf("set ssh-port: %v", err) } - if got := flag.Value.String(); got != "2200" { - t.Fatalf("ssh-port = %q, want 2200", got) + if got := flag.Value.String(); got != "2222" { + t.Fatalf("ssh-port = %q, want 2222", got) + } +} + +func TestRepoCreateRejectsMalformedSSHPort(t *testing.T) { + command := newRepoCreateCommand(&app.Service{}, "configured.example", "not-a-port") + err := command.RunE(command, []string{"example"}) + if err == nil || !strings.Contains(err.Error(), `invalid SSH port "not-a-port"`) { + t.Fatalf("repo create error = %v", err) + } +} + +func TestRepoCreateKnotFlag(t *testing.T) { + create, _, err := newRoot(&app.Service{}, "configured.example", "22").Find([]string{"repo", "create"}) + if err != nil { + t.Fatalf("find repo create command: %v", err) + } + flag := create.Flags().Lookup("knot") + if flag == nil { + t.Fatal("repo create has no knot flag") + } + if flag.DefValue != "configured.example" || flag.Usage != "Knot host to provision and optionally push to (overrides config file and TG_KNOT)" { + t.Fatalf("knot flag = %+v", flag) + } + if err := create.Flags().Set("knot", "flag.example"); err != nil { + t.Fatalf("set knot flag: %v", err) + } + if got := flag.Value.String(); got != "flag.example" { + t.Fatalf("explicit knot = %q, want flag.example", got) + } + if NewRoot(&app.Service{}).PersistentFlags().Lookup("knot") != nil { + t.Fatal("knot flag must not be global") } }