v0.22.0 a85da87

applier.go RawHistoryFolder

1package updater
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8
9	"golang.org/x/sys/unix"
10
11	"github.com/bnema/dumber/internal/infrastructure/config"
12	"github.com/bnema/dumber/internal/logging"
13)
14
15const (
16	// Staging directory name within XDG_STATE_HOME.
17	stagingDirName = "pending-update"
18	// Staged binary filename.
19	stagedBinaryName = "dumber"
20	// Backup suffix for old binary.
21	backupSuffix = ".old"
22	// File permission for directories and executables.
23	applierExecPerm = 0o755
24	// Execute permission bit for owner.
25	execBit = 0o100
26)
27
28// Applier implements UpdateApplier for self-updating the binary.
29type Applier struct {
30	stateDir string
31}
32
33// NewApplier creates a new update applier.
34// stateDir is the XDG state directory (e.g., ~/.local/state/dumber).
35func NewApplier(stateDir string) *Applier {
36	return &Applier{
37		stateDir: stateDir,
38	}
39}
40
41// NewApplierFromXDG creates a new update applier using XDG directories.
42func NewApplierFromXDG() (*Applier, error) {
43	stateDir, err := config.GetStateDir()
44	if err != nil {
45		return nil, fmt.Errorf("failed to get state directory: %w", err)
46	}
47	return NewApplier(stateDir), nil
48}
49
50// stagingDir returns the path to the staging directory.
51func (a *Applier) stagingDir() string {
52	return filepath.Join(a.stateDir, stagingDirName)
53}
54
55// stagedBinaryPath returns the path to the staged binary.
56func (a *Applier) stagedBinaryPath() string {
57	return filepath.Join(a.stagingDir(), stagedBinaryName)
58}
59
60// CanSelfUpdate checks if the current binary is writable by the current user.
61func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
62	log := logging.FromContext(ctx)
63
64	binaryPath, err := a.GetBinaryPath()
65	if err != nil {
66		log.Debug().Err(err).Msg("failed to get binary path")
67		return false
68	}
69
70	// Check if we have write permission on the binary file.
71	err = unix.Access(binaryPath, unix.W_OK)
72	if err != nil {
73		log.Debug().
74			Str("path", binaryPath).
75			Err(err).
76			Msg("binary is not writable, self-update disabled")
77		return false
78	}
79
80	log.Debug().Str("path", binaryPath).Msg("binary is writable, self-update enabled")
81	return true
82}
83
84// GetBinaryPath returns the path to the currently running binary.
85func (*Applier) GetBinaryPath() (string, error) {
86	path, err := os.Executable()
87	if err != nil {
88		return "", fmt.Errorf("failed to get executable path: %w", err)
89	}
90
91	// Resolve any symlinks to get the actual binary path.
92	resolved, err := filepath.EvalSymlinks(path)
93	if err != nil {
94		return "", fmt.Errorf("failed to resolve symlinks: %w", err)
95	}
96
97	return resolved, nil
98}
99
100// StageUpdate copies the new binary to the staging directory.
101func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error {
102	log := logging.FromContext(ctx)
103
104	// Create staging directory.
105	stagingDir := a.stagingDir()
106	if err := os.MkdirAll(stagingDir, applierExecPerm); err != nil {
107		return fmt.Errorf("failed to create staging directory: %w", err)
108	}
109
110	// Read the new binary.
111	newBinary, err := os.ReadFile(newBinaryPath)
112	if err != nil {
113		return fmt.Errorf("failed to read new binary: %w", err)
114	}
115
116	// Write to staging location.
117	stagedPath := a.stagedBinaryPath()
118	if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil {
119		return fmt.Errorf("failed to write staged binary: %w", err)
120	}
121
122	log.Info().
123		Str("staged_path", stagedPath).
124		Int("size", len(newBinary)).
125		Msg("update staged for exit")
126
127	return nil
128}
129
130// HasStagedUpdate checks if there is an update staged and ready to apply.
131func (a *Applier) HasStagedUpdate(_ context.Context) bool {
132	stagedPath := a.stagedBinaryPath()
133	info, err := os.Stat(stagedPath)
134	if err != nil {
135		return false
136	}
137	// Ensure it's a regular file with execute permission.
138	return info.Mode().IsRegular() && info.Mode()&execBit != 0
139}
140
141// ApplyOnExit replaces the current binary with the staged update.
142// This should be called during graceful shutdown.
143func (a *Applier) ApplyOnExit(ctx context.Context) (string, error) {
144	log := logging.FromContext(ctx)
145
146	if !a.HasStagedUpdate(ctx) {
147		return "", fmt.Errorf("no staged update found")
148	}
149
150	binaryPath, err := a.GetBinaryPath()
151	if err != nil {
152		return "", err
153	}
154
155	stagedPath := a.stagedBinaryPath()
156	backupPath := binaryPath + backupSuffix
157
158	log.Info().
159		Str("current", binaryPath).
160		Str("staged", stagedPath).
161		Str("backup", backupPath).
162		Msg("applying staged update")
163
164	// Step 1: Remove old backup if exists.
165	_ = os.Remove(backupPath)
166
167	// Step 2: Rename current binary to backup.
168	if err := os.Rename(binaryPath, backupPath); err != nil {
169		return "", fmt.Errorf("failed to backup current binary: %w", err)
170	}
171
172	// Step 3: Rename staged binary to current.
173	if err := os.Rename(stagedPath, binaryPath); err != nil {
174		// Try to restore backup.
175		if restoreErr := os.Rename(backupPath, binaryPath); restoreErr != nil {
176			log.Error().Err(restoreErr).Msg("failed to restore backup after update failure")
177		}
178		return "", fmt.Errorf("failed to install new binary: %w", err)
179	}
180
181	// Step 4: Clean up staging directory.
182	_ = os.RemoveAll(a.stagingDir())
183
184	log.Info().
185		Str("binary", binaryPath).
186		Str("backup", backupPath).
187		Msg("update applied successfully")
188
189	return backupPath, nil
190}
191
192// ClearStagedUpdate removes any staged update without applying it.
193func (a *Applier) ClearStagedUpdate(ctx context.Context) error {
194	log := logging.FromContext(ctx)
195
196	stagingDir := a.stagingDir()
197	if err := os.RemoveAll(stagingDir); err != nil {
198		return fmt.Errorf("failed to clear staged update: %w", err)
199	}
200
201	log.Debug().Str("path", stagingDir).Msg("cleared staged update")
202	return nil
203}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close