279 lines
8.2 KiB
Go
279 lines
8.2 KiB
Go
package clicmd
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/cliutil"
|
|
)
|
|
|
|
// projectFlags are the settings shared by "project create" and "project
|
|
// update". They map one-to-one onto api.ProjectPatch, whose fields are pointers
|
|
// so an absent flag and an explicitly emptied one stay distinguishable.
|
|
type projectFlags struct {
|
|
displayName cliutil.OptString
|
|
indexFile cliutil.OptString
|
|
notFoundFile cliutil.OptString
|
|
spa cliutil.OptBool
|
|
cacheControl cliutil.OptString
|
|
retention cliutil.OptInt
|
|
grace cliutil.OptDuration
|
|
maxFiles cliutil.OptInt
|
|
maxFileBytes cliutil.OptBytes
|
|
maxTotalBytes cliutil.OptBytes
|
|
}
|
|
|
|
func (p *projectFlags) register(fs *flag.FlagSet) {
|
|
fs.Var(&p.displayName, "display-name", "human-readable `name` shown in listings")
|
|
fs.Var(&p.indexFile, "index-file", "document served for a directory, e.g. `index.html`")
|
|
fs.Var(&p.notFoundFile, "not-found-file", "document served with 404, e.g. `404.html`; empty clears it")
|
|
fs.Var(&p.spa, "spa", "serve the index document for unknown paths that accept HTML")
|
|
fs.Var(&p.cacheControl, "cache-control", "Cache-Control `header` sent with every file")
|
|
fs.Var(&p.retention, "retention", "`count` of finished deployments to keep per project")
|
|
fs.Var(&p.grace, "retention-grace", "`duration` a deployment stays after being replaced, e.g. 1h")
|
|
fs.Var(&p.maxFiles, "max-files", "`count` of files allowed in one deployment")
|
|
fs.Var(&p.maxFileBytes, "max-file-bytes", "largest single file, e.g. `256MiB`")
|
|
fs.Var(&p.maxTotalBytes, "max-total-bytes", "largest total deployment, e.g. `2GiB`")
|
|
}
|
|
|
|
func (p *projectFlags) patch() api.ProjectPatch {
|
|
return api.ProjectPatch{
|
|
DisplayName: p.displayName.Ptr(),
|
|
IndexFile: p.indexFile.Ptr(),
|
|
NotFoundFile: p.notFoundFile.Ptr(),
|
|
SPAFallback: p.spa.Ptr(),
|
|
CacheControl: p.cacheControl.Ptr(),
|
|
RetentionCount: p.retention.Ptr(),
|
|
RetentionGrace: p.grace.SecondsPtr(),
|
|
MaxFiles: p.maxFiles.Ptr(),
|
|
MaxFileBytes: p.maxFileBytes.Ptr(),
|
|
MaxTotalBytes: p.maxTotalBytes.Ptr(),
|
|
}
|
|
}
|
|
|
|
func projectCmd(g *Globals) *cliutil.Command {
|
|
return &cliutil.Command{
|
|
Name: "project",
|
|
Short: "Manage projects",
|
|
Sub: []*cliutil.Command{
|
|
projectCreateCmd(g),
|
|
projectListCmd(g),
|
|
projectShowCmd(g),
|
|
projectUpdateCmd(g),
|
|
projectDeleteCmd(g),
|
|
},
|
|
}
|
|
}
|
|
|
|
func projectCreateCmd(g *Globals) *cliutil.Command {
|
|
var pf projectFlags
|
|
return &cliutil.Command{
|
|
Name: "create",
|
|
Args: "<name>",
|
|
Short: "Create a project",
|
|
Long: "The name becomes the URL prefix, so it is restricted to lowercase\n" +
|
|
"letters, digits, dot, dash and underscore, and cannot be changed later.\n" +
|
|
"Settings left unset take the server's defaults. Requires an admin key.",
|
|
Flags: pf.register,
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
if err := exactArgs(args, 1, "one project name"); err != nil {
|
|
return err
|
|
}
|
|
c, err := g.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
patch := pf.patch()
|
|
p, err := c.CreateProject(ctx, api.CreateProjectRequest{Name: args[0], Patch: &patch})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return g.printProject(p)
|
|
},
|
|
}
|
|
}
|
|
|
|
func projectListCmd(g *Globals) *cliutil.Command {
|
|
return &cliutil.Command{
|
|
Name: "list",
|
|
Short: "List projects",
|
|
Long: "Requires an admin key. Follows paging to the end.",
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
|
return err
|
|
}
|
|
c, err := g.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ps, err := c.ListAllProjects(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, err := g.Printer()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// api.ProjectList rather than the bare slice, so -o json produces the
|
|
// same shape the API returns and a nil slice still prints as [].
|
|
out := api.ProjectList{Projects: ps}
|
|
if out.Projects == nil {
|
|
out.Projects = []api.Project{}
|
|
}
|
|
return p.Print(out, func() *cliutil.Table {
|
|
t := cliutil.NewTable("NAME", "DISPLAY NAME", "INDEX", "SPA", "KEEP", "UPDATED")
|
|
for _, pr := range ps {
|
|
t.Row(pr.Name, cliutil.Str(cliutil.Truncate(pr.DisplayName, 32)),
|
|
pr.IndexFile, cliutil.Bool(pr.SPAFallback),
|
|
strconv.Itoa(pr.RetentionCount), cliutil.Time(pr.UpdatedAt))
|
|
}
|
|
return t
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
func projectShowCmd(g *Globals) *cliutil.Command {
|
|
return &cliutil.Command{
|
|
Name: "show",
|
|
Args: "[name]",
|
|
Short: "Show one project",
|
|
Long: "Defaults to --project. A project key may read only its own project.",
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
name, err := g.oneProject(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := g.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, err := c.GetProject(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return g.printProject(p)
|
|
},
|
|
}
|
|
}
|
|
|
|
func projectUpdateCmd(g *Globals) *cliutil.Command {
|
|
var pf projectFlags
|
|
return &cliutil.Command{
|
|
Name: "update",
|
|
Args: "[name]",
|
|
Short: "Change a project's settings",
|
|
Long: "Only the settings named by flags are changed. Requires an admin key.\n" +
|
|
"--not-found-file= with an empty value clears the custom 404 document.",
|
|
Flags: pf.register,
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
name, err := g.oneProject(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
patch := pf.patch()
|
|
if patch == (api.ProjectPatch{}) {
|
|
return fmt.Errorf("nothing to change: pass at least one setting flag")
|
|
}
|
|
c, err := g.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, err := c.PatchProject(ctx, name, patch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return g.printProject(p)
|
|
},
|
|
}
|
|
}
|
|
|
|
func projectDeleteCmd(g *Globals) *cliutil.Command {
|
|
var yes bool
|
|
return &cliutil.Command{
|
|
Name: "delete",
|
|
Args: "[name]",
|
|
Short: "Delete a project and everything in it",
|
|
Long: "Removes the project's keys and deployments and unpublishes the site.\n" +
|
|
"The uploaded content is reclaimed by the next garbage collection.\n" +
|
|
"Requires an admin key.",
|
|
Flags: func(fs *flag.FlagSet) {
|
|
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
|
},
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
name, err := g.oneProject(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !yes {
|
|
if err := cliutil.Confirm(g.In, g.Err,
|
|
fmt.Sprintf("Delete project %q, its keys and all its deployments?", name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
c, err := g.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.DeleteProject(ctx, name); err != nil {
|
|
return err
|
|
}
|
|
p, err := g.Printer()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Printf("deleted project %s\n", name)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// oneProject takes the project from the positional argument, falling back to
|
|
// --project. Both are accepted because "pages project show demo" reads better
|
|
// at a prompt while "--project" is what a CI job already has set.
|
|
func (g *Globals) oneProject(args []string) (string, error) {
|
|
switch len(args) {
|
|
case 0:
|
|
return g.ProjectName()
|
|
case 1:
|
|
return args[0], nil
|
|
default:
|
|
return "", fmt.Errorf("expected at most one project name")
|
|
}
|
|
}
|
|
|
|
func (g *Globals) printProject(pr api.Project) error {
|
|
p, err := g.Printer()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.Print(pr, func() *cliutil.Table {
|
|
t := cliutil.NewTable()
|
|
t.Row("name", pr.Name)
|
|
t.Row("display_name", cliutil.Str(pr.DisplayName))
|
|
t.Row("url", cliutil.Str(pr.URL))
|
|
t.Row("index_file", pr.IndexFile)
|
|
t.Row("not_found_file", cliutil.Str(pr.NotFoundFile))
|
|
t.Row("spa_fallback", cliutil.Bool(pr.SPAFallback))
|
|
t.Row("cache_control", pr.CacheControl)
|
|
t.Row("retention_count", strconv.Itoa(pr.RetentionCount))
|
|
t.Row("retention_grace", (time.Duration(pr.RetentionGrace) * time.Second).String())
|
|
t.Row("max_files", strconv.Itoa(pr.MaxFiles))
|
|
t.Row("max_file_bytes", cliutil.Bytes(pr.MaxFileBytes))
|
|
t.Row("max_total_bytes", cliutil.Bytes(pr.MaxTotalBytes))
|
|
t.Row("created_at", cliutil.Time(pr.CreatedAt))
|
|
t.Row("updated_at", cliutil.Time(pr.UpdatedAt))
|
|
if d := pr.ActiveDeployment; d != nil {
|
|
t.Row("active_deployment", d.ID)
|
|
t.Row("active_files", strconv.Itoa(d.FileCount))
|
|
t.Row("active_bytes", cliutil.Bytes(d.TotalBytes))
|
|
t.Row("activated_at", cliutil.TimePtr(d.ActivatedAt))
|
|
}
|
|
return t
|
|
})
|
|
}
|