1package agent
2
3import (
4 "bufio"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "sort"
12 "strconv"
13 "strings"
14
15 "github.com/patriceckhart/zot/packages/core"
16)
17
18type sessionsPruneOptions struct {
19 dryRun bool
20 all bool
21 yes bool
22}
23
24type statPathFunc func(string) (fs.FileInfo, error)
25
26// runSessionsCommand dispatches standalone session management commands before
27// normal agent startup, so they do not require a provider or credentials.
28func runSessionsCommand(rawArgs []string) (handled bool, err error) {
29 if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
30 return false, nil
31 }
32 if len(rawArgs) == 1 {
33 printSessionsHelp(os.Stdout)
34 return true, nil
35 }
36 switch rawArgs[1] {
37 case "help", "-h", "--help":
38 printSessionsHelp(os.Stdout)
39 return true, nil
40 case "prune":
41 opts, err := parseSessionsPruneOptions(rawArgs[2:])
42 if errors.Is(err, errSessionsPruneHelp) {
43 printSessionsHelp(os.Stdout)
44 return true, nil
45 }
46 if err != nil {
47 printSessionsHelp(os.Stderr)
48 return true, err
49 }
50 return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
51 default:
52 printSessionsHelp(os.Stderr)
53 return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
54 }
55}
56
57func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
58 var opts sessionsPruneOptions
59 for _, arg := range args {
60 switch arg {
61 case "--dry-run":
62 opts.dryRun = true
63 case "--all":
64 opts.all = true
65 case "-y", "--yes":
66 opts.yes = true
67 case "-h", "--help":
68 return opts, errSessionsPruneHelp
69 default:
70 return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
71 }
72 }
73 if opts.yes && !opts.all {
74 return opts, fmt.Errorf("--yes requires --all")
75 }
76 if opts.yes && opts.dryRun {
77 return opts, fmt.Errorf("--yes cannot be used with --dry-run")
78 }
79 return opts, nil
80}
81
82var errSessionsPruneHelp = errors.New("sessions prune help requested")
83
84func printSessionsHelp(out io.Writer) {
85 fmt.Fprintln(out, `zot sessions - manage stored sessions
86
87usage:
88 zot sessions prune select stale directory groups and confirm deletion
89 zot sessions prune --dry-run list stale groups without deleting anything
90 zot sessions prune --all select every stale group, then confirm
91 zot sessions prune --all --yes delete every stale group without prompting
92
93A group is stale only when its recorded working directory no longer exists.
94Unreadable, malformed, and temporarily inaccessible entries are preserved.`)
95}
96
97func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
98 groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
99 for _, issue := range scanIssues {
100 fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
101 }
102
103 stale := make([]core.StoredSessionGroup, 0, len(groups))
104 for _, group := range groups {
105 if !filepath.IsAbs(group.CWD) {
106 fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
107 continue
108 }
109 _, err := stat(group.CWD)
110 switch {
111 case err == nil:
112 continue
113 case errors.Is(err, fs.ErrNotExist):
114 stale = append(stale, group)
115 default:
116 fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
117 }
118 }
119 if len(stale) == 0 {
120 fmt.Fprintln(out, "no stale session directories found")
121 return nil
122 }
123
124 fmt.Fprintln(out, "stale session directories:")
125 for idx, group := range stale {
126 fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
127 }
128 if opts.dryRun {
129 fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(stale)), directoryCount(len(stale)))
130 return nil
131 }
132
133 reader := bufio.NewReader(in)
134 selected := make([]int, 0, len(stale))
135 if opts.all {
136 for idx := range stale {
137 selected = append(selected, idx)
138 }
139 } else {
140 fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
141 line, err := reader.ReadString('\n')
142 if err != nil && !errors.Is(err, io.EOF) {
143 return fmt.Errorf("read selection: %w", err)
144 }
145 selected, err = parseSessionSelection(line, len(stale))
146 if err != nil {
147 return err
148 }
149 if len(selected) == 0 {
150 fmt.Fprintln(out, "no sessions deleted")
151 return nil
152 }
153 }
154
155 selectedSessions := 0
156 for _, idx := range selected {
157 selectedSessions += len(stale[idx].Paths)
158 }
159 if !opts.yes {
160 fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
161 line, err := reader.ReadString('\n')
162 if err != nil && !errors.Is(err, io.EOF) {
163 return fmt.Errorf("read confirmation: %w", err)
164 }
165 answer := strings.ToLower(strings.TrimSpace(line))
166 if answer != "y" && answer != "yes" {
167 fmt.Fprintln(out, "no sessions deleted")
168 return nil
169 }
170 }
171
172 deletedSessions := 0
173 deletedGroups := 0
174 var deleteErrors []error
175 for _, idx := range selected {
176 group := stale[idx]
177 _, err := stat(group.CWD)
178 if err == nil {
179 fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
180 continue
181 }
182 if !errors.Is(err, fs.ErrNotExist) {
183 fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
184 continue
185 }
186
187 groupDeleted := 0
188 for _, path := range group.Paths {
189 if err := core.DeleteSession(path); err != nil {
190 deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
191 continue
192 }
193 groupDeleted++
194 _ = os.Remove(filepath.Dir(path))
195 }
196 deletedSessions += groupDeleted
197 if groupDeleted == len(group.Paths) {
198 deletedGroups++
199 }
200 }
201 fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
202 return errors.Join(deleteErrors...)
203}
204
205func parseSessionSelection(input string, count int) ([]int, error) {
206 input = strings.ToLower(strings.TrimSpace(input))
207 switch input {
208 case "", "none", "n":
209 return nil, nil
210 case "all", "a":
211 selected := make([]int, count)
212 for idx := range selected {
213 selected[idx] = idx
214 }
215 return selected, nil
216 }
217
218 seen := make(map[int]bool)
219 for _, field := range strings.Split(input, ",") {
220 field = strings.TrimSpace(field)
221 if field == "" {
222 return nil, fmt.Errorf("invalid empty selection")
223 }
224 startText, endText, isRange := strings.Cut(field, "-")
225 start, err := strconv.Atoi(strings.TrimSpace(startText))
226 if err != nil {
227 return nil, fmt.Errorf("invalid selection %q", field)
228 }
229 end := start
230 if isRange {
231 end, err = strconv.Atoi(strings.TrimSpace(endText))
232 if err != nil || end < start {
233 return nil, fmt.Errorf("invalid selection range %q", field)
234 }
235 }
236 if start < 1 || end > count {
237 return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
238 }
239 for value := start; value <= end; value++ {
240 seen[value-1] = true
241 }
242 }
243 selected := make([]int, 0, len(seen))
244 for idx := range seen {
245 selected = append(selected, idx)
246 }
247 sort.Ints(selected)
248 return selected, nil
249}
250
251func totalSessions(groups []core.StoredSessionGroup) int {
252 total := 0
253 for _, group := range groups {
254 total += len(group.Paths)
255 }
256 return total
257}
258
259func sessionCount(count int) string {
260 if count == 1 {
261 return "1 session"
262 }
263 return fmt.Sprintf("%d sessions", count)
264}
265
266func directoryCount(count int) string {
267 if count == 1 {
268 return "1 directory"
269 }
270 return fmt.Sprintf("%d directories", count)
271}