This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
package clicmd
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// ConfigFile is the CLI's on-disk configuration.
//
// JSON rather than TOML: encoding/json is already linked into the binary for
// the API, and adding a TOML parser to read four fields would cost more than
// the file saves. CI never reads it at all — there, everything comes from
// PAGES_* environment variables.
type ConfigFile struct {
Server string `json:"server,omitempty"`
Token string `json:"token,omitempty"`
Project string `json:"project,omitempty"`
Output string `json:"output,omitempty"`
}
// DefaultConfigPath is $PAGES_CONFIG, else $XDG_CONFIG_HOME/pages/config.json,
// else ~/.config/pages/config.json. It returns "" when no home directory can be
// determined, which simply means there is no config file.
func DefaultConfigPath() string {
if p := os.Getenv("PAGES_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "pages", "config.json")
}
// LoadConfig reads path. A missing file is not an error — it is the normal
// state on a CI runner. warn receives a note if the file is readable by anyone
// but its owner, because it may hold a token.
func LoadConfig(path string, warn io.Writer) (ConfigFile, error) {
var f ConfigFile
if path == "" {
return f, nil
}
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return f, nil
}
if err != nil {
return f, fmt.Errorf("read config %s: %w", path, err)
}
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
fmt.Fprintf(warn, "warning: %s is mode %#o and may contain a token; run: chmod 600 %s\n",
path, fi.Mode().Perm(), path)
}
if err := json.Unmarshal(raw, &f); err != nil {
return ConfigFile{}, fmt.Errorf("parse config %s: %w", path, err)
}
return f, nil
}
// SaveConfig writes f to path with mode 0600, replacing any existing file
// atomically so a crash cannot leave a truncated config behind.
func SaveConfig(path string, f ConfigFile) error {
if path == "" {
return errors.New("no config path: set PAGES_CONFIG or pass --config")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
raw, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
// Written in the destination directory so the rename stays within one
// filesystem, and created 0600 from the start so the token is never briefly
// world-readable.
tmp, err := os.CreateTemp(dir, ".config-*.tmp")
if err != nil {
return fmt.Errorf("create temporary config: %w", err)
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("install config: %w", err)
}
return nil
}
// redactToken shows enough of a token to recognise which one it is and nothing
// that could be used with it. The key id is the public half by construction
// (pgs_<keyid>_<secret>), so it is safe to print in full.
func redactToken(token string) string {
if token == "" {
return ""
}
parts := strings.SplitN(token, "_", 3)
if len(parts) == 3 {
return parts[0] + "_" + parts[1] + "_…"
}
return "…"
}
+136
View File
@@ -0,0 +1,136 @@
package clicmd
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSaveConfigIsOwnerOnlyAndAtomic(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
path := filepath.Join(dir, "config.json")
want := ConfigFile{Server: "https://p.example.com", Token: "pgs_abcdefghijklmnop_secret", Output: "json"}
if err := SaveConfig(path, want); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("config mode = %#o, want 0600 — it holds a token", perm)
}
if di, err := os.Stat(dir); err == nil {
if perm := di.Mode().Perm(); perm&0o077 != 0 {
t.Errorf("config directory mode = %#o, want no group or other bits", perm)
}
}
got, err := LoadConfig(path, io.Discard)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if got != want {
t.Errorf("round trip = %+v, want %+v", got, want)
}
// The temporary file is written in the destination directory; leaving one
// behind would leave a mode-0600 copy of the token lying around.
ents, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(ents) != 1 || ents[0].Name() != "config.json" {
names := make([]string, len(ents))
for i, e := range ents {
names[i] = e.Name()
}
t.Errorf("directory contains %q, want only config.json", names)
}
}
func TestSaveConfigReplacesInPlace(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := SaveConfig(path, ConfigFile{Server: "https://one.example.com"}); err != nil {
t.Fatal(err)
}
if err := SaveConfig(path, ConfigFile{Server: "https://two.example.com"}); err != nil {
t.Fatal(err)
}
got, err := LoadConfig(path, io.Discard)
if err != nil {
t.Fatal(err)
}
if got.Server != "https://two.example.com" {
t.Errorf("server = %q, want the second write", got.Server)
}
}
func TestLoadConfig(t *testing.T) {
t.Run("a missing file is the normal state on CI", func(t *testing.T) {
got, err := LoadConfig(filepath.Join(t.TempDir(), "absent.json"), io.Discard)
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if got != (ConfigFile{}) {
t.Errorf("got %+v, want the zero value", got)
}
})
t.Run("an empty path means there is no config file", func(t *testing.T) {
if _, err := LoadConfig("", io.Discard); err != nil {
t.Fatalf("err = %v, want nil", err)
}
})
t.Run("malformed JSON is reported, not ignored", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(path, []byte("{not json"), 0o600)
if _, err := LoadConfig(path, io.Discard); err == nil {
t.Fatal("expected an error")
}
})
t.Run("a readable-by-others config warns", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(path, []byte(`{"token":"pgs_abcdefghijklmnop_secret"}`), 0o644)
var warn strings.Builder
if _, err := LoadConfig(path, &warn); err != nil {
t.Fatal(err)
}
if !strings.Contains(warn.String(), "chmod 600") {
t.Errorf("warning = %q, want it to say how to fix the mode", warn.String())
}
if strings.Contains(warn.String(), "secret") {
t.Error("the warning printed the token it was warning about")
}
})
}
func TestRedactToken(t *testing.T) {
cases := []struct{ in, want string }{
{"pgs_abcdefghijklmnop_thesecrethalf", "pgs_abcdefghijklmnop_…"},
{"", ""},
{"garbage", "…"},
{"pgs_onlytwo", "…"},
// The secret half is base64url, so it can itself contain underscores;
// SplitN with n=3 keeps them in the part that gets dropped.
{"pgs_abcdefghijklmnop_a_b_c", "pgs_abcdefghijklmnop_…"},
}
for _, tc := range cases {
if got := redactToken(tc.in); got != tc.want {
t.Errorf("redactToken(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDefaultConfigPathPrefersEnv(t *testing.T) {
t.Setenv("PAGES_CONFIG", "/tmp/explicit.json")
if got := DefaultConfigPath(); got != "/tmp/explicit.json" {
t.Errorf("DefaultConfigPath = %q", got)
}
}
+220
View File
@@ -0,0 +1,220 @@
package clicmd
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"strings"
"github.com/iceBear67/simplepages/internal/client"
"github.com/iceBear67/simplepages/internal/cliutil"
)
// stringList is a flag that may be given more than once.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
*s = append(*s, v)
return nil
}
func deployCmd(g *Globals) *cliutil.Command {
var (
activate = true
meta stringList
include stringList
exclude stringList
concurrency int
retries int
follow bool
dryRun bool
)
return &cliutil.Command{
Name: "deploy",
Args: "<dir>",
Short: "Upload a directory and switch the site to it",
Long: "Hashes the directory, uploads only the files the server does not\n" +
"already have, then switches the project over in one step — a visitor\n" +
"sees the old site or the new one, never a mixture.\n\n" +
"Interrupting a deploy is safe: the blobs that made it are kept, so\n" +
"running the same command again uploads only what is still missing.\n\n" +
"Commit metadata is read from the CI environment (GitHub Actions,\n" +
"GitLab CI) and can be set or overridden with --meta.",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&activate, "activate", true, "switch the project to this deployment once it is uploaded")
fs.Var(&meta, "meta", "`key=value` recorded with the deployment; repeatable")
fs.Var(&include, "include", "only upload files matching `glob`; repeatable")
fs.Var(&exclude, "exclude", "skip files and directories matching `glob`; repeatable")
fs.IntVar(&concurrency, "concurrency", 8, "`number` of blobs uploaded at once")
fs.IntVar(&retries, "retries", 4, "`number` of extra attempts per blob on a transient failure")
fs.BoolVar(&follow, "follow-symlinks", false, "upload what symlinks point at instead of refusing them")
fs.BoolVar(&dryRun, "dry-run", false, "scan and report without contacting the server")
},
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 1, "one directory"); err != nil {
return err
}
dir := args[0]
metaMap, err := parseMeta(meta)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
src, err := client.Scan(ctx, dir, client.ScanOptions{
Include: include,
Exclude: exclude,
FollowSymlinks: follow,
})
if err != nil {
return err
}
defer src.Close()
if dryRun {
// Deliberately offline: negotiating the manifest to find out what is
// missing would create a deployment on the server, which is exactly
// what --dry-run promises not to do.
return p.Print(dryRunResult{
Dir: src.Dir,
FileCount: len(src.Files),
TotalBytes: src.TotalBytes,
UniqueBlobs: src.UniqueBlobs(),
Files: src.Files,
}, func() *cliutil.Table {
t := cliutil.NewTable("PATH", "SIZE", "DIGEST")
for _, f := range src.Files {
t.Row(f.Path, cliutil.Bytes(f.Size), f.Digest[:12])
}
t.Row("", "", "")
t.Row(fmt.Sprintf("%d files", len(src.Files)),
cliutil.Bytes(src.TotalBytes),
fmt.Sprintf("%d unique", src.UniqueBlobs()))
return t
})
}
project, err := g.ProjectName()
if err != nil {
return err
}
c, err := g.Client()
if err != nil {
return err
}
res, err := c.Deploy(ctx, client.DeployOptions{
Project: project,
Source: src,
Meta: metaMap,
Activate: activate,
Concurrency: concurrency,
Retries: retries,
// Progress goes to stderr so it stays out of a piped -o json
// document, and is shown even without --verbose: the deduplication
// win is the reason this tool exists, and a CI log should record it.
Progress: func(msg string) { fmt.Fprintln(g.Err, msg) },
})
if err != nil {
return err
}
return p.Print(res, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("deployment", res.Deployment.ID)
t.Row("project", project)
t.Row("state", res.Deployment.State)
t.Row("files", strconv.Itoa(res.FileCount))
t.Row("total_bytes", cliutil.Bytes(res.TotalBytes))
t.Row("uploaded", fmt.Sprintf("%s, %s",
cliutil.Plural(res.Uploaded, "blob"), cliutil.Bytes(res.UploadedBytes)))
t.Row("reused", strconv.Itoa(res.Deduplicated))
t.Row("activated", cliutil.Bool(res.Activated))
t.Row("url", cliutil.Str(res.URL))
return t
})
},
}
}
// dryRunResult is what --dry-run reports: everything decided locally, and
// nothing that would need the server.
type dryRunResult struct {
Dir string `json:"dir"`
FileCount int `json:"file_count"`
TotalBytes int64 `json:"total_bytes"`
UniqueBlobs int `json:"unique_blobs"`
Files []client.LocalFile `json:"files"`
}
// parseMeta folds --meta over whatever the CI environment reveals, so an
// explicit flag always wins over a guessed value.
func parseMeta(pairs []string) (map[string]string, error) {
out := detectCIMeta()
for _, p := range pairs {
k, v, ok := strings.Cut(p, "=")
if !ok {
return nil, cliutil.UsageErrorf("--meta %q: expected key=value", p)
}
k = strings.TrimSpace(k)
if k == "" {
return nil, cliutil.UsageErrorf("--meta %q: empty key", p)
}
if out == nil {
out = make(map[string]string, len(pairs))
}
out[k] = v
}
return out, nil
}
// detectCIMeta reads the commit metadata the common CI systems export, so a
// deployment can be traced back to what produced it without every pipeline
// having to spell out the same four --meta flags.
func detectCIMeta() map[string]string {
out := make(map[string]string, 4)
set := func(key string, envs ...string) {
for _, e := range envs {
if v := strings.TrimSpace(os.Getenv(e)); v != "" {
out[key] = v
return
}
}
}
set("git_sha", "GITHUB_SHA", "CI_COMMIT_SHA", "GIT_COMMIT")
set("git_ref", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "GIT_BRANCH")
set("ci_run", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "BUILD_NUMBER")
set("actor", "GITHUB_ACTOR", "GITLAB_USER_LOGIN")
if url := ciRunURL(); url != "" {
out["ci_url"] = url
}
if len(out) == 0 {
return nil
}
return out
}
// ciRunURL reconstructs a link back to the job. GitHub does not export one
// directly; GitLab does.
func ciRunURL() string {
if v := strings.TrimSpace(os.Getenv("CI_PIPELINE_URL")); v != "" {
return v
}
server := strings.TrimRight(os.Getenv("GITHUB_SERVER_URL"), "/")
repo := os.Getenv("GITHUB_REPOSITORY")
run := os.Getenv("GITHUB_RUN_ID")
if server != "" && repo != "" && run != "" {
return server + "/" + repo + "/actions/runs/" + run
}
return ""
}
+357
View File
@@ -0,0 +1,357 @@
package clicmd
import (
"context"
"flag"
"fmt"
"maps"
"slices"
"strconv"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/client"
"github.com/iceBear67/simplepages/internal/cliutil"
)
func deploymentCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "deployment",
Short: "Inspect, roll back and remove deployments",
Long: "Every deployment a project has ever finished is kept until retention\n" +
"drops it, which is what makes a rollback one command rather than a\n" +
"rebuild. A project key may manage only its own project's deployments.",
Sub: []*cliutil.Command{
deploymentListCmd(g),
deploymentShowCmd(g),
deploymentActivateCmd(g),
deploymentDeleteCmd(g),
},
}
}
func deploymentListCmd(g *Globals) *cliutil.Command {
var (
state string
limit int
)
return &cliutil.Command{
Name: "list",
Short: "List a project's deployments",
Long: "Newest first. The one marked active is what the site is serving, and\n" +
"is the one to roll back from; any other ready deployment can be rolled\n" +
"back to with \"pages deployment activate\".",
Flags: func(fs *flag.FlagSet) {
fs.StringVar(&state, "state", "", "show only deployments in this `state`: pending, uploading, ready, failed or deleting")
fs.IntVar(&limit, "limit", 0, "stop after this many deployments; 0 lists them all")
},
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 0, "no arguments"); err != nil {
return err
}
project, err := g.ProjectName()
if err != nil {
return err
}
c, err := g.Client()
if err != nil {
return err
}
deps, err := listDeployments(ctx, c, project, state, limit)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
// The API's own shape, so -o json is the same document whether it came
// from here or from curl, and an empty listing prints as [] not null.
out := api.DeploymentList{Deployments: deps}
if out.Deployments == nil {
out.Deployments = []api.Deployment{}
}
return p.Print(out, func() *cliutil.Table {
t := cliutil.NewTable("ID", "STATE", "ACTIVE", "FILES", "SIZE", "CREATED", "COMMIT")
for _, d := range deps {
t.Row(d.ID, d.State, cliutil.Bool(d.Active),
strconv.Itoa(d.FileCount), cliutil.Bytes(d.TotalBytes),
cliutil.Time(d.CreatedAt),
cliutil.Str(cliutil.Truncate(d.Meta["git_sha"], 12)))
}
return t
})
},
}
}
// listDeployments follows the cursor, stopping at limit when one was given.
// Paging on the caller's behalf matters here for the same reason it does for
// projects: a listing that silently showed the first page would be a lie, and
// this one is read to decide which deployment to roll back to.
func listDeployments(ctx context.Context, c *client.Client, project, state string, limit int) ([]api.Deployment, error) {
opts := client.DeploymentListOptions{State: state}
opts.Limit = 500
if limit > 0 && limit < opts.Limit {
opts.Limit = limit
}
var all []api.Deployment
for {
page, err := c.ListDeployments(ctx, project, opts)
if err != nil {
return nil, err
}
all = append(all, page.Deployments...)
if limit > 0 && len(all) >= limit {
return all[:limit], nil
}
if page.NextCursor == "" || len(page.Deployments) == 0 {
return all, nil
}
opts.Cursor = page.NextCursor
}
}
func deploymentShowCmd(g *Globals) *cliutil.Command {
var files bool
return &cliutil.Command{
Name: "show",
Args: "<id>",
Short: "Show one deployment",
Long: "With --files, also lists the manifest: every path, its size and the\n" +
"digest of its content. That is one line per file, so it is a lot of\n" +
"output for a large site.",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&files, "files", false, "also list the deployment's files")
},
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 1, "one deployment id"); err != nil {
return err
}
project, err := g.ProjectName()
if err != nil {
return err
}
c, err := g.Client()
if err != nil {
return err
}
d, err := c.GetDeployment(ctx, project, args[0], files)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(d, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("id", d.ID)
t.Row("project", d.Project)
t.Row("state", d.State)
t.Row("active", cliutil.Bool(d.Active))
t.Row("files", strconv.Itoa(d.FileCount))
t.Row("total_bytes", cliutil.Bytes(d.TotalBytes))
t.Row("created_at", cliutil.Time(d.CreatedAt))
t.Row("finalized_at", cliutil.TimePtr(d.FinalizedAt))
t.Row("activated_at", cliutil.TimePtr(d.ActivatedAt))
if d.URL != "" {
t.Row("url", d.URL)
}
if d.Error != "" {
t.Row("error", d.Error)
}
for _, k := range sortedKeys(d.Meta) {
t.Row("meta."+k, d.Meta[k])
}
for _, f := range d.Files {
t.Row(f.Path, fmt.Sprintf("%s %s",
cliutil.Bytes(f.Size), cliutil.Truncate(f.Digest, 12)))
}
return t
})
},
}
}
// sortedKeys gives map-backed output a stable order, so two runs of the same
// command produce the same lines and a diff of them means something.
func sortedKeys(m map[string]string) []string {
return slices.Sorted(maps.Keys(m))
}
func deploymentActivateCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "activate",
Args: "<id>",
Short: "Switch the site to a deployment",
Long: "This is how a rollback is done: name an older deployment and the\n" +
"project serves it again. The switch is atomic and costs nothing —\n" +
"the content is still on disk — so it takes effect immediately.",
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 1, "one deployment id"); err != nil {
return err
}
project, err := g.ProjectName()
if err != nil {
return err
}
c, err := g.Client()
if err != nil {
return err
}
d, err := c.Activate(ctx, project, args[0])
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(d, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("deployment", d.ID)
t.Row("project", project)
t.Row("state", d.State)
t.Row("active", cliutil.Bool(d.Active))
t.Row("files", strconv.Itoa(d.FileCount))
t.Row("url", cliutil.Str(d.URL))
return t
})
},
}
}
func deploymentDeleteCmd(g *Globals) *cliutil.Command {
var yes bool
return &cliutil.Command{
Name: "delete",
Args: "<id>",
Short: "Delete a deployment",
Long: "The deployment the project is serving cannot be deleted; activate\n" +
"another one first. Content no other deployment references is\n" +
"reclaimed by the next garbage collection, not immediately.",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
},
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 1, "one deployment id"); err != nil {
return err
}
id := args[0]
project, err := g.ProjectName()
if err != nil {
return err
}
if !yes {
if err := cliutil.Confirm(g.In, g.Err,
fmt.Sprintf("Delete deployment %s of project %q?", id, project)); err != nil {
return err
}
}
c, err := g.Client()
if err != nil {
return err
}
if err := c.DeleteDeployment(ctx, project, id); err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
p.Printf("deleted deployment %s\n", id)
return nil
},
}
}
// ------------------------------------------------------------------ upkeep
//
// These two are server-wide rather than per-project, which is why they sit
// under "pages system" next to "system info" and not under "deployment".
func systemGCCmd(g *Globals) *cliutil.Command {
var dryRun bool
return &cliutil.Command{
Name: "gc",
Short: "Run a garbage collection pass now",
Long: "The server collects on a timer anyway; this is for an operator who\n" +
"wants the disk back sooner. --dry-run reports what would be deleted\n" +
"without deleting it, though it cannot count the content the listed\n" +
"deployments hold — nothing was deleted, so it is all still in use.\n" +
"Requires an admin key.",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&dryRun, "dry-run", false, "report what would be deleted without deleting it")
},
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
}
stats, err := c.Collect(ctx, dryRun)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(stats, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("dry_run", cliutil.Bool(stats.DryRun))
t.Row("deployments_deleted", strconv.Itoa(stats.DeploymentsDeleted))
t.Row("blobs_deleted", strconv.Itoa(stats.BlobsDeleted))
t.Row("bytes_freed", cliutil.Bytes(stats.BytesFreed))
return t
})
},
}
}
func systemFsckCmd(g *Globals) *cliutil.Command {
var repair bool
return &cliutil.Command{
Name: "fsck",
Short: "Check the stored reference counts against the manifests",
Long: "On a healthy server this always reports no drift: the counts are\n" +
"maintained by database triggers. It is for the cases outside normal\n" +
"operation — a restored backup, a database edited by hand — because a\n" +
"count that reads low is content the collector will delete while a\n" +
"deployment still needs it. --repair rewrites the counts from the\n" +
"manifests. Requires an admin key.",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&repair, "repair", false, "correct the counts that disagree")
},
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
}
rep, err := c.Fsck(ctx, repair)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(rep, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("blobs", strconv.FormatInt(rep.Blobs, 10))
t.Row("drift", strconv.Itoa(rep.DriftCount))
t.Row("repaired", strconv.Itoa(rep.Repaired))
for _, d := range rep.Drift {
t.Row(cliutil.Truncate(d.Digest, 12),
fmt.Sprintf("stored %d, actual %d", d.Stored, d.Actual))
}
return t
})
},
}
}
+326
View File
@@ -0,0 +1,326 @@
package clicmd
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cliutil"
)
// cliToken is syntactically plausible and otherwise meaningless: none of the
// fake servers below look at it, but Globals refuses to build a client without
// one.
const cliToken = "pgs_abcdefghijklmnop_secret"
// runCLI drives the real command tree the way main does and returns what the
// user would have seen. Going through Root rather than calling a command's Exec
// directly is the point: it covers the flag registration and the dispatch that
// a hand-built call would skip.
func runCLI(t *testing.T, server, stdin string, args ...string) (stdout, stderr string, err error) {
t.Helper()
clearEnv(t)
t.Setenv("PAGES_TOKEN", cliToken)
var out, errOut strings.Builder
g := &Globals{
In: strings.NewReader(stdin),
Out: &out,
Err: &errOut,
Config: filepath.Join(t.TempDir(), "absent.json"),
Server: server,
}
err = cliutil.Run(context.Background(), Root(g), args, &errOut, g.Register)
return out.String(), errOut.String(), err
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Error(err)
}
}
func fakeDeployment(id string) api.Deployment {
return api.Deployment{
ID: id, Project: "demo", State: "ready",
FileCount: 2, TotalBytes: 4096,
CreatedAt: time.Unix(1700000000, 0).UTC(),
}
}
// indexOf is strings.Index with a failure message, used to assert ordering.
func indexOf(t *testing.T, haystack, needle string) int {
t.Helper()
i := strings.Index(haystack, needle)
if i < 0 {
t.Fatalf("output does not mention %q:\n%s", needle, haystack)
}
return i
}
// TestDeploymentListFollowsTheCursor: the listing is read to decide which
// deployment to roll back to, so one that silently showed the first page would
// be worse than one that failed.
func TestDeploymentListFollowsTheCursor(t *testing.T) {
var queries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queries = append(queries, r.URL.RawQuery)
if r.URL.Query().Get("cursor") == "dpl_2" {
writeJSON(t, w, api.DeploymentList{
Deployments: []api.Deployment{fakeDeployment("dpl_1")},
})
return
}
writeJSON(t, w, api.DeploymentList{
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
NextCursor: "dpl_2",
})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo")
if err != nil {
t.Fatalf("deployment list: %v", err)
}
// Newest first, as the server returned them: the order is what tells the
// reader which one is the previous release.
first := indexOf(t, out, "dpl_3")
second := indexOf(t, out, "dpl_2")
third := indexOf(t, out, "dpl_1")
if !(first < second && second < third) {
t.Errorf("rows out of order:\n%s", out)
}
if len(queries) != 2 {
t.Fatalf("made %d requests (%q), want 2", len(queries), queries)
}
if !strings.Contains(queries[1], "cursor=dpl_2") {
t.Errorf("second request query = %q, want the cursor from the first page", queries[1])
}
}
// TestDeploymentListStopsAtTheLimit — the server here always offers another
// page, so a --limit that was not honoured would page until the test timed out.
func TestDeploymentListStopsAtTheLimit(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 4 {
t.Errorf("still paging after %d requests; --limit was ignored", requests)
writeJSON(t, w, api.DeploymentList{})
return
}
writeJSON(t, w, api.DeploymentList{
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
NextCursor: "dpl_2",
})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo", "--limit", "2")
if err != nil {
t.Fatalf("deployment list: %v", err)
}
if requests != 1 {
t.Errorf("made %d requests, want 1: two rows already satisfy --limit 2", requests)
}
if !strings.Contains(out, "dpl_3") || !strings.Contains(out, "dpl_2") {
t.Errorf("output is missing a row:\n%s", out)
}
}
// TestDeploymentListEmptyIsAnEmptyArray: -o json is what a CI step parses, and
// jq treats null and [] very differently.
func TestDeploymentListEmptyIsAnEmptyArray(t *testing.T) {
var query string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.RawQuery
writeJSON(t, w, api.DeploymentList{})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "",
"deployment", "list", "--project", "demo", "--state", "failed", "-o", "json")
if err != nil {
t.Fatalf("deployment list: %v", err)
}
if !strings.Contains(query, "state=failed") {
t.Errorf("query = %q, want the state filter", query)
}
if !strings.Contains(out, `"deployments": []`) {
t.Errorf("output = %s, want an empty array", out)
}
if strings.Contains(out, "null") {
t.Errorf("output = %s, want no null", out)
}
}
// TestDeploymentShowAsksForFilesOnlyWhenTold — the manifest is one line per
// file, so a large site's would drown the rest of the output.
func TestDeploymentShowAsksForFilesOnlyWhenTold(t *testing.T) {
d := fakeDeployment("dpl_1")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
out := d
if r.URL.Query().Get("files") == "true" {
out.Files = []api.FileEntry{
{Path: "assets/app.js", Digest: strings.Repeat("ab", 32), Size: 15},
{Path: "index.html", Digest: strings.Repeat("cd", 32), Size: 42},
}
}
writeJSON(t, w, out)
}))
defer srv.Close()
plain, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo")
if err != nil {
t.Fatalf("deployment show: %v", err)
}
if strings.Contains(plain, "assets/app.js") {
t.Errorf("the manifest was printed without --files:\n%s", plain)
}
full, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo", "--files")
if err != nil {
t.Fatalf("deployment show --files: %v", err)
}
if !strings.Contains(full, "assets/app.js") || !strings.Contains(full, "index.html") {
t.Errorf("--files did not list the manifest:\n%s", full)
}
}
// TestDeploymentDeleteAsksFirst. Deleting the wrong deployment is not
// recoverable from the CLI, so the prompt is the safety net and --yes is the
// documented way past it.
func TestDeploymentDeleteAsksFirst(t *testing.T) {
newServer := func(seen *[]string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = append(*seen, r.Method+" "+r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
}
t.Run("declining deletes nothing", func(t *testing.T) {
var seen []string
srv := newServer(&seen)
defer srv.Close()
_, errOut, err := runCLI(t, srv.URL, "n\n", "deployment", "delete", "dpl_1", "--project", "demo")
if !errors.Is(err, cliutil.ErrAborted) {
t.Fatalf("err = %v, want it to report the abort", err)
}
if len(seen) != 0 {
t.Errorf("requests = %q, want none", seen)
}
if !strings.Contains(errOut, "dpl_1") {
t.Errorf("prompt = %q, want it to name the deployment", errOut)
}
})
t.Run("confirming deletes", func(t *testing.T) {
var seen []string
srv := newServer(&seen)
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "y\n", "deployment", "delete", "dpl_1", "--project", "demo")
if err != nil {
t.Fatalf("deployment delete: %v", err)
}
want := "DELETE " + api.PathDeployment("demo", "dpl_1")
if len(seen) != 1 || seen[0] != want {
t.Errorf("requests = %q, want [%q]", seen, want)
}
if !strings.Contains(out, "dpl_1") {
t.Errorf("output = %q, want it to confirm what was deleted", out)
}
})
t.Run("--yes does not prompt", func(t *testing.T) {
var seen []string
srv := newServer(&seen)
defer srv.Close()
// Empty stdin: without --yes this would abort rather than delete.
_, errOut, err := runCLI(t, srv.URL, "", "deployment", "delete", "dpl_1", "--project", "demo", "--yes")
if err != nil {
t.Fatalf("deployment delete --yes: %v", err)
}
if len(seen) != 1 {
t.Errorf("requests = %q, want one delete", seen)
}
if strings.Contains(errOut, "[y/N]") {
t.Errorf("stderr = %q, want no prompt", errOut)
}
})
}
// TestSystemGCPostsTheDryRunFlag: a dry run that silently ran for real is the
// worst bug this command could have, so the flag's trip to the wire is checked
// rather than assumed.
func TestSystemGCPostsTheDryRunFlag(t *testing.T) {
var gotPath, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, 256)
n, _ := r.Body.Read(body)
gotPath, gotBody = r.Method+" "+r.URL.Path, string(body[:n])
writeJSON(t, w, api.GCStats{DryRun: true, DeploymentsDeleted: 3, BlobsDeleted: 4, BytesFreed: 5120})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "system", "gc", "--dry-run")
if err != nil {
t.Fatalf("system gc: %v", err)
}
if want := "POST " + api.PathGC(); gotPath != want {
t.Errorf("request = %q, want %q", gotPath, want)
}
if !strings.Contains(gotBody, `"dry_run":true`) {
t.Errorf("body = %q, want dry_run set", gotBody)
}
for _, want := range []string{"dry_run", "yes", "deployments_deleted", "3", "blobs_deleted", "4"} {
if !strings.Contains(out, want) {
t.Errorf("output is missing %q:\n%s", want, out)
}
}
}
// TestSystemFsckReportsDrift — the report exists for the case where the counts
// are wrong, so the drifting digests have to reach the operator's screen.
func TestSystemFsckReportsDrift(t *testing.T) {
digest := strings.Repeat("ab", 32)
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, 256)
n, _ := r.Body.Read(body)
gotBody = string(body[:n])
writeJSON(t, w, api.FsckReport{
Blobs: 9,
DriftCount: 1,
Repaired: 1,
Drift: []api.BlobDrift{{Digest: digest, Stored: 7, Actual: 1}},
})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "system", "fsck", "--repair")
if err != nil {
t.Fatalf("system fsck: %v", err)
}
if !strings.Contains(gotBody, `"repair":true`) {
t.Errorf("body = %q, want repair set", gotBody)
}
// Truncate spends its last column on the ellipsis, so twelve columns of
// digest are eleven characters and a marker that there is more.
if !strings.Contains(out, digest[:11]+"…") {
t.Errorf("output does not name the drifting blob:\n%s", out)
}
if !strings.Contains(out, "stored 7, actual 1") {
t.Errorf("output does not give the counts:\n%s", out)
}
}
+216
View File
@@ -0,0 +1,216 @@
// Package clicmd implements the pages subcommands.
package clicmd
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/iceBear67/simplepages/internal/client"
"github.com/iceBear67/simplepages/internal/cliutil"
)
// Globals are the settings every command shares.
//
// Precedence is flag > environment > config file > default. Flags are parsed
// into these fields first, so Resolve fills in only what is still empty — which
// is why a flag given as the empty string counts as absent.
type Globals struct {
Server string
Token string
TokenFile string
Project string
Config string
Output string
Timeout time.Duration
Verbose bool
In io.Reader
Out io.Writer
Err io.Writer
file ConfigFile
resolved bool
}
// NewGlobals returns Globals wired to the process's standard streams.
func NewGlobals() *Globals {
return &Globals{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}
}
// Register adds the global flags to fs.
//
// Every flag's default is the field's current value, so registering on a
// subcommand's FlagSet preserves what an outer parse already set. Passing a
// fixed default here would make "pages --server X project list" lose --server
// the moment the subcommand registered its own copy.
func (g *Globals) Register(fs *flag.FlagSet) {
fs.StringVar(&g.Server, "server", g.Server, "management API base `URL` (env PAGES_SERVER)")
fs.StringVar(&g.Token, "token", g.Token,
"API `token`; avoid it — argv is world-readable via /proc on shared runners, so prefer --token-file or PAGES_TOKEN")
fs.StringVar(&g.TokenFile, "token-file", g.TokenFile, "read the API token from `path` (env PAGES_TOKEN_FILE)")
fs.StringVar(&g.Project, "project", g.Project, "project `name` to operate on (env PAGES_PROJECT)")
fs.StringVar(&g.Config, "config", g.Config, "config file `path` (env PAGES_CONFIG)")
fs.StringVar(&g.Output, "o", g.Output, "output `format`: table or json (env PAGES_OUTPUT)")
fs.StringVar(&g.Output, "output", g.Output, "output `format`: table or json")
fs.DurationVar(&g.Timeout, "timeout", g.Timeout, "per-request `timeout`")
fs.BoolVar(&g.Verbose, "v", g.Verbose, "verbose progress on stderr")
fs.BoolVar(&g.Verbose, "verbose", g.Verbose, "verbose progress on stderr")
}
// Resolve applies the environment, then the config file, to anything the flags
// did not set. It runs once; later calls are no-ops.
func (g *Globals) Resolve() error {
if g.resolved {
return nil
}
g.resolved = true
if g.Config == "" {
g.Config = DefaultConfigPath()
}
f, err := LoadConfig(g.Config, g.Err)
if err != nil {
return err
}
g.file = f
g.Server = firstNonEmpty(g.Server, os.Getenv("PAGES_SERVER"), f.Server)
g.Project = firstNonEmpty(g.Project, os.Getenv("PAGES_PROJECT"), f.Project)
if err := g.resolveToken(); err != nil {
return err
}
g.Output = firstNonEmpty(g.Output, os.Getenv("PAGES_OUTPUT"), f.Output, cliutil.FormatTable)
if !cliutil.ValidFormat(g.Output) {
return fmt.Errorf("unknown output format %q: use table or json", g.Output)
}
if g.Timeout == 0 {
if s := os.Getenv("PAGES_TIMEOUT"); s != "" {
d, err := cliutil.ParseDuration(s)
if err != nil {
return fmt.Errorf("PAGES_TIMEOUT: %w", err)
}
g.Timeout = d
}
}
if g.Timeout <= 0 {
g.Timeout = client.DefaultTimeout
}
return nil
}
// resolveToken picks the token from the most specific source that has one:
// --token, --token-file, $PAGES_TOKEN, $PAGES_TOKEN_FILE, config file.
func (g *Globals) resolveToken() error {
if g.Token != "" {
// Only a flag can have set this: the environment and the config file are
// read below. Say so once — on a shared runner every other user can read
// this token out of /proc/<pid>/cmdline for as long as the process lives.
fmt.Fprintln(g.Err, "warning: --token puts the token in the process list; prefer --token-file or PAGES_TOKEN")
return nil
}
if g.TokenFile != "" {
t, err := readTokenFile(g.TokenFile, g.Err)
if err != nil {
return err
}
g.Token = t
return nil
}
if t := os.Getenv("PAGES_TOKEN"); t != "" {
g.Token = strings.TrimSpace(t)
return nil
}
if p := os.Getenv("PAGES_TOKEN_FILE"); p != "" {
t, err := readTokenFile(p, g.Err)
if err != nil {
return err
}
g.Token = t
return nil
}
g.Token = g.file.Token
return nil
}
// readTokenFile reads a token, tolerating the trailing newline that every text
// editor and `pages key create ... > token` adds.
func readTokenFile(path string, warn io.Writer) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read token file: %w", err)
}
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
fmt.Fprintf(warn, "warning: token file %s is mode %#o; run: chmod 600 %s\n",
path, fi.Mode().Perm(), path)
}
t := strings.TrimSpace(string(raw))
if t == "" {
return "", fmt.Errorf("token file %s is empty", path)
}
return t, nil
}
// Client builds an API client from the resolved settings.
func (g *Globals) Client() (*client.Client, error) {
if err := g.Resolve(); err != nil {
return nil, err
}
return client.New(client.Config{
BaseURL: g.Server,
Token: g.Token,
Timeout: g.Timeout,
})
}
// Printer renders command output in the requested format.
func (g *Globals) Printer() (*cliutil.Printer, error) {
if err := g.Resolve(); err != nil {
return nil, err
}
return &cliutil.Printer{Out: g.Out, Format: g.Output}, nil
}
// ProjectName returns the project to operate on, or an explanation of how to
// name one.
func (g *Globals) ProjectName() (string, error) {
if err := g.Resolve(); err != nil {
return "", err
}
if g.Project == "" {
return "", errors.New("no project: pass --project or set PAGES_PROJECT")
}
return g.Project, nil
}
// logf writes a progress note to stderr under --verbose.
func (g *Globals) logf(format string, args ...any) {
if g.Verbose {
fmt.Fprintf(g.Err, format+"\n", args...)
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// exactArgs is the argument-count check every leaf command starts with. It
// reports a usage error, so the command's own usage text follows the message.
func exactArgs(args []string, n int, want string) error {
if len(args) != n {
return cliutil.UsageErrorf("expected %s, got %d argument(s)", want, len(args))
}
return nil
}
+341
View File
@@ -0,0 +1,341 @@
package clicmd
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/iceBear67/simplepages/internal/cliutil"
)
// clearEnv makes a test independent of whatever the developer has exported.
func clearEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"PAGES_SERVER", "PAGES_TOKEN", "PAGES_TOKEN_FILE",
"PAGES_PROJECT", "PAGES_OUTPUT", "PAGES_TIMEOUT", "PAGES_CONFIG",
} {
t.Setenv(k, "")
}
}
// writeConfig returns the path to a config file holding f.
func writeConfig(t *testing.T, f ConfigFile) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := SaveConfig(path, f); err != nil {
t.Fatal(err)
}
return path
}
// newTestGlobals is what NewGlobals would give a command, with the streams
// captured and the config file pinned so no real one can interfere.
func newTestGlobals(config string) (*Globals, *strings.Builder, *strings.Builder) {
var out, errOut strings.Builder
return &Globals{In: strings.NewReader(""), Out: &out, Err: &errOut, Config: config}, &out, &errOut
}
// TestPrecedence is the rule stated in the help text: flag, then environment,
// then config file, then default. It is easy to get subtly wrong, and wrong
// here means a CI job deploying to the wrong server.
func TestPrecedence(t *testing.T) {
file := ConfigFile{Server: "https://file.example.com", Project: "fileproj", Output: "json"}
t.Run("flag wins", func(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_SERVER", "https://env.example.com")
t.Setenv("PAGES_PROJECT", "envproj")
g, _, _ := newTestGlobals(writeConfig(t, file))
g.Server, g.Project = "https://flag.example.com", "flagproj"
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Server != "https://flag.example.com" || g.Project != "flagproj" {
t.Errorf("server=%q project=%q, want the flag values", g.Server, g.Project)
}
})
t.Run("environment beats the config file", func(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_SERVER", "https://env.example.com")
t.Setenv("PAGES_PROJECT", "envproj")
g, _, _ := newTestGlobals(writeConfig(t, file))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Server != "https://env.example.com" || g.Project != "envproj" {
t.Errorf("server=%q project=%q, want the environment values", g.Server, g.Project)
}
})
t.Run("the config file is the last word before defaults", func(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(writeConfig(t, file))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Server != "https://file.example.com" || g.Project != "fileproj" || g.Output != "json" {
t.Errorf("server=%q project=%q output=%q, want the file values", g.Server, g.Project, g.Output)
}
})
t.Run("defaults fill the rest", func(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Output != cliutil.FormatTable {
t.Errorf("output = %q, want %q", g.Output, cliutil.FormatTable)
}
if g.Timeout <= 0 {
t.Errorf("timeout = %v, want a positive default", g.Timeout)
}
})
}
// TestFlagsSurviveTheSubcommandParse covers the trap that makes this design
// work: every global flag is re-registered on each subcommand's FlagSet, and
// registering with a fixed default would wipe a value given before the
// subcommand name.
func TestFlagsSurviveTheSubcommandParse(t *testing.T) {
clearEnv(t)
cfg := writeConfig(t, ConfigFile{})
for _, args := range [][]string{
{"--server", "https://flag.example.com", "--config", cfg, "probe", "demo"},
{"probe", "--server", "https://flag.example.com", "--config", cfg, "demo"},
{"--server", "https://flag.example.com", "probe", "demo", "--config", cfg},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
g, _, _ := newTestGlobals("")
var gotProject string
root := &cliutil.Command{
Name: "pages",
Sub: []*cliutil.Command{{
Name: "probe",
Exec: func(ctx context.Context, args []string) error {
if err := g.Resolve(); err != nil {
return err
}
if len(args) == 1 {
gotProject = args[0]
}
return nil
},
}},
}
if err := cliutil.Run(context.Background(), root, args, io.Discard, g.Register); err != nil {
t.Fatalf("Run: %v", err)
}
if g.Server != "https://flag.example.com" {
t.Errorf("server = %q, want the flag to survive dispatch", g.Server)
}
if gotProject != "demo" {
t.Errorf("positional = %q, want demo", gotProject)
}
})
}
}
func TestTokenPrecedence(t *testing.T) {
const (
flagTok = "pgs_flagflagflagfl_secret"
flagFileTok = "pgs_flagfileflagfi_secret"
envTok = "pgs_envenvenvenven_secret"
envFileTok = "pgs_envfileenvfile_secret"
fileTok = "pgs_fileconfigfile_secret"
)
tokenFile := func(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
t.Run("--token wins but warns", func(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_TOKEN", envTok)
g, _, errOut := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
g.Token = flagTok
g.TokenFile = tokenFile(t, flagFileTok)
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Token != flagTok {
t.Errorf("token = %q, want the flag", g.Token)
}
// argv is world-readable through /proc on a shared runner, so this
// warning is the whole reason --token is documented as a last resort.
if !strings.Contains(errOut.String(), "process list") {
t.Errorf("stderr = %q, want a warning about the process list", errOut.String())
}
if strings.Contains(errOut.String(), "secret") {
t.Error("the warning printed the token")
}
})
t.Run("--token-file beats the environment", func(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_TOKEN", envTok)
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
g.TokenFile = tokenFile(t, flagFileTok)
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Token != flagFileTok {
t.Errorf("token = %q, want the one from --token-file", g.Token)
}
})
t.Run("PAGES_TOKEN beats PAGES_TOKEN_FILE", func(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_TOKEN", envTok)
t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok))
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Token != envTok {
t.Errorf("token = %q, want PAGES_TOKEN", g.Token)
}
})
t.Run("PAGES_TOKEN_FILE beats the config file", func(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok))
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Token != envFileTok {
t.Errorf("token = %q, want PAGES_TOKEN_FILE", g.Token)
}
})
t.Run("the config file is the fallback", func(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Token != fileTok {
t.Errorf("token = %q, want the config file's", g.Token)
}
})
}
// TestTokenFileTrimsTrailingNewline: `pages key create ... > token` and every
// text editor add one.
func TestTokenFileTrimsTrailingNewline(t *testing.T) {
clearEnv(t)
p := filepath.Join(t.TempDir(), "token")
os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret\n"), 0o600)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
g.TokenFile = p
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Token != "pgs_abcdefghijklmnop_secret" {
t.Errorf("token = %q, want the newline trimmed", g.Token)
}
}
func TestTokenFileProblemsAreReported(t *testing.T) {
t.Run("missing", func(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
g.TokenFile = filepath.Join(t.TempDir(), "nope")
if err := g.Resolve(); err == nil {
t.Fatal("expected an error")
}
})
t.Run("empty", func(t *testing.T) {
clearEnv(t)
p := filepath.Join(t.TempDir(), "token")
os.WriteFile(p, []byte("\n\n"), 0o600)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
g.TokenFile = p
err := g.Resolve()
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("err = %v, want it to say the file is empty", err)
}
})
t.Run("world readable warns", func(t *testing.T) {
clearEnv(t)
p := filepath.Join(t.TempDir(), "token")
os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret"), 0o644)
g, _, errOut := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
g.TokenFile = p
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if !strings.Contains(errOut.String(), "chmod 600") {
t.Errorf("stderr = %q, want a mode warning", errOut.String())
}
})
}
func TestResolveRejectsUnknownOutputFormat(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
g.Output = "yaml"
err := g.Resolve()
if err == nil || !strings.Contains(err.Error(), "table or json") {
t.Fatalf("err = %v, want it to list the formats", err)
}
}
func TestResolveParsesFriendlyTimeouts(t *testing.T) {
clearEnv(t)
t.Setenv("PAGES_TIMEOUT", "2m")
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
if err := g.Resolve(); err != nil {
t.Fatal(err)
}
if g.Timeout.Minutes() != 2 {
t.Errorf("timeout = %v, want 2m", g.Timeout)
}
clearEnv(t)
t.Setenv("PAGES_TIMEOUT", "later")
g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
err := g2.Resolve()
if err == nil || !strings.Contains(err.Error(), "PAGES_TIMEOUT") {
t.Fatalf("err = %v, want it to name the variable", err)
}
}
func TestProjectNameExplainsHowToSetIt(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
_, err := g.ProjectName()
if err == nil || !strings.Contains(err.Error(), "PAGES_PROJECT") {
t.Fatalf("err = %v, want it to name the flag and the variable", err)
}
}
// TestClientNeedsServerAndToken: the two settings a fresh CI job forgets.
func TestClientNeedsServerAndToken(t *testing.T) {
clearEnv(t)
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
if _, err := g.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_SERVER") {
t.Fatalf("err = %v, want it to name PAGES_SERVER", err)
}
clearEnv(t)
g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
g2.Server = "https://p.example.com"
if _, err := g2.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_TOKEN") {
t.Fatalf("err = %v, want it to name PAGES_TOKEN", err)
}
}
+208
View File
@@ -0,0 +1,208 @@
package clicmd
import (
"context"
"errors"
"flag"
"fmt"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cliutil"
)
func keyCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "key",
Short: "Manage API keys",
Sub: []*cliutil.Command{
keyCreateCmd(g),
keyListCmd(g),
keyRevokeCmd(g),
},
}
}
func keyCreateCmd(g *Globals) *cliutil.Command {
var (
name string
admin bool
expires string
)
return &cliutil.Command{
Name: "create",
Short: "Mint a key",
Long: "The token is printed once and cannot be retrieved again — the server\n" +
"stores only its hash. Redirect it straight into a file or a secret\n" +
"store; do not let it reach a CI log.\n\n" +
"Without --admin the key is scoped to one project and can do nothing\n" +
"outside it. Requires an admin key either way.",
Flags: func(fs *flag.FlagSet) {
fs.StringVar(&name, "name", "", "`label` recorded with the key, e.g. github-actions")
fs.BoolVar(&admin, "admin", false, "mint an admin key instead of a project key")
fs.StringVar(&expires, "expires", "", "expire after this `duration`, e.g. 90d; default never")
},
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 0, "no arguments"); err != nil {
return err
}
req := api.CreateKeyRequest{Name: name}
if expires != "" {
d, err := cliutil.ParseDuration(expires)
if err != nil {
return err
}
if d <= 0 {
return errors.New("--expires must be positive")
}
// Computed here, sent absolute: a clock difference between this
// machine and the server then shifts nothing.
t := time.Now().Add(d).UTC().Truncate(time.Second)
req.ExpiresAt = &t
}
c, err := g.Client()
if err != nil {
return err
}
var out api.CreateKeyResponse
if admin {
out, err = c.CreateAdminKey(ctx, req)
} else {
var project string
if project, err = g.ProjectName(); err != nil {
return fmt.Errorf("%w, or pass --admin for a server-wide key", err)
}
out, err = c.CreateProjectKey(ctx, project, req)
}
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
if p.Format == cliutil.FormatJSON {
return p.JSON(out)
}
// Table format prints the token on a line of its own so it survives
// a copy-paste and so `pages key create | tail -1` is not tempting.
if err := keyTable(out.Key).Write(g.Out); err != nil {
return err
}
fmt.Fprintf(g.Out, "\n%s\n", out.Token)
fmt.Fprintln(g.Err, "this token is shown once and cannot be recovered; store it now")
return nil
},
}
}
func keyListCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "list",
Short: "List keys",
Long: "With --project, lists that project's keys; a project key may list its\n" +
"own. Without it, lists every key on the server and requires an admin\n" +
"key. Secrets are never listed — only key ids.",
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 0, "no arguments"); err != nil {
return err
}
if err := g.Resolve(); err != nil {
return err
}
c, err := g.Client()
if err != nil {
return err
}
var list api.KeyList
if g.Project != "" {
list, err = c.ListProjectKeys(ctx, g.Project)
} else {
list, err = c.ListKeys(ctx)
}
if err != nil {
return err
}
if list.Keys == nil {
list.Keys = []api.Key{}
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(list, func() *cliutil.Table {
t := cliutil.NewTable("ID", "SCOPE", "PROJECT", "NAME", "CREATED", "EXPIRES", "LAST USED", "STATE")
for _, k := range list.Keys {
t.Row(k.ID, k.Scope, cliutil.Str(k.Project), cliutil.Str(k.Name),
cliutil.Time(k.CreatedAt), cliutil.TimePtr(k.ExpiresAt),
cliutil.TimePtr(k.LastUsed), keyState(k))
}
return t
})
},
}
}
func keyRevokeCmd(g *Globals) *cliutil.Command {
var yes bool
return &cliutil.Command{
Name: "revoke",
Args: "<key-id>",
Short: "Revoke a key",
Long: "Takes effect immediately across the server. The key id is the middle\n" +
"segment of a token (pgs_<key-id>_<secret>) and is what `key list`\n" +
"shows. Revoking an already-revoked key succeeds.",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
},
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 1, "one key id"); err != nil {
return err
}
id := args[0]
if !yes {
if err := cliutil.Confirm(g.In, g.Err, fmt.Sprintf("Revoke key %s?", id)); err != nil {
return err
}
}
c, err := g.Client()
if err != nil {
return err
}
if err := c.RevokeKey(ctx, id); err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
p.Printf("revoked key %s\n", id)
return nil
},
}
}
// keyState collapses the two timestamps that decide whether a key still works.
func keyState(k api.Key) string {
switch {
case k.Revoked():
return "revoked"
case k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()):
return "expired"
default:
return "active"
}
}
func keyTable(k api.Key) *cliutil.Table {
t := cliutil.NewTable()
t.Row("id", k.ID)
t.Row("scope", k.Scope)
t.Row("project", cliutil.Str(k.Project))
t.Row("name", cliutil.Str(k.Name))
t.Row("created_at", cliutil.Time(k.CreatedAt))
t.Row("expires_at", cliutil.TimePtr(k.ExpiresAt))
return t
}
+278
View File
@@ -0,0 +1,278 @@
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
})
}
+272
View File
@@ -0,0 +1,272 @@
package clicmd
import (
"context"
"flag"
"fmt"
"strconv"
"time"
"github.com/iceBear67/simplepages/internal/cliutil"
"github.com/iceBear67/simplepages/internal/version"
)
// Root builds the command tree.
func Root(g *Globals) *cliutil.Command {
var showVersion bool
return &cliutil.Command{
Name: "pages",
Short: "Deploy static sites atomically",
Long: "Settings are taken from flags first, then PAGES_* environment\n" +
"variables, then the config file, then built-in defaults.\n\n" +
" PAGES_SERVER management API base URL\n" +
" PAGES_TOKEN API token\n" +
" PAGES_TOKEN_FILE file holding the API token\n" +
" PAGES_PROJECT default project\n" +
" PAGES_OUTPUT table or json\n" +
" PAGES_TIMEOUT per-request timeout\n" +
" PAGES_CONFIG config file path",
Flags: func(fs *flag.FlagSet) {
fs.BoolVar(&showVersion, "version", false, "print the version and exit")
},
Exec: func(ctx context.Context, args []string) error {
if showVersion {
fmt.Fprintln(g.Out, version.String())
return nil
}
return cliutil.ErrUsage
},
Sub: []*cliutil.Command{
deployCmd(g),
projectCmd(g),
deploymentCmd(g),
keyCmd(g),
whoamiCmd(g),
systemCmd(g),
configCmd(g),
versionCmd(g),
},
}
}
func versionCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "version",
Short: "Print the version",
Long: "Reports the client build only; it does not contact the server.",
Exec: func(ctx context.Context, args []string) error {
fmt.Fprintln(g.Out, version.String())
return nil
},
}
}
func whoamiCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "whoami",
Short: "Show which key is being used",
Long: "Useful for confirming a CI runner picked up the credential you meant.",
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
}
who, err := c.WhoAmI(ctx)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(who, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("key_id", who.KeyID)
t.Row("scope", who.Scope)
t.Row("project", cliutil.Str(who.Project))
t.Row("name", cliutil.Str(who.Name))
t.Row("expires_at", cliutil.TimePtr(who.ExpiresAt))
t.Row("server", c.BaseURL())
return t
})
},
}
}
func systemCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "system",
Short: "Inspect and maintain the server",
Sub: []*cliutil.Command{systemInfoCmd(g), systemGCCmd(g), systemFsckCmd(g)},
}
}
func systemInfoCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "info",
Short: "Show server version and storage counters",
Long: "Requires an admin key.",
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
}
info, err := c.SystemInfo(ctx)
if err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(info, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("version", info.Version)
t.Row("uptime", (time.Duration(info.UptimeS) * time.Second).String())
t.Row("schema_version", strconv.Itoa(info.SchemaVer))
t.Row("projects", strconv.FormatInt(info.Projects, 10))
t.Row("deployments", strconv.FormatInt(info.Deployments, 10))
t.Row("blobs", strconv.FormatInt(info.Blobs, 10))
t.Row("cas_bytes", cliutil.Bytes(info.CASBytes))
// How deployment trees are built on this host: hardlink shares
// inodes with the store, copy does not, and the difference shows
// up as disk usage.
t.Row("link_mode", info.LinkMode)
return t
})
},
}
}
// -------------------------------------------------------------------- config
func configCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "config",
Short: "Read and write the CLI config file",
Long: "The config file is JSON, mode 0600, at $PAGES_CONFIG or\n" +
"$XDG_CONFIG_HOME/pages/config.json. It is a convenience for a\n" +
"workstation; CI should use PAGES_* environment variables instead.",
Sub: []*cliutil.Command{
configShowCmd(g),
configSetCmd(g),
configPathCmd(g),
},
}
}
// configView is what "config show" prints: the file's contents with the token
// reduced to its public half.
type configView struct {
Path string `json:"path"`
Server string `json:"server,omitempty"`
Token string `json:"token,omitempty"`
Project string `json:"project,omitempty"`
Output string `json:"output,omitempty"`
}
func configShowCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "show",
Short: "Show the config file, with the token redacted",
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 0, "no arguments"); err != nil {
return err
}
if err := g.Resolve(); err != nil {
return err
}
// From the file, not from the resolved settings: this command answers
// "what is stored here", and printing an environment-supplied token
// back at the user would be actively misleading.
view := configView{
Path: g.Config,
Server: g.file.Server,
Token: redactToken(g.file.Token),
Project: g.file.Project,
Output: g.file.Output,
}
p, err := g.Printer()
if err != nil {
return err
}
return p.Print(view, func() *cliutil.Table {
t := cliutil.NewTable()
t.Row("path", cliutil.Str(view.Path))
t.Row("server", cliutil.Str(view.Server))
t.Row("token", cliutil.Str(view.Token))
t.Row("project", cliutil.Str(view.Project))
t.Row("output", cliutil.Str(view.Output))
return t
})
},
}
}
func configSetCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "set",
Args: "<server|token|project|output> <value>",
Short: "Set one config value",
Long: "An empty value removes the setting. The file is created mode 0600.\n" +
"Reading a token from a file avoids putting it in your shell history:\n" +
" pages config set token \"$(cat token.txt)\"",
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 2, "a key and a value"); err != nil {
return err
}
if err := g.Resolve(); err != nil {
return err
}
key, value := args[0], args[1]
f := g.file
switch key {
case "server":
f.Server = value
case "token":
f.Token = value
case "project":
f.Project = value
case "output":
if value != "" && !cliutil.ValidFormat(value) {
return fmt.Errorf("unknown output format %q: use table or json", value)
}
f.Output = value
default:
return cliutil.UsageErrorf("unknown config key %q", key)
}
if err := SaveConfig(g.Config, f); err != nil {
return err
}
p, err := g.Printer()
if err != nil {
return err
}
p.Printf("set %s in %s\n", key, g.Config)
return nil
},
}
}
func configPathCmd(g *Globals) *cliutil.Command {
return &cliutil.Command{
Name: "path",
Short: "Print the config file path",
Exec: func(ctx context.Context, args []string) error {
if err := exactArgs(args, 0, "no arguments"); err != nil {
return err
}
if err := g.Resolve(); err != nil {
return err
}
fmt.Fprintln(g.Out, g.Config)
return nil
},
}
}