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: "", 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 }) }