main pico / pkg / apps / pgs / cli.go
Antonio Mika  ·  2026-10-10
  1package pgs
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"log/slog"
  9	"path/filepath"
 10	"slices"
 11	"strings"
 12	"text/tabwriter"
 13	"time"
 14
 15	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 16	"github.com/picosh/pico/pkg/db"
 17	"github.com/picosh/pico/pkg/shared"
 18	"github.com/picosh/pico/pkg/storage"
 19)
 20
 21func NewTabWriter(out io.Writer) *tabwriter.Writer {
 22	return tabwriter.NewWriter(out, 0, 0, 2, ' ', tabwriter.TabIndent)
 23}
 24
 25func projectTable(sesh io.Writer, projects []*db.Project) {
 26	writer := NewTabWriter(sesh)
 27	_, _ = fmt.Fprintln(writer, "Name\tLast Updated\tLinks To\tACL Type\tACL\tBlocked")
 28
 29	for _, project := range projects {
 30		links := ""
 31		if project.ProjectDir != project.Name {
 32			links = project.ProjectDir
 33		}
 34		_, _ = fmt.Fprintf(
 35			writer,
 36			"%s\t%s\t%s\t%s\t%s\t%s\r\n",
 37			project.Name,
 38			project.UpdatedAt.Format("2006-01-02 15:04:05"),
 39			links,
 40			project.Acl.Type,
 41			strings.Join(project.Acl.Data, " "),
 42			project.Blocked,
 43		)
 44	}
 45	_ = writer.Flush()
 46}
 47
 48type Cmd struct {
 49	User    *db.User
 50	Session shared.CmdSession
 51	Log     *slog.Logger
 52	Store   storage.StorageServe
 53	Dbpool  pgsdb.PgsDB
 54	Write   bool
 55	Width   int
 56	Height  int
 57	Cfg     *Config
 58}
 59
 60func (c *Cmd) output(out string) {
 61	_, _ = c.Session.Write([]byte(out + "\r\n"))
 62}
 63
 64func (c *Cmd) error(err error) {
 65	_, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
 66	_ = c.Session.Exit(1)
 67	_ = c.Session.Close()
 68}
 69
 70func (c *Cmd) bail(err error) {
 71	if err == nil {
 72		return
 73	}
 74	c.Log.Error(err.Error())
 75	c.error(err)
 76}
 77
 78func (c *Cmd) notice() {
 79	if !c.Write {
 80		c.output("\nNOTICE: changes not commited, use `--write` to save operation")
 81	}
 82}
 83
 84// purge clears what the web servers cache about a project after a command
 85// changed it.
 86func (c *Cmd) purge(projectName string) {
 87	c.Cfg.CacheClearingQueue <- getSurrogateKey(c.User.Name, projectName)
 88}
 89
 90func (c *Cmd) RmProjectAssets(projectName string) error {
 91	bucketName := shared.GetAssetBucketName(c.User.ID)
 92	bucket, err := c.Store.GetBucket(bucketName)
 93	if err != nil {
 94		return err
 95	}
 96	c.output(fmt.Sprintf("removing project assets (%s)", projectName))
 97
 98	fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
 99	if err != nil {
100		return err
101	}
102
103	if len(fileList) == 0 {
104		c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
105		if c.Write {
106			return c.Store.DeleteObject(bucket, projectName)
107		}
108		return nil
109	}
110	c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
111	if c.Write {
112		defer c.purge(projectName)
113	}
114
115	for _, file := range fileList {
116		if file.IsDir() {
117			continue
118		}
119		intent := fmt.Sprintf("deleted (%s)", file.Name())
120		c.Log.Info(
121			"attempting to delete file",
122			"user", c.User.Name,
123			"bucket", bucket.Name,
124			"filename", file.Name(),
125		)
126		if c.Write {
127			err = c.Store.DeleteObject(
128				bucket,
129				filepath.Join(projectName, file.Name()),
130			)
131			if err == nil {
132				c.output(intent)
133			} else {
134				return err
135			}
136		} else {
137			c.output(intent)
138		}
139	}
140	if !c.Write {
141		return nil
142	}
143
144	// Directories outlive their files, so remove them deepest first.
145	dirs := []string{projectName}
146	for _, file := range fileList {
147		if file.IsDir() {
148			dirs = append(dirs, filepath.Join(projectName, file.Name()))
149		}
150	}
151	slices.SortStableFunc(dirs, func(a, b string) int {
152		return strings.Count(b, "/") - strings.Count(a, "/")
153	})
154	for _, dir := range dirs {
155		if err := c.Store.DeleteObject(bucket, dir); err != nil {
156			return err
157		}
158	}
159	return nil
160}
161
162func (c *Cmd) help() {
163	helpStr := `pgs.sh
164======
165
166Deploy static sites with a single command. No passwords. No config files. No CI setup. Just your SSH key and rsync.
167
168	rsync --delete -rv ./public/ pgs.sh:/mysite
169	# => https://erock-mysite.pgs.sh
170
171That's the entire workflow. Your SSH key is your identity and every deploy is instant.
172
173You can fetch project assets with the same command in reverse:
174
175	rsync -rv pgs.sh:/mysite/ ./public/
176
177You can also use unix pipes to directly upload files by providing the project name as part of the path:
178
179	echo "<body>hello world!</body>" | ssh pgs.sh /mysite/index.html
180	# => https://erock-mysite.pgs.sh/index.html
181
182The leading "/" is important.
183
184You can also create private projects when you prefix the project name with 'private':
185
186	rsync -rv ./public/ pgs.sh:/private-site/
187
188This means only you can access the site through a web tunnel or by downloading the files.
189`
190	helpStr += "\r\nCommands: [help, stats, ls, fzf, rm, link, unlink, prune, retain, depends, acl, cache, forms]\r\n"
191	helpStr += "For most of these commands you can provide a `-h` to learn about its usage.\r\n"
192	helpStr += "\r\n> NOTICE:" + " *must* append with `--write` for the changes to persist.\r\n"
193	c.output(helpStr)
194	projectName := "{project}"
195
196	data := [][]string{
197		{
198			"help",
199			"Prints this screen",
200		},
201		{
202			"stats",
203			"Usage statistics (quota, % quota used, number of projects)",
204		},
205		{
206			"ls",
207			"Lists all projects and meta data",
208		},
209		{
210			fmt.Sprintf("fzf %s", projectName),
211			"Lists urls of all assets in project",
212		},
213		{
214			fmt.Sprintf("rm %s", projectName),
215			"Removes all files in project and then deletes the project",
216		},
217		{
218			fmt.Sprintf("link %s --to projB", projectName),
219			fmt.Sprintf("Instant promotion and rollback mechanism that symbolic links %s to `projB`", projectName),
220		},
221		{
222			fmt.Sprintf("unlink %s", projectName),
223			"Removes symbolic link",
224		},
225		{
226			fmt.Sprintf("prune %s", projectName),
227			"Delete all projects matching a prefix (except projects with linked projects)",
228		},
229		{
230			fmt.Sprintf("retain %s", projectName),
231			"Delete all projects matching a prefix except the last N recently updated projects.",
232		},
233		{
234			fmt.Sprintf("depends %s", projectName),
235			"Lists all projects linked to project",
236		},
237		{
238			fmt.Sprintf("acl %s", projectName),
239			"Access control for project",
240		},
241		{
242			fmt.Sprintf("cache %s", projectName),
243			"Clear http cache",
244		},
245		{
246			"forms ls",
247			"Print list of forms",
248		},
249		{
250			fmt.Sprintf("forms %s", projectName),
251			"Print form submissions in json",
252		},
253	}
254
255	writer := NewTabWriter(c.Session)
256	_, _ = fmt.Fprintln(writer, "Cmd\tDescription")
257	_, _ = fmt.Fprintf(writer, "===\t===========\r\n")
258	for _, dat := range data {
259		_, _ = fmt.Fprintf(writer, "%s\t%s\r\n", dat[0], dat[1])
260	}
261	_ = writer.Flush()
262}
263
264func (c *Cmd) stats(cfgMaxSize uint64) error {
265	ff, err := findFeatureFlag(c.Dbpool, c.Cfg, c.User.ID)
266	if err != nil {
267		ff = db.NewFeatureFlag(c.User.ID, "pgs", cfgMaxSize, 0, 0)
268	}
269	storageMax := ff.FindStorageMax(cfgMaxSize)
270
271	bucketName := shared.GetAssetBucketName(c.User.ID)
272	bucket, err := c.Store.GetBucket(bucketName)
273	if err != nil {
274		return err
275	}
276
277	totalFileSize, err := c.Store.GetBucketQuota(bucket)
278	if err != nil {
279		return err
280	}
281
282	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
283	if err != nil {
284		return err
285	}
286
287	writer := NewTabWriter(c.Session)
288	_, _ = fmt.Fprintln(writer, "Used (GB)\tQuota (GB)\tUsed (%)\tProjects (#)")
289	_, _ = fmt.Fprintf(
290		writer,
291		"%.4f\t%.4f\t%.4f\t%d\r\n",
292		shared.BytesToGB(int(totalFileSize)),
293		shared.BytesToGB(int(storageMax)),
294		(float32(totalFileSize)/float32(storageMax))*100,
295		len(projects),
296	)
297	return writer.Flush()
298}
299
300func (c *Cmd) ls() error {
301	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
302	if err != nil {
303		return err
304	}
305
306	if len(projects) == 0 {
307		c.output("no projects found")
308	}
309
310	projectTable(c.Session, projects)
311
312	return nil
313}
314
315func (c *Cmd) unlink(projectName string) error {
316	c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
317	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
318	if err != nil {
319		return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
320	}
321
322	err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
323	if err != nil {
324		return err
325	}
326	if c.Write {
327		c.purge(project.Name)
328	}
329	c.output(fmt.Sprintf("(%s) unlinked", project.Name))
330
331	return nil
332}
333
334func (c *Cmd) fzf(projectName string) error {
335	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
336	if err != nil {
337		return err
338	}
339
340	bucket, err := c.Store.GetBucket(shared.GetAssetBucketName(c.User.ID))
341	if err != nil {
342		return err
343	}
344
345	objs, err := c.Store.ListObjects(bucket, project.ProjectDir+"/", true)
346	if err != nil {
347		return err
348	}
349
350	for _, obj := range objs {
351		url := c.Cfg.AssetURL(
352			c.User.Name,
353			project.Name,
354			strings.TrimPrefix(obj.Name(), "/"),
355		)
356		c.output(url)
357	}
358
359	return nil
360}
361
362func (c *Cmd) link(projectName, linkTo string) error {
363	c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
364
365	projectDir := linkTo
366	_, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
367	if err != nil {
368		e := fmt.Errorf("(%s) project doesn't exist", linkTo)
369		return e
370	}
371
372	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
373	projectID := ""
374	if err == nil {
375		projectID = project.ID
376		c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
377		err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
378		if err != nil {
379			return err
380		}
381	} else {
382		c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
383		if !c.Write {
384			out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
385			c.output(out)
386			return nil
387		}
388		id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
389		if err != nil {
390			return err
391		}
392		projectID = id
393	}
394
395	c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
396	err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
397	if err != nil {
398		return err
399	}
400	if c.Write {
401		c.purge(projectName)
402	}
403
404	out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
405	c.output(out)
406
407	err = c.RmProjectAssets(projectName)
408	if err != nil {
409		return err
410	}
411
412	out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
413	c.output(out)
414	return nil
415}
416
417func (c *Cmd) depends(projectName string) error {
418	projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
419	if err != nil {
420		return err
421	}
422
423	if len(projects) == 0 {
424		out := fmt.Sprintf("no projects linked to (%s)", projectName)
425		c.output(out)
426		return nil
427	}
428
429	projectTable(c.Session, projects)
430	return nil
431}
432
433// delete all the projects and associated assets matching prefix
434// but keep the latest N records.
435func (c *Cmd) prune(prefix string, keepNumLatest int) error {
436	c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
437	c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
438
439	if prefix == "" || prefix == "*" {
440		e := fmt.Errorf("must provide valid prefix")
441		return e
442	}
443
444	projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
445	if err != nil {
446		return err
447	}
448
449	if len(projects) == 0 {
450		c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
451		return nil
452	}
453
454	rmProjects := []*db.Project{}
455	for _, project := range projects {
456		links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
457		if err != nil {
458			return err
459		}
460
461		if len(links) == 0 {
462			rmProjects = append(rmProjects, project)
463		} else {
464			out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
465			c.output(out)
466		}
467	}
468
469	goodbye := rmProjects
470	if keepNumLatest > 0 {
471		pmax := len(rmProjects) - (keepNumLatest)
472		if pmax <= 0 {
473			out := fmt.Sprintf(
474				"no projects available to prune (retention policy: %d, total: %d)",
475				keepNumLatest,
476				len(rmProjects),
477			)
478			c.output(out)
479			return nil
480		}
481		goodbye = rmProjects[:pmax]
482	}
483
484	for _, project := range goodbye {
485		out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
486		c.output(out)
487		err = c.RmProjectAssets(project.Name)
488		if err != nil {
489			return err
490		}
491
492		out = fmt.Sprintf("(%s) removing", project.Name)
493		c.output(out)
494
495		if c.Write {
496			c.Log.Info("removing project", "project", project.Name)
497			err = c.Dbpool.RemoveProject(project.ID)
498			if err != nil {
499				return err
500			}
501			c.purge(project.Name)
502		}
503	}
504
505	c.output("\r\nsummary")
506	c.output("=======")
507	for _, project := range goodbye {
508		c.output(fmt.Sprintf("project (%s) removed", project.Name))
509	}
510
511	return nil
512}
513
514func (c *Cmd) rm(projectName string) error {
515	c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
516
517	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
518	if err == nil {
519		c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
520
521		links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
522		if err != nil {
523			return err
524		}
525
526		if len(links) > 0 {
527			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))
528			return e
529		}
530
531		out := fmt.Sprintf("(%s) removing", project.Name)
532		c.output(out)
533		if c.Write {
534			c.Log.Info("removing project", "project", project.Name)
535			err = c.Dbpool.RemoveProject(project.ID)
536			if err != nil {
537				return err
538			}
539			c.purge(project.Name)
540		}
541	} else {
542		msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
543		c.output(msg)
544	}
545
546	err = c.RmProjectAssets(projectName)
547	return err
548}
549
550func (c *Cmd) acl(projectName, aclType string, acls []string) error {
551	c.Log.Info(
552		"user running `acl` command",
553		"user", c.User.Name,
554		"project", projectName,
555		"actType", aclType,
556		"acls", acls,
557	)
558	c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
559	acl := db.ProjectAcl{
560		Type: aclType,
561		Data: acls,
562	}
563	if c.Write {
564		if err := c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl); err != nil {
565			return err
566		}
567		c.purge(projectName)
568	}
569	return nil
570}
571
572func (c *Cmd) cache(projectName string) error {
573	c.Log.Info(
574		"user running `cache` command",
575		"user", c.User.Name,
576		"project", projectName,
577	)
578
579	c.output(fmt.Sprintf("clearing http cache for %s", projectName))
580
581	if c.Write {
582		surrogate := getSurrogateKey(c.User.Name, projectName)
583		return purgeCache(c.Cfg, c.Cfg.Pubsub, surrogate)
584	}
585	return nil
586}
587
588func (c *Cmd) cacheAll() error {
589	isAdmin := false
590	ff, _ := c.Dbpool.FindFeature(c.User.ID, "admin")
591	if ff != nil {
592		if ff.ExpiresAt.After(time.Now()) {
593			isAdmin = true
594		}
595	}
596
597	if !isAdmin {
598		return fmt.Errorf("must be admin to use this command")
599	}
600
601	c.Log.Info(
602		"admin running `cache-all` command",
603		"user", c.User.Name,
604	)
605	c.output("clearing http cache for all sites")
606	if c.Write {
607		return purgeAllCache(c.Cfg, c.Cfg.Pubsub)
608	}
609	return nil
610}
611
612func (c *Cmd) formsLs() error {
613	forms, err := c.Dbpool.FindFormNamesByUser(c.User.ID)
614	if err != nil {
615		return err
616	}
617	if len(forms) == 0 {
618		c.output("no forms found")
619		return nil
620	}
621	for _, name := range forms {
622		c.output(name)
623	}
624	return nil
625}
626
627func (c *Cmd) formData(formName string) error {
628	formData, err := c.Dbpool.FindFormEntriesByUserAndName(c.User.ID, formName)
629	if err != nil {
630		return err
631	}
632	data, err := json.Marshal(formData)
633	if err != nil {
634		return err
635	}
636	c.output(string(data))
637	return nil
638}
639
640func (c *Cmd) formRm(formName string) error {
641	c.output(fmt.Sprintf("removing all data associated with form: %s", formName))
642	if c.Write {
643		return c.Dbpool.RemoveFormEntriesByUserAndName(c.User.ID, formName)
644	}
645	return nil
646}