Eric Bower
·
2026-03-05
cli.go
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 sst "github.com/picosh/pico/pkg/pobj/storage"
17 "github.com/picosh/pico/pkg/shared"
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 sst.ObjectStorage
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]\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
215 writer := NewTabWriter(c.Session)
216 _, _ = fmt.Fprintln(writer, "Cmd\tDescription")
217 _, _ = fmt.Fprintf(writer, "===\t===========\r\n")
218 for _, dat := range data {
219 _, _ = fmt.Fprintf(writer, "%s\t%s\r\n", dat[0], dat[1])
220 }
221 _ = writer.Flush()
222}
223
224func (c *Cmd) stats(cfgMaxSize uint64) error {
225 ff, err := c.Dbpool.FindFeature(c.User.ID, "plus")
226 if err != nil {
227 ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0, 0)
228 }
229 // this is jank
230 ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
231 storageMax := ff.Data.StorageMax
232
233 bucketName := shared.GetAssetBucketName(c.User.ID)
234 bucket, err := c.Store.GetBucket(bucketName)
235 if err != nil {
236 return err
237 }
238
239 totalFileSize, err := c.Store.GetBucketQuota(bucket)
240 if err != nil {
241 return err
242 }
243
244 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
245 if err != nil {
246 return err
247 }
248
249 writer := NewTabWriter(c.Session)
250 _, _ = fmt.Fprintln(writer, "Used (GB)\tQuota (GB)\tUsed (%)\tProjects (#)")
251 _, _ = fmt.Fprintf(
252 writer,
253 "%.4f\t%.4f\t%.4f\t%d\r\n",
254 shared.BytesToGB(int(totalFileSize)),
255 shared.BytesToGB(int(storageMax)),
256 (float32(totalFileSize)/float32(storageMax))*100,
257 len(projects),
258 )
259 return writer.Flush()
260}
261
262func (c *Cmd) ls() error {
263 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
264 if err != nil {
265 return err
266 }
267
268 if len(projects) == 0 {
269 c.output("no projects found")
270 }
271
272 projectTable(c.Session, projects)
273
274 return nil
275}
276
277func (c *Cmd) unlink(projectName string) error {
278 c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
279 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
280 if err != nil {
281 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
282 }
283
284 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
285 if err != nil {
286 return err
287 }
288 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
289
290 return nil
291}
292
293func (c *Cmd) fzf(projectName string) error {
294 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
295 if err != nil {
296 return err
297 }
298
299 bucket, err := c.Store.GetBucket(shared.GetAssetBucketName(c.User.ID))
300 if err != nil {
301 return err
302 }
303
304 objs, err := c.Store.ListObjects(bucket, project.ProjectDir+"/", true)
305 if err != nil {
306 return err
307 }
308
309 for _, obj := range objs {
310 if strings.Contains(obj.Name(), "._pico_keep_dir") {
311 continue
312 }
313 url := c.Cfg.AssetURL(
314 c.User.Name,
315 project.Name,
316 strings.TrimPrefix(obj.Name(), "/"),
317 )
318 c.output(url)
319 }
320
321 return nil
322}
323
324func (c *Cmd) link(projectName, linkTo string) error {
325 c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
326
327 projectDir := linkTo
328 _, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
329 if err != nil {
330 e := fmt.Errorf("(%s) project doesn't exist", linkTo)
331 return e
332 }
333
334 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
335 projectID := ""
336 if err == nil {
337 projectID = project.ID
338 c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
339 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
340 if err != nil {
341 return err
342 }
343 } else {
344 c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
345 if !c.Write {
346 out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
347 c.output(out)
348 return nil
349 }
350 id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
351 if err != nil {
352 return err
353 }
354 projectID = id
355 }
356
357 c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
358 err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
359 if err != nil {
360 return err
361 }
362
363 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
364 c.output(out)
365
366 err = c.RmProjectAssets(projectName)
367 if err != nil {
368 return err
369 }
370
371 out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
372 c.output(out)
373 return nil
374}
375
376func (c *Cmd) depends(projectName string) error {
377 projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
378 if err != nil {
379 return err
380 }
381
382 if len(projects) == 0 {
383 out := fmt.Sprintf("no projects linked to (%s)", projectName)
384 c.output(out)
385 return nil
386 }
387
388 projectTable(c.Session, projects)
389 return nil
390}
391
392// delete all the projects and associated assets matching prefix
393// but keep the latest N records.
394func (c *Cmd) prune(prefix string, keepNumLatest int) error {
395 c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
396 c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
397
398 if prefix == "" || prefix == "*" {
399 e := fmt.Errorf("must provide valid prefix")
400 return e
401 }
402
403 projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
404 if err != nil {
405 return err
406 }
407
408 if len(projects) == 0 {
409 c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
410 return nil
411 }
412
413 rmProjects := []*db.Project{}
414 for _, project := range projects {
415 links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
416 if err != nil {
417 return err
418 }
419
420 if len(links) == 0 {
421 rmProjects = append(rmProjects, project)
422 } else {
423 out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
424 c.output(out)
425 }
426 }
427
428 goodbye := rmProjects
429 if keepNumLatest > 0 {
430 pmax := len(rmProjects) - (keepNumLatest)
431 if pmax <= 0 {
432 out := fmt.Sprintf(
433 "no projects available to prune (retention policy: %d, total: %d)",
434 keepNumLatest,
435 len(rmProjects),
436 )
437 c.output(out)
438 return nil
439 }
440 goodbye = rmProjects[:pmax]
441 }
442
443 for _, project := range goodbye {
444 out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
445 c.output(out)
446 err = c.RmProjectAssets(project.Name)
447 if err != nil {
448 return err
449 }
450
451 out = fmt.Sprintf("(%s) removing", project.Name)
452 c.output(out)
453
454 if c.Write {
455 c.Log.Info("removing project", "project", project.Name)
456 err = c.Dbpool.RemoveProject(project.ID)
457 if err != nil {
458 return err
459 }
460 }
461 }
462
463 c.output("\r\nsummary")
464 c.output("=======")
465 for _, project := range goodbye {
466 c.output(fmt.Sprintf("project (%s) removed", project.Name))
467 }
468
469 return nil
470}
471
472func (c *Cmd) rm(projectName string) error {
473 c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
474
475 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
476 if err == nil {
477 c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
478
479 links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
480 if err != nil {
481 return err
482 }
483
484 if len(links) > 0 {
485 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))
486 return e
487 }
488
489 out := fmt.Sprintf("(%s) removing", project.Name)
490 c.output(out)
491 if c.Write {
492 c.Log.Info("removing project", "project", project.Name)
493 err = c.Dbpool.RemoveProject(project.ID)
494 if err != nil {
495 return err
496 }
497 }
498 } else {
499 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
500 c.output(msg)
501 }
502
503 err = c.RmProjectAssets(projectName)
504 return err
505}
506
507func (c *Cmd) acl(projectName, aclType string, acls []string) error {
508 c.Log.Info(
509 "user running `acl` command",
510 "user", c.User.Name,
511 "project", projectName,
512 "actType", aclType,
513 "acls", acls,
514 )
515 c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
516 acl := db.ProjectAcl{
517 Type: aclType,
518 Data: acls,
519 }
520 if c.Write {
521 return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
522 }
523 return nil
524}
525
526func (c *Cmd) cache(projectName string) error {
527 c.Log.Info(
528 "user running `cache` command",
529 "user", c.User.Name,
530 "project", projectName,
531 )
532
533 c.output(fmt.Sprintf("clearing http cache for %s", projectName))
534
535 if c.Write {
536 surrogate := getSurrogateKey(c.User.Name, projectName)
537 return purgeCache(c.Cfg, c.Cfg.Pubsub, surrogate)
538 }
539 return nil
540}
541
542func (c *Cmd) cacheAll() error {
543 isAdmin := false
544 ff, _ := c.Dbpool.FindFeature(c.User.ID, "admin")
545 if ff != nil {
546 if ff.ExpiresAt.Before(time.Now()) {
547 isAdmin = true
548 }
549 }
550
551 if !isAdmin {
552 return fmt.Errorf("must be admin to use this command")
553 }
554
555 c.Log.Info(
556 "admin running `cache-all` command",
557 "user", c.User.Name,
558 )
559 c.output("clearing http cache for all sites")
560 if c.Write {
561 return purgeAllCache(c.Cfg, c.Cfg.Pubsub)
562 }
563 return nil
564}
565
566func (c *Cmd) formsLs() error {
567 forms, err := c.Dbpool.FindFormNamesByUser(c.User.ID)
568 if err != nil {
569 return err
570 }
571 if len(forms) == 0 {
572 c.output("no forms found")
573 return nil
574 }
575 for _, name := range forms {
576 c.output(name)
577 }
578 return nil
579}
580
581func (c *Cmd) formData(formName string) error {
582 formData, err := c.Dbpool.FindFormEntriesByUserAndName(c.User.ID, formName)
583 if err != nil {
584 return err
585 }
586 data, err := json.Marshal(formData)
587 if err != nil {
588 return err
589 }
590 c.output(string(data))
591 return nil
592}
593
594func (c *Cmd) formRm(formName string) error {
595 c.output(fmt.Sprintf("removing all data associated with form: %s", formName))
596 if c.Write {
597 return c.Dbpool.RemoveFormEntriesByUserAndName(c.User.ID, formName)
598 }
599 return nil
600}