Eric Bower
·
2026-01-25
cli.go
1package pgs
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "log/slog"
8 "path/filepath"
9 "strings"
10 "text/tabwriter"
11 "time"
12
13 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
14 "github.com/picosh/pico/pkg/db"
15 sst "github.com/picosh/pico/pkg/pobj/storage"
16 "github.com/picosh/pico/pkg/shared"
17)
18
19func NewTabWriter(out io.Writer) *tabwriter.Writer {
20 return tabwriter.NewWriter(out, 0, 0, 2, ' ', tabwriter.TabIndent)
21}
22
23func projectTable(sesh io.Writer, projects []*db.Project) {
24 writer := NewTabWriter(sesh)
25 _, _ = fmt.Fprintln(writer, "Name\tLast Updated\tLinks To\tACL Type\tACL\tBlocked")
26
27 for _, project := range projects {
28 links := ""
29 if project.ProjectDir != project.Name {
30 links = project.ProjectDir
31 }
32 _, _ = fmt.Fprintf(
33 writer,
34 "%s\t%s\t%s\t%s\t%s\t%s\r\n",
35 project.Name,
36 project.UpdatedAt.Format("2006-01-02 15:04:05"),
37 links,
38 project.Acl.Type,
39 strings.Join(project.Acl.Data, " "),
40 project.Blocked,
41 )
42 }
43 _ = writer.Flush()
44}
45
46type Cmd struct {
47 User *db.User
48 Session shared.CmdSession
49 Log *slog.Logger
50 Store sst.ObjectStorage
51 Dbpool pgsdb.PgsDB
52 Write bool
53 Width int
54 Height int
55 Cfg *PgsConfig
56}
57
58func (c *Cmd) output(out string) {
59 _, _ = c.Session.Write([]byte(out + "\r\n"))
60}
61
62func (c *Cmd) error(err error) {
63 _, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
64 _ = c.Session.Exit(1)
65 _ = c.Session.Close()
66}
67
68func (c *Cmd) bail(err error) {
69 if err == nil {
70 return
71 }
72 c.Log.Error(err.Error())
73 c.error(err)
74}
75
76func (c *Cmd) notice() {
77 if !c.Write {
78 c.output("\nNOTICE: changes not commited, use `--write` to save operation")
79 }
80}
81
82func (c *Cmd) RmProjectAssets(projectName string) error {
83 bucketName := shared.GetAssetBucketName(c.User.ID)
84 bucket, err := c.Store.GetBucket(bucketName)
85 if err != nil {
86 return err
87 }
88 c.output(fmt.Sprintf("removing project assets (%s)", projectName))
89
90 fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
91 if err != nil {
92 return err
93 }
94
95 if len(fileList) == 0 {
96 c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
97 return nil
98 }
99 c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
100
101 for _, file := range fileList {
102 if file.IsDir() {
103 continue
104 }
105 intent := fmt.Sprintf("deleted (%s)", file.Name())
106 c.Log.Info(
107 "attempting to delete file",
108 "user", c.User.Name,
109 "bucket", bucket.Name,
110 "filename", file.Name(),
111 )
112 if c.Write {
113 err = c.Store.DeleteObject(
114 bucket,
115 filepath.Join(projectName, file.Name()),
116 )
117 if err == nil {
118 c.output(intent)
119 } else {
120 return err
121 }
122 } else {
123 c.output(intent)
124 }
125 }
126 return nil
127}
128
129func (c *Cmd) help() {
130 helpStr := "Commands: [help, stats, ls, fzf, rm, link, unlink, prune, retain, depends, acl, cache]\r\n"
131 helpStr += "NOTICE:" + " *must* append with `--write` for the changes to persist.\r\n"
132 c.output(helpStr)
133 projectName := "projA"
134
135 data := [][]string{
136 {
137 "help",
138 "prints this screen",
139 },
140 {
141 "stats",
142 "usage statistics",
143 },
144 {
145 "ls",
146 "lists projects",
147 },
148 {
149 fmt.Sprintf("fzf %s", projectName),
150 fmt.Sprintf("lists urls of all assets in %s", projectName),
151 },
152 {
153 fmt.Sprintf("rm %s", projectName),
154 fmt.Sprintf("delete %s", projectName),
155 },
156 {
157 fmt.Sprintf("link %s --to projB", projectName),
158 fmt.Sprintf("symbolic link `%s` to `projB`", projectName),
159 },
160 {
161 fmt.Sprintf("unlink %s", projectName),
162 fmt.Sprintf("removes symbolic link for `%s`", projectName),
163 },
164 {
165 fmt.Sprintf("prune %s", projectName),
166 fmt.Sprintf("removes projects that match prefix `%s`", projectName),
167 },
168 {
169 fmt.Sprintf("retain %s", projectName),
170 "alias to `prune` but keeps last N projects",
171 },
172 {
173 fmt.Sprintf("depends %s", projectName),
174 fmt.Sprintf("lists all projects linked to `%s`", projectName),
175 },
176 {
177 fmt.Sprintf("acl %s", projectName),
178 fmt.Sprintf("access control for `%s`", projectName),
179 },
180 {
181 fmt.Sprintf("cache %s", projectName),
182 fmt.Sprintf("clear http cache for `%s`", projectName),
183 },
184 }
185
186 writer := NewTabWriter(c.Session)
187 _, _ = fmt.Fprintln(writer, "Cmd\tDescription")
188 for _, dat := range data {
189 _, _ = fmt.Fprintf(writer, "%s\t%s\r\n", dat[0], dat[1])
190 }
191 _ = writer.Flush()
192}
193
194func (c *Cmd) stats(cfgMaxSize uint64) error {
195 ff, err := c.Dbpool.FindFeature(c.User.ID, "plus")
196 if err != nil {
197 ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0, 0)
198 }
199 // this is jank
200 ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
201 storageMax := ff.Data.StorageMax
202
203 bucketName := shared.GetAssetBucketName(c.User.ID)
204 bucket, err := c.Store.GetBucket(bucketName)
205 if err != nil {
206 return err
207 }
208
209 totalFileSize, err := c.Store.GetBucketQuota(bucket)
210 if err != nil {
211 return err
212 }
213
214 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
215 if err != nil {
216 return err
217 }
218
219 writer := NewTabWriter(c.Session)
220 _, _ = fmt.Fprintln(writer, "Used (GB)\tQuota (GB)\tUsed (%)\tProjects (#)")
221 _, _ = fmt.Fprintf(
222 writer,
223 "%.4f\t%.4f\t%.4f\t%d\r\n",
224 shared.BytesToGB(int(totalFileSize)),
225 shared.BytesToGB(int(storageMax)),
226 (float32(totalFileSize)/float32(storageMax))*100,
227 len(projects),
228 )
229 return writer.Flush()
230}
231
232func (c *Cmd) ls() error {
233 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
234 if err != nil {
235 return err
236 }
237
238 if len(projects) == 0 {
239 c.output("no projects found")
240 }
241
242 projectTable(c.Session, projects)
243
244 return nil
245}
246
247func (c *Cmd) unlink(projectName string) error {
248 c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
249 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
250 if err != nil {
251 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
252 }
253
254 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
255 if err != nil {
256 return err
257 }
258 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
259
260 return nil
261}
262
263func (c *Cmd) fzf(projectName string) error {
264 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
265 if err != nil {
266 return err
267 }
268
269 bucket, err := c.Store.GetBucket(shared.GetAssetBucketName(c.User.ID))
270 if err != nil {
271 return err
272 }
273
274 objs, err := c.Store.ListObjects(bucket, project.ProjectDir+"/", true)
275 if err != nil {
276 return err
277 }
278
279 for _, obj := range objs {
280 if strings.Contains(obj.Name(), "._pico_keep_dir") {
281 continue
282 }
283 url := c.Cfg.AssetURL(
284 c.User.Name,
285 project.Name,
286 strings.TrimPrefix(obj.Name(), "/"),
287 )
288 c.output(url)
289 }
290
291 return nil
292}
293
294func (c *Cmd) link(projectName, linkTo string) error {
295 c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
296
297 projectDir := linkTo
298 _, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
299 if err != nil {
300 e := fmt.Errorf("(%s) project doesn't exist", linkTo)
301 return e
302 }
303
304 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
305 projectID := ""
306 if err == nil {
307 projectID = project.ID
308 c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
309 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
310 if err != nil {
311 return err
312 }
313 } else {
314 c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
315 if !c.Write {
316 out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
317 c.output(out)
318 return nil
319 }
320 id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
321 if err != nil {
322 return err
323 }
324 projectID = id
325 }
326
327 c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
328 err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
329 if err != nil {
330 return err
331 }
332
333 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
334 c.output(out)
335
336 err = c.RmProjectAssets(projectName)
337 if err != nil {
338 return err
339 }
340
341 out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
342 c.output(out)
343 return nil
344}
345
346func (c *Cmd) depends(projectName string) error {
347 projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
348 if err != nil {
349 return err
350 }
351
352 if len(projects) == 0 {
353 out := fmt.Sprintf("no projects linked to (%s)", projectName)
354 c.output(out)
355 return nil
356 }
357
358 projectTable(c.Session, projects)
359 return nil
360}
361
362// delete all the projects and associated assets matching prefix
363// but keep the latest N records.
364func (c *Cmd) prune(prefix string, keepNumLatest int) error {
365 c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
366 c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
367
368 if prefix == "" || prefix == "*" {
369 e := fmt.Errorf("must provide valid prefix")
370 return e
371 }
372
373 projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
374 if err != nil {
375 return err
376 }
377
378 if len(projects) == 0 {
379 c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
380 return nil
381 }
382
383 rmProjects := []*db.Project{}
384 for _, project := range projects {
385 links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
386 if err != nil {
387 return err
388 }
389
390 if len(links) == 0 {
391 rmProjects = append(rmProjects, project)
392 } else {
393 out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
394 c.output(out)
395 }
396 }
397
398 goodbye := rmProjects
399 if keepNumLatest > 0 {
400 pmax := len(rmProjects) - (keepNumLatest)
401 if pmax <= 0 {
402 out := fmt.Sprintf(
403 "no projects available to prune (retention policy: %d, total: %d)",
404 keepNumLatest,
405 len(rmProjects),
406 )
407 c.output(out)
408 return nil
409 }
410 goodbye = rmProjects[:pmax]
411 }
412
413 for _, project := range goodbye {
414 out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
415 c.output(out)
416 err = c.RmProjectAssets(project.Name)
417 if err != nil {
418 return err
419 }
420
421 out = fmt.Sprintf("(%s) removing", project.Name)
422 c.output(out)
423
424 if c.Write {
425 c.Log.Info("removing project", "project", project.Name)
426 err = c.Dbpool.RemoveProject(project.ID)
427 if err != nil {
428 return err
429 }
430 }
431 }
432
433 c.output("\r\nsummary")
434 c.output("=======")
435 for _, project := range goodbye {
436 c.output(fmt.Sprintf("project (%s) removed", project.Name))
437 }
438
439 return nil
440}
441
442func (c *Cmd) rm(projectName string) error {
443 c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
444
445 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
446 if err == nil {
447 c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
448
449 links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
450 if err != nil {
451 return err
452 }
453
454 if len(links) > 0 {
455 e := fmt.Errorf("project (%s) has (%d) projects linking to it, cannot delete project until they have been unlinked or removed, aborting", projectName, len(links))
456 return e
457 }
458
459 out := fmt.Sprintf("(%s) removing", project.Name)
460 c.output(out)
461 if c.Write {
462 c.Log.Info("removing project", "project", project.Name)
463 err = c.Dbpool.RemoveProject(project.ID)
464 if err != nil {
465 return err
466 }
467 }
468 } else {
469 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
470 c.output(msg)
471 }
472
473 err = c.RmProjectAssets(projectName)
474 return err
475}
476
477func (c *Cmd) acl(projectName, aclType string, acls []string) error {
478 c.Log.Info(
479 "user running `acl` command",
480 "user", c.User.Name,
481 "project", projectName,
482 "actType", aclType,
483 "acls", acls,
484 )
485 c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
486 acl := db.ProjectAcl{
487 Type: aclType,
488 Data: acls,
489 }
490 if c.Write {
491 return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
492 }
493 return nil
494}
495
496func (c *Cmd) cache(projectName string) error {
497 c.Log.Info(
498 "user running `cache` command",
499 "user", c.User.Name,
500 "project", projectName,
501 )
502
503 c.output(fmt.Sprintf("clearing http cache for %s", projectName))
504
505 if c.Write {
506 surrogate := getSurrogateKey(c.User.Name, projectName)
507 return purgeCache(c.Cfg, c.Cfg.Pubsub, surrogate)
508 }
509 return nil
510}
511
512func (c *Cmd) cacheAll() error {
513 isAdmin := false
514 ff, _ := c.Dbpool.FindFeature(c.User.ID, "admin")
515 if ff != nil {
516 if ff.ExpiresAt.Before(time.Now()) {
517 isAdmin = true
518 }
519 }
520
521 if !isAdmin {
522 return fmt.Errorf("must be admin to use this command")
523 }
524
525 c.Log.Info(
526 "admin running `cache-all` command",
527 "user", c.User.Name,
528 )
529 c.output("clearing http cache for all sites")
530 if c.Write {
531 return purgeAllCache(c.Cfg, c.Cfg.Pubsub)
532 }
533 return nil
534}