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}