Files
pages/internal/clicmd/project.go
T
2026-08-15 07:13:00 +00:00

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
})
}