repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
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}