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