From deb23fe61f70cc711c11d799614aa7ff5cb74644 Mon Sep 17 00:00:00 2001 From: Spencer Delcore Date: Tue, 14 Jul 2026 19:42:33 -0400 Subject: [PATCH 1/2] deploy: attribution, overwrite protection, and view metrics Record who deployed (user@hostname via X-Shared-Deployer) and when in a per-site meta/.json, guard deploys with a force-with-lease style version check (409 + interactive overwrite prompt, --force to skip), and count per-site HTML page views with periodic flush. Views and last deployer surface in shared list, shared versions, /api/sites, and the homepage. --- README.md | 43 ++++++- cmd/shared/main.go | 192 +++++++++++++++++++++++++++-- internal/server/deploy.go | 52 ++++++-- internal/server/meta.go | 239 +++++++++++++++++++++++++++++++++++++ internal/server/server.go | 6 + internal/server/static.go | 4 + internal/web/home.html | 3 + internal/web/init/SKILL.md | 7 +- 8 files changed, 520 insertions(+), 26 deletions(-) create mode 100644 internal/server/meta.go diff --git a/README.md b/README.md index 8cc708f..f939f22 100644 --- a/README.md +++ b/README.md @@ -159,8 +159,9 @@ The `shared` binary talks to the server over HTTP (`--server`, default ```sh shared init [dir] # scaffold index.html + a shared-sites agent skill -shared deploy [dir] --name NAME # pack a directory and deploy it -shared list # deployed sites with size + last-updated time +shared deploy [dir] --name NAME # pack a directory and deploy it (--force skips + # the overwrite check) +shared list # deployed sites with size, views, last deployer shared open NAME # print and open a site URL shared versions NAME # a site's saved versions, newest first shared rollback NAME # roll back to the newest saved version @@ -287,10 +288,10 @@ derived from `SHARED_USER` (or `$USER`). | Method | Path | Description | |---|---|---| -| `POST` | `/api/deploy?site=N` | Body: gzipped tarball of site dir → `{"site","url"}` | -| `POST` | `/api/rollback?site=N` | Restore the newest saved version → `{"site","url"}` | -| `GET` | `/api/versions?site=N` | `{"versions":[{"timestamp"}]}`, newest first | -| `GET` | `/api/sites` | `{"sites":[{"name","updatedAt","bytes"}]}` (`bytes` = total on-disk size) | +| `POST` | `/api/deploy?site=N` | Body: gzipped tarball of site dir → `{"site","url","version"}`; 409 on version conflict (see below) | +| `POST` | `/api/rollback?site=N` | Restore the newest saved version → `{"site","url","version"}` | +| `GET` | `/api/versions?site=N` | `{"current":{"seq","time","deployer","source"},"versions":[{"timestamp"}]}`, newest first | +| `GET` | `/api/sites` | `{"sites":[{"name","updatedAt","bytes","views","deployer","deploys"}]}` (`bytes` = total on-disk size) | | `DELETE` | `/api/sites/{name}` | Remove a site and all its data → `{"deleted":true}` | | `GET` | `/api/export` | Streams a gzipped tarball of the entire data directory | | `GET` | `/api/db/{col}` | `{"docs":[...]}` | @@ -331,6 +332,7 @@ data/ versions/// prior deploys kept for rollback, named by unix time db//.json document store, one JSON file per collection uploads// uploaded files + meta/.json deploy history + view counts identity.json optional identity override: {"email","name"} ``` @@ -351,3 +353,32 @@ shared rm mysite # delete the site, its db, uploads, and versions `shared rm` (or `DELETE /api/sites/`) removes everything for a site and evicts its in-memory collection state, closing any open subscriptions. + +## Deploy attribution and overwrite protection + +Every deploy and rollback is recorded in `meta/.json` with a sequence +number, timestamp, and deployer identity (`user@hostname`, sent by the CLI as +`X-Shared-Deployer`). `shared list` and `shared versions` show who deployed +last. + +Deploys are also guarded against accidental overwrites, git +`--force-with-lease` style: the CLI remembers the version it last deployed +(in `.shared/state.json` next to the site, never uploaded) and sends it as +`X-Shared-Prev-Version`. If someone else deployed in between, the server +answers 409 with their identity and the CLI asks before overwriting: + +``` +hello was deployed by alice@dayman at 2026-07-14T23:40:23Z since your last deploy — overwrite? [y/N] +``` + +With no local state (fresh checkout, new machine) the CLI instead asks the +server who deployed last and warns if it wasn't you. `--force` (or the +`X-Shared-Force: 1` header) skips both checks; requests without a +prev-version header (curl, older CLIs) are accepted unchecked. + +## Metrics + +The server counts page views per site — `GET` requests for HTML documents, +not assets or API calls — batched in memory and flushed to `meta/.json` +every 30 seconds, with per-day buckets kept for 30 days. Totals show up in +`shared list`, on the homepage, and in `GET /api/sites`. diff --git a/cmd/shared/main.go b/cmd/shared/main.go index 58c6810..7adc84a 100644 --- a/cmd/shared/main.go +++ b/cmd/shared/main.go @@ -2,6 +2,7 @@ package main import ( "archive/tar" + "bufio" "bytes" "compress/gzip" "encoding/json" @@ -14,8 +15,10 @@ import ( "net/url" "os" "os/exec" + "os/user" "path/filepath" "runtime" + "strconv" "strings" "time" @@ -25,7 +28,8 @@ import ( const usage = `usage: shared [arguments] commands: - deploy [dir] [--name NAME] [--server URL] deploy a site directory + deploy [dir] [--name NAME] [--server URL] [--force] + deploy a site directory list [--server URL] list deployed sites open NAME [--server URL] print and open a site URL rm NAME [--server URL] delete a site and its data @@ -81,6 +85,7 @@ func cmdDeploy(args []string) { fs := flag.NewFlagSet("deploy", flag.ExitOnError) name := fs.String("name", "", "site name (default: directory base name)") server := fs.String("server", defaultServer(), "shared server URL") + force := fs.Bool("force", false, "overwrite without checking who deployed last") fs.Parse(args) dir := "." @@ -109,24 +114,172 @@ func cmdDeploy(args []string) { fatal("packing %s: %v", dir, err) } + deployer := deployerIdentity() + prevSeq := loadDeployState(abs, *name, *server) + + // With no local deploy state (fresh checkout, new machine) the server + // cannot check for us, so ask it who deployed last and warn if it wasn't + // this identity. + if prevSeq == 0 && !*force { + if cur := currentDeploy(*server, *name); cur != nil && cur.Deployer != "" && cur.Deployer != deployer { + if !confirm(fmt.Sprintf("%s was last deployed by %s at %s — overwrite?", *name, cur.Deployer, cur.Time)) { + fatal("deploy cancelled") + } + } + } + endpoint := strings.TrimRight(*server, "/") + "/api/deploy?site=" + url.QueryEscape(*name) - resp, err := http.Post(endpoint, "application/gzip", tarball) + data := tarball.Bytes() + status, body := postDeploy(endpoint, data, deployer, prevSeq, *force) + + if status == http.StatusConflict { + var c struct { + Deployer string `json:"deployer"` + Time string `json:"time"` + } + json.Unmarshal(body, &c) + if c.Deployer == "" { + c.Deployer = "someone else" + } + if c.Time == "" { + c.Time = "an unknown time" + } + if !confirm(fmt.Sprintf("%s was deployed by %s at %s since your last deploy — overwrite?", *name, c.Deployer, c.Time)) { + fatal("deploy cancelled") + } + status, body = postDeploy(endpoint, data, deployer, prevSeq, true) + } + + if status < 200 || status > 299 { + fatal("deploy failed: %s", serverError(body, http.StatusText(status))) + } + var out struct { + URL string `json:"url"` + Version int64 `json:"version"` + } + if err := json.Unmarshal(body, &out); err != nil || out.URL == "" { + fatal("unexpected response: %s", strings.TrimSpace(string(body))) + } + if out.Version > 0 { + saveDeployState(abs, *name, *server, out.Version) + } + fmt.Println(out.URL) +} + +func postDeploy(endpoint string, data []byte, deployer string, prevSeq int64, force bool) (int, []byte) { + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + fatal("%v", err) + } + req.Header.Set("Content-Type", "application/gzip") + if deployer != "" { + req.Header.Set("X-Shared-Deployer", deployer) + } + if prevSeq > 0 { + req.Header.Set("X-Shared-Prev-Version", strconv.FormatInt(prevSeq, 10)) + } + if force { + req.Header.Set("X-Shared-Force", "1") + } + resp, err := http.DefaultClient.Do(req) if err != nil { fatal("%v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, body +} - if resp.StatusCode < 200 || resp.StatusCode > 299 { - fatal("deploy failed: %s", serverError(body, resp.Status)) +func deployerIdentity() string { + name := os.Getenv("USER") + if name == "" { + if u, err := user.Current(); err == nil { + name = u.Username + } + } + if name == "" { + name = "unknown" + } + host, err := os.Hostname() + if err != nil || host == "" { + host = "unknown" + } + return name + "@" + host +} + +type deployInfo struct { + Seq int64 `json:"seq"` + Time string `json:"time"` + Deployer string `json:"deployer"` + Source string `json:"source"` +} + +func currentDeploy(server, name string) *deployInfo { + resp, err := http.Get(strings.TrimRight(server, "/") + "/api/versions?site=" + url.QueryEscape(name)) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil } var out struct { - URL string `json:"url"` + Current *deployInfo `json:"current"` } - if err := json.Unmarshal(body, &out); err != nil || out.URL == "" { - fatal("unexpected response: %s", strings.TrimSpace(string(body))) + if json.NewDecoder(resp.Body).Decode(&out) != nil { + return nil } - fmt.Println(out.URL) + return out.Current +} + +func confirm(prompt string) bool { + fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + line = strings.ToLower(strings.TrimSpace(line)) + return line == "y" || line == "yes" +} + +type deployState struct { + Deploys map[string]int64 `json:"deploys"` +} + +func stateFile(dir string) string { + return filepath.Join(dir, ".shared", "state.json") +} + +func stateKey(name, server string) string { + return name + "@" + strings.TrimRight(server, "/") +} + +func loadDeployState(dir, name, server string) int64 { + data, err := os.ReadFile(stateFile(dir)) + if err != nil { + return 0 + } + var st deployState + if json.Unmarshal(data, &st) != nil { + return 0 + } + return st.Deploys[stateKey(name, server)] +} + +func saveDeployState(dir, name, server string, seq int64) { + st := deployState{} + if data, err := os.ReadFile(stateFile(dir)); err == nil { + json.Unmarshal(data, &st) + } + if st.Deploys == nil { + st.Deploys = map[string]int64{} + } + st.Deploys[stateKey(name, server)] = seq + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(stateFile(dir)), 0o755); err != nil { + return + } + os.WriteFile(stateFile(dir), b, 0o644) } func buildTarball(root string) (*bytes.Buffer, error) { @@ -239,6 +392,8 @@ func cmdList(args []string) { Name string `json:"name"` UpdatedAt string `json:"updatedAt"` Bytes int64 `json:"bytes"` + Views int64 `json:"views"` + Deployer string `json:"deployer"` } `json:"sites"` } if err := json.Unmarshal(body, &out); err != nil { @@ -249,7 +404,11 @@ func cmdList(args []string) { return } for _, site := range out.Sites { - fmt.Printf("%s\t%s\t%s\n", site.Name, humanSize(site.Bytes), site.UpdatedAt) + deployer := site.Deployer + if deployer == "" { + deployer = "-" + } + fmt.Printf("%s\t%s\t%d views\t%s\t%s\n", site.Name, humanSize(site.Bytes), site.Views, deployer, site.UpdatedAt) } } @@ -330,7 +489,12 @@ func cmdRollback(args []string) { fs.Parse(fs.Args()[1:]) endpoint := strings.TrimRight(*server, "/") + "/api/rollback?site=" + url.QueryEscape(name) - resp, err := http.Post(endpoint, "", nil) + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + fatal("%v", err) + } + req.Header.Set("X-Shared-Deployer", deployerIdentity()) + resp, err := http.DefaultClient.Do(req) if err != nil { fatal("%v", err) } @@ -372,6 +536,7 @@ func cmdVersions(args []string) { fatal("versions failed: %s", serverError(body, resp.Status)) } var out struct { + Current *deployInfo `json:"current"` Versions []struct { Timestamp int64 `json:"timestamp"` } `json:"versions"` @@ -379,6 +544,13 @@ func cmdVersions(args []string) { if err := json.Unmarshal(body, &out); err != nil { fatal("unexpected response: %s", strings.TrimSpace(string(body))) } + if out.Current != nil { + deployer := out.Current.Deployer + if deployer == "" { + deployer = "unknown" + } + fmt.Printf("current\tv%d\t%s (%s)\t%s\n", out.Current.Seq, deployer, out.Current.Source, out.Current.Time) + } if len(out.Versions) == 0 { fmt.Println("(none)") return diff --git a/internal/server/deploy.go b/internal/server/deploy.go index c6eac5f..fb9ad2e 100644 --- a/internal/server/deploy.go +++ b/internal/server/deploy.go @@ -32,6 +32,24 @@ func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "invalid site name") return } + deployer := sanitizeDeployer(r.Header.Get("X-Shared-Deployer")) + + // Optimistic concurrency: a client that knows which version it is + // replacing sends it in X-Shared-Prev-Version; if the site has moved on + // since, refuse with the conflicting deploy's details so the client can + // warn before overwriting. Clients that send no version (curl, older + // CLIs) are not checked. + if prev := r.Header.Get("X-Shared-Prev-Version"); prev != "" && r.Header.Get("X-Shared-Force") != "1" { + if cur := s.meta.current(site); cur != nil && prev != strconv.FormatInt(cur.Seq, 10) { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": "version conflict", + "version": cur.Seq, + "deployer": cur.Deployer, + "time": cur.Time, + }) + return + } + } tmpDir, err := os.MkdirTemp(s.DataDir, "deploy-*") if err != nil { @@ -88,9 +106,11 @@ func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { } } - writeJSON(w, http.StatusOK, map[string]string{ - "site": site, - "url": s.siteURL(site), + seq := s.meta.record(site, deployer, "deploy") + writeJSON(w, http.StatusOK, map[string]any{ + "site": site, + "url": s.siteURL(site), + "version": seq, }) } @@ -137,9 +157,11 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { } s.pruneVersions(site) - writeJSON(w, http.StatusOK, map[string]string{ - "site": site, - "url": s.siteURL(site), + seq := s.meta.record(site, sanitizeDeployer(r.Header.Get("X-Shared-Deployer")), "rollback") + writeJSON(w, http.StatusOK, map[string]any{ + "site": site, + "url": s.siteURL(site), + "version": seq, }) } @@ -159,7 +181,11 @@ func (s *Server) handleVersions(w http.ResponseWriter, r *http.Request) { ts, _ := versionKey(names[i]) versions = append(versions, map[string]any{"timestamp": ts}) } - writeJSON(w, http.StatusOK, map[string]any{"versions": versions}) + out := map[string]any{"versions": versions} + if cur := s.meta.current(site); cur != nil { + out["current"] = cur + } + writeJSON(w, http.StatusOK, out) } func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) { @@ -174,6 +200,7 @@ func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) { return } s.store.DropSite(name) + s.meta.drop(name) for _, p := range []string{ siteDir, filepath.Join(s.DataDir, "db", name), @@ -387,11 +414,18 @@ func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) { if info, err := e.Info(); err == nil { updatedAt = info.ModTime().UTC().Format(time.RFC3339) } - sites = append(sites, map[string]any{ + entry := map[string]any{ "name": e.Name(), "updatedAt": updatedAt, "bytes": s.siteBytes(e.Name()), - }) + } + views, current, deploys := s.meta.stats(e.Name()) + entry["views"] = views.Total + if current != nil { + entry["deployer"] = current.Deployer + entry["deploys"] = deploys + } + sites = append(sites, entry) } sort.Slice(sites, func(i, j int) bool { return sites[i]["name"].(string) < sites[j]["name"].(string) diff --git a/internal/server/meta.go b/internal/server/meta.go new file mode 100644 index 0000000..187f79e --- /dev/null +++ b/internal/server/meta.go @@ -0,0 +1,239 @@ +package server + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + maxDeployHistory = 50 + maxDailyViewDays = 30 + viewFlushEvery = 30 * time.Second +) + +type deployRecord struct { + Seq int64 `json:"seq"` + Time string `json:"time"` + Deployer string `json:"deployer,omitempty"` + Source string `json:"source"` +} + +type viewStats struct { + Total int64 `json:"total"` + LastVisit string `json:"lastVisit,omitempty"` + Daily map[string]int64 `json:"daily,omitempty"` +} + +type siteMeta struct { + Deploys []deployRecord `json:"deploys"` + Views viewStats `json:"views"` +} + +type pendingViews struct { + count int64 + last time.Time +} + +// metaStore persists per-site metadata (deploy history, view counts) as one +// JSON file per site under /meta. Page views are accumulated in +// memory and flushed periodically so serving traffic never writes to disk. +type metaStore struct { + mu sync.Mutex + dir string + cache map[string]*siteMeta + views map[string]*pendingViews +} + +func newMetaStore(dir string) (*metaStore, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + m := &metaStore{ + dir: dir, + cache: map[string]*siteMeta{}, + views: map[string]*pendingViews{}, + } + go m.flushLoop() + return m, nil +} + +func (m *metaStore) path(site string) string { + return filepath.Join(m.dir, site+".json") +} + +// load returns the cached meta for a site, reading it from disk on first use. +// Callers must hold m.mu. +func (m *metaStore) load(site string) *siteMeta { + if sm, ok := m.cache[site]; ok { + return sm + } + sm := &siteMeta{} + if data, err := os.ReadFile(m.path(site)); err == nil { + if err := json.Unmarshal(data, sm); err != nil { + log.Printf("meta: corrupt %s, starting fresh: %v", m.path(site), err) + sm = &siteMeta{} + } + } else if !errors.Is(err, os.ErrNotExist) { + log.Printf("meta: could not read %s: %v", m.path(site), err) + } + m.cache[site] = sm + return sm +} + +// Callers must hold m.mu. +func (m *metaStore) persist(site string) { + sm := m.cache[site] + b, err := json.MarshalIndent(sm, "", " ") + if err != nil { + log.Printf("meta: could not encode %s: %v", site, err) + return + } + tmp, err := os.CreateTemp(m.dir, "."+site+"-*.tmp") + if err != nil { + log.Printf("meta: could not write %s: %v", site, err) + return + } + if _, err := tmp.Write(b); err == nil { + err = tmp.Sync() + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err == nil { + err = os.Rename(tmp.Name(), m.path(site)) + } + if err != nil { + os.Remove(tmp.Name()) + log.Printf("meta: could not write %s: %v", site, err) + } +} + +// current returns the most recent deploy record, or nil if none. +func (m *metaStore) current(site string) *deployRecord { + m.mu.Lock() + defer m.mu.Unlock() + sm := m.load(site) + if len(sm.Deploys) == 0 { + return nil + } + rec := sm.Deploys[len(sm.Deploys)-1] + return &rec +} + +// record appends a deploy record and returns its sequence number. +func (m *metaStore) record(site, deployer, source string) int64 { + m.mu.Lock() + defer m.mu.Unlock() + sm := m.load(site) + seq := int64(1) + if n := len(sm.Deploys); n > 0 { + seq = sm.Deploys[n-1].Seq + 1 + } + sm.Deploys = append(sm.Deploys, deployRecord{ + Seq: seq, + Time: time.Now().UTC().Format(time.RFC3339), + Deployer: deployer, + Source: source, + }) + if len(sm.Deploys) > maxDeployHistory { + sm.Deploys = sm.Deploys[len(sm.Deploys)-maxDeployHistory:] + } + m.persist(site) + return seq +} + +func (m *metaStore) stats(site string) (views viewStats, current *deployRecord, deploys int64) { + m.mu.Lock() + defer m.mu.Unlock() + sm := m.load(site) + views = sm.Views + if p := m.views[site]; p != nil { + views.Total += p.count + } + if n := len(sm.Deploys); n > 0 { + rec := sm.Deploys[n-1] + current = &rec + deploys = rec.Seq + } + return views, current, deploys +} + +func (m *metaStore) drop(site string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.cache, site) + delete(m.views, site) + if err := os.Remove(m.path(site)); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("meta: could not remove %s: %v", m.path(site), err) + } +} + +// countView records a page view in memory; flushLoop persists it later. +func (m *metaStore) countView(site string) { + m.mu.Lock() + defer m.mu.Unlock() + p := m.views[site] + if p == nil { + p = &pendingViews{} + m.views[site] = p + } + p.count++ + p.last = time.Now() +} + +func (m *metaStore) flushLoop() { + for range time.Tick(viewFlushEvery) { + m.flushViews() + } +} + +func (m *metaStore) flushViews() { + m.mu.Lock() + defer m.mu.Unlock() + for site, p := range m.views { + if p.count == 0 { + continue + } + sm := m.load(site) + sm.Views.Total += p.count + sm.Views.LastVisit = p.last.UTC().Format(time.RFC3339) + if sm.Views.Daily == nil { + sm.Views.Daily = map[string]int64{} + } + sm.Views.Daily[p.last.UTC().Format("2006-01-02")] += p.count + pruneDaily(sm.Views.Daily, p.last) + m.persist(site) + delete(m.views, site) + } +} + +func pruneDaily(daily map[string]int64, now time.Time) { + cutoff := now.UTC().AddDate(0, 0, -maxDailyViewDays).Format("2006-01-02") + for day := range daily { + if day < cutoff { + delete(daily, day) + } + } +} + +// sanitizeDeployer trims the client-supplied deployer identity to something +// safe to store and echo back: printable characters, bounded length. +func sanitizeDeployer(s string) string { + s = strings.TrimSpace(s) + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, s) + if len(s) > 120 { + s = s[:120] + } + return s +} diff --git a/internal/server/server.go b/internal/server/server.go index d924e21..5b11107 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,6 +15,7 @@ type Server struct { KeepVersions int store *store.Store + meta *metaStore hub *Hub api *http.ServeMux ai *limiter @@ -30,12 +31,17 @@ func New(addr, dataDir, baseHost string, keepVersions int) (*Server, error) { if err != nil { return nil, err } + meta, err := newMetaStore(filepath.Join(dataDir, "meta")) + if err != nil { + return nil, err + } s := &Server{ Addr: addr, DataDir: dataDir, BaseHost: baseHost, KeepVersions: keepVersions, store: st, + meta: meta, hub: NewHub(), api: http.NewServeMux(), ai: newAILimiter(), diff --git a/internal/server/static.go b/internal/server/static.go index 464b179..e2727bc 100644 --- a/internal/server/static.go +++ b/internal/server/static.go @@ -102,6 +102,10 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { return } defer f.Close() + // Count page views, not asset fetches: only GETs of HTML documents. + if r.Method == http.MethodGet && strings.HasSuffix(target, ".html") { + s.meta.countView(site) + } http.ServeContent(w, r, target, info.ModTime(), f) } diff --git a/internal/web/home.html b/internal/web/home.html index b0fef98..1f55913 100644 --- a/internal/web/home.html +++ b/internal/web/home.html @@ -200,6 +200,9 @@

Getting started

const meta = document.createElement('div'); meta.className = 'meta'; const parts = [humanSize(site.bytes)]; + if (site.views) { + parts.push(site.views + (site.views === 1 ? ' view' : ' views')); + } if (site.updatedAt) { parts.push('updated ' + new Date(site.updatedAt).toLocaleString()); } diff --git a/internal/web/init/SKILL.md b/internal/web/init/SKILL.md index 65475b4..0553ae3 100644 --- a/internal/web/init/SKILL.md +++ b/internal/web/init/SKILL.md @@ -97,6 +97,11 @@ tarball and POSTs it. The site goes live immediately at `--name` defaults to the lowercased directory base name; `--server` overrides the target (default `http://localhost:8787`, or `$SHARED_SERVER`). +Deploys are attributed (`user@hostname`) and guarded against overwriting +someone else's deploy: if the site changed since your last deploy, the CLI +asks before overwriting. Non-interactive runs get "deploy cancelled" — +re-run with `--force` if overwriting is intended. + Data is scoped strictly by the first label of the request Host, so one site cannot reach another's db/uploads/ws. Site names must match `^[a-z0-9][a-z0-9-]{0,62}$`. @@ -104,7 +109,7 @@ cannot reach another's db/uploads/ws. Site names must match ## Managing sites ```sh -shared list # deployed sites with size and last-updated time +shared list # deployed sites with size, views, last deployer shared open mysite # print + open the site URL shared versions mysite # saved prior deploys, newest first shared rollback mysite # swap in the newest version (reversible) From 79b226a8aedf47589353e347a8eb6fc6e89e88fe Mon Sep 17 00:00:00 2001 From: Spencer Delcore Date: Tue, 14 Jul 2026 19:45:26 -0400 Subject: [PATCH 2/2] deploy: prefer git email for deployer identity --- README.md | 7 ++++--- cmd/shared/main.go | 16 ++++++++++++---- internal/web/init/SKILL.md | 3 ++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f939f22..78dd6d5 100644 --- a/README.md +++ b/README.md @@ -357,9 +357,10 @@ evicts its in-memory collection state, closing any open subscriptions. ## Deploy attribution and overwrite protection Every deploy and rollback is recorded in `meta/.json` with a sequence -number, timestamp, and deployer identity (`user@hostname`, sent by the CLI as -`X-Shared-Deployer`). `shared list` and `shared versions` show who deployed -last. +number, timestamp, and deployer identity, sent by the CLI as +`X-Shared-Deployer`: the git email configured for the deployed directory plus +the machine, e.g. `you@example.com (user@hostname)`, or just `user@hostname` +without git. `shared list` and `shared versions` show who deployed last. Deploys are also guarded against accidental overwrites, git `--force-with-lease` style: the CLI remembers the version it last deployed diff --git a/cmd/shared/main.go b/cmd/shared/main.go index 7adc84a..c91d501 100644 --- a/cmd/shared/main.go +++ b/cmd/shared/main.go @@ -114,7 +114,7 @@ func cmdDeploy(args []string) { fatal("packing %s: %v", dir, err) } - deployer := deployerIdentity() + deployer := deployerIdentity(abs) prevSeq := loadDeployState(abs, *name, *server) // With no local deploy state (fresh checkout, new machine) the server @@ -190,7 +190,9 @@ func postDeploy(endpoint string, data []byte, deployer string, prevSeq int64, fo return resp.StatusCode, body } -func deployerIdentity() string { +// deployerIdentity is the git email configured for dir (repo-local config +// wins) plus the machine's user@host, or just user@host without git. +func deployerIdentity(dir string) string { name := os.Getenv("USER") if name == "" { if u, err := user.Current(); err == nil { @@ -204,7 +206,13 @@ func deployerIdentity() string { if err != nil || host == "" { host = "unknown" } - return name + "@" + host + id := name + "@" + host + if out, err := exec.Command("git", "-C", dir, "config", "user.email").Output(); err == nil { + if email := strings.TrimSpace(string(out)); email != "" { + return email + " (" + id + ")" + } + } + return id } type deployInfo struct { @@ -493,7 +501,7 @@ func cmdRollback(args []string) { if err != nil { fatal("%v", err) } - req.Header.Set("X-Shared-Deployer", deployerIdentity()) + req.Header.Set("X-Shared-Deployer", deployerIdentity(".")) resp, err := http.DefaultClient.Do(req) if err != nil { fatal("%v", err) diff --git a/internal/web/init/SKILL.md b/internal/web/init/SKILL.md index 0553ae3..b2dfc84 100644 --- a/internal/web/init/SKILL.md +++ b/internal/web/init/SKILL.md @@ -97,7 +97,8 @@ tarball and POSTs it. The site goes live immediately at `--name` defaults to the lowercased directory base name; `--server` overrides the target (default `http://localhost:8787`, or `$SHARED_SERVER`). -Deploys are attributed (`user@hostname`) and guarded against overwriting +Deploys are attributed (git email if configured, plus `user@hostname`) and +guarded against overwriting someone else's deploy: if the site changed since your last deploy, the CLI asks before overwriting. Non-interactive runs get "deploy cancelled" — re-run with `--force` if overwriting is intended.