diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e8eb4f7..01386f8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,7 +15,7 @@ builds: goos: - linux - darwin - # No windows - symlinks not supported well + - windows # Windows applies themes by copying instead of symlinking goarch: - amd64 - arm64 @@ -33,7 +33,7 @@ archives: name_template: "stellar-{{ .Os }}-{{ .Arch }}" files: - stellar - format: binary + formats: [binary] # Checksums checksum: @@ -69,9 +69,14 @@ release: ```bash # Update stellar stellar update - # Install stellar + # Install stellar (Linux / macOS) curl -fsSL https://raw.githubusercontent.com/a3chron/stellar/main/install.sh | bash ``` + + ```powershell + # Install stellar (Windows) + irm https://raw.githubusercontent.com/a3chron/stellar/main/install.ps1 | iex + ``` **Full Changelog**: https://github.com/a3chron/stellar/compare/{{ .PreviousTag }}...{{ .Tag }} diff --git a/README.md b/README.md index 887b98d..ae49e0d 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,22 @@ Easily get and switch between starship configs ## Installation +### Linux / macOS + Just run the [install script](https://raw.githubusercontent.com/a3chron/stellar/main/install.sh) (which will download the binary and move it to `~/.local/bin`) ```bash curl -fsSL https://raw.githubusercontent.com/a3chron/stellar/main/install.sh | bash ``` +### Windows + +Run the [PowerShell install script](https://raw.githubusercontent.com/a3chron/stellar/main/install.ps1) +(which downloads the binary to `%LOCALAPPDATA%\stellar\bin` and adds it to your PATH) +```powershell +irm https://raw.githubusercontent.com/a3chron/stellar/main/install.ps1 | iex +``` + Check that stellar is installed with `stellar --version` or `stellar --help`, and search for a theme you like on the [stellar hub](https://stellar-hub.vercel.app) to apply. @@ -30,15 +40,21 @@ Some [basic usage](#basic-usage) covered here, for more info, run `stellar --hel > [!NOTE] -> stellar is not yet available for windows, because stellar uses symlinks, and windows is weird with symlinks. -> Windows users would need to either: -> - Run it with admin privileges -> - Enable Developer Mode in Windows 10+ -> - (Use WSL -> not really "Windows") -> -> which is not optimal, and means we will have to do a special case for windows, which may take some time +> On Linux and macOS, stellar applies a theme by **symlinking** `~/.config/starship.toml` to the cached +> config, which gives you hot-reload while editing local configs. +> +> Windows handles symlinks poorly (they need Developer Mode or admin privileges), so on Windows stellar +> **copies** the theme file over `starship.toml` instead. Everything works the same, with one caveat: +> editing a local theme file does **not** live-update `starship.toml` (it's a copy, not a link) — just +> re-run `stellar apply /` after editing. > -> In the meantime, you can try out the [starship theme switcher](https://github.com/a3chron/starship-theme-switcher), the first version of stellar, with a lot of features missing, but should be able to run on anything at least. +> You can force copy mode anywhere (e.g. for testing) by setting `STELLAR_APPLY_MODE=copy`. +> +> **Release note:** the backup folder name is now the sanitized OS username (e.g. `john.doe` -> `john-doe`, +> spaces and other characters `starship.toml`'s theme-identifier parser rejects are replaced with `-`), so a +> raw Windows `DOMAIN\user`-style name always produces a valid, restorable `stellar apply /backup` +> hint. Backups created before this change under the old, unsanitized folder name are left in place at +> their original path and are not migrated automatically. ## Why use @@ -112,11 +128,24 @@ with beeing able to update either metadata like the theme name, description, pre When you first use `stellar apply`, if you have an existing `~/.config/starship.toml` that's not managed by stellar, it will be automatically backed up to `~/.config/stellar//backup/1.0.toml` before creating the symlink. -This ensures your carefully crafted config is never lost :) You can apply it anytime with: +Backups are versioned. If stellar later finds another unmanaged `starship.toml` (for example one you restored or hand-wrote), it backs that up too as `2.0.toml`, then `3.0.toml`, and so on, so an earlier backup is never overwritten. + +This also works in copy mode (the Windows default): if you edit the applied `starship.toml` directly, stellar notices the file no longer matches the theme it applied and backs up your edits before applying the next theme. + +Stellar recognizes its own applied file by a checksum recorded in `~/.config/stellar/config.json`, independent of apply mode (symlink or copy) or OS. This means editing a cached theme file directly and re-applying it, or running `stellar clean`/`stellar clean --all` and then applying another theme, never creates a spurious backup — only a config file you actually hand-edited yourself gets preserved. + +`stellar clean` (with or without `--all`) never deletes your backups either — they're preserved automatically and only removed if you explicitly run `stellar remove /backup`. + +This ensures your carefully crafted config is never lost :) You can apply the newest backup anytime with: ```bash stellar apply /backup ``` +To restore a specific one — for instance your very first original config — pin the version: +```bash +stellar apply /backup@1.0 +``` + You can also rename the backup folder to give it a proper theme name: ```bash mv ~/.config/stellar//backup ~/.config/stellar//my-custom-theme @@ -143,6 +172,10 @@ and then switch to it using `stellar apply ...`. Because stellar is using a symlink to the currently selected config file, you get hot-reload as well for editing configs, just like with the usual `starship.toml`. +> [!NOTE] +> On Windows stellar copies the config instead of symlinking it (see the [Windows note](#windows)), +> so editing a local theme file does **not** hot-reload — re-run `stellar apply /` after editing. + ## Troubleshooting ### "Theme not found online, using local cache" @@ -222,8 +255,13 @@ When adding new CLI features, please add corresponding E2E tests in `cmd/e2e_tes - [x] Allow removing several themes at once: `stellar remove a3chron/ctp-green a3chron/ctp-red` - [x] Preview: maybe cache in /tmp, os not downloading two times, but also not saving previewed themes in stellar cache +- [x] Add tests +- [x] **Windows support**: apply themes by copying instead of symlinking (`STELLAR_APPLY_MODE`), Windows release binary, PowerShell installer, and `stellar update` - [ ] **Preview: fix bash formatting** +- [ ] **`stellar preview` on Windows**: `cmd/preview.go` only spawns terminals on macOS/Linux and returns "unsupported platform" on Windows. Needs a Windows Terminal / PowerShell branch that opens a shell with `STARSHIP_CONFIG` set. +- [ ] **Windows packaging**: consider scoop/winget packaging (leftover `stellar.exe.old` from self-update is already cleaned up on the next run). +- [ ] **CI test job**: the release workflow runs no `go test` today; add one (ideally with a `windows-latest` runner) to guard the copy path natively. - [ ] **`stellar publish` command**: Upload local themes directly to stellar-hub - Challenge: Need to implement CLI authentication (OAuth flow with browser redirect or API keys) - Would read from `~/.config/stellar///.toml` @@ -233,8 +271,7 @@ When adding new CLI features, please add corresponding E2E tests in `cmd/e2e_tes - Requires authentication (same challenge as publish) - Upload new version of already published theme - Interactive prompts for version notes, dependencies, etc. -- [ ] Add progress bars for downloads? -- [x] Add tests +- [ ] Add progress bars for downloads - [ ] Get into nix pckgs
diff --git a/cmd/apply.go b/cmd/apply.go index d06df1d..08d5c7e 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "os/user" "strings" "github.com/a3chron/stellar/internal/api" @@ -20,14 +19,6 @@ import ( var forceApply bool var updateTheme bool -func getCurrentUsername() string { - currentUser, err := user.Current() - if err != nil { - return "local" - } - return currentUser.Username -} - // promptConfirmation asks for user confirmation, defaults to No func promptConfirmation(prompt string) bool { reader := bufio.NewReader(os.Stdin) @@ -42,6 +33,15 @@ func promptConfirmation(prompt string) bool { return response == "y" || response == "yes" } +// printBackupNotice tells the user their original starship.toml was preserved +// and how to restore it. Shared by apply and rollback so both surface the +// same notice whenever backupOriginalConfig actually creates a backup. +func printBackupNotice(info *symlink.BackupInfo) { + color.Yellow("Your original starship.toml has been backed up to:") + color.Yellow(" %s", info.Path) + color.Cyan("\nYou can apply it later with: stellar apply %s \n", info.Identifier) +} + var applyCmd = &cobra.Command{ Use: "apply [author/theme[@version]]", Short: "Apply a Starship theme", @@ -174,14 +174,15 @@ var applyCmd = &cobra.Command{ return err } - // 5. Create symlink FIRST (before saving config) - // This ensures that if symlink fails, config remains unchanged - backupPath, err := symlink.CreateSymlink(themePath) + // 5. Apply the theme FIRST (before saving config) + // This ensures that if applying fails, config remains unchanged + backupInfo, err := symlink.ApplyTheme(themePath, cfg) if err != nil { return err } - // 6. Update config only AFTER symlink succeeds + // 6. Update config only AFTER applying succeeds + // (ApplyTheme has already recorded cfg.AppliedHash for the applied file) cfg.PreviousTheme = cfg.CurrentTheme cfg.PreviousPath = cfg.CurrentPath cfg.CurrentTheme = t.String() @@ -194,10 +195,8 @@ var applyCmd = &cobra.Command{ } // Notify user if their original config was backed up - if backupPath != "" { - color.Yellow("Your original starship.toml has been backed up to:") - color.Yellow(" %s", backupPath) - color.Cyan("\nYou can apply it later with: stellar apply %s/backup \n", getCurrentUsername()) + if backupInfo != nil { + printBackupNotice(backupInfo) } color.Green("Applied %s", t) diff --git a/cmd/current.go b/cmd/current.go index a0c6301..b7809a7 100644 --- a/cmd/current.go +++ b/cmd/current.go @@ -10,6 +10,26 @@ import ( "github.com/spf13/cobra" ) +// printReapplyHint prints the two-line diagnostic tail shown whenever +// starship.toml is in a state that a re-apply would fix (missing, broken +// symlink, replaced by a directory, unknown path): what config.json believes +// is applied, and the exact command to re-apply it. +func printReapplyHint(cfg *config.Config) { + fmt.Printf("Config says: %s\n", cfg.CurrentTheme) + fmt.Println("\nRe-apply with: stellar apply " + cfg.CurrentTheme) +} + +// printThemeFileMissing prints the "Theme file missing" diagnostic. expectedLabel +// is the only part that differs between call sites (the symlink branch points at +// the link target, the regular-file branch at the cached theme file), so it's +// passed in; everything else is identical. +func printThemeFileMissing(cfg *config.Config, expectedLabel string) { + color.Red("Theme file missing") + fmt.Printf("Theme: %s\n", cfg.CurrentTheme) + fmt.Printf("%s: %s\n", expectedLabel, cfg.CurrentPath) + fmt.Println("\nRe-download with: stellar apply " + cfg.CurrentTheme) +} + var currentCmd = &cobra.Command{ Use: "current", Short: "Show the currently applied theme", @@ -27,22 +47,80 @@ var currentCmd = &cobra.Command{ return nil } - // Verify symlink is still valid - target, err := symlink.GetCurrentTarget() + starshipConfig, err := symlink.StarshipConfigPath() if err != nil { - color.Red("Symlink broken or missing") - fmt.Printf("Config says: %s\n", cfg.CurrentTheme) - fmt.Println("\nRe-apply with: stellar apply " + cfg.CurrentTheme) - return nil + return fmt.Errorf("failed to resolve starship config path: %w", err) } - // Check if target exists - if _, err := os.Stat(target); os.IsNotExist(err) { - color.Red("Theme file missing") - fmt.Printf("Theme: %s\n", cfg.CurrentTheme) - fmt.Printf("Expected at: %s\n", cfg.CurrentPath) - fmt.Println("\nRe-download with: stellar apply " + cfg.CurrentTheme) + // Branch on what's actually on disk (via a single Lstat), not on + // STELLAR_APPLY_MODE/GOOS: a copy-mode config inspected with + // STELLAR_APPLY_MODE=symlink (or vice versa) must still get the right + // diagnostics, since the env var only affects how a *future* apply + // behaves, not what's currently on disk. + info, statErr := os.Lstat(starshipConfig) + switch { + case os.IsNotExist(statErr): + color.Red("Starship config missing") + printReapplyHint(cfg) + return nil + + case statErr == nil && info.Mode()&os.ModeSymlink != 0: + // Symlink mode. Verify the link points at an existing theme file. + target, terr := symlink.GetCurrentTarget() + if terr != nil { + color.Red("Symlink broken or missing") + printReapplyHint(cfg) + return nil + } + + if _, err := os.Stat(target); os.IsNotExist(err) { + printThemeFileMissing(cfg, "Expected at") + return nil + } + + case statErr == nil && info.IsDir(): + // Something (not stellar) replaced starship.toml with a + // directory. Neither symlink nor copy mode ever produces this, + // so there's nothing further to check. + color.Red("Starship config path is a directory, not a file") + printReapplyHint(cfg) return nil + + case statErr == nil: + // Regular file (copy mode, or a symlink-mode config someone + // hand-edited into a plain file). There's no link to read, so the + // managed-file check below (IsManaged) verifies the file's + // *content* still matches what was applied, not just that some file + // is present at cfg.CurrentPath. + if cfg.CurrentPath == "" { + color.Red("Current theme path is unknown") + printReapplyHint(cfg) + return nil + } + + if _, err := os.Stat(cfg.CurrentPath); os.IsNotExist(err) { + printThemeFileMissing(cfg, "Cached theme file missing, expected at") + return nil + } + + // Report "modified" using the same predicate apply uses to decide + // whether it would back up this file, so `stellar current` never + // disagrees with what the next `stellar apply` actually does. + modified := !symlink.IsManaged(starshipConfig, cfg) + + if modified { + color.Red("Starship config was modified or replaced") + fmt.Printf("Theme: %s\n", cfg.CurrentTheme) + fmt.Println("starship.toml no longer matches the theme that was applied.") + fmt.Println("The next `stellar apply` will automatically back up your current starship.toml before applying.") + fmt.Println("\nRe-apply with: stellar apply " + cfg.CurrentTheme) + return nil + } + + default: + // Some other Lstat error (e.g. permission denied). Surface it + // rather than silently reporting a healthy state. + return fmt.Errorf("failed to inspect starship config: %w", statErr) } // All good - display current theme @@ -52,8 +130,7 @@ var currentCmd = &cobra.Command{ fmt.Printf(" Path: %s\n", cfg.CurrentPath) fmt.Println() - // Show symlink info - starshipConfig, _ := symlink.StarshipConfigPath() + // Show starship config path fmt.Printf(" Starship config: %s\n", starshipConfig) return nil diff --git a/cmd/e2e_test.go b/cmd/e2e_test.go index 760d06e..5dbee28 100644 --- a/cmd/e2e_test.go +++ b/cmd/e2e_test.go @@ -4,11 +4,20 @@ package cmd import ( "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "github.com/a3chron/stellar/internal/paths" + "github.com/a3chron/stellar/internal/symlink" "github.com/a3chron/stellar/internal/testutil" + "github.com/a3chron/stellar/internal/theme" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,6 +28,7 @@ import ( func TestE2E_Apply(t *testing.T) { t.Run("Download and apply remote theme", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -39,6 +49,7 @@ func TestE2E_Apply(t *testing.T) { }) t.Run("Apply cached theme", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -55,6 +66,208 @@ func TestE2E_Apply(t *testing.T) { assert.Equal(t, themePath, env.ReadSymlink(env.StarshipPath)) }) + t.Run("Apply in copy mode (Windows behavior)", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + + err := cmd.Execute() + require.NoError(t, err) + + // In copy mode starship.toml is a regular file with the theme's content + assert.True(t, env.FileExists(env.StarshipPath)) + assert.False(t, env.IsSymlink(env.StarshipPath)) + assert.Equal(t, testutil.SampleTOML(), env.ReadFile(env.StarshipPath)) + }) + + t.Run("Backup hint is a valid identifier (copy mode)", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + // A pre-existing, unmanaged starship.toml that will be backed up. + env.CreateStarshipConfig("# my hand-written original config") + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + + // Find the printed restore hint and pull the identifier out of it. + var hint string + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "stellar apply ") { + hint = line + break + } + } + require.NotEmpty(t, hint, "expected a backup restore hint in output:\n%s", output) + + idx := strings.Index(hint, "stellar apply ") + identifier := strings.TrimSpace(hint[idx+len("stellar apply "):]) + + // The regression: on Windows a raw "DOMAIN\user/backup" hint would not + // parse. The sanitized identifier must both parse and target the backup. + parsed, perr := theme.ParseIdentifier(identifier) + require.NoError(t, perr, "backup hint %q must be a valid identifier", identifier) + assert.True(t, strings.HasSuffix(parsed.String(), "/backup@1.0"), + "backup hint %q should target /backup@1.0", identifier) + }) + + t.Run("Hand-edited config is backed up (copy mode)", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + env.CreateThemeFile("local", "other", "1.0", testutil.SampleTOMLWithCustom()) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + require.NoError(t, cmd.Execute()) + + // The user edits the applied copy directly, then applies another theme. + editedContent := "# MY HAND-EDITED CONFIG" + env.CreateStarshipConfig(editedContent) + resetFlags() + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/other@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + + // The edit was detected and preserved as a backup, and the user was told. + assert.Contains(t, output, "has been backed up") + backupPath := filepath.Join(env.StellarDir, symlink.BackupAuthor(), "backup", "1.0.toml") + assert.Equal(t, editedContent, env.ReadFile(backupPath)) + assert.Equal(t, testutil.SampleTOMLWithCustom(), env.ReadFile(env.StarshipPath)) + }) + + t.Run("Editing cached theme file then re-applying creates no backup (copy mode)", func(t *testing.T) { + // Regression for finding #1: the README-documented workflow of editing + // a cached theme file directly and re-applying it (for a copy-mode + // hot-reload equivalent) must not be mistaken for a hand-edited + // starship.toml. + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + themePath := env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + require.NoError(t, cmd.Execute()) + + // The user edits the cached theme file itself, not starship.toml. + editedThemeContent := "# EDITED CACHED THEME\nformat = \"$all\"\n" + require.NoError(t, os.WriteFile(themePath, []byte(editedThemeContent), 0644)) + resetFlags() + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + + assert.NotContains(t, output, "backed up", "re-applying an edited cached theme must not trigger a backup") + assert.Equal(t, editedThemeContent, env.ReadFile(env.StarshipPath)) + }) + + t.Run("Clean --all then applying another theme creates no backup (copy mode)", func(t *testing.T) { + // Regression for finding #1: "stellar clean --all" removes the cached + // theme file that stellar's copy came from, but the copy on disk (and + // its recorded applied_hash) don't change, so it must still be + // recognized as stellar's own file. + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + require.NoError(t, cmd.Execute()) + resetFlags() + + cleanRunner := NewRootCmd() + cleanRunner.SetArgs([]string{"clean", "--all"}) + cleanRunner.SetOut(new(bytes.Buffer)) + require.NoError(t, cleanRunner.Execute()) + resetFlags() + + // The next theme becomes available only after the clean (e.g. a fresh + // download); what matters for this regression is that stellar's own + // existing copy on disk is still recognized despite its cache source + // being gone. + env.CreateThemeFile("local", "other", "1.0", testutil.SampleTOMLWithCustom()) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/other@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + + assert.NotContains(t, output, "backed up", "clean --all followed by apply must not trigger a junk backup") + assert.Equal(t, testutil.SampleTOMLWithCustom(), env.ReadFile(env.StarshipPath)) + }) + + t.Run("Switching apply mode copy to symlink creates no backup", func(t *testing.T) { + // Regression for finding #2: the managed-file check must not be + // mode-gated. Applying in copy mode, then applying again in symlink + // mode, must recognize the copy-mode file via applied_hash instead of + // backing it up as if it were a foreign original. + testutil.RequireSymlinks(t) + env := testutil.SetupTestEnv(t) + resetFlags() + + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + env.CreateThemeFile("local", "other", "1.0", testutil.SampleTOMLWithCustom()) + + t.Setenv(paths.EnvApplyMode, "copy") + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + require.NoError(t, cmd.Execute()) + resetFlags() + + t.Setenv(paths.EnvApplyMode, "symlink") + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"apply", "local/other@1.0"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + + assert.NotContains(t, output, "backed up", "mode switch must not trigger a junk backup") + assert.True(t, env.IsSymlink(env.StarshipPath)) + }) + t.Run("Apply previewed theme from tmp", func(t *testing.T) { env := testutil.SetupTestEnv(t) resetFlags() @@ -78,6 +291,7 @@ func TestE2E_Apply(t *testing.T) { }) t.Run("Apply with update flag", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -99,6 +313,7 @@ func TestE2E_Apply(t *testing.T) { }) t.Run("Apply uses local cache without update", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -120,6 +335,7 @@ func TestE2E_Apply(t *testing.T) { }) t.Run("Apply specific version", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -139,6 +355,7 @@ func TestE2E_Apply(t *testing.T) { }) t.Run("Apply backs up original config", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -170,6 +387,7 @@ func TestE2E_Apply(t *testing.T) { }) t.Run("Apply updates config file", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) resetFlags() @@ -185,6 +403,7 @@ func TestE2E_Apply(t *testing.T) { configContent := env.ReadFile(filepath.Join(env.StellarDir, "config.json")) assert.Contains(t, configContent, "local/mytheme@1.0") assert.Contains(t, configContent, themePath) + assert.Contains(t, configContent, "applied_hash", "config should record the hash of the applied theme") }) t.Run("Apply nonexistent theme errors", func(t *testing.T) { @@ -297,6 +516,7 @@ func TestE2E_Current(t *testing.T) { }) t.Run("Shows applied theme", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) @@ -316,7 +536,114 @@ func TestE2E_Current(t *testing.T) { require.NoError(t, err) }) + t.Run("Shows applied theme (copy mode)", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + // Copy mode: starship.toml is a regular file, not a symlink + env.CreateStarshipConfig(testutil.SampleTOML()) + + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + themePath + `" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "alice/rainbow@1.0") + }) + + t.Run("Missing starship.toml in copy mode", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + // No starship.toml on disk, but config claims a theme is applied + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + env.StellarDir + `/alice/rainbow/1.0.toml" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + assert.NoError(t, execErr) + assert.Contains(t, output, "Starship config missing") + assert.Contains(t, output, "stellar apply alice/rainbow@1.0") + }) + + t.Run("Copy mode with cached theme file deleted reports missing", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + // starship.toml (the copy) is present and healthy, but the cached + // theme file it was copied from has since been removed (e.g. via + // "stellar clean --all"). current.go has no symlink to follow in + // copy mode, so cfg.CurrentPath is the only thing it can check. + env.CreateStarshipConfig(testutil.SampleTOML()) + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + env.StellarDir + `/alice/rainbow/1.0.toml" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + assert.NoError(t, execErr) + assert.Contains(t, output, "Theme file missing") + assert.Contains(t, output, "Cached theme file missing", "wording should acknowledge the file is a standalone copy") + assert.Contains(t, output, "stellar apply alice/rainbow@1.0") + }) + + t.Run("Copy-applied config inspected with mode=symlink env still reports healthy", func(t *testing.T) { + // Regression for the mode-switch misclassification bug: current.go + // must branch on what's actually on disk (a regular file here), not + // on STELLAR_APPLY_MODE, which only describes how a *future* apply + // would behave. + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "symlink") + + themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + env.CreateStarshipConfig(testutil.SampleTOML()) + + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + themePath + `" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "Current Theme") + assert.NotContains(t, output, "missing") + assert.NotContains(t, output, "broken") + }) + t.Run("Broken symlink", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) require.NoError(t, os.Symlink("/nonexistent/path.toml", env.StarshipPath)) @@ -351,6 +678,108 @@ func TestE2E_Current(t *testing.T) { err := cmd.Execute() assert.NoError(t, err) }) + + t.Run("Copy-mode apply left untouched reports healthy", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + + applyCmd := NewRootCmd() + applyCmd.SetArgs([]string{"apply", "alice/rainbow@1.0"}) + applyCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, applyCmd.Execute()) + resetFlags() + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "Current Theme") + assert.NotContains(t, output, "modified") + }) + + t.Run("Copy-mode apply then hand-edit reports modified, not healthy", func(t *testing.T) { + // Regression: current.go used to only check that cfg.CurrentPath (the + // cached theme file) existed, never comparing starship.toml's actual + // content against cfg.AppliedHash, so a modified/replaced starship.toml + // was reported as a healthy applied theme. + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + resetFlags() + + env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + + applyCmd := NewRootCmd() + applyCmd.SetArgs([]string{"apply", "alice/rainbow@1.0"}) + applyCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, applyCmd.Execute()) + resetFlags() + + // Hand-edit the applied copy directly (no re-apply). + env.CreateStarshipConfig("# hand-edited after apply, no longer matches the theme") + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "modified or replaced") + assert.NotContains(t, output, "Current Theme", "a modified starship.toml must not be reported as healthy") + }) + + t.Run("Empty current path reports broken, not healthy", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + env.CreateStarshipConfig(testutil.SampleTOML()) + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "Current theme path is unknown") + assert.NotContains(t, output, "Current Theme") + }) + + t.Run("Starship config replaced by a directory reports broken", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + + require.NoError(t, os.MkdirAll(env.StarshipPath, 0755)) + + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + env.StellarDir + `/alice/rainbow/1.0.toml" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"current"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "directory, not a file") + }) } // ============================================================================= @@ -517,6 +946,7 @@ func TestE2E_Rollback(t *testing.T) { }) t.Run("Swaps current and previous", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) currentPath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) @@ -542,7 +972,73 @@ func TestE2E_Rollback(t *testing.T) { assert.Equal(t, previousPath, env.ReadSymlink(env.StarshipPath)) }) + t.Run("Swaps current and previous (copy mode)", func(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + currentPath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + previousPath := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOMLWithCustom()) + // Copy mode: starship.toml is a regular file with the current theme's content + env.CreateStarshipConfig(testutil.SampleTOML()) + + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + currentPath + `", + "previous_theme": "bob/sunset@2.0", + "previous_path": "` + previousPath + `" +}` + env.CreateConfig(config) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"rollback"}) + cmd.SetOut(new(bytes.Buffer)) + + err := cmd.Execute() + require.NoError(t, err) + + // starship.toml now holds the previous theme's content, still a regular file + assert.False(t, env.IsSymlink(env.StarshipPath)) + assert.Equal(t, testutil.SampleTOMLWithCustom(), env.ReadFile(env.StarshipPath)) + }) + + t.Run("Rollback backup notice (copy mode, hand-edited config)", func(t *testing.T) { + // Regression for the silent-rollback-backup bug: rollback must print + // the same backup notice apply does whenever backupPath != "". + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + currentPath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + previousPath := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOMLWithCustom()) + + // The user hand-edited the applied copy; config has no applied_hash + // (fabricated directly), so this can't be recognized as stellar's own. + editedContent := "# MY HAND-EDITED CONFIG BEFORE ROLLBACK" + env.CreateStarshipConfig(editedContent) + + config := `{ + "current_theme": "alice/rainbow@1.0", + "current_path": "` + currentPath + `", + "previous_theme": "bob/sunset@2.0", + "previous_path": "` + previousPath + `" +}` + env.CreateConfig(config) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"rollback"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + + assert.Contains(t, output, "has been backed up") + backupPath := filepath.Join(env.StellarDir, symlink.BackupAuthor(), "backup", "1.0.toml") + assert.Equal(t, editedContent, env.ReadFile(backupPath)) + }) + t.Run("Double rollback returns to original", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) currentPath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) @@ -569,6 +1065,7 @@ func TestE2E_Rollback(t *testing.T) { }) t.Run("Redownloads missing theme", func(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) mockAPI := testutil.CreateDefaultMockAPI() @@ -742,6 +1239,70 @@ func TestE2E_Clean(t *testing.T) { assert.False(t, env.FileExists(filepath.Join(env.StellarDir, "alice", "rainbow", "1.0.toml"))) assert.False(t, env.FileExists(filepath.Join(env.StellarDir, "alice", "rainbow", "2.0.toml"))) }) + + t.Run("Preserves backups", func(t *testing.T) { + // Regression: CleanCache used to sweep /backup like any other + // cached theme, permanently destroying the user's original config and + // making the printed restore hint ("stellar apply /backup@1.0") + // unrecoverable, since "backup" isn't a real author on the hub. + testutil.RequireSymlinks(t) + env := testutil.SetupTestEnv(t) + resetFlags() + + // An unmanaged starship.toml gets backed up when a theme is applied. + originalContent := "# my hand-written original config" + env.CreateStarshipConfig(originalContent) + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + + applyCmd := NewRootCmd() + applyCmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + applyCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, applyCmd.Execute()) + resetFlags() + + backupPath := filepath.Join(env.StellarDir, symlink.BackupAuthor(), "backup", "1.0.toml") + require.True(t, env.FileExists(backupPath), "precondition: backup should exist before cleaning") + + cleanCmd := NewRootCmd() + cleanCmd.SetArgs([]string{"clean"}) + cleanCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, cleanCmd.Execute()) + + assert.True(t, env.FileExists(backupPath), "stellar clean must never delete backups of the user's original config") + resetFlags() + + // The restore hint stellar printed must still work. + restoreCmd := NewRootCmd() + restoreCmd.SetArgs([]string{"apply", symlink.BackupAuthor() + "/backup@1.0"}) + restoreCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, restoreCmd.Execute()) + assert.Equal(t, originalContent, env.ReadFile(env.StarshipPath)) + }) + + t.Run("--all also preserves backups", func(t *testing.T) { + testutil.RequireSymlinks(t) + env := testutil.SetupTestEnv(t) + resetFlags() + + env.CreateStarshipConfig("# another hand-written original config") + env.CreateThemeFile("local", "mytheme", "1.0", testutil.SampleTOML()) + + applyCmd := NewRootCmd() + applyCmd.SetArgs([]string{"apply", "local/mytheme@1.0"}) + applyCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, applyCmd.Execute()) + resetFlags() + + backupPath := filepath.Join(env.StellarDir, symlink.BackupAuthor(), "backup", "1.0.toml") + require.True(t, env.FileExists(backupPath), "precondition: backup should exist before cleaning") + + cleanCmd := NewRootCmd() + cleanCmd.SetArgs([]string{"clean", "--all"}) + cleanCmd.SetOut(new(bytes.Buffer)) + require.NoError(t, cleanCmd.Execute()) + + assert.True(t, env.FileExists(backupPath), "stellar clean --all must never delete backups either") + }) } // ============================================================================= @@ -848,6 +1409,162 @@ func TestE2E_Preview(t *testing.T) { }) } +// ============================================================================= +// Update Tests +// ============================================================================= + +// TestE2E_Update exercises `stellar update` against a fake GitHub-shaped +// server, using the LatestReleaseAPIURL/LatestReleaseURL test seams (see +// cmd/version.go and cmd/update.go). versionInfo is pinned to a known +// non-dev version via SetVersionInfo (and restored afterward) so +// IsUpdateAvailable's comparison is deterministic. +// +// The "successful update" subtest deliberately lets the command rename a +// fresh file over os.Executable() - which in a test binary is the compiled +// go-test executable itself. Overwriting it is safe for *this* running +// process (Linux keeps the old, now-nameless inode mapped until the process +// exits), but backupBinaryForUpdateTest restores the original bytes +// afterward so later test runs (and any cached test binary on disk) aren't +// left corrupted. +func TestE2E_Update(t *testing.T) { + binaryName := platformBinaryName() + + // pinVersion sets versionInfo to a known non-dev version (IsDev() gates + // all update-checking on the version not being "dev") and restores the + // original values via t.Cleanup. versionInfo is an unexported package-level + // var in this same package, so the test can read/write it directly instead + // of needing an exported getter. + pinVersion := func(t *testing.T, version string) { + t.Helper() + origVersion, origCommit, origDate := versionInfo.version, versionInfo.commit, versionInfo.date + SetVersionInfo(version, "testcommit", "2024-01-01") + t.Cleanup(func() { + SetVersionInfo(origVersion, origCommit, origDate) + }) + } + + // pointAtServer starts an httptest server for mux, redirects both URL + // seams at it, and restores the real GitHub URLs afterward. + pointAtServer := func(t *testing.T, mux *http.ServeMux) { + t.Helper() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + origAPIURL, origReleaseURL := LatestReleaseAPIURL, LatestReleaseURL + LatestReleaseAPIURL = server.URL + "/api/latest" + LatestReleaseURL = server.URL + "/release" + t.Cleanup(func() { + LatestReleaseAPIURL = origAPIURL + LatestReleaseURL = origReleaseURL + }) + } + + releaseJSON := func(tag string) string { + return fmt.Sprintf( + `{"tag_name":%q,"name":%q,"published_at":"2024-01-01T00:00:00Z","html_url":"https://example.com/%s"}`, + tag, tag, tag, + ) + } + + t.Run("Already up to date", func(t *testing.T) { + _ = testutil.SetupTestEnv(t) + pinVersion(t, "1.2.3") + + mux := http.NewServeMux() + mux.HandleFunc("/api/latest", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(releaseJSON("v1.2.3"))) + }) + pointAtServer(t, mux) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"update"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "already on the latest version") + }) + + t.Run("Checksum mismatch aborts and leaves binary untouched", func(t *testing.T) { + _ = testutil.SetupTestEnv(t) + pinVersion(t, "1.0.0") + + fakeBody := []byte("fake stellar binary contents for checksum-mismatch test") + + execPath, err := os.Executable() + require.NoError(t, err) + originalContent, err := os.ReadFile(execPath) + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/api/latest", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(releaseJSON("v1.1.0"))) + }) + mux.HandleFunc("/release/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + // A well-formed but wrong hash (64 hex chars). + wrongHash := strings.Repeat("a", 64) + _, _ = fmt.Fprintf(w, "%s %s\n", wrongHash, binaryName) + }) + mux.HandleFunc("/release/"+binaryName, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(fakeBody) + }) + pointAtServer(t, mux) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"update"}) + cmd.SetOut(new(bytes.Buffer)) + err = cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum verification failed") + + // Nothing was ever renamed into place: the real binary is untouched. + currentContent, rerr := os.ReadFile(execPath) + require.NoError(t, rerr) + assert.Equal(t, originalContent, currentContent, "binary must be untouched when checksum verification fails") + }) + + t.Run("Successful update replaces the binary", func(t *testing.T) { + _ = testutil.SetupTestEnv(t) + pinVersion(t, "1.0.0") + backupBinaryForUpdateTest(t) + + fakeBody := []byte("fake stellar binary contents for successful-update test") + sum := sha256.Sum256(fakeBody) + hexSum := hex.EncodeToString(sum[:]) + + mux := http.NewServeMux() + mux.HandleFunc("/api/latest", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(releaseJSON("v1.1.0"))) + }) + mux.HandleFunc("/release/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s %s\n", hexSum, binaryName) + }) + mux.HandleFunc("/release/"+binaryName, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(fakeBody) + }) + pointAtServer(t, mux) + + var execErr error + output := testutil.CaptureOutput(t, func() { + cmd := NewRootCmd() + cmd.SetArgs([]string{"update"}) + cmd.SetOut(new(bytes.Buffer)) + execErr = cmd.Execute() + }) + require.NoError(t, execErr) + assert.Contains(t, output, "Successfully updated to version v1.1.0") + + execPath, err := os.Executable() + require.NoError(t, err) + content, err := os.ReadFile(execPath) + require.NoError(t, err) + assert.Equal(t, fakeBody, content, "the file at os.Executable() should now hold the downloaded content") + }) +} + // ============================================================================= // Helper functions // ============================================================================= @@ -860,6 +1577,30 @@ func resetFlags() { cleanAll = false } +// backupBinaryForUpdateTest saves the current test binary's bytes and mode +// (os.Executable() in a test process resolves to the compiled test binary) +// and restores them via t.Cleanup. It must be called by any test that lets +// "stellar update" actually replace os.Executable(), so the on-disk test +// binary isn't left holding fake content for later test runs. +func backupBinaryForUpdateTest(t *testing.T) { + t.Helper() + + execPath, err := os.Executable() + require.NoError(t, err) + + original, err := os.ReadFile(execPath) + require.NoError(t, err) + + info, err := os.Stat(execPath) + require.NoError(t, err) + mode := info.Mode() + + t.Cleanup(func() { + _ = os.WriteFile(execPath, original, mode) + _ = os.Chmod(execPath, mode) + }) +} + // init ensures flags are reset at test start func init() { resetFlags() diff --git a/cmd/rollback.go b/cmd/rollback.go index e6e5f92..1b35666 100644 --- a/cmd/rollback.go +++ b/cmd/rollback.go @@ -68,14 +68,15 @@ var rollbackCmd = &cobra.Command{ previousTheme := cfg.PreviousTheme previousPath := cfg.PreviousPath - // Update symlink FIRST (before modifying config) - // This ensures that if symlink fails, config remains unchanged - _, err = symlink.CreateSymlink(previousPath) + // Apply the previous theme FIRST (before modifying config) + // This ensures that if applying fails, config remains unchanged + backupInfo, err := symlink.ApplyTheme(previousPath, cfg) if err != nil { - return fmt.Errorf("failed to update symlink: %w", err) + return fmt.Errorf("failed to apply previous theme: %w", err) } - // Only swap config after symlink succeeds + // Only swap config after applying succeeds + // (ApplyTheme has already recorded cfg.AppliedHash for the applied file) cfg.PreviousTheme = cfg.CurrentTheme cfg.PreviousPath = cfg.CurrentPath cfg.CurrentTheme = previousTheme @@ -88,6 +89,11 @@ var rollbackCmd = &cobra.Command{ return fmt.Errorf("rollback applied but failed to save config: %w", err) } + // Notify user if their original config was backed up (previously silent) + if backupInfo != nil { + printBackupNotice(backupInfo) + } + color.Green("Rolled back to: %s", cfg.CurrentTheme) return nil diff --git a/cmd/root.go b/cmd/root.go index b1efa4f..4d821db 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,8 @@ func NewRootCmd() *cobra.Command { Short: "Starship theme manager", Long: `Stellar - Discover, preview, and apply Starship themes from the community`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Best-effort cleanup of files left behind by a previous self-update + cleanupUpdateLeftovers() // Initialize stellar directory structure before any command runs return stellarinit.EnsureStellarDir() }, diff --git a/cmd/update.go b/cmd/update.go index 9c956a7..f8b295d 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -2,22 +2,38 @@ package cmd import ( "bufio" - "crypto/sha256" - "encoding/hex" "fmt" "io" "net/http" "os" + "path/filepath" "runtime" "strings" + "time" + "github.com/a3chron/stellar/internal/symlink" "github.com/fatih/color" "github.com/spf13/cobra" ) -const ( - LatestReleaseURL = "https://github.com/a3chron/stellar/releases/latest/download" -) +// LatestReleaseURL is the base URL for downloading the latest release's +// assets (checksums.txt and the per-platform binaries). It is a var rather +// than a const so tests can point it at an httptest server; production +// behavior is unchanged since the default is assigned here. +var LatestReleaseURL = "https://github.com/a3chron/stellar/releases/latest/download" + +// updateTempPrefix is the filename prefix for the in-progress download temp +// file that "stellar update" writes next to the running binary. It is the +// single string removeUpdateLeftovers matches on to garbage-collect the +// leftovers of an interrupted update. +const updateTempPrefix = ".stellar-update-" + +// updateRecoveryPrefix is the filename prefix for a checksum-verified download +// that survived a double-failure in replaceExecutable and must be preserved for +// manual recovery. It deliberately does NOT begin with updateTempPrefix, so +// removeUpdateLeftovers never matches — and thus never deletes — the exact file +// the recovery instructions tell the user to move into place by hand. +const updateRecoveryPrefix = ".stellar-recovery-" // fetchChecksums downloads checksums.txt from GitHub releases func fetchChecksums() (string, error) { @@ -79,26 +95,6 @@ func parseChecksum(checksums, binaryName string) (string, error) { return "", fmt.Errorf("checksum not found for binary: %s", binaryName) } -// computeFileHash computes the SHA256 hash of a file and returns it as a hex string -func computeFileHash(filePath string) (hash string, err error) { - f, err := os.Open(filePath) - if err != nil { - return "", fmt.Errorf("failed to open file for hashing: %w", err) - } - defer func() { - if cerr := f.Close(); cerr != nil && err == nil { - err = cerr - } - }() - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", fmt.Errorf("failed to hash file: %w", err) - } - - return hex.EncodeToString(h.Sum(nil)), nil -} - // verifyChecksum compares expected and actual checksums func verifyChecksum(expected, actual, binaryName string) error { expected = strings.ToLower(strings.TrimSpace(expected)) @@ -114,6 +110,18 @@ func verifyChecksum(expected, actual, binaryName string) error { return nil } +// platformBinaryName returns the release asset name for the running platform, +// matching the per-platform artifact names goreleaser produces +// ("stellar--", with a ".exe" suffix on Windows). It is the single +// definition of that name, shared by updateCmd and the update E2E test. +func platformBinaryName() string { + name := fmt.Sprintf("stellar-%s-%s", runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + var updateCmd = &cobra.Command{ Use: "update", Short: "Update stellar CLI to the latest version", @@ -133,11 +141,8 @@ var updateCmd = &cobra.Command{ color.Yellow("Updating to version %s...", latestVersion) - // Construct binary name based on OS/arch - binary := fmt.Sprintf("stellar-%s-%s", runtime.GOOS, runtime.GOARCH) - if runtime.GOOS == "windows" { - return fmt.Errorf("why would you use windows? Anyways, stellar does not yet support windows, but support is planned") - } + // Release asset name for this platform (matches goreleaser artifact names) + binary := platformBinaryName() // Step 1: Fetch checksums.txt for verification color.Yellow("Fetching checksums...") @@ -168,8 +173,16 @@ var updateCmd = &cobra.Command{ return fmt.Errorf("download failed (status: %d)", resp.StatusCode) } - // Write to temporary file - tmpFile, err := os.CreateTemp("", "stellar-update-*") + // Resolve the running binary's path up front so the downloaded file can be + // written next to it. Keeping them on the same volume means the final + // rename works on all OSes (system temp may be on a different volume). + execPath, err := os.Executable() + if err != nil { + return err + } + + // Write to a temporary file in the same directory as the current binary + tmpFile, err := os.CreateTemp(filepath.Dir(execPath), updateTempPrefix+"*") if err != nil { return err } @@ -181,6 +194,9 @@ var updateCmd = &cobra.Command{ } if _, err := io.Copy(tmpFile, resp.Body); err != nil { + // Close first so the temp file isn't left with an open handle + // (which would block removal on Windows). + _ = tmpFile.Close() cleanup() return err } @@ -191,7 +207,7 @@ var updateCmd = &cobra.Command{ // Step 4: Compute hash of downloaded file color.Yellow("Verifying checksum...") - actualHash, err := computeFileHash(tmpPath) + actualHash, err := symlink.HashFile(tmpPath) if err != nil { cleanup() return fmt.Errorf("failed to compute checksum: %w", err) @@ -205,24 +221,196 @@ var updateCmd = &cobra.Command{ color.Green("Checksum verified successfully") // Step 6: Replace current binary (only after checksum verified) - execPath, err := os.Executable() - if err != nil { - cleanup() - return err - } - if err := os.Chmod(tmpPath, 0755); err != nil { cleanup() return err } - if err := os.Rename(tmpPath, execPath); err != nil { - cleanup() + // replaceExecutable owns the fate of tmpPath from here on: on success it + // has been renamed into place, and on failure it has either removed it + // (nothing of value lost) or deliberately preserved it for manual + // recovery (with instructions in the returned error). The caller must + // not call cleanup() itself, or it could delete the only verified copy + // of the new binary out from under a recovery path. + if err := replaceExecutable(tmpPath, execPath); err != nil { return err } - // No cleanup needed - temp file was successfully moved color.Green("Successfully updated to version %s!", latestVersion) return nil }, } + +// cleanupUpdateLeftovers is the PersistentPreRunE gate: it best-effort removes +// artifacts a previous "stellar update" run may have left behind — a ".old" +// binary (Windows renames the running .exe aside rather than overwriting it, +// since a running .exe can't be deleted) and any ".stellar-update-*" temp file +// that survived an interrupted update (e.g. Ctrl-C or SIGKILL before the +// update command's own cleanup ran). Temp files are created next to the +// binary on every OS, so this cleanup runs on every OS, not just Windows. +func cleanupUpdateLeftovers() { + execPath, err := os.Executable() + if err != nil { + return + } + removeUpdateLeftovers(execPath) +} + +// removeUpdateLeftovers deletes exactly this binary's ".old" leftover and any +// ".stellar-update-*" temp files next to it. Only stellar's own artifacts are +// touched — the install dir may be a shared bin directory (STELLAR_INSTALL_DIR), +// so a broad "*.old" glob is off-limits. Every removal is best-effort and safe +// to call when nothing is left over. +// +// Directory entries are listed with os.ReadDir and matched with +// strings.HasPrefix rather than filepath.Glob: Glob interprets "[", "]", "*" +// etc. in the directory path itself as pattern metacharacters, so an install +// dir containing one of those characters (e.g. "tools[1]") could silently +// corrupt the pattern or return ErrBadPattern, both of which left leftovers +// on disk forever. +// +// This runs from PersistentPreRunE, before the update command creates its own +// temp file, so it never deletes an in-progress download of the current +// process. To also protect a concurrent "stellar update" in another process, a +// ".stellar-update-*" temp file is only removed once it is older than an hour: +// an in-flight download is touched continuously and always younger than that, +// while a genuine leftover from an interrupted update is not. This closes the +// race for any update that finishes in under an hour. The ".old" removal stays +// unconditional: it's the previous update's now-replaced binary, never a file +// an in-flight update is still writing. +// +// Recovery files (updateRecoveryPrefix, ".stellar-recovery-*") are deliberately +// NOT matched here and are never auto-removed, regardless of age. They exist +// only after a replaceExecutable double-failure has preserved a checksum- +// verified download the user must move into place by hand; the recovery error +// points straight at one, and the user may not re-run stellar for days. Their +// distinct prefix keeps this cleanup from ever eating them out from under those +// instructions. +func removeUpdateLeftovers(execPath string) { + _ = os.Remove(execPath + ".old") + + dir := filepath.Dir(execPath) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), updateTempPrefix) { + continue + } + info, ierr := entry.Info() + if ierr != nil { + // Can't tell how old it is; leave it rather than risk deleting a + // concurrent update's in-flight download. + continue + } + if time.Since(info.ModTime()) < time.Hour { + continue + } + _ = os.Remove(filepath.Join(dir, entry.Name())) + } +} + +// replaceExecutable swaps the running binary at execPath for the checksum- +// verified file at newPath. It owns newPath's fate entirely: on success +// newPath has been renamed into place and no longer exists; on failure it has +// either removed newPath (nothing of value was lost) or deliberately +// preserved it with recovery instructions in the returned error. Callers must +// not remove newPath themselves after calling this. +// +// On Unix a plain rename over the target works, and a failure means execPath +// was never touched, so newPath is simply discarded. +// +// On Windows a running .exe cannot be overwritten, but it can be renamed: +// move the current binary aside, then put the new one in its place. If +// putting the new binary in place fails, we try to restore the original +// binary automatically. If even that restore fails (execPath now missing +// entirely), newPath is deliberately NOT removed — it is the only verified +// copy of the new binary. Instead it is moved to a ".stellar-recovery-*" name +// (see preserveForRecovery) that removeUpdateLeftovers will never auto-delete, +// and the error tells the user exactly what to rename to recover. All the +// Windows renames go through symlink.RenameWithRetry to ride out the transient +// Defender/indexer locks that plague freshly written .exe files. +func replaceExecutable(newPath, execPath string) error { + if runtime.GOOS != "windows" { + if err := os.Rename(newPath, execPath); err != nil { + _ = os.Remove(newPath) + return err + } + return nil + } + + oldPath := execPath + ".old" + _ = os.Remove(oldPath) // clear any leftover from a previous update + + if err := symlink.RenameWithRetry(execPath, oldPath); err != nil { + // execPath was never touched, so the verified download isn't needed. + _ = os.Remove(newPath) + return err + } + + if err := symlink.RenameWithRetry(newPath, execPath); err != nil { + if restoreErr := symlink.RenameWithRetry(oldPath, execPath); restoreErr == nil { + // Original binary restored; the verified download isn't needed. + _ = os.Remove(newPath) + return err + } + + // Double failure: execPath is now missing entirely. Preserve newPath + // (the verified new binary) and oldPath (the previous binary) and + // tell the user exactly how to recover instead of silently deleting + // the one file that's known-good. + // + // Move the download to a ".stellar-recovery-*" name first: it currently + // sits under the ".stellar-update-*" temp name, which a later stellar + // run's removeUpdateLeftovers deletes once it is older than an hour. A + // recovery file has to outlive that window (the user may not re-run + // stellar for days), so it is renamed to the prefix cleanup never + // touches. The error then references wherever the file actually landed. + recoveryPath := preserveForRecovery(newPath) + return fmt.Errorf( + "failed to install update and could not restore the previous binary: %w\n\n"+ + "Your files were not deleted, but manual recovery is needed:\n"+ + " - verified new binary: %s\n"+ + " - previous binary: %s\n\n"+ + "To finish the update yourself, run:\n"+ + " move %q %q", + err, recoveryPath, oldPath, recoveryPath, execPath, + ) + } + + // New binary is now in place; the old one stays locked until this process + // exits, so it is left for cleanupUpdateLeftovers on the next stellar run. + return nil +} + +// recoveryPathFor maps a preserved ".stellar-update-" download to the +// ".stellar-recovery-" name it should be moved to before the recovery +// error is shown. The unique suffix is carried across unchanged so two +// concurrent double-failures can't collide on the same recovery filename. If +// tempPath somehow lacks the update prefix (it always has it in practice, being +// the os.CreateTemp name), the base name is used as the suffix so the result is +// still a well-formed recovery sibling in the same directory. +func recoveryPathFor(tempPath string) string { + suffix := strings.TrimPrefix(filepath.Base(tempPath), updateTempPrefix) + return filepath.Join(filepath.Dir(tempPath), updateRecoveryPrefix+suffix) +} + +// preserveForRecovery renames the checksum-verified download at tempPath to its +// ".stellar-recovery-*" sibling so a later removeUpdateLeftovers run can never +// delete it (that cleanup only matches ".stellar-update-*"). It returns the +// path the file actually ended up at, so the caller's recovery instructions +// always point at the real location. +// +// This only runs on replaceExecutable's Windows double-failure path, so it uses +// the same transient-lock retry as the renames there. If the rename still fails, +// it falls back to the original tempPath: keeping the file under its temp name +// is far better than losing it, and the worst case is a much-later cleanup +// removing it rather than the user recovering it in time. +func preserveForRecovery(tempPath string) string { + recoveryPath := recoveryPathFor(tempPath) + if err := symlink.RenameWithRetry(tempPath, recoveryPath); err != nil { + return tempPath + } + return recoveryPath +} diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..72839e2 --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRemoveUpdateLeftovers covers the worker split out of +// cleanupUpdateLeftovers so the removal logic is testable on any OS, even +// though the gate that calls it in production only fires on Windows. +func TestRemoveUpdateLeftovers(t *testing.T) { + dir := t.TempDir() + + execPath := filepath.Join(dir, "stellar.exe") + oldBinary := execPath + ".old" + tmpUpdate := filepath.Join(dir, ".stellar-update-abc123") + unrelated := filepath.Join(dir, "keep-me.txt") + unrelatedOld := filepath.Join(dir, "other-tool.old") + + require.NoError(t, os.WriteFile(oldBinary, []byte("old binary"), 0644)) + require.NoError(t, os.WriteFile(tmpUpdate, []byte("partial download"), 0644)) + require.NoError(t, os.WriteFile(unrelated, []byte("unrelated"), 0644)) + require.NoError(t, os.WriteFile(unrelatedOld, []byte("someone else's leftover"), 0644)) + + // Age the temp file past the 1h cutoff so it reads as a genuine leftover + // from an interrupted update, not a concurrent in-flight download. + ageFile(t, tmpUpdate) + + removeUpdateLeftovers(execPath) + + assert.NoFileExists(t, oldBinary, "leftover .old binary should be removed") + assert.NoFileExists(t, tmpUpdate, "leftover update temp file should be removed") + assert.FileExists(t, unrelated, "unrelated files must not be touched") + assert.FileExists(t, unrelatedOld, "another tool's .old file must not be touched") +} + +// ageFile backdates path's mtime well past the 1h cutoff removeUpdateLeftovers +// uses, so a test temp file reads as a genuine interrupted-update leftover +// rather than a concurrent in-flight download. +func ageFile(t *testing.T, path string) { + t.Helper() + old := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(path, old, old)) +} + +// TestRemoveUpdateLeftovers_FreshTempPreserved verifies the race fix: a +// recently-written ".stellar-update-*" temp file (younger than the 1h cutoff) +// is left alone, since it could be a concurrent "stellar update" still writing +// its download. The ".old" binary is still removed unconditionally. +func TestRemoveUpdateLeftovers_FreshTempPreserved(t *testing.T) { + dir := t.TempDir() + + execPath := filepath.Join(dir, "stellar.exe") + oldBinary := execPath + ".old" + freshTmp := filepath.Join(dir, ".stellar-update-inflight") + + require.NoError(t, os.WriteFile(oldBinary, []byte("old binary"), 0644)) + require.NoError(t, os.WriteFile(freshTmp, []byte("in-flight download"), 0644)) + + removeUpdateLeftovers(execPath) + + assert.NoFileExists(t, oldBinary, "leftover .old binary should still be removed") + assert.FileExists(t, freshTmp, "a fresh temp file (possible in-flight update) must not be removed") +} + +// TestRemoveUpdateLeftovers_NothingToRemove verifies the worker is a safe +// no-op when there's nothing left over from a previous update. +func TestRemoveUpdateLeftovers_NothingToRemove(t *testing.T) { + dir := t.TempDir() + + require.NotPanics(t, func() { + removeUpdateLeftovers(filepath.Join(dir, "stellar.exe")) + }) +} + +// TestRemoveUpdateLeftovers_BracketDirName proves the fix for the +// filepath.Glob metacharacter bug: Glob interprets "[", "]" etc. in the +// directory path itself as pattern syntax, so an install dir like +// "tools[1]" used to silently disable cleanup (either matching nothing, or +// hitting ErrBadPattern, which was swallowed). os.ReadDir + strings.HasPrefix +// has no such issue since the directory is opened directly rather than +// pattern-matched. +func TestRemoveUpdateLeftovers_BracketDirName(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "tools[1]") + require.NoError(t, os.Mkdir(dir, 0755)) + + execPath := filepath.Join(dir, "stellar.exe") + oldBinary := execPath + ".old" + tmpUpdate := filepath.Join(dir, ".stellar-update-xyz789") + + require.NoError(t, os.WriteFile(oldBinary, []byte("old binary"), 0644)) + require.NoError(t, os.WriteFile(tmpUpdate, []byte("partial download"), 0644)) + ageFile(t, tmpUpdate) + + removeUpdateLeftovers(execPath) + + assert.NoFileExists(t, oldBinary, "leftover .old binary should be removed even when the dir name contains glob metacharacters") + assert.NoFileExists(t, tmpUpdate, "leftover update temp file should be removed even when the dir name contains glob metacharacters") +} + +// TestRemoveUpdateLeftovers_RecoveryFilePreserved is the guard for Fix 2: a +// ".stellar-recovery-*" file (the checksum-verified download a double-failure +// preserved for manual recovery) must never be auto-removed, even when it is +// older than the 1h cutoff, while a stale ".stellar-update-*" temp file next to +// it is still cleaned up. Without the distinct prefix, cleanup would silently +// delete the exact file the recovery instructions point at. +func TestRemoveUpdateLeftovers_RecoveryFilePreserved(t *testing.T) { + dir := t.TempDir() + + execPath := filepath.Join(dir, "stellar.exe") + staleUpdate := filepath.Join(dir, ".stellar-update-abc123") + recovery := filepath.Join(dir, ".stellar-recovery-abc123") + + require.NoError(t, os.WriteFile(staleUpdate, []byte("partial download"), 0644)) + require.NoError(t, os.WriteFile(recovery, []byte("verified binary awaiting manual move"), 0644)) + + // Age both past the 1h cutoff: the stale update temp is fair game, the + // recovery file must survive regardless of age. + ageFile(t, staleUpdate) + ageFile(t, recovery) + + removeUpdateLeftovers(execPath) + + assert.NoFileExists(t, staleUpdate, "a stale update temp file should still be removed") + assert.FileExists(t, recovery, "a recovery file must never be auto-removed, even when old") +} + +// TestRecoveryPathFor verifies the temp-to-recovery name mapping keeps the +// unique suffix and switches to a prefix removeUpdateLeftovers never matches. +func TestRecoveryPathFor(t *testing.T) { + tempPath := filepath.Join("/some/bin dir", ".stellar-update-987654") + + got := recoveryPathFor(tempPath) + + assert.Equal(t, filepath.Join("/some/bin dir", ".stellar-recovery-987654"), got) + assert.False(t, strings.HasPrefix(filepath.Base(got), updateTempPrefix), + "recovery name must not carry the cleanup-matched update prefix") +} + +// TestPreserveForRecovery verifies the download is renamed to its recovery +// sibling and the returned path reflects where the file actually ended up. +func TestPreserveForRecovery(t *testing.T) { + dir := t.TempDir() + tempPath := filepath.Join(dir, ".stellar-update-555") + require.NoError(t, os.WriteFile(tempPath, []byte("verified binary"), 0644)) + + got := preserveForRecovery(tempPath) + + assert.Equal(t, filepath.Join(dir, ".stellar-recovery-555"), got) + assert.NoFileExists(t, tempPath, "the temp-named file should have been renamed away") + assert.FileExists(t, got, "the file should now live at its recovery path") +} + +// TestCleanupUpdateLeftovers_RemovesRealArtifacts verifies the +// PersistentPreRunE gate cleans up leftovers next to the actual running +// binary (os.Executable() in a test resolves to the compiled test binary). +// This now runs unconditionally on every OS: temp files are created next to +// the binary regardless of platform, so an interrupted update (Ctrl-C, +// SIGKILL) would litter the bin dir on Linux/macOS too if cleanup were still +// gated to Windows only. +func TestCleanupUpdateLeftovers_RemovesRealArtifacts(t *testing.T) { + execPath, err := os.Executable() + require.NoError(t, err) + + oldBinary := execPath + ".old" + tmpUpdate := filepath.Join(filepath.Dir(execPath), ".stellar-update-test123") + + require.NoError(t, os.WriteFile(oldBinary, []byte("old binary"), 0644)) + require.NoError(t, os.WriteFile(tmpUpdate, []byte("partial download"), 0644)) + ageFile(t, tmpUpdate) + t.Cleanup(func() { + _ = os.Remove(oldBinary) + _ = os.Remove(tmpUpdate) + }) + + require.NotPanics(t, func() { + cleanupUpdateLeftovers() + }) + + assert.NoFileExists(t, oldBinary, "leftover .old binary next to the real binary should be removed") + assert.NoFileExists(t, tmpUpdate, "leftover update temp file next to the real binary should be removed") +} diff --git a/cmd/version.go b/cmd/version.go index d163f26..aa521b9 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -27,6 +27,12 @@ var ( commit: "none", date: "unknown", } + + // LatestReleaseAPIURL is the GitHub API endpoint used to fetch the latest + // release's metadata (tag, publish date, HTML URL). It is a var rather + // than a const so tests can point it at an httptest server; production + // behavior is unchanged since the default is assigned here. + LatestReleaseAPIURL = "https://api.github.com/repos/a3chron/stellar/releases/latest" ) // SetVersionInfo is called from main to set version information @@ -128,7 +134,7 @@ func getVersionAsciiArt() string { func GetLatestRelease() (*GitHubRelease, error) { client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get("https://api.github.com/repos/a3chron/stellar/releases/latest") + resp, err := client.Get(LatestReleaseAPIURL) if err != nil { return nil, fmt.Errorf("failed to check for updates: %w", err) } diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..634a6b3 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,129 @@ +#Requires -Version 5.1 +# Installer for stellar on Windows. +# Usage: irm https://raw.githubusercontent.com/a3chron/stellar/main/install.ps1 | iex + +$ErrorActionPreference = "Stop" + +# 3072 = Tls12; needed on WinPS 5.1 with old .NET defaults (TLS 1.0 only), +# which otherwise fail Invoke-WebRequest against github.com with +# "Could not create SSL/TLS secure channel". -bor preserves existing protocols. +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 + +# Configurable install directory (override with $env:STELLAR_INSTALL_DIR) +$BinDir = if ($env:STELLAR_INSTALL_DIR) { $env:STELLAR_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "stellar\bin" } + +# Detect architecture. On a 32-bit process running under WoW64 (32-bit +# PowerShell on a 64-bit Windows host), PROCESSOR_ARCHITECTURE reports "x86" +# even though the OS is 64-bit; PROCESSOR_ARCHITEW6432 holds the real +# architecture in that case, so it must be checked first. +$RawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +switch ($RawArch) { + "AMD64" { $Arch = "amd64" } + "ARM64" { $Arch = "arm64" } + default { + throw "Unsupported architecture: $RawArch" + } +} + +$Binary = "stellar-windows-$Arch.exe" +$Url = "https://github.com/a3chron/stellar/releases/latest/download/$Binary" +$ChecksumsUrl = "https://github.com/a3chron/stellar/releases/latest/download/checksums.txt" +$Target = Join-Path $BinDir "stellar.exe" +$DownloadPath = "$Target.download" + +Write-Host "Installing stellar for windows-$Arch" +Write-Host "Target directory: $BinDir" + +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + +# Download to a temp path first (mirrors what "stellar update" already does): +# verify the checksum before ever touching the final "stellar.exe", so a +# corrupted or interrupted download never leaves a broken binary in place. +try { + Write-Host "Downloading $Binary..." + Invoke-WebRequest -Uri $Url -OutFile $DownloadPath -UseBasicParsing + + Write-Host "Fetching checksums..." + $Checksums = (Invoke-WebRequest -Uri $ChecksumsUrl -UseBasicParsing).Content + + $ExpectedHash = $null + foreach ($line in ($Checksums -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $fields = $line -split '\s+' | Where-Object { $_ } + if ($fields.Count -lt 2) { continue } + if ($fields[-1] -eq $Binary) { + $ExpectedHash = $fields[0] + break + } + } + + if (-not $ExpectedHash) { + throw "checksum not found for binary: $Binary" + } + + Write-Host "Verifying checksum..." + $ActualHash = (Get-FileHash -Path $DownloadPath -Algorithm SHA256).Hash + + if ($ActualHash.ToLower() -ne $ExpectedHash.ToLower()) { + throw "checksum verification failed for $Binary`n expected: $ExpectedHash`n got: $ActualHash`n`nThe downloaded file may be corrupted or tampered with. Please try again" + } + + Write-Host "Checksum verified successfully" + + Move-Item -Path $DownloadPath -Destination $Target -Force +} +catch { + if (Test-Path $DownloadPath) { + Remove-Item -Path $DownloadPath -Force -ErrorAction SilentlyContinue + } + throw +} + +Write-Host "stellar installed successfully!" + +# Add install dir to the user PATH if it isn't already there. +# Compare entry-by-entry (ignoring a trailing backslash) instead of a substring +# match, so an unrelated path that merely contains $BinDir as a prefix doesn't +# make us skip the update. +# +# Read/write the registry value directly via Microsoft.Win32.Registry (rather +# than [Environment]::GetEnvironmentVariable/SetEnvironmentVariable) so an +# existing REG_EXPAND_SZ "Path" entry (one containing unexpanded references +# like %SystemRoot%) is preserved as REG_EXPAND_SZ instead of being flattened +# to a REG_SZ, which would break those references for every other program +# reading the raw registry value. +$EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) +$RawPath = $EnvKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + +$Normalized = $BinDir.TrimEnd('\') +# Compare the EXPANDED form of each raw entry against the expanded $BinDir, so +# an existing entry stored unexpanded (e.g. %LOCALAPPDATA%\stellar\bin) is +# still recognized as a match instead of getting a redundant duplicate +# appended. $RawPath itself stays untouched (see below) so REG_EXPAND_SZ +# entries are preserved as-is when we rewrite the value. +$PathEntries = ($RawPath -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') } | Where-Object { $_ } +if ($PathEntries -notcontains $Normalized) { + Write-Host "" + Write-Host "Adding $BinDir to your user PATH" + + # PowerShell 5.1 has no ternary operator, so use if/else explicitly. + if ($RawPath) { + $NewPath = "$RawPath;$BinDir" + $Kind = $EnvKey.GetValueKind('Path') + } + else { + $NewPath = $BinDir + $Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + } + + $EnvKey.SetValue('Path', $NewPath, $Kind) + + # Make it available in the current session too + $env:Path = "$env:Path;$BinDir" + Write-Host "Restart your terminal for the PATH change to take effect everywhere." +} +$EnvKey.Close() + +Write-Host "" +Write-Host "Run:" +Write-Host " stellar --help" diff --git a/install.sh b/install.sh index 004901e..6d7c447 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Check for Windows +# Check for Windows - use the PowerShell installer instead of this bash script if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then - echo "Windows is not supported yet." + echo "On Windows, install stellar with PowerShell instead:" + echo " irm https://raw.githubusercontent.com/a3chron/stellar/main/install.ps1 | iex" echo "For more information, see: https://github.com/a3chron/stellar#windows" exit 1 fi diff --git a/internal/cache/manager.go b/internal/cache/manager.go index 83fc987..c44c1fc 100644 --- a/internal/cache/manager.go +++ b/internal/cache/manager.go @@ -107,6 +107,22 @@ func CleanCache(excludeCurrentPath string) error { continue } + // Backups of the user's original config live at /backup + // (see internal/symlink.backupOriginalConfig) and must survive + // cleaning: they're the only copy of whatever starship.toml the user + // had before ever running stellar. If clean swept them up, the + // restore hint stellar itself prints ("stellar apply + // /backup@") would become permanently + // unrecoverable, since "backup" isn't a real author on the hub and + // there's nothing to re-download. This applies to every author, and + // to every clean variant (CleanCache is the only removal path for + // both `stellar clean` and `stellar clean --all`; they differ only + // in excludeCurrentPath). Backups are still removable explicitly via + // `stellar remove /backup[@version]`. + if t.Name == theme.BackupThemeName { + continue + } + path, err := t.CachePath() if err != nil { continue diff --git a/internal/config/config.go b/internal/config/config.go index bd54fae..76134a7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,13 @@ type Config struct { PreviousTheme string `json:"previous_theme,omitempty"` PreviousPath string `json:"previous_path,omitempty"` DownloadedThemes []string `json:"downloaded_themes,omitempty"` // ["alice/rainbow", "bob/sunset"] + // AppliedHash is the SHA-256 hex hash of the content stellar last wrote to + // starship.toml. It lets stellar recognize its own applied file regardless + // of apply mode (symlink vs copy) or OS, without depending on how the file + // happens to be on disk right now. Absent on configs written before this + // field existed; callers must treat that as "unknown" rather than + // "mismatch". + AppliedHash string `json:"applied_hash,omitempty"` } func ConfigPath() (string, error) { diff --git a/internal/paths/paths.go b/internal/paths/paths.go index d4c2e6a..42651be 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -17,6 +17,9 @@ const ( EnvTmpDir = "STELLAR_TMP_DIR" // EnvAPIURL overrides the API base URL EnvAPIURL = "STELLAR_API_URL" + // EnvApplyMode overrides how themes are applied ("symlink" or "copy"). + // Defaults to symlink on Unix and copy on Windows. + EnvApplyMode = "STELLAR_APPLY_MODE" ) // StellarHome returns the stellar configuration directory. diff --git a/internal/symlink/symlink.go b/internal/symlink/symlink.go index b214131..ea9d2f5 100644 --- a/internal/symlink/symlink.go +++ b/internal/symlink/symlink.go @@ -1,19 +1,102 @@ package symlink import ( + "bytes" + "crypto/sha256" + "encoding/hex" "fmt" "io" "os" "os/user" "path/filepath" + "runtime" + "strings" + "time" + "github.com/a3chron/stellar/internal/config" "github.com/a3chron/stellar/internal/paths" + "github.com/a3chron/stellar/internal/theme" ) func StarshipConfigPath() (string, error) { return paths.StarshipConfig() } +// renameRetryEnabled gates RenameWithRetry's retry loop. It is true only on +// Windows, the one platform where os.Rename intermittently fails on a lock that +// clears by itself; everywhere else a rename either succeeds or fails for a +// real, persistent reason, so retrying would only add latency. It's a +// package-level var (rather than an inline runtime.GOOS check) purely so a unit +// test running on Linux can flip it on and exercise the retry path. +var renameRetryEnabled = runtime.GOOS == "windows" + +// renameRetryBackoff is the sequence of sleeps between rename attempts when the +// retry path is active. Four retries at 50/100/200/400ms cap the total added +// latency of a genuinely persistent failure at 750ms — comfortably under a +// second and a half — while giving a transient Defender/indexer lock ample time +// to clear. It's a package-level var so a test can shrink or replace it. +var renameRetryBackoff = []time.Duration{ + 50 * time.Millisecond, + 100 * time.Millisecond, + 200 * time.Millisecond, + 400 * time.Millisecond, +} + +// renameSleep is the sleep used between rename retries, injectable so a unit +// test can verify the retry/backoff behavior without actually sleeping. +var renameSleep = time.Sleep + +// RenameWithRetry renames oldpath to newpath, retrying a few times on Windows +// when the rename fails. +// +// Why: on Windows os.Rename intermittently fails with ERROR_SHARING_VIOLATION +// or ERROR_ACCESS_DENIED when another process holds a brief lock on the source +// or destination. Windows Defender's real-time scan and the Search Indexer both +// do exactly this to freshly written files and brand-new .exe binaries — which +// is precisely what stellar renames when applying a theme (a just-written temp +// file) or installing an update (a just-downloaded executable). These locks +// clear on their own within milliseconds, so a short, bounded retry turns an +// intermittent, user-visible failure into a silent success. +// +// On every other OS renameRetryEnabled is false, so this is a single os.Rename +// with no sleep and no retry — behavior there is identical to calling os.Rename +// directly. +// +// We retry on any error rather than trying to match specific Windows error +// codes: the sharing-violation codes don't map cleanly onto Go's portable error +// types, and retrying indiscriminately is safe here because a genuinely +// persistent failure still returns promptly — the total backoff is bounded by +// renameRetryBackoff. +func RenameWithRetry(oldpath, newpath string) error { + err := os.Rename(oldpath, newpath) + if err == nil || !renameRetryEnabled { + return err + } + + // Retry with growing backoff, returning the last error if none succeed. + for _, backoff := range renameRetryBackoff { + renameSleep(backoff) + if err = os.Rename(oldpath, newpath); err == nil { + return nil + } + } + + return err +} + +// IsCopyMode reports whether themes are applied by copying the file instead of +// symlinking. This is the default on Windows (where symlinks require Developer +// Mode or admin privileges) and can be forced anywhere via STELLAR_APPLY_MODE. +func IsCopyMode() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(paths.EnvApplyMode))) { + case "copy": + return true + case "symlink": + return false + } + return runtime.GOOS == "windows" +} + // isSymlink checks if the given path is a symlink func isSymlink(path string) bool { info, err := os.Lstat(path) @@ -23,108 +106,321 @@ func isSymlink(path string) bool { return info.Mode()&os.ModeSymlink != 0 } -// backupOriginalConfig backs up the user's original starship.toml to ~/.config/stellar//backup/1.0.toml -// Returns the backup path if successful, empty string otherwise -func backupOriginalConfig(configPath string) (backupPath string, err error) { - // Check if the file exists and is NOT a symlink - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return "", nil // No file to back up - } - - if isSymlink(configPath) { - return "", nil // Already a symlink, no need to back up +// copyFile copies the contents of src to dst, creating or truncating dst. +// It returns the SHA-256 hex hash of the copied content, computed while the +// data streams through so callers that need the hash (e.g. ApplyTheme) don't +// have to re-read the file afterward. +func copyFile(src, dst string) (hash string, err error) { + source, err := os.Open(src) + if err != nil { + return "", fmt.Errorf("failed to open source file: %w", err) } + defer func() { + if cerr := source.Close(); cerr != nil && err == nil { + err = fmt.Errorf("failed to close source file: %w", cerr) + } + }() - // Get the current user's username - currentUser, err := user.Current() + destination, err := os.Create(dst) if err != nil { - return "", fmt.Errorf("failed to get current user: %w", err) + return "", fmt.Errorf("failed to create destination file: %w", err) } + defer func() { + if cerr := destination.Close(); cerr != nil && err == nil { + err = fmt.Errorf("failed to close destination file: %w", cerr) + } + }() - // Construct backup path: ~/.config/stellar//backup/1.0.toml - stellarHome, err := paths.StellarHome() - if err != nil { - return "", fmt.Errorf("failed to get stellar home directory: %w", err) + h := sha256.New() + if _, err := io.Copy(io.MultiWriter(destination, h), source); err != nil { + return "", fmt.Errorf("failed to copy file: %w", err) } - backupDir := filepath.Join(stellarHome, currentUser.Username, "backup") - backupPath = filepath.Join(backupDir, "1.0.toml") + return hex.EncodeToString(h.Sum(nil)), nil +} - // Create backup directory - if err := os.MkdirAll(backupDir, 0755); err != nil { - return "", fmt.Errorf("failed to create backup directory: %w", err) +// filesEqual reports whether both files exist and have identical content. +// Any read error counts as "not equal", which for backup decisions errs on +// the side of preserving the file. +func filesEqual(a, b string) bool { + contentA, err := os.ReadFile(a) + if err != nil { + return false } + contentB, err := os.ReadFile(b) + if err != nil { + return false + } + return bytes.Equal(contentA, contentB) +} - // Copy the original file to backup location - source, err := os.Open(configPath) +// HashFile computes the SHA-256 hash of a file and returns it as a hex string. +func HashFile(path string) (hash string, err error) { + f, err := os.Open(path) if err != nil { - return "", fmt.Errorf("failed to open original config: %w", err) + return "", fmt.Errorf("failed to open file for hashing: %w", err) } defer func() { - if cerr := source.Close(); cerr != nil && err == nil { - err = fmt.Errorf("failed to close source file: %w", cerr) + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr } }() - destination, err := os.Create(backupPath) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("failed to hash file: %w", err) + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// hashOf returns the SHA-256 hex hash of path, or "" if it can't be read. +// Used for "is this managed" comparisons, where an unreadable file should +// simply fail to match rather than propagate an error. +func hashOf(path string) string { + h, err := HashFile(path) if err != nil { - return "", fmt.Errorf("failed to create backup file: %w", err) + return "" } - defer func() { - if cerr := destination.Close(); cerr != nil && err == nil { - err = fmt.Errorf("failed to close destination file: %w", cerr) + return h +} + +// sanitizeBackupAuthor turns a raw OS username into a valid theme-author +// segment. Windows usernames from user.Current() look like "DOMAIN\user" and +// may contain spaces or other characters the theme-identifier parser rejects +// (it allows only [a-zA-Z0-9_-]). We drop any domain prefix and replace every +// disallowed character with '-', falling back to "local" if nothing usable is +// left, so the printed restore hint always parses. +func sanitizeBackupAuthor(name string) string { + // Drop everything up to and including the last path separator so a + // "DOMAIN\user" (or "domain/user") input keeps only the "user" part. + if i := strings.LastIndexAny(name, `\/`); i >= 0 { + name = name[i+1:] + } + + var b strings.Builder + for _, r := range name { + if theme.IsValidIdentifierRune(r) { + b.WriteRune(r) + } else { + b.WriteByte('-') } - }() + } - if _, err := io.Copy(destination, source); err != nil { - return "", fmt.Errorf("failed to copy config to backup: %w", err) + sanitized := b.String() + if sanitized == "" { + return "local" } + return sanitized +} + +// BackupAuthor returns the theme-author segment used for backups of the user's +// original config: the sanitized current username, or "local" if it can't be +// determined. Exported so callers can build a restore hint that matches where +// the backup was actually written. +func BackupAuthor() string { + u, err := user.Current() + if err != nil { + return "local" + } + return sanitizeBackupAuthor(u.Username) +} + +// BackupInfo describes a backup of the user's original config created by +// backupOriginalConfig/ApplyTheme. +type BackupInfo struct { + // Path is the on-disk location the original config was copied to. + Path string + // Identifier is the theme identifier ("/backup@") that + // targets this exact backup, built at creation time from the same + // author/version values used to construct Path (rather than re-derived + // from Path later), so it always matches where the backup actually lives. + Identifier string +} - return backupPath, nil +// IsManaged reports whether the file at configPath is one stellar itself +// applied, as opposed to a user's own original config that must be preserved. +// +// The check is mode-independent: it never asks whether we're in symlink or copy +// mode, only what's actually on disk and recorded in cfg. Each signal only ever +// *confirms* management, so a false negative just means an unmanaged file is +// (harmlessly) backed up rather than data being lost — it fails safe toward +// preserving the file: +// - configPath is a symlink (symlink mode's own marker), or +// - cfg.AppliedHash is set and matches configPath's current content, or +// - cfg.CurrentTheme is set and configPath's content matches the cached +// theme file byte-for-byte (legacy fallback for configs saved before +// AppliedHash existed). +// +// cfg may be nil, treated the same as an empty config (i.e. no known state, so +// nothing is recognized as managed). +func IsManaged(configPath string, cfg *config.Config) bool { + if cfg == nil { + cfg = &config.Config{} + } + return isSymlink(configPath) || + (cfg.AppliedHash != "" && hashOf(configPath) == cfg.AppliedHash) || + (cfg.CurrentTheme != "" && filesEqual(configPath, cfg.CurrentPath)) } -// CreateSymlink creates a symlink from ~/.config/starship.toml to the target file. +// backupOriginalConfig backs up the user's original starship.toml under +// ~/.config/stellar//backup/. Backups are versioned: the first one is +// 1.0.toml (the user's genuine original), and every later unmanaged +// starship.toml stellar finds gets the next major version (2.0.toml, 3.0.toml, +// …) so no earlier backup is ever clobbered. +// Returns a *BackupInfo if a backup was created, nil otherwise. +// +// Whether the current file is stellar's own (and so must not be backed up) is +// decided by IsManaged; an unmanaged file always backs up. +// +// cfg may be nil, treated the same as an empty config. +func backupOriginalConfig(configPath string, cfg *config.Config) (info *BackupInfo, err error) { + // Check if the file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, nil // No file to back up + } + + if IsManaged(configPath, cfg) { + return nil, nil + } + + author := BackupAuthor() + + // Construct backup directory: ~/.config/stellar//backup + backupDir, err := paths.ThemeCacheDir(author, theme.BackupThemeName) + if err != nil { + return nil, fmt.Errorf("failed to resolve backup directory: %w", err) + } + + // Pick the first free version slot so an existing backup is never + // overwritten. The first backup (1.0.toml) holds the user's real original + // config; any later unmanaged starship.toml (e.g. a hand-written one) is + // preserved as the next version rather than clobbering the original. + var backupPath, version string + for n := 1; ; n++ { + version = fmt.Sprintf("%d.0", n) + candidate, perr := paths.ThemeCachePath(author, theme.BackupThemeName, version) + if perr != nil { + return nil, fmt.Errorf("failed to resolve backup path: %w", perr) + } + // A free slot (ENOENT) ends the search; a slot that already exists + // (statErr == nil) moves to the next version. Any other stat error + // (e.g. ENOTDIR because the backup dir is actually a plain file, or + // EACCES) is persistent and would otherwise spin this loop forever, so + // fail loudly instead of hanging. + _, statErr := os.Stat(candidate) + if os.IsNotExist(statErr) { + backupPath = candidate + break + } + if statErr != nil { + return nil, fmt.Errorf("failed to probe backup slot %s: %w", candidate, statErr) + } + } + + // Create backup directory + if err := os.MkdirAll(backupDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create backup directory: %w", err) + } + + // Copy the original file to backup location + if _, err := copyFile(configPath, backupPath); err != nil { + return nil, fmt.Errorf("failed to back up config: %w", err) + } + + // Build the identifier from the same author/version values used above, + // via the identifier format theme.Theme.String() already owns, instead of + // re-deriving it later by parsing backupPath. + identifier := (&theme.Theme{Author: author, Name: theme.BackupThemeName, Version: version}).String() + + return &BackupInfo{Path: backupPath, Identifier: identifier}, nil +} + +// ApplyTheme points ~/.config/starship.toml at the target theme file. +// On Unix a symlink is created; on Windows (or when STELLAR_APPLY_MODE=copy) the +// theme file is copied over starship.toml instead, since Windows handles symlinks +// poorly. +// +// cfg is the caller's loaded config, used to recognize stellar's own +// previously-applied file so it isn't mistaken for a user's original (see +// backupOriginalConfig). It may be nil. On a successful apply, ApplyTheme also +// records the hash of the applied content into cfg.AppliedHash (when cfg is +// non-nil) so future calls can recognize this exact file as stellar-managed; +// this mutates the in-memory cfg, but the caller is still responsible for +// persisting it via cfg.Save(). +// // If an original (non-symlink) starship.toml exists, it's backed up first. -// Returns the backup path if a backup was created (empty string if no backup was needed). +// Returns a *BackupInfo if a backup was created (nil if no backup was needed). // -// Uses atomic symlink replacement (temp-then-rename) to prevent data loss: -// If symlink creation fails, the original config remains intact. -func CreateSymlink(target string) (backupPath string, err error) { +// Uses atomic replacement (temp-then-rename) to prevent data loss: +// if applying fails, the original config remains intact. +func ApplyTheme(target string, cfg *config.Config) (info *BackupInfo, err error) { configPath, err := StarshipConfigPath() if err != nil { - return "", err + return nil, err } - // Back up original config if it exists and is not a symlink - backupPath, err = backupOriginalConfig(configPath) + // Back up original config if it exists and isn't recognized as stellar's own + info, err = backupOriginalConfig(configPath, cfg) if err != nil { - return "", fmt.Errorf("failed to backup original config: %w", err) + return nil, fmt.Errorf("failed to backup original config: %w", err) } - // Use atomic symlink replacement to avoid data loss window - // Strategy: Create temp symlink, then rename over target + // Use atomic replacement to avoid a data-loss window. + // Strategy: write the new symlink/copy to a temp path, then rename over target. configDir := filepath.Dir(configPath) tempPath := filepath.Join(configDir, ".starship.toml.stellar-tmp") // Remove any stale temp file from a previous failed attempt _ = os.Remove(tempPath) - // Create new symlink at temp location - if err := os.Symlink(target, tempPath); err != nil { - return backupPath, fmt.Errorf("failed to create symlink: %w", err) + // Best-effort: compute the hash of what's actually being applied so it can + // be recorded into cfg below. A failure here just leaves appliedHash empty; + // that degrades a future run back to the legacy CurrentPath comparison + // instead of failing this apply outright. + var appliedHash string + + if IsCopyMode() { + // copyFile already reads the file in full; compute the hash while the + // data streams through instead of re-reading it afterward. + h, err := copyFile(target, tempPath) + if err != nil { + return info, fmt.Errorf("failed to copy theme: %w", err) + } + appliedHash = h + } else { + if err := os.Symlink(target, tempPath); err != nil { + return info, fmt.Errorf("failed to create symlink: %w", err) + } + // In symlink mode there's no copy to piggyback on; hash the target + // file once. + appliedHash = hashOf(target) } - // Atomic rename over the target (atomic on POSIX systems) - // If this fails, the original file/symlink is still intact - if err := os.Rename(tempPath, configPath); err != nil { + // Atomic rename over the target (replaces the destination on POSIX and Windows) + // If this fails, the original file/symlink is still intact. RenameWithRetry + // rides out the transient Defender/indexer locks Windows takes on the + // freshly written temp file (a no-op single rename everywhere else). + if err := RenameWithRetry(tempPath, configPath); err != nil { // Clean up temp file on failure _ = os.Remove(tempPath) - return backupPath, fmt.Errorf("failed to replace config: %w", err) + return info, fmt.Errorf("failed to replace config: %w", err) + } + + // Best-effort: record the hash of what was actually applied so future + // runs can recognize this exact file as stellar-managed regardless of + // apply mode. Empty on error; that just means a future run falls back to + // the legacy CurrentPath comparison instead of failing outright. + if cfg != nil { + cfg.AppliedHash = appliedHash } - return backupPath, nil + return info, nil } +// GetCurrentTarget returns the theme file that starship.toml points at. +// Only meaningful in symlink mode; in copy mode there is no link to read, so +// callers should gate this behind !IsCopyMode() and rely on config state instead. func GetCurrentTarget() (string, error) { configPath, err := StarshipConfigPath() if err != nil { diff --git a/internal/symlink/symlink_test.go b/internal/symlink/symlink_test.go index d828b15..7d6e900 100644 --- a/internal/symlink/symlink_test.go +++ b/internal/symlink/symlink_test.go @@ -2,29 +2,35 @@ package symlink import ( "os" - "os/user" "path/filepath" + "strings" "testing" + "time" + "github.com/a3chron/stellar/internal/config" + "github.com/a3chron/stellar/internal/paths" "github.com/a3chron/stellar/internal/testutil" + "github.com/a3chron/stellar/internal/theme" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestCreateSymlink_NoExistingFile(t *testing.T) { +func TestApplyTheme_NoExistingFile(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) - backupPath, err := CreateSymlink(themePath) + info, err := ApplyTheme(themePath, &config.Config{}) require.NoError(t, err) - assert.Empty(t, backupPath, "should not create backup when no existing file") + assert.Nil(t, info, "should not create backup when no existing file") assert.True(t, env.IsSymlink(env.StarshipPath)) assert.Equal(t, themePath, env.ReadSymlink(env.StarshipPath)) } -func TestCreateSymlink_ExistingRegularFile(t *testing.T) { +func TestApplyTheme_ExistingRegularFile(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) originalContent := "# Original config\n[character]\nsuccess_symbol = \"OLD\"" @@ -32,44 +38,394 @@ func TestCreateSymlink_ExistingRegularFile(t *testing.T) { themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) - backupPath, err := CreateSymlink(themePath) + info, err := ApplyTheme(themePath, &config.Config{}) require.NoError(t, err) - assert.NotEmpty(t, backupPath) - assert.True(t, env.FileExists(backupPath)) - assert.Equal(t, originalContent, env.ReadFile(backupPath)) + require.NotNil(t, info) + assert.True(t, env.FileExists(info.Path)) + assert.Equal(t, originalContent, env.ReadFile(info.Path)) - currentUser, err := user.Current() - require.NoError(t, err) - expectedBackupPath := filepath.Join(env.StellarDir, currentUser.Username, "backup", "1.0.toml") - assert.Equal(t, expectedBackupPath, backupPath) + expectedBackupPath := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "1.0.toml") + assert.Equal(t, expectedBackupPath, info.Path) + assert.Equal(t, BackupAuthor()+"/backup@1.0", info.Identifier) assert.True(t, env.IsSymlink(env.StarshipPath)) assert.Equal(t, themePath, env.ReadSymlink(env.StarshipPath)) } -func TestCreateSymlink_ExistingSymlink(t *testing.T) { +func TestApplyTheme_ExistingSymlink(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) themePath1 := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) - _, err := CreateSymlink(themePath1) + _, err := ApplyTheme(themePath1, &config.Config{}) require.NoError(t, err) themePath2 := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) - backupPath, err := CreateSymlink(themePath2) + info, err := ApplyTheme(themePath2, &config.Config{}) require.NoError(t, err) - assert.Empty(t, backupPath, "should not create backup when replacing symlink") + assert.Nil(t, info, "should not create backup when replacing symlink") assert.True(t, env.IsSymlink(env.StarshipPath)) assert.Equal(t, themePath2, env.ReadSymlink(env.StarshipPath)) } +func TestApplyTheme_CopyMode(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + assert.True(t, IsCopyMode()) + + themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + + info, err := ApplyTheme(themePath, &config.Config{}) + require.NoError(t, err) + assert.Nil(t, info, "should not create backup when no existing file") + + // In copy mode starship.toml is a regular file, not a symlink + assert.True(t, env.FileExists(env.StarshipPath)) + assert.False(t, env.IsSymlink(env.StarshipPath)) + assert.Equal(t, testutil.SampleTOML(), env.ReadFile(env.StarshipPath)) +} + +func TestApplyTheme_CopyMode_ExistingRegularFile(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + originalContent := "# Original config\n[character]\nsuccess_symbol = \"OLD\"" + env.CreateStarshipConfig(originalContent) + + themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + + info, err := ApplyTheme(themePath, &config.Config{}) + require.NoError(t, err) + + // Original is backed up before being overwritten by the copy + require.NotNil(t, info) + assert.True(t, env.FileExists(info.Path)) + assert.Equal(t, originalContent, env.ReadFile(info.Path)) + + assert.False(t, env.IsSymlink(env.StarshipPath)) + assert.Equal(t, testutil.SampleTOML(), env.ReadFile(env.StarshipPath)) +} + +func TestApplyTheme_CopyMode_ReplacesPreviousCopy(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + themePath1 := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + _, err := ApplyTheme(themePath1, &config.Config{}) + require.NoError(t, err) + + // Simulate the config state that cmd/apply.go would have saved after the + // first apply, so the second apply knows the current file is stellar's copy. + env.CreateConfig(`{"current_theme":"alice/rainbow@1.0","current_path":"` + themePath1 + `"}`) + cfg, err := config.Load() + require.NoError(t, err) + + themePath2 := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOMLWithCustom()) + info, err := ApplyTheme(themePath2, cfg) + require.NoError(t, err) + + // A stellar-managed copy should not be backed up when replaced + assert.Nil(t, info, "should not back up a config stellar already manages") + assert.False(t, env.IsSymlink(env.StarshipPath)) + assert.Equal(t, testutil.SampleTOMLWithCustom(), env.ReadFile(env.StarshipPath)) +} + +// TestApplyTheme_CopyMode_BacksUpHandEditedConfig verifies that copy mode +// detects a hand-edited starship.toml: even though config.json says a theme is +// applied, the file no longer matches the cached theme (nor the recorded +// applied hash, since this fabricated config predates that field), so it must +// be backed up instead of being treated as stellar's own copy. +func TestApplyTheme_CopyMode_BacksUpHandEditedConfig(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + themePath1 := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + _, err := ApplyTheme(themePath1, &config.Config{}) + require.NoError(t, err) + env.CreateConfig(`{"current_theme":"alice/rainbow@1.0","current_path":"` + themePath1 + `"}`) + cfg, err := config.Load() + require.NoError(t, err) + + // The user edits the applied copy directly. + editedContent := "# MY HAND-EDITED CONFIG" + env.CreateStarshipConfig(editedContent) + + themePath2 := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOMLWithCustom()) + info, err := ApplyTheme(themePath2, cfg) + require.NoError(t, err) + + expectedBackup := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "1.0.toml") + require.NotNil(t, info, "hand-edited config should be backed up") + assert.Equal(t, expectedBackup, info.Path) + assert.Equal(t, editedContent, env.ReadFile(info.Path)) + assert.Equal(t, testutil.SampleTOMLWithCustom(), env.ReadFile(env.StarshipPath)) +} + +// TestApplyTheme_CopyMode_MissingCachedThemeBacksUp covers the fallback when the +// cached theme referenced by config.json is gone (e.g. removed via +// "stellar clean --all"): the file can't be verified as stellar's copy via the +// legacy CurrentPath comparison, and there is no applied_hash recorded either +// (this fabricated config predates that field), so it is backed up rather than +// risking data loss. +func TestApplyTheme_CopyMode_MissingCachedThemeBacksUp(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + // config.json points at a cached theme file that no longer exists. + env.CreateConfig(`{"current_theme":"alice/rainbow@1.0","current_path":"` + env.StellarDir + `/alice/rainbow/1.0.toml"}`) + env.CreateStarshipConfig("# unverifiable stellar copy") + cfg, err := config.Load() + require.NoError(t, err) + + themePath := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) + info, err := ApplyTheme(themePath, cfg) + require.NoError(t, err) + + expectedBackup := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "1.0.toml") + require.NotNil(t, info, "unverifiable config should be backed up") + assert.Equal(t, expectedBackup, info.Path) + assert.Equal(t, "# unverifiable stellar copy", env.ReadFile(info.Path)) +} + +// TestApplyTheme_CopyMode_AppliedHashRecognizesCopy is the hash-present +// counterpart to TestApplyTheme_CopyMode_MissingCachedThemeBacksUp: when +// applied_hash is recorded and still matches what's on disk, stellar +// recognizes its own copy and skips the backup even though the cached theme +// file it originally came from is gone. +func TestApplyTheme_CopyMode_AppliedHashRecognizesCopy(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + appliedContent := "# stellar copy, cached source now missing" + env.CreateStarshipConfig(appliedContent) + hash, err := HashFile(env.StarshipPath) + require.NoError(t, err) + + // config.json points at a cached theme file that no longer exists, but + // applied_hash matches the current starship.toml content. + env.CreateConfig(`{"current_theme":"alice/rainbow@1.0","current_path":"` + env.StellarDir + `/alice/rainbow/1.0.toml","applied_hash":"` + hash + `"}`) + cfg, err := config.Load() + require.NoError(t, err) + + themePath := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) + info, err := ApplyTheme(themePath, cfg) + require.NoError(t, err) + + assert.Nil(t, info, "applied_hash match should prevent a junk backup") +} + +// TestApplyTheme_CopyMode_VersionedBackupPreservesOriginal covers the case where +// stellar's config state is lost (CurrentTheme == "") but the current +// starship.toml is actually a stellar copy. Rather than skipping the backup (and +// risking silent data loss on a genuinely new original), stellar writes the file +// as the next backup version. The genuine original at 1.0.toml is never touched: +// the unrecognized file gets 2.0.toml instead. +func TestApplyTheme_CopyMode_VersionedBackupPreservesOriginal(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + // A real original backup already exists from an earlier apply. + origBackupPath := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "1.0.toml") + require.NoError(t, os.MkdirAll(filepath.Dir(origBackupPath), 0755)) + require.NoError(t, os.WriteFile(origBackupPath, []byte("# PRECIOUS ORIGINAL"), 0644)) + + // starship.toml holds a stellar copy, but there is no config.json state + // (CurrentTheme == ""), so the config-based marker can't help here. + env.CreateStarshipConfig("# stellar copy of some theme") + + themePath := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) + newInfo, err := ApplyTheme(themePath, &config.Config{}) + require.NoError(t, err) + + // The unrecognized file is preserved as the next version (2.0.toml)... + require.NotNil(t, newInfo, "new backup should use the next version") + expectedNewBackup := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "2.0.toml") + assert.Equal(t, expectedNewBackup, newInfo.Path) + assert.Equal(t, "# stellar copy of some theme", env.ReadFile(newInfo.Path)) + + // ...and the genuine original at 1.0.toml is never clobbered. + assert.Equal(t, "# PRECIOUS ORIGINAL", env.ReadFile(origBackupPath), "original backup must be preserved") + assert.Equal(t, testutil.SampleTOML(), env.ReadFile(env.StarshipPath)) +} + +// TestApplyTheme_VersionedBackup verifies that each unmanaged original starship +// config is preserved under its own version instead of overwriting an earlier +// backup. +func TestApplyTheme_VersionedBackup(t *testing.T) { + testutil.RequireSymlinks(t) + env := testutil.SetupTestEnv(t) + + // First original: backed up as 1.0.toml. + firstOriginal := "# FIRST ORIGINAL" + env.CreateStarshipConfig(firstOriginal) + + themePath1 := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + firstInfo, err := ApplyTheme(themePath1, &config.Config{}) + require.NoError(t, err) + + require.NotNil(t, firstInfo) + expectedFirstBackup := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "1.0.toml") + assert.Equal(t, expectedFirstBackup, firstInfo.Path) + assert.Equal(t, firstOriginal, env.ReadFile(firstInfo.Path)) + + // The user later replaces starship.toml with a new hand-written config + // (a regular file, not the symlink stellar just created). + secondOriginal := "# SECOND ORIGINAL" + require.NoError(t, os.Remove(env.StarshipPath)) + env.CreateStarshipConfig(secondOriginal) + + themePath2 := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) + secondInfo, err := ApplyTheme(themePath2, &config.Config{}) + require.NoError(t, err) + + // The second original is preserved as 2.0.toml, and 1.0.toml is unchanged. + require.NotNil(t, secondInfo) + expectedSecondBackup := filepath.Join(env.StellarDir, BackupAuthor(), "backup", "2.0.toml") + assert.Equal(t, expectedSecondBackup, secondInfo.Path) + assert.Equal(t, secondOriginal, env.ReadFile(secondInfo.Path)) + assert.Equal(t, firstOriginal, env.ReadFile(firstInfo.Path), "earlier backup must stay intact") +} + +// TestApplyTheme_BackupSlotAsFileFails is a regression test for a hang: the +// backup version-slot search used to loop forever if os.Stat returned a +// persistent non-ENOENT error. Here //backup exists +// as a plain FILE, so resolving any version slot beneath it fails with ENOTDIR; +// ApplyTheme must return that error promptly instead of spinning. +func TestApplyTheme_BackupSlotAsFileFails(t *testing.T) { + env := testutil.SetupTestEnv(t) + t.Setenv(paths.EnvApplyMode, "copy") + + // Create the backup dir path as a regular file, not a directory. + backupAsFile := filepath.Join(env.StellarDir, BackupAuthor(), theme.BackupThemeName) + require.NoError(t, os.MkdirAll(filepath.Dir(backupAsFile), 0755)) + require.NoError(t, os.WriteFile(backupAsFile, []byte("not a directory"), 0644)) + + // An unmanaged starship.toml so a backup is actually attempted. + env.CreateStarshipConfig("# unmanaged original config") + + themePath := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) + + done := make(chan error, 1) + go func() { + _, err := ApplyTheme(themePath, &config.Config{}) + done <- err + }() + + select { + case err := <-done: + require.Error(t, err, "backup slot resolution must fail loudly, not silently skip") + case <-time.After(10 * time.Second): + t.Fatal("ApplyTheme hung on the backup slot search instead of failing") + } +} + +// TestIsManaged exercises the three signals that mark a starship.toml as +// stellar's own, plus the nil-config and unmanaged fail-safe cases. +func TestIsManaged(t *testing.T) { + env := testutil.SetupTestEnv(t) + + themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + + t.Run("nil config is treated as empty and unmanaged", func(t *testing.T) { + env.CreateStarshipConfig(testutil.SampleTOML()) + assert.False(t, IsManaged(env.StarshipPath, nil)) + }) + + t.Run("symlink is managed", func(t *testing.T) { + testutil.RequireSymlinks(t) + require.NoError(t, os.Remove(env.StarshipPath)) + require.NoError(t, os.Symlink(themePath, env.StarshipPath)) + assert.True(t, IsManaged(env.StarshipPath, &config.Config{})) + }) + + t.Run("matching applied hash is managed", func(t *testing.T) { + require.NoError(t, os.Remove(env.StarshipPath)) + env.CreateStarshipConfig(testutil.SampleTOML()) + hash, err := HashFile(env.StarshipPath) + require.NoError(t, err) + assert.True(t, IsManaged(env.StarshipPath, &config.Config{AppliedHash: hash})) + }) + + t.Run("legacy current-path content match is managed", func(t *testing.T) { + require.NoError(t, os.Remove(env.StarshipPath)) + env.CreateStarshipConfig(testutil.SampleTOML()) + cfg := &config.Config{CurrentTheme: "alice/rainbow@1.0", CurrentPath: themePath} + assert.True(t, IsManaged(env.StarshipPath, cfg)) + }) + + t.Run("hand-edited content matching no signal is unmanaged", func(t *testing.T) { + require.NoError(t, os.Remove(env.StarshipPath)) + env.CreateStarshipConfig("# hand-edited, matches nothing") + cfg := &config.Config{CurrentTheme: "alice/rainbow@1.0", CurrentPath: themePath, AppliedHash: "deadbeef"} + assert.False(t, IsManaged(env.StarshipPath, cfg)) + }) +} + +func TestSanitizeBackupAuthor(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"windows domain prefix", `DESKTOP-ABC\kurt`, "kurt"}, + {"plain username", "kurt", "kurt"}, + {"spaces replaced", "John Doe", "John-Doe"}, + {"domain and spaces", `domain\John Doe`, "John-Doe"}, + {"empty falls back to local", "", "local"}, + {"only separator falls back to local", `\`, "local"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, sanitizeBackupAuthor(tt.input)) + }) + } +} + +// TestBackupInfo_Identifier verifies that BackupInfo.Identifier is built from +// the same author/version values used to construct BackupInfo.Path (rather +// than re-derived by parsing the path afterward), and that it always targets +// the backup that was actually just written. +func TestBackupInfo_Identifier(t *testing.T) { + testutil.RequireSymlinks(t) + env := testutil.SetupTestEnv(t) + + // First unmanaged config: backed up as version 1.0. + env.CreateStarshipConfig("# FIRST ORIGINAL") + themePath1 := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) + firstInfo, err := ApplyTheme(themePath1, &config.Config{}) + require.NoError(t, err) + + require.NotNil(t, firstInfo) + assert.Equal(t, BackupAuthor()+"/backup@1.0", firstInfo.Identifier) + + // A second, later unmanaged config gets the next version (2.0), and its + // identifier tracks that version rather than staying pinned at 1.0. + require.NoError(t, os.Remove(env.StarshipPath)) + env.CreateStarshipConfig("# SECOND ORIGINAL") + themePath2 := env.CreateThemeFile("bob", "sunset", "2.0", testutil.SampleTOML()) + secondInfo, err := ApplyTheme(themePath2, &config.Config{}) + require.NoError(t, err) + + require.NotNil(t, secondInfo) + assert.Equal(t, BackupAuthor()+"/backup@2.0", secondInfo.Identifier) + + // The identifier must parse back into a theme that targets the file + // actually written to disk. + parsedVersion := strings.TrimSuffix(filepath.Base(secondInfo.Path), ".toml") + assert.True(t, strings.HasSuffix(secondInfo.Identifier, "/backup@"+parsedVersion)) +} + func TestGetCurrentTarget(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) themePath := env.CreateThemeFile("alice", "rainbow", "1.0", testutil.SampleTOML()) - _, err := CreateSymlink(themePath) + _, err := ApplyTheme(themePath, &config.Config{}) require.NoError(t, err) target, err := GetCurrentTarget() @@ -84,7 +440,20 @@ func TestGetCurrentTarget_NoSymlink(t *testing.T) { assert.Error(t, err) } +func TestIsCopyMode(t *testing.T) { + t.Run("forced copy", func(t *testing.T) { + t.Setenv(paths.EnvApplyMode, "copy") + assert.True(t, IsCopyMode()) + }) + + t.Run("forced symlink", func(t *testing.T) { + t.Setenv(paths.EnvApplyMode, "symlink") + assert.False(t, IsCopyMode()) + }) +} + func TestIsSymlink(t *testing.T) { + testutil.RequireSymlinks(t) env := testutil.SetupTestEnv(t) regularPath := filepath.Join(env.RootDir, "regular.txt") @@ -97,3 +466,102 @@ func TestIsSymlink(t *testing.T) { assert.True(t, isSymlink(symlinkPath)) assert.False(t, isSymlink("/nonexistent/path")) } + +// TestRenameWithRetry exercises the Windows transient-lock retry path on Linux +// by flipping the renameRetryEnabled flag and injecting renameSleep, so no +// actual sleeping happens. os.Rename is driven to fail/succeed by controlling +// whether the source file exists at each attempt. +func TestRenameWithRetry(t *testing.T) { + t.Run("transient failure eventually succeeds", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + // Force the retry path on even though the test runs on Linux. + oldFlag := renameRetryEnabled + renameRetryEnabled = true + defer func() { renameRetryEnabled = oldFlag }() + + // The source doesn't exist yet, so the first os.Rename fails. On the + // first backoff sleep we create it, standing in for a transient + // Defender/indexer lock clearing so the next attempt succeeds. + sleeps := 0 + oldSleep := renameSleep + renameSleep = func(time.Duration) { + sleeps++ + if sleeps == 1 { + require.NoError(t, os.WriteFile(src, []byte("binary"), 0644)) + } + } + defer func() { renameSleep = oldSleep }() + + require.NoError(t, RenameWithRetry(src, dst)) + assert.Equal(t, 1, sleeps, "should have retried exactly once before succeeding") + assert.FileExists(t, dst) + }) + + t.Run("persistent failure returns the last error", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "never-appears") + dst := filepath.Join(dir, "dst") + + oldFlag := renameRetryEnabled + renameRetryEnabled = true + defer func() { renameRetryEnabled = oldFlag }() + + sleeps := 0 + oldSleep := renameSleep + renameSleep = func(time.Duration) { sleeps++ } + defer func() { renameSleep = oldSleep }() + + err := RenameWithRetry(src, dst) + require.Error(t, err, "a source that never appears must ultimately fail") + assert.Equal(t, len(renameRetryBackoff), sleeps, "should exhaust every backoff step before giving up") + }) + + t.Run("no retry or sleep when the flag is off", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "missing") + dst := filepath.Join(dir, "dst") + + oldFlag := renameRetryEnabled + renameRetryEnabled = false + defer func() { renameRetryEnabled = oldFlag }() + + sleeps := 0 + oldSleep := renameSleep + renameSleep = func(time.Duration) { sleeps++ } + defer func() { renameSleep = oldSleep }() + + err := RenameWithRetry(src, dst) + require.Error(t, err, "single-shot rename of a missing source must fail") + assert.Zero(t, sleeps, "the non-Windows path must not sleep or retry") + }) +} + +func TestHashFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.toml") + require.NoError(t, os.WriteFile(path, []byte(testutil.SampleTOML()), 0644)) + + hash1, err := HashFile(path) + require.NoError(t, err) + assert.NotEmpty(t, hash1) + + // Same content hashes the same. + path2 := filepath.Join(dir, "sample-copy.toml") + require.NoError(t, os.WriteFile(path2, []byte(testutil.SampleTOML()), 0644)) + hash2, err := HashFile(path2) + require.NoError(t, err) + assert.Equal(t, hash1, hash2) + + // Different content hashes differently. + path3 := filepath.Join(dir, "other.toml") + require.NoError(t, os.WriteFile(path3, []byte(testutil.SampleTOMLWithCustom()), 0644)) + hash3, err := HashFile(path3) + require.NoError(t, err) + assert.NotEqual(t, hash1, hash3) + + _, err = HashFile(filepath.Join(dir, "missing.toml")) + assert.Error(t, err) +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 522f412..3da23b6 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -3,12 +3,15 @@ package testutil import ( + "bytes" + "io" "net/http/httptest" "os" "path/filepath" "testing" "github.com/a3chron/stellar/internal/paths" + "github.com/fatih/color" ) // TestEnv holds the test environment configuration @@ -56,11 +59,17 @@ func SetupTestEnv(t *testing.T) *TestEnv { env.origEnv[paths.EnvStarshipPath] = os.Getenv(paths.EnvStarshipPath) env.origEnv[paths.EnvTmpDir] = os.Getenv(paths.EnvTmpDir) env.origEnv[paths.EnvAPIURL] = os.Getenv(paths.EnvAPIURL) + env.origEnv[paths.EnvApplyMode] = os.Getenv(paths.EnvApplyMode) // Set test env variables _ = os.Setenv(paths.EnvStellarHome, env.StellarDir) _ = os.Setenv(paths.EnvStarshipPath, env.StarshipPath) _ = os.Setenv(paths.EnvTmpDir, env.TmpDir) + // Pin apply mode to symlink so the suite is deterministic regardless of + // the OS running it (IsCopyMode defaults to copy on Windows). Tests that + // specifically want copy-mode behavior call t.Setenv(paths.EnvApplyMode, + // "copy") themselves *after* calling SetupTestEnv, so their override wins. + _ = os.Setenv(paths.EnvApplyMode, "symlink") // Register cleanup t.Cleanup(func() { @@ -172,6 +181,78 @@ func (e *TestEnv) ReadSymlink(path string) string { return target } +// CaptureOutput runs fn with stdout redirected to a pipe and returns everything +// written to it. It swaps both os.Stdout and color.Output: fatih/color captures +// os.Stdout at package-init time, so reassigning os.Stdout alone would miss any +// color.* output. Both are restored before returning. The pipe is drained in a +// goroutine so large output can't deadlock on the pipe buffer. +// +// The restore is deferred and idempotent: if fn panics (e.g. a t.Fatal inside +// it), os.Stdout/color.Output are still put back before the panic propagates, +// instead of staying pointed at a pipe that no longer has a reader for every +// later test in the process. +func CaptureOutput(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + + origStdout := os.Stdout + origColorOutput := color.Output + os.Stdout = w + color.Output = w + + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + + restored := false + restore := func() { + if !restored { + restored = true + os.Stdout = origStdout + color.Output = origColorOutput + _ = w.Close() + } + } + defer restore() + + fn() + + // Restore first, then close the writer so the reader goroutine sees EOF. + restore() + + out := <-done + if cerr := r.Close(); cerr != nil { + t.Fatalf("failed to close pipe reader: %v", cerr) + } + return out +} + +// RequireSymlinks skips the test if the environment can't create symlinks +// (e.g. Windows without Developer Mode or admin privileges). Call it at the +// top of any test that exercises symlink-mode behavior directly. +func RequireSymlinks(t *testing.T) { + t.Helper() + + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + + if err := os.WriteFile(target, []byte("probe"), 0644); err != nil { + t.Fatalf("failed to create symlink probe target: %v", err) + } + + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks not supported in this environment: %v", err) + } +} + // SampleTOML returns a valid sample starship config func SampleTOML() string { return `# Sample starship config diff --git a/internal/theme/parser.go b/internal/theme/parser.go index 262657b..a00e6a0 100644 --- a/internal/theme/parser.go +++ b/internal/theme/parser.go @@ -18,6 +18,24 @@ type Theme struct { VersionExplicit bool // True if version was explicitly specified in the identifier } +// BackupThemeName is the reserved theme name used for backups of the user's +// original starship config (they live at /backup). It is not a real +// theme on the hub: internal/symlink writes backups under it, and +// internal/cache skips it at clean time so a backup is never swept away. +const BackupThemeName = "backup" + +// IsValidIdentifierRune reports whether r is allowed inside an author or name +// segment of a theme identifier. It is the single definition of that character +// class ([a-zA-Z0-9_-]); ParseIdentifier's regex below and any caller that +// sanitizes input (e.g. internal/symlink.sanitizeBackupAuthor) must agree with +// it, so the two can't silently drift. +func IsValidIdentifierRune(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' +} + // ParseIdentifier parses "alice/rainbow@1.2", "alice/rainbow@latest", or "alice/rainbow" func ParseIdentifier(identifier string) (*Theme, error) { // Normalize: remove leading/trailing whitespace diff --git a/internal/theme/parser_test.go b/internal/theme/parser_test.go index 543474f..7b22ff4 100644 --- a/internal/theme/parser_test.go +++ b/internal/theme/parser_test.go @@ -143,6 +143,23 @@ func TestParseIdentifier(t *testing.T) { } } +// TestIsValidIdentifierRune_MatchesParser guards against drift between +// IsValidIdentifierRune and the character class ParseIdentifier's regex +// accepts. For every rune across a broad sample range, building an identifier +// from that single rune must parse iff IsValidIdentifierRune allows it, so the +// two definitions stay in lockstep. +func TestIsValidIdentifierRune_MatchesParser(t *testing.T) { + for r := rune(0); r < 0x300; r++ { + identifier := string(r) + "/" + string(r) + _, err := ParseIdentifier(identifier) + parserAccepts := err == nil + + assert.Equal(t, IsValidIdentifierRune(r), parserAccepts, + "rune %q (U+%04X): IsValidIdentifierRune=%v but parser-accepts=%v", + r, r, IsValidIdentifierRune(r), parserAccepts) + } +} + func TestTheme_String(t *testing.T) { tests := []struct { name string