repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
a7bc7f3
parent
8c8fe2b
author
Eric Bower
date
2023-08-17 04:03:48 +0000 UTC
refactor(pgs): reorganized ssh cmds

feat: all cmds are safe by default, use `--write` to commit
3 files changed,  +360, -269
M db/db.go
+1, -1
1@@ -207,7 +207,7 @@ type DB interface {
2 	FindFeedItemsByPostID(postID string) ([]*FeedItem, error)
3 
4 	InsertProject(userID, name, projectDir string) (string, error)
5-	LinkToProject(userID, projectID, projectDir string) error
6+	LinkToProject(userID, projectID, projectDir string, commit bool) error
7 	RemoveProject(projectID string) error
8 	FindProjectByName(userID, name string) (*Project, error)
9 	FindProjectLinks(userID, name string) ([]*Project, error)
M db/postgres/storage.go
+10, -8
 1@@ -247,7 +247,7 @@ const (
 2 	sqlInsertProject        = `INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`
 3 	sqlFindProjectByName    = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 AND name = $2;`
 4 	sqlFindProjectsByUser   = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 ORDER BY name ASC;`
 5-	sqlFindProjectsByPrefix = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 AND name = project_dir AND name ILIKE $2 ORDER BY name ASC;`
 6+	sqlFindProjectsByPrefix = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 AND name = project_dir AND name ILIKE $2 ORDER BY updated_at ASC, name ASC;`
 7 	sqlFindProjectLinks     = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 AND name != project_dir AND project_dir = $2 ORDER BY name ASC;`
 8 	sqlLinkToProject        = `UPDATE projects SET project_dir = $1, updated_at = $2 WHERE id = $3;`
 9 	sqlRemoveProject        = `DELETE FROM projects WHERE id = $1;`
10@@ -1196,7 +1196,7 @@ func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error)
11 	return id, nil
12 }
13 
14-func (me *PsqlDB) LinkToProject(userID, projectID, projectDir string) error {
15+func (me *PsqlDB) LinkToProject(userID, projectID, projectDir string, commit bool) error {
16 	linkToProject, err := me.FindProjectByName(userID, projectDir)
17 	if err != nil {
18 		return err
19@@ -1229,12 +1229,14 @@ func (me *PsqlDB) LinkToProject(userID, projectID, projectDir string) error {
20 		)
21 	}
22 
23-	_, err = me.Db.Exec(
24-		sqlLinkToProject,
25-		projectDir,
26-		time.Now(),
27-		projectID,
28-	)
29+	if commit {
30+		_, err = me.Db.Exec(
31+			sqlLinkToProject,
32+			projectDir,
33+			time.Now(),
34+			projectID,
35+		)
36+	}
37 	return err
38 }
39 
M pgs/wish.go
+349, -260
  1@@ -1,6 +1,7 @@
  2 package pgs
  3 
  4 import (
  5+	"errors"
  6 	"fmt"
  7 	"strings"
  8 
  9@@ -12,6 +13,7 @@ import (
 10 	"github.com/picosh/pico/shared/storage"
 11 	"github.com/picosh/pico/wish/cms/util"
 12 	"github.com/picosh/pico/wish/send/utils"
 13+	"go.uber.org/zap"
 14 )
 15 
 16 func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
 17@@ -38,44 +40,309 @@ type ProjectDetails struct {
 18 	store   storage.ObjectStorage
 19 }
 20 
 21-func (pd *ProjectDetails) rmProjectAssets(userID string, projectName string) error {
 22-	bucketName := shared.GetAssetBucketName(userID)
 23-	bucket, err := pd.store.GetBucket(bucketName)
 24+func getHelpText(userName, projectName string) string {
 25+	helpStr := "commands: [help, stats, ls, rm, link, unlink, prune, retain, depends]\n\n"
 26+	sshCmdStr := fmt.Sprintf("ssh %s@pgs.sh", userName)
 27+	helpStr += fmt.Sprintf("`%s help`: prints this screen\n", sshCmdStr)
 28+	helpStr += fmt.Sprintf("`%s stats`: prints stats for user\n", sshCmdStr)
 29+	helpStr += fmt.Sprintf("`%s ls`: lists projects\n", sshCmdStr)
 30+	helpStr += fmt.Sprintf("`%s rm %s`: deletes `%s`\n", sshCmdStr, projectName, projectName)
 31+	helpStr += fmt.Sprintf("`%s link %s project-b`: symbolic link from `%s` to `project-b`\n", sshCmdStr, projectName, projectName)
 32+	helpStr += fmt.Sprintf(
 33+		"`%s unlink %s`: alias for `link %s %s`, which removes symbolic link for `%s`\n",
 34+		sshCmdStr, projectName, projectName, projectName, projectName,
 35+	)
 36+	helpStr += fmt.Sprintf("`%s prune %s`: removes all projects that match prefix `%s` and is not linked to another project\n", sshCmdStr, projectName, projectName)
 37+	helpStr += fmt.Sprintf("`%s retain %s`: alias for `prune` but retains the (3) most recently updated projects\n", sshCmdStr, projectName)
 38+	helpStr += fmt.Sprintf("`%s depends %s`: lists all projects linked to `%s`\n", sshCmdStr, projectName, projectName)
 39+	return helpStr
 40+}
 41+
 42+type Cmd struct {
 43+	user    *db.User
 44+	session ssh.Session
 45+	log     *zap.SugaredLogger
 46+	store   storage.ObjectStorage
 47+	dbpool  db.DB
 48+	write   bool
 49+}
 50+
 51+func (c *Cmd) rmProjectAssets(projectName string) error {
 52+	bucketName := shared.GetAssetBucketName(c.user.ID)
 53+	bucket, err := c.store.GetBucket(bucketName)
 54 	if err != nil {
 55 		return err
 56 	}
 57 
 58-	fileList, err := pd.store.ListFiles(bucket, projectName+"/", true)
 59+	fileList, err := c.store.ListFiles(bucket, projectName+"/", true)
 60 	if err != nil {
 61 		return err
 62 	}
 63 
 64 	for _, file := range fileList {
 65-		err = pd.store.DeleteFile(bucket, file.Name())
 66-		if err == nil {
 67-			_, _ = pd.session.Write([]byte(fmt.Sprintf("deleted (%s)\n", file.Name())))
 68+		intent := []byte(fmt.Sprintf("deleted (%s)\n", file.Name()))
 69+		if c.write {
 70+			err = c.store.DeleteFile(bucket, file.Name())
 71+			if err == nil {
 72+				_, _ = c.session.Write(intent)
 73+			} else {
 74+				return err
 75+			}
 76 		} else {
 77-			return err
 78+			_, _ = c.session.Write(intent)
 79 		}
 80 	}
 81 	return nil
 82 }
 83 
 84-func getHelpText(userName, projectName string) string {
 85-	helpStr := "commands: [help, stats, ls, rm, clean, link, links, unlink]\n\n"
 86-	sshCmdStr := fmt.Sprintf("ssh %s@pgs.sh", userName)
 87-	helpStr += fmt.Sprintf("`%s help`: prints this screen\n", sshCmdStr)
 88-	helpStr += fmt.Sprintf("`%s stats`: prints stats for user\n", sshCmdStr)
 89-	helpStr += fmt.Sprintf("`%s ls`: lists projects\n", sshCmdStr)
 90-	helpStr += fmt.Sprintf("`%s %s rm`: deletes `%s`\n", sshCmdStr, projectName, projectName)
 91-	helpStr += fmt.Sprintf("`%s %s clean`: removes all projects that match prefix `%s` and is not linked to another project\n", sshCmdStr, projectName, projectName)
 92-	helpStr += fmt.Sprintf("`%s %s links`: lists all projects linked to `%s`\n", sshCmdStr, projectName, projectName)
 93-	helpStr += fmt.Sprintf("`%s %s link project-b`: symbolic link from `%s` to `project-b`\n", sshCmdStr, projectName, projectName)
 94-	helpStr += fmt.Sprintf(
 95-		"`%s %s unlink`: alias for `%s link %s`, which removes symbolic link for `%s`\n",
 96-		sshCmdStr, projectName, projectName, projectName, projectName,
 97+func (c *Cmd) help() {
 98+	_, _ = c.session.Write([]byte(getHelpText(c.user.Name, "project-a")))
 99+}
100+
101+func (c *Cmd) stats(maxSize int) error {
102+	bucketName := shared.GetAssetBucketName(c.user.ID)
103+	bucket, err := c.store.UpsertBucket(bucketName)
104+	if err != nil {
105+		return err
106+	}
107+
108+	totalFileSize, err := c.store.GetBucketQuota(bucket)
109+	if err != nil {
110+		return err
111+	}
112+
113+	projects, err := c.dbpool.FindProjectsByUser(c.user.ID)
114+	if err != nil {
115+		return err
116+	}
117+
118+	str := "stats\n"
119+	str += "=====\n"
120+	str += fmt.Sprintf(
121+		"space:\t\t%.4f/%.4fGB, %.4f%%\n",
122+		shared.BytesToGB(int(totalFileSize)),
123+		shared.BytesToGB(maxSize),
124+		(float32(totalFileSize)/float32(maxSize))*100,
125 	)
126-	return helpStr
127+	str += fmt.Sprintf("projects:\t%d\n", len(projects))
128+	_, _ = c.session.Write([]byte(str))
129+
130+	return nil
131+}
132+
133+func (c *Cmd) ls() error {
134+	projects, err := c.dbpool.FindProjectsByUser(c.user.ID)
135+	if err != nil {
136+		return err
137+	}
138+
139+	if len(projects) == 0 {
140+		out := "no linked projects found\n"
141+		_, _ = c.session.Write([]byte(out))
142+	}
143+
144+	for _, project := range projects {
145+		out := fmt.Sprintf("%s (links to: %s)\n", project.Name, project.ProjectDir)
146+		if project.Name == project.ProjectDir {
147+			out = fmt.Sprintf("%s\n", project.Name)
148+		}
149+		_, _ = c.session.Write([]byte(out))
150+	}
151+
152+	return nil
153+}
154+
155+func (c *Cmd) unlink(projectName string) error {
156+	c.log.Infof("user (%s) running `unlink` command with (%s)", c.user.Name, projectName)
157+	project, err := c.dbpool.FindProjectByName(c.user.ID, projectName)
158+	if err != nil {
159+		return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
160+	}
161+
162+	err = c.dbpool.LinkToProject(c.user.ID, project.ID, project.Name, c.write)
163+	if err != nil {
164+		return err
165+	}
166+
167+	return nil
168+}
169+
170+func (c *Cmd) link(projectName, linkTo string) error {
171+	c.log.Infof("user (%s) running `link` command with (%s) (%s)", c.user.Name, projectName, linkTo)
172+
173+	projectDir := linkTo
174+	_, err := c.dbpool.FindProjectByName(c.user.ID, linkTo)
175+	if err != nil {
176+		e := fmt.Errorf("(%s) project doesn't exist", linkTo)
177+		return e
178+	}
179+
180+	project, err := c.dbpool.FindProjectByName(c.user.ID, projectName)
181+	projectID := ""
182+	if err == nil {
183+		projectID = project.ID
184+		c.log.Infof("user (%s) already has project (%s), updating ...", c.user.Name, projectName)
185+		err = c.dbpool.LinkToProject(c.user.ID, project.ID, projectDir, c.write)
186+		if err != nil {
187+			return err
188+		}
189+	} else {
190+		c.log.Infof("user (%s) has no project record (%s), creating ...", c.user.Name, projectName)
191+		if !c.write {
192+			out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting ...\n", projectName)
193+			_, _ = c.session.Write([]byte(out))
194+			return nil
195+		}
196+		id, err := c.dbpool.InsertProject(c.user.ID, projectName, projectName)
197+		if err != nil {
198+			return err
199+		}
200+		projectID = id
201+	}
202+
203+	c.log.Infof("user (%s) linking (%s) to (%s) ...", c.user.Name, projectName, projectDir)
204+	err = c.dbpool.LinkToProject(c.user.ID, projectID, projectDir, c.write)
205+	if err != nil {
206+		return err
207+	}
208+
209+	out := fmt.Sprintf("(%s) might have orphaned assets, removing ...\n", projectName)
210+	_, _ = c.session.Write([]byte(out))
211+
212+	err = c.rmProjectAssets(projectName)
213+	if err != nil {
214+		return err
215+	}
216+
217+	out = fmt.Sprintf("(%s) now points to (%s)\n", projectName, linkTo)
218+	_, _ = c.session.Write([]byte(out))
219+	return nil
220+}
221+
222+func (c *Cmd) depends(projectName string) error {
223+	projects, err := c.dbpool.FindProjectLinks(c.user.ID, projectName)
224+	if err != nil {
225+		return err
226+	}
227+
228+	if len(projects) == 0 {
229+		out := fmt.Sprintf("no projects linked to this project (%s) found\n", projectName)
230+		_, _ = c.session.Write([]byte(out))
231+		return nil
232+	}
233+
234+	for _, project := range projects {
235+		out := fmt.Sprintf("%s (links to: %s)\n", project.Name, project.ProjectDir)
236+		if project.Name == project.ProjectDir {
237+			out = fmt.Sprintf("%s\n", project.Name)
238+		}
239+		_, _ = c.session.Write([]byte(out))
240+	}
241+
242+	return nil
243+}
244+
245+// delete all the projects and associated assets matching prefix
246+// but keep the latest N records
247+func (c *Cmd) prune(prefix string, keepNumLatest int) error {
248+	c.log.Infof("user (%s) running `clean` command for (%s)", c.user.Name, prefix)
249+	if prefix == "" || prefix == "*" {
250+		e := fmt.Errorf("must provide valid prefix")
251+		return e
252+	}
253+
254+	projects, err := c.dbpool.FindProjectsByPrefix(c.user.ID, prefix)
255+	if err != nil {
256+		return err
257+	}
258+
259+	rmProjects := []*db.Project{}
260+	for _, project := range projects {
261+		links, err := c.dbpool.FindProjectLinks(c.user.ID, project.Name)
262+		if err != nil {
263+			return err
264+		}
265+
266+		if len(links) == 0 {
267+			out := fmt.Sprintf("project (%s) is available to delete\n", project.Name)
268+			_, _ = c.session.Write([]byte(out))
269+			rmProjects = append(rmProjects, project)
270+		}
271+	}
272+
273+	goodbye := rmProjects
274+	if keepNumLatest > 0 {
275+		goodbye = rmProjects[:len(rmProjects)-keepNumLatest]
276+	}
277+
278+	for _, project := range goodbye {
279+		err = c.rmProjectAssets(project.Name)
280+		if err != nil {
281+			return err
282+		}
283+
284+		out := fmt.Sprintf("(%s) removing ...\n", project.Name)
285+		_, _ = c.session.Write([]byte(out))
286+
287+		if c.write {
288+			c.log.Infof("(%s) removing ...", project.Name)
289+			err = c.dbpool.RemoveProject(project.ID)
290+			if err != nil {
291+				return err
292+			}
293+		}
294+	}
295+
296+	return nil
297+}
298+
299+func (c *Cmd) rm(projectName string) error {
300+	c.log.Infof("user (%s) running `rm` command for (%s)", c.user.Name, projectName)
301+	project, err := c.dbpool.FindProjectByName(c.user.ID, projectName)
302+	if err == nil {
303+		c.log.Infof("found project (%s) (%s), checking dependencies ...", projectName, project.ID)
304+
305+		links, err := c.dbpool.FindProjectLinks(c.user.ID, projectName)
306+		if err != nil {
307+			return err
308+		}
309+
310+		if len(links) > 0 {
311+			e := fmt.Errorf("project (%s) has (%d) other projects linking to it, cannot delete project until they have been unlinked or removed, aborting ...", projectName, len(links))
312+			return e
313+		}
314+
315+		out := fmt.Sprintf("(%s) removing ...\n", project.Name)
316+		_, _ = c.session.Write([]byte(out))
317+		if c.write {
318+			c.log.Infof("(%s) removing ...", project.Name)
319+			err = c.dbpool.RemoveProject(project.ID)
320+			if err != nil {
321+				return err
322+			}
323+		}
324+	} else {
325+		e := fmt.Errorf("(%s) project not found for user (%s)", projectName, c.user.Name)
326+		return e
327+	}
328+
329+	err = c.rmProjectAssets(project.Name)
330+	return err
331+}
332+
333+func (c *Cmd) bail(err error) {
334+	if err == nil {
335+		return
336+	}
337+	c.log.Error(err)
338+	utils.ErrorHandler(c.session, err)
339+}
340+
341+func (c *Cmd) notice() {
342+	if !c.write {
343+		out := fmt.Sprintf("\nNOTICE: changes not commited, use `--write` to save operation\n")
344+		_, _ = c.session.Write([]byte(out))
345+	}
346 }
347 
348 func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
349@@ -100,271 +367,93 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
350 			}
351 
352 			args := session.Command()
353+
354+			opts := Cmd{
355+				session: session,
356+				user:    user,
357+				store:   store,
358+				log:     log,
359+				dbpool:  dbpool,
360+				write:   false,
361+			}
362+
363+			cmd := strings.TrimSpace(args[0])
364 			if len(args) == 1 {
365-				cmd := strings.TrimSpace(args[0])
366 				if cmd == "help" {
367-					_, _ = session.Write([]byte(getHelpText(user.Name, "project-a")))
368+					opts.help()
369+					return
370 				} else if cmd == "stats" {
371-					bucketName := shared.GetAssetBucketName(user.ID)
372-					bucket, err := store.UpsertBucket(bucketName)
373-					if err != nil {
374-						log.Error(err)
375-						utils.ErrorHandler(session, err)
376-						return
377-					}
378-
379-					totalFileSize, err := store.GetBucketQuota(bucket)
380-					if err != nil {
381-						log.Error(err)
382-						utils.ErrorHandler(session, err)
383-						return
384-					}
385-
386-					projects, err := dbpool.FindProjectsByUser(user.ID)
387-					if err != nil {
388-						log.Error(err)
389-						utils.ErrorHandler(session, err)
390-						return
391-					}
392-
393-					str := "stats\n"
394-					str += "=====\n"
395-					str += fmt.Sprintf(
396-						"space:\t\t%.4f/%.4fGB, %.4f%%\n",
397-						shared.BytesToGB(int(totalFileSize)),
398-						shared.BytesToGB(cfg.MaxSize),
399-						(float32(totalFileSize)/float32(cfg.MaxSize))*100,
400-					)
401-					str += fmt.Sprintf("projects:\t%d\n", len(projects))
402-					_, _ = session.Write([]byte(str))
403+					err := opts.stats(cfg.MaxSize)
404+					opts.bail(err)
405+					return
406+				} else if cmd == "ls" {
407+					err := opts.ls()
408+					opts.bail(err)
409+					return
410+				} else {
411+					e := fmt.Errorf("%s not a valid command", args)
412+					opts.bail(e)
413 					return
414-				} else if cmd == "list" || cmd == "ls" {
415-					projects, err := dbpool.FindProjectsByUser(user.ID)
416-					if err != nil {
417-						log.Error(err)
418-						utils.ErrorHandler(session, err)
419-						return
420-					}
421-
422-					if len(projects) == 0 {
423-						out := "no linked projects found\n"
424-						_, _ = session.Write([]byte(out))
425-					}
426-
427-					for _, project := range projects {
428-						out := fmt.Sprintf("%s (links to: %s)\n", project.Name, project.ProjectDir)
429-						if project.Name == project.ProjectDir {
430-							out = fmt.Sprintf("%s\n", project.Name)
431-						}
432-						_, _ = session.Write([]byte(out))
433-					}
434 				}
435-				return
436-			} else if len(args) < 2 {
437-				utils.ErrorHandler(session, fmt.Errorf("must supply project name and then a command"))
438-				return
439 			}
440 
441-			projectName := strings.TrimSpace(args[0])
442-			cmd := strings.TrimSpace(args[1])
443 			log.Infof("pgs middleware detected command: %s", args)
444+			projectName := strings.TrimSpace(args[1])
445 
446-			if cmd == "help" {
447-				log.Infof("user (%s) running `help` command", user.Name)
448-				_, _ = session.Write([]byte(getHelpText(user.Name, projectName)))
449+			if projectName == "--write" {
450+				utils.ErrorHandler(session, fmt.Errorf("`--write` should be placed at end of command"))
451 				return
452-			} else if cmd == "unlink" {
453-				log.Infof("user (%s) running `unlink` command with (%s)", user.Name, projectName)
454-				project, err := dbpool.FindProjectByName(user.ID, projectName)
455-				if err != nil {
456-					log.Error(err)
457-					utils.ErrorHandler(session, fmt.Errorf("project (%s) does not exit", projectName))
458-					return
459-				}
460-				err = dbpool.LinkToProject(user.ID, project.ID, project.Name)
461-				if err != nil {
462-					log.Error(err)
463-					utils.ErrorHandler(session, err)
464-					return
465-				}
466+			}
467 
468-				return
469-			} else if cmd == "link" {
470+			if cmd == "link" {
471 				if len(args) < 3 {
472 					utils.ErrorHandler(session, fmt.Errorf("must supply link command like: `projectA link projectB`"))
473 					return
474 				}
475 				linkTo := strings.TrimSpace(args[2])
476-				log.Infof("user (%s) running `link` command with (%s) (%s)", user.Name, projectName, linkTo)
477-
478-				projectDir := linkTo
479-				_, err := dbpool.FindProjectByName(user.ID, linkTo)
480-				if err != nil {
481-					e := fmt.Errorf("(%s) project doesn't exist", linkTo)
482-					log.Error(e)
483-					utils.ErrorHandler(session, e)
484-					return
485+				if len(args) >= 4 && strings.TrimSpace(args[3]) == "--write" {
486+					opts.write = true
487 				}
488 
489-				project, err := dbpool.FindProjectByName(user.ID, projectName)
490-				projectID := ""
491-				if err == nil {
492-					projectID = project.ID
493-					log.Infof("user (%s) already has project (%s), updating ...", user.Name, projectName)
494-					err = dbpool.LinkToProject(user.ID, project.ID, projectDir)
495-					if err != nil {
496-						log.Error(err)
497-						utils.ErrorHandler(session, err)
498-						return
499-					}
500-				} else {
501-					log.Infof("user (%s) has no project record (%s), creating ...", user.Name, projectName)
502-					id, err := dbpool.InsertProject(user.ID, projectName, projectName)
503-					if err != nil {
504-						log.Error(err)
505-						utils.ErrorHandler(session, err)
506-						return
507-					}
508-					projectID = id
509-				}
510-
511-				log.Infof("user (%s) linking (%s) to (%s) ...", user.Name, projectName, projectDir)
512-				err = dbpool.LinkToProject(user.ID, projectID, projectDir)
513-				if err != nil {
514-					log.Error(err)
515-					utils.ErrorHandler(session, err)
516-					return
517-				}
518-
519-				out := fmt.Sprintf("(%s) might have orphaned assets, removing ...\n", projectName)
520-				_, _ = session.Write([]byte(out))
521-
522-				pd := ProjectDetails{
523-					session: session,
524-					store:   store,
525-				}
526-				err = pd.rmProjectAssets(user.ID, projectName)
527+				err := opts.link(projectName, linkTo)
528+				opts.notice()
529 				if err != nil {
530-					log.Error(err)
531-					utils.ErrorHandler(session, err)
532+					opts.bail(err)
533 				}
534-
535-				out = fmt.Sprintf("(%s) now points to (%s)\n", projectName, linkTo)
536-				_, _ = session.Write([]byte(out))
537 				return
538-			} else if cmd == "links" {
539-				projects, err := dbpool.FindProjectLinks(user.ID, projectName)
540-				if err != nil {
541-					log.Error(err)
542-					utils.ErrorHandler(session, err)
543-					return
544-				}
545-
546-				if len(projects) == 0 {
547-					out := fmt.Sprintf("no projects linked to this project (%s) found\n", projectName)
548-					_, _ = session.Write([]byte(out))
549-					return
550-				}
551-
552-				for _, project := range projects {
553-					out := fmt.Sprintf("%s (links to: %s)\n", project.Name, project.ProjectDir)
554-					if project.Name == project.ProjectDir {
555-						out = fmt.Sprintf("%s\n", project.Name)
556-					}
557-					_, _ = session.Write([]byte(out))
558-				}
559-			} else if cmd == "clean" {
560-				log.Infof("user (%s) running `clean` command for (%s)", user.Name, projectName)
561-				if projectName == "" || projectName == "*" {
562-					e := fmt.Errorf("must provide valid prefix")
563-					log.Error(e)
564-					utils.ErrorHandler(session, e)
565-					return
566-				}
567-
568-				projects, err := dbpool.FindProjectsByPrefix(user.ID, projectName)
569-				if err != nil {
570-					log.Error(err)
571-					utils.ErrorHandler(session, err)
572-				}
573+			}
574 
575-				rmProjects := []*db.Project{}
576-				for _, project := range projects {
577-					links, err := dbpool.FindProjectLinks(user.ID, project.Name)
578-					if err != nil {
579-						log.Error(err)
580-						utils.ErrorHandler(session, err)
581-					}
582-
583-					if len(links) == 0 {
584-						out := fmt.Sprintf("project (%s) is available to delete\n", project.Name)
585-						_, _ = session.Write([]byte(out))
586-						rmProjects = append(rmProjects, project)
587-					}
588-				}
589+			if len(args) >= 3 && strings.TrimSpace(args[2]) == "--write" {
590+				opts.write = true
591+			}
592 
593-				for _, project := range rmProjects {
594-					pd := ProjectDetails{
595-						session: session,
596-						store:   store,
597-					}
598-					err = pd.rmProjectAssets(user.ID, project.Name)
599-					if err != nil {
600-						log.Error(err)
601-						utils.ErrorHandler(session, err)
602-					}
603-
604-					err = dbpool.RemoveProject(project.ID)
605-					if err != nil {
606-						log.Error(err)
607-						utils.ErrorHandler(session, err)
608-					}
609-				}
610+			if cmd == "unlink" {
611+				err := opts.unlink(projectName)
612+				opts.notice()
613+				opts.bail(err)
614+				return
615+			} else if cmd == "depends" {
616+				err := opts.depends(projectName)
617+				opts.bail(err)
618+				return
619+			} else if cmd == "retain" {
620+				err := opts.prune(projectName, 3)
621+				opts.notice()
622+				opts.bail(err)
623+				return
624+			} else if cmd == "prune" {
625+				err := opts.prune(projectName, 0)
626+				opts.notice()
627+				opts.bail(err)
628+				return
629 			} else if cmd == "rm" {
630-				log.Infof("user (%s) running `rm` command for (%s)", user.Name, projectName)
631-				project, err := dbpool.FindProjectByName(user.ID, projectName)
632-				if err == nil {
633-					log.Infof("found project (%s) (%s), checking dependencies ...", projectName, project.ID)
634-
635-					links, err := dbpool.FindProjectLinks(user.ID, projectName)
636-					if err != nil {
637-						log.Error(err)
638-						utils.ErrorHandler(session, err)
639-					}
640-
641-					if len(links) > 0 {
642-						e := fmt.Errorf("project (%s) has (%d) other projects linking to it, can't delete project until they have been unlinked or removed, aborting ...", projectName, len(links))
643-						log.Error(e)
644-						return
645-					}
646-
647-					err = dbpool.RemoveProject(project.ID)
648-					if err != nil {
649-						log.Error(err)
650-						utils.ErrorHandler(session, err)
651-					}
652-				} else {
653-					e := fmt.Errorf("(%s) project not found for user (%s)", projectName, user.Name)
654-					log.Error(e)
655-					utils.ErrorHandler(session, e)
656-					return
657-				}
658-
659-				pd := ProjectDetails{
660-					session: session,
661-					store:   store,
662-				}
663-				err = pd.rmProjectAssets(user.ID, project.Name)
664-				if err != nil {
665-					log.Error(err)
666-					utils.ErrorHandler(session, err)
667-				}
668-
669+				err := opts.rm(projectName)
670+				opts.notice()
671+				opts.bail(err)
672 				return
673 			} else {
674 				e := fmt.Errorf("%s not a valid command", args)
675-				log.Error(e)
676-				utils.ErrorHandler(session, e)
677+				opts.bail(e)
678 				return
679 			}
680 		}