diff --git a/cmd/disttown/changelog.go b/cmd/disttown/changelog.go
index da24de3..2961709 100644
--- a/cmd/disttown/changelog.go
+++ b/cmd/disttown/changelog.go
@@ -28,7 +28,7 @@ var cmdChangelog = &cli.Command{
Usage: "attach or update release notes for a published version",
ArgsUsage: "",
Flags: []cli.Flag{
- &cli.StringFlag{Name: "project", Usage: "project name", Required: true},
+ &cli.StringFlag{Name: "project", Usage: "project name (default: the governing manifest's)"},
&cli.StringFlag{Name: "file", Usage: "markdown file with the notes (plaintext fallback is derived)", Required: true},
&cli.StringFlag{Name: "title", Usage: "document title (default: \" \")"},
&cli.StringFlag{Name: "site", Usage: "document site (default: the project's publication, else its dist.town page)"},
@@ -42,7 +42,7 @@ var cmdChangelog = &cli.Command{
Usage: "adopt an existing document as a release's changelog — appends the release link, changes nothing else",
ArgsUsage: "",
Flags: []cli.Flag{
- &cli.StringFlag{Name: "project", Usage: "project name", Required: true},
+ &cli.StringFlag{Name: "project", Usage: "project name (default: the governing manifest's)"},
&cli.StringFlag{Name: "doc", Usage: "the document to adopt: an rkey or at-uri in your own repo", Required: true},
},
Action: runChangelogLink,
@@ -52,7 +52,7 @@ var cmdChangelog = &cli.Command{
Usage: "detach a document from a release — removes the release link, changes nothing else",
ArgsUsage: "",
Flags: []cli.Flag{
- &cli.StringFlag{Name: "project", Usage: "project name", Required: true},
+ &cli.StringFlag{Name: "project", Usage: "project name (default: the governing manifest's)"},
&cli.StringFlag{Name: "doc", Usage: "the document to detach: an rkey or at-uri in your own repo", Required: true},
},
Action: runChangelogUnlink,
@@ -66,10 +66,13 @@ func runChangelogUnlink(c *cli.Context) error {
return fmt.Errorf("usage: disttown changelog unlink ")
}
version := c.Args().First()
- project := canon.NormalizeName(c.String("project"))
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectFlagOrManifest(c, "disttown changelog unlink --project ")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
s, err := atsession.Get(ctx)
if err != nil {
@@ -123,10 +126,13 @@ func runChangelogLink(c *cli.Context) error {
return fmt.Errorf("usage: disttown changelog link ")
}
version := c.Args().First()
- project := canon.NormalizeName(c.String("project"))
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectFlagOrManifest(c, "disttown changelog link --project ")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
s, err := atsession.Get(ctx)
if err != nil {
@@ -200,10 +206,13 @@ func runChangelogSet(c *cli.Context) error {
return fmt.Errorf("usage: disttown changelog set ")
}
version := c.Args().First()
- project := canon.NormalizeName(c.String("project"))
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectFlagOrManifest(c, "disttown changelog set --project ")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
md, err := os.ReadFile(c.String("file"))
if err != nil {
return err
diff --git a/cmd/disttown/doctor.go b/cmd/disttown/doctor.go
index 3bc36be..71b68ef 100644
--- a/cmd/disttown/doctor.go
+++ b/cmd/disttown/doctor.go
@@ -37,6 +37,11 @@ func runDoctor(c *cli.Context) error {
if err := canon.ValidateName(project); err != nil {
return err
}
+ } else if m, err := currentManifest(); err != nil {
+ return err
+ } else if m != nil {
+ project = m.Project
+ fmt.Println("project " + m.Project + " (from " + manifestRef(m) + ")")
}
ctx := c.Context
diff --git a/cmd/disttown/history.go b/cmd/disttown/history.go
index e00e580..33a9e45 100644
--- a/cmd/disttown/history.go
+++ b/cmd/disttown/history.go
@@ -70,19 +70,15 @@ type historyEnvelope struct {
var cmdReleaseHistory = &cli.Command{
Name: "history",
Usage: "print a release's full lifecycle timeline — every statement, reasons intact, the governing one marked",
- ArgsUsage: " ",
+ ArgsUsage: "[] ",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "emit machine-readable JSON (additive-only compatibility surface)"},
},
Action: func(c *cli.Context) error {
- if c.Args().Len() != 2 {
- return fmt.Errorf("usage: disttown release history ")
- }
- project := canon.NormalizeName(c.Args().Get(0))
- if err := canon.ValidateName(project); err != nil {
+ project, version, note, err := projectVersionArgs(c, "disttown release history [] ")
+ if err != nil {
return err
}
- version := c.Args().Get(1)
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
@@ -97,6 +93,9 @@ var cmdReleaseHistory = &cli.Command{
return fmt.Errorf("no release of %s claims version %q", project, version)
}
subject := atURI(s.DID, canon.NSIDRelease, rkey)
+ if note != "" && !c.Bool("json") {
+ fmt.Println(note)
+ }
return printHistory(c, s, subject, fmt.Sprintf("%s@%s", project, version))
},
}
@@ -104,18 +103,18 @@ var cmdReleaseHistory = &cli.Command{
var cmdProjectHistory = &cli.Command{
Name: "history",
Usage: "print a project's full lifecycle timeline — every statement, reasons intact, the governing one marked",
- ArgsUsage: "",
+ ArgsUsage: "[]",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "emit machine-readable JSON (additive-only compatibility surface)"},
},
Action: func(c *cli.Context) error {
- if c.Args().Len() != 1 {
- return fmt.Errorf("usage: disttown project history ")
- }
- project := canon.NormalizeName(c.Args().First())
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectNameArg(c, "disttown project history []")
+ if err != nil {
return err
}
+ if note != "" && !c.Bool("json") {
+ fmt.Println(note)
+ }
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
diff --git a/cmd/disttown/init.go b/cmd/disttown/init.go
new file mode 100644
index 0000000..93c90ef
--- /dev/null
+++ b/cmd/disttown/init.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ cli "github.com/urfave/cli/v2"
+ "golang.org/x/term"
+
+ "dist.town/internal/canon"
+ "dist.town/internal/cliconf"
+)
+
+// init writes the manifest and sequences the first run (design D8):
+// propose, detect, ask, write, point at the next command. It creates
+// state; doctor diagnoses it — the two compose instead of overlapping.
+var cmdInit = &cli.Command{
+ Name: "init",
+ Usage: "write this repo's disttown.jsonc — the committed facts publish infers from",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "project", Usage: "project name (default: proposed from the directory name)"},
+ &cli.StringFlag{Name: "storage", Usage: `storage choice: a profile name, "blobs", or empty to decide later`},
+ &cli.BoolFlag{Name: "yes", Usage: "accept the proposals without prompting"},
+ },
+ Action: runInit,
+}
+
+// ask prompts with a proposal and returns the answer, the proposal
+// standing on empty input.
+func ask(prompt, proposal string) string {
+ if proposal != "" {
+ fmt.Printf("%s [%s]: ", prompt, proposal)
+ } else {
+ fmt.Printf("%s: ", prompt)
+ }
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+ if err != nil {
+ return proposal
+ }
+ if answer := strings.TrimSpace(line); answer != "" {
+ return answer
+ }
+ return proposal
+}
+
+func runInit(c *cli.Context) error {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ // Same-directory refusal only: a sub-project init inside a
+ // governed monorepo is the walk-up design working as intended.
+ if _, err := os.Stat(filepath.Join(cwd, manifestName)); err == nil {
+ return fmt.Errorf("%s already exists here — edit it directly", manifestName)
+ }
+
+ interactive := term.IsTerminal(int(os.Stdin.Fd())) && !c.Bool("yes")
+
+ project := c.String("project")
+ if project == "" {
+ project = canon.NormalizeName(filepath.Base(cwd))
+ }
+ if interactive {
+ project = canon.NormalizeName(ask("project name (colons nest, e.g. app:cli)", project))
+ }
+ if err := canon.ValidateName(project); err != nil {
+ return err
+ }
+
+ // Detect the artifact convention: dist/ is the default the build
+ // scripts around here already follow.
+ artifactsGlob := ""
+ if info, err := os.Stat(filepath.Join(cwd, "dist")); err == nil && info.IsDir() {
+ artifactsGlob = "dist/*"
+ }
+
+ storage := c.String("storage")
+ if interactive && !c.IsSet("storage") {
+ storage = ask(`storage: a profile name, "blobs" (bytes on your PDS), or empty to decide later`, storage)
+ }
+
+ // The expected publisher seeds from the current login when one
+ // exists — the guard's baseline, captured at the moment it is
+ // known true.
+ publisher := ""
+ if ident, err := currentIdentity(); err == nil {
+ publisher = ident.DID
+ }
+
+ var b strings.Builder
+ b.WriteString("{\n")
+ fmt.Fprintf(&b, " \"$schema\": %q,\n", manifestSchemaURL)
+ b.WriteString(" // The project this repo publishes. Colons nest: \"app:cli\".\n")
+ fmt.Fprintf(&b, " \"project\": %q,\n", project)
+ if publisher != "" {
+ b.WriteString(" // The DID expected to publish. A session under any other\n")
+ b.WriteString(" // identity is warned before a byte moves.\n")
+ fmt.Fprintf(&b, " \"publisher\": %q,\n", publisher)
+ }
+ b.WriteString(" // What each release contains. Globs resolve inside this repo\n")
+ b.WriteString(" // only; entries can be objects for per-entry storage:\n")
+ b.WriteString(" // { \"glob\": \"big/*\", \"storage\": \"cdn\" }\n")
+ if artifactsGlob != "" {
+ fmt.Fprintf(&b, " \"artifacts\": [\n %q,\n ],\n", artifactsGlob)
+ } else {
+ b.WriteString(" \"artifacts\": [],\n")
+ }
+ if storage != "" {
+ b.WriteString(" // A profile from `disttown storage setup --profile `,\n")
+ b.WriteString(" // or \"blobs\" to store bytes on your own PDS.\n")
+ fmt.Fprintf(&b, " \"storage\": %q,\n", storage)
+ }
+ b.WriteString("}\n")
+
+ path := filepath.Join(cwd, manifestName)
+ if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
+ return err
+ }
+ // The scaffold must parse by our own rules — refuse to leave a
+ // broken file behind.
+ if _, err := loadManifest(path); err != nil {
+ os.Remove(path)
+ return fmt.Errorf("scaffold failed validation (nothing written): %w", err)
+ }
+
+ fmt.Printf("wrote %s\n", manifestName)
+ fmt.Printf(" project: %s\n", project)
+ if artifactsGlob != "" {
+ fmt.Printf(" artifacts: %s (detected)\n", artifactsGlob)
+ } else {
+ fmt.Println(" artifacts: none yet — add globs once your build lands files")
+ }
+ switch {
+ case storage == "":
+ fmt.Println(" storage: undecided — name a profile or \"blobs\" before the first bucket publish")
+ case storage == cliconf.BlobsProfile:
+ fmt.Println(" storage: blobs (bytes go to your own PDS)")
+ default:
+ fmt.Printf(" storage: profile %q — define it with `disttown storage setup --profile %s` if you haven't\n", storage, storage)
+ }
+ if publisher == "" {
+ fmt.Println("next: `disttown login`, then `disttown doctor` to check publish-readiness")
+ } else {
+ fmt.Printf(" publisher: %s (your current login)\n", publisher)
+ fmt.Println("next: `disttown doctor` to check publish-readiness")
+ }
+ return nil
+}
diff --git a/cmd/disttown/lifecycle.go b/cmd/disttown/lifecycle.go
index e495513..3887a3d 100644
--- a/cmd/disttown/lifecycle.go
+++ b/cmd/disttown/lifecycle.go
@@ -61,7 +61,7 @@ func releaseLifecycleCmd(name, usage, status string, withMoves bool) *cli.Comman
return &cli.Command{
Name: name,
Usage: usage,
- ArgsUsage: " ",
+ ArgsUsage: "[] ",
Flags: flags,
Action: func(c *cli.Context) error {
return runReleaseLifecycle(c, name, status, withMoves)
@@ -81,14 +81,13 @@ var (
)
func runReleaseLifecycle(c *cli.Context, verb, status string, withMoves bool) error {
- if c.Args().Len() != 2 {
- return fmt.Errorf("usage: disttown release %s ", verb)
- }
- project := canon.NormalizeName(c.Args().Get(0))
- if err := canon.ValidateName(project); err != nil {
+ project, version, note, err := projectVersionArgs(c, fmt.Sprintf("disttown release %s [] ", verb))
+ if err != nil {
return err
}
- version := c.Args().Get(1)
+ if note != "" {
+ fmt.Println(note)
+ }
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
@@ -147,16 +146,16 @@ func projectLifecycleCmd(name, usage, status string) *cli.Command {
return &cli.Command{
Name: name,
Usage: usage,
- ArgsUsage: "",
+ ArgsUsage: "[]",
Flags: reasonFlags(),
Action: func(c *cli.Context) error {
- if c.Args().Len() != 1 {
- return fmt.Errorf("usage: disttown project %s ", name)
- }
- project := canon.NormalizeName(c.Args().First())
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectNameArg(c, fmt.Sprintf("disttown project %s []", name))
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
diff --git a/cmd/disttown/main.go b/cmd/disttown/main.go
index 73f5014..eda884c 100644
--- a/cmd/disttown/main.go
+++ b/cmd/disttown/main.go
@@ -24,6 +24,7 @@ var version = "dev"
// The generator itself joins only at app construction (it walks
// this slice, so listing it here would cycle).
var commands = []*cli.Command{
+ cmdInit,
cmdLogin,
cmdWhoami,
cmdLogout,
diff --git a/cmd/disttown/manifest.go b/cmd/disttown/manifest.go
new file mode 100644
index 0000000..c5db71a
--- /dev/null
+++ b/cmd/disttown/manifest.go
@@ -0,0 +1,317 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tailscale/hujson"
+ cli "github.com/urfave/cli/v2"
+
+ "dist.town/internal/canon"
+)
+
+// manifestName is the committed repo manifest: JSON data model, JWCC
+// syntax (comments and trailing commas), honestly labeled by its
+// extension so editors light up jsonc mode by default (design D1).
+const manifestName = "disttown.jsonc"
+
+// manifestSchemaURL is the published JSON Schema the init scaffold
+// references; editors that fetch it get completion and validation.
+const manifestSchemaURL = "https://dist.town/disttown.schema.json"
+
+// Manifest is the committed repo-facts file. It describes what a
+// release record will contain and who is expected to publish it —
+// never how the machine uploads (storage definitions live in cliconf;
+// the manifest only names a profile, design D4).
+type Manifest struct {
+ Schema string `json:"$schema,omitempty"`
+ Project string `json:"project"`
+ Publisher string `json:"publisher,omitempty"`
+ DerivedFrom string `json:"derivedFrom,omitempty"`
+ Artifacts []ManifestEntry `json:"artifacts,omitempty"`
+ Storage string `json:"storage,omitempty"`
+
+ // Dir is the directory the manifest was found in — the containment
+ // root for glob resolution. Not part of the file.
+ Dir string `json:"-"`
+ // Path is the manifest file itself, for messages and the adopt
+ // flow's rewrite.
+ Path string `json:"-"`
+}
+
+// ManifestEntry is one artifact entry: a plain string is sugar for a
+// glob; an object carries per-entry storage. The vocabulary mirrors
+// the release record's storage union, so future kinds (a size
+// predicate, an OCI reference template) are additive — and unknown
+// kinds refuse loudly rather than silently publishing nothing
+// (design D2).
+type ManifestEntry struct {
+ Glob string `json:"glob"`
+ Storage string `json:"storage,omitempty"`
+}
+
+func (e *ManifestEntry) UnmarshalJSON(b []byte) error {
+ var s string
+ if err := json.Unmarshal(b, &s); err == nil {
+ e.Glob = s
+ return nil
+ }
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(b, &raw); err != nil {
+ return fmt.Errorf("artifact entry must be a glob string or an object: %s", string(b))
+ }
+ type entry ManifestEntry // no recursion
+ var known entry
+ if err := json.Unmarshal(b, &known); err != nil {
+ return err
+ }
+ *e = ManifestEntry(known)
+ for k := range raw {
+ switch k {
+ case "glob", "storage":
+ default:
+ return fmt.Errorf("artifact entry has unknown kind %q — this version of disttown does not understand it (refusing rather than publishing the wrong thing)", k)
+ }
+ }
+ if e.Glob == "" {
+ return fmt.Errorf("artifact entry %s has no glob", string(b))
+ }
+ return nil
+}
+
+// findManifest walks up from dir looking for disttown.jsonc, nearest
+// wins — monorepo sub-projects get their own context for free
+// (design D1). Returns "" without error when no manifest governs.
+func findManifest(dir string) (string, error) {
+ dir, err := filepath.Abs(dir)
+ if err != nil {
+ return "", err
+ }
+ for {
+ p := filepath.Join(dir, manifestName)
+ if info, err := os.Stat(p); err == nil && !info.IsDir() {
+ return p, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "", nil
+ }
+ dir = parent
+ }
+}
+
+// loadManifest reads and validates one manifest file. JWCC reads
+// through hujson.Standardize into encoding/json — a strict superset,
+// not a second format.
+func loadManifest(path string) (*Manifest, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ std, err := hujson.Standardize(b)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", path, err)
+ }
+ var m Manifest
+ dec := json.NewDecoder(strings.NewReader(string(std)))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&m); err != nil {
+ return nil, fmt.Errorf("%s: %w", path, err)
+ }
+ m.Project = canon.NormalizeName(m.Project)
+ if err := canon.ValidateName(m.Project); err != nil {
+ return nil, fmt.Errorf("%s: project: %w", path, err)
+ }
+ if m.Publisher != "" && !strings.HasPrefix(m.Publisher, "did:") {
+ return nil, fmt.Errorf("%s: publisher must be a DID (handles drift); got %q", path, m.Publisher)
+ }
+ for _, e := range m.Artifacts {
+ if err := validateGlob(e.Glob); err != nil {
+ return nil, fmt.Errorf("%s: artifact entry %q: %w", path, e.Glob, err)
+ }
+ }
+ m.Path = path
+ m.Dir = filepath.Dir(path)
+ return &m, nil
+}
+
+// currentManifest resolves the governing manifest for the working
+// directory; (nil, nil) when none does.
+func currentManifest() (*Manifest, error) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return nil, err
+ }
+ path, err := findManifest(cwd)
+ if err != nil || path == "" {
+ return nil, err
+ }
+ return loadManifest(path)
+}
+
+// manifestRef is how messages name a manifest: relative to the
+// working directory when it is nearby, absolute otherwise.
+func manifestRef(m *Manifest) string {
+ if cwd, err := os.Getwd(); err == nil {
+ if rel, err := filepath.Rel(cwd, m.Path); err == nil && !strings.HasPrefix(rel, "..") {
+ return rel
+ }
+ }
+ return m.Path
+}
+
+// governingProject supplies the project from the governing manifest
+// for a command that got no explicit one, with a provenance note the
+// command must surface — inference is always shown (design D5). The
+// error names both remedies.
+func governingProject(usageHint string) (project, note string, err error) {
+ m, err := currentManifest()
+ if err != nil {
+ return "", "", err
+ }
+ if m == nil {
+ return "", "", fmt.Errorf("no project named, and no %s governs this directory\n(usage: %s)", manifestName, usageHint)
+ }
+ return m.Project, "project " + m.Project + " (from " + manifestRef(m) + ")", nil
+}
+
+// projectFlagOrManifest resolves a command's --project flag, relaxed
+// to the governing manifest when absent. note is "" when explicit.
+func projectFlagOrManifest(c *cli.Context, usageHint string) (project, note string, err error) {
+ if v := c.String("project"); v != "" {
+ project = canon.NormalizeName(v)
+ } else if project, note, err = governingProject(usageHint); err != nil {
+ return "", "", err
+ }
+ if err := canon.ValidateName(project); err != nil {
+ return "", "", err
+ }
+ return project, note, nil
+}
+
+// projectNameArg reads a lone `` positional, relaxed to the
+// governing manifest when absent.
+func projectNameArg(c *cli.Context, usageHint string) (project, note string, err error) {
+ switch c.Args().Len() {
+ case 1:
+ project = canon.NormalizeName(c.Args().First())
+ case 0:
+ if project, note, err = governingProject(usageHint); err != nil {
+ return "", "", err
+ }
+ default:
+ return "", "", fmt.Errorf("usage: %s", usageHint)
+ }
+ if err := canon.ValidateName(project); err != nil {
+ return "", "", err
+ }
+ return project, note, nil
+}
+
+// manifestLineageFor returns the manifest's derivedFrom claim when
+// the governing manifest is about the named project — the only case
+// where its lineage belongs on the record.
+func manifestLineageFor(project string) string {
+ m, err := currentManifest()
+ if err != nil || m == nil || m.Project != project {
+ return ""
+ }
+ return m.DerivedFrom
+}
+
+// projectVersionArgs reads the ` ` positional pair,
+// relaxed to `` alone under a governing manifest. note is ""
+// when the project was explicit.
+func projectVersionArgs(c *cli.Context, usageHint string) (project, version, note string, err error) {
+ switch c.Args().Len() {
+ case 2:
+ project = canon.NormalizeName(c.Args().Get(0))
+ version = c.Args().Get(1)
+ case 1:
+ version = c.Args().Get(0)
+ if project, note, err = governingProject(usageHint); err != nil {
+ return "", "", "", err
+ }
+ default:
+ return "", "", "", fmt.Errorf("usage: %s", usageHint)
+ }
+ if err := canon.ValidateName(project); err != nil {
+ return "", "", "", err
+ }
+ return project, version, note, nil
+}
+
+// validateGlob enforces the containment boundary (design D3): a
+// cloned manifest is attacker-authored and chooses what gets
+// published to the world, so globs resolve relative to the manifest's
+// directory only — never absolute, never escaping upward.
+func validateGlob(pattern string) error {
+ if pattern == "" {
+ return fmt.Errorf("empty glob")
+ }
+ if filepath.IsAbs(pattern) || strings.HasPrefix(pattern, "~") {
+ return fmt.Errorf("absolute paths are not allowed — globs resolve inside the repo only")
+ }
+ for _, seg := range strings.Split(filepath.ToSlash(pattern), "/") {
+ if seg == ".." {
+ return fmt.Errorf("path escapes the repo via \"..\" — globs resolve inside the repo only")
+ }
+ }
+ return nil
+}
+
+// resolveEntries expands the manifest's globs against its own
+// directory and re-checks containment on every match. Returns the
+// matched files (manifest-dir-relative paths joined back to real
+// paths) with the entry that produced each, preserving entry order
+// then lexical order within an entry.
+type resolvedFile struct {
+ Path string // filesystem path, usable with os.Open
+ Storage string // profile name; "" means the manifest default
+}
+
+func (m *Manifest) resolveEntries() ([]resolvedFile, error) {
+ root, err := filepath.EvalSymlinks(m.Dir)
+ if err != nil {
+ return nil, err
+ }
+ var out []resolvedFile
+ seen := map[string]bool{}
+ for _, e := range m.Artifacts {
+ matches, err := filepath.Glob(filepath.Join(m.Dir, filepath.FromSlash(e.Glob)))
+ if err != nil {
+ return nil, fmt.Errorf("artifact entry %q: %w", e.Glob, err)
+ }
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("artifact entry %q matched no files under %s", e.Glob, m.Dir)
+ }
+ for _, match := range matches {
+ info, err := os.Stat(match)
+ if err != nil {
+ return nil, err
+ }
+ if info.IsDir() {
+ continue
+ }
+ // Containment holds for the real file too: a symlink inside
+ // the repo pointing outside would otherwise smuggle bytes.
+ real, err := filepath.EvalSymlinks(match)
+ if err != nil {
+ return nil, err
+ }
+ rel, err := filepath.Rel(root, real)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ return nil, fmt.Errorf("artifact entry %q: %s resolves outside the repo — refusing", e.Glob, match)
+ }
+ if seen[real] {
+ continue
+ }
+ seen[real] = true
+ out = append(out, resolvedFile{Path: match, Storage: e.Storage})
+ }
+ }
+ return out, nil
+}
diff --git a/cmd/disttown/manifest_test.go b/cmd/disttown/manifest_test.go
new file mode 100644
index 0000000..466cd3f
--- /dev/null
+++ b/cmd/disttown/manifest_test.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func writeManifest(t *testing.T, dir, content string) string {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, manifestName)
+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// TestManifestWalkUp pins nearest-wins resolution: a monorepo
+// sub-project's manifest governs its own subtree, the root's governs
+// the rest, and outside both nothing governs.
+func TestManifestWalkUp(t *testing.T) {
+ root := t.TempDir()
+ writeManifest(t, root, `{"project": "mono"}`)
+ writeManifest(t, filepath.Join(root, "sub"), `{"project": "mono:sub"}`)
+ if err := os.MkdirAll(filepath.Join(root, "sub", "deep"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for dir, want := range map[string]string{
+ filepath.Join(root, "sub", "deep"): "mono:sub",
+ filepath.Join(root, "sub"): "mono:sub",
+ root: "mono",
+ } {
+ p, err := findManifest(dir)
+ if err != nil || p == "" {
+ t.Fatalf("findManifest(%s): %v, %q", dir, err, p)
+ }
+ m, err := loadManifest(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Project != want {
+ t.Fatalf("in %s: project %q, want %q", dir, m.Project, want)
+ }
+ }
+ p, err := findManifest(t.TempDir())
+ if err != nil || p != "" {
+ t.Fatalf("ungoverned dir: %v, %q", err, p)
+ }
+}
+
+// TestManifestJWCC: comments and trailing commas are the point of
+// the extension — they must parse.
+func TestManifestJWCC(t *testing.T) {
+ p := writeManifest(t, t.TempDir(), `{
+ // the project this repo publishes
+ "project": "my-cli",
+ "artifacts": [
+ "dist/*", // built by dist.sh
+ ],
+ }`)
+ m, err := loadManifest(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Project != "my-cli" || len(m.Artifacts) != 1 || m.Artifacts[0].Glob != "dist/*" {
+ t.Fatalf("parsed wrong: %+v", m)
+ }
+}
+
+// TestManifestEntryUnion pins the sugar, the object form, and the
+// loud refusal of unknown kinds (design D2).
+func TestManifestEntryUnion(t *testing.T) {
+ var e ManifestEntry
+ if err := json.Unmarshal([]byte(`"dist/*"`), &e); err != nil || e.Glob != "dist/*" {
+ t.Fatalf("string sugar: %v, %+v", err, e)
+ }
+ if err := json.Unmarshal([]byte(`{"glob": "big/*", "storage": "cdn"}`), &e); err != nil || e.Storage != "cdn" {
+ t.Fatalf("object form: %v, %+v", err, e)
+ }
+ err := json.Unmarshal([]byte(`{"oci": "ghcr.io/x/y:{version}"}`), &e)
+ if err == nil || !strings.Contains(err.Error(), `"oci"`) {
+ t.Fatalf("unknown kind must refuse naming it, got: %v", err)
+ }
+ if err := json.Unmarshal([]byte(`{"storage": "cdn"}`), &e); err == nil {
+ t.Fatal("globless object must refuse")
+ }
+}
+
+// TestManifestContainment: the security boundary (design D3) —
+// absolute paths and upward escapes refuse at load, and a symlink
+// pointing outside the repo refuses at resolve.
+func TestManifestContainment(t *testing.T) {
+ dir := t.TempDir()
+ for _, glob := range []string{"/etc/passwd", "../secrets/*", "dist/../../x", "~/x"} {
+ p := writeManifest(t, dir, `{"project": "p", "artifacts": ["`+glob+`"]}`)
+ if _, err := loadManifest(p); err == nil {
+ t.Fatalf("glob %q must refuse at load", glob)
+ }
+ }
+
+ outside := filepath.Join(t.TempDir(), "loot")
+ if err := os.WriteFile(outside, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ repo := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(repo, "dist"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(repo, "dist", "sneaky")); err != nil {
+ t.Skip("symlinks unavailable")
+ }
+ p := writeManifest(t, repo, `{"project": "p", "artifacts": ["dist/*"]}`)
+ m, err := loadManifest(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.resolveEntries(); err == nil || !strings.Contains(err.Error(), "outside the repo") {
+ t.Fatalf("symlink escape must refuse at resolve, got: %v", err)
+ }
+}
+
+// TestManifestResolveEntries: globs resolve against the manifest's
+// own directory, matches are per-entry ordered, and an entry matching
+// nothing is an error (a silent empty release is the demo-night class).
+func TestManifestResolveEntries(t *testing.T) {
+ repo := t.TempDir()
+ for _, f := range []string{"dist/a.tar.gz", "dist/b.tar.gz", "big/huge.bin"} {
+ full := filepath.Join(repo, f)
+ os.MkdirAll(filepath.Dir(full), 0o755)
+ os.WriteFile(full, []byte("x"), 0o644)
+ }
+ p := writeManifest(t, repo, `{
+ "project": "p",
+ "artifacts": ["dist/*.tar.gz", {"glob": "big/*", "storage": "cdn"}],
+ }`)
+ m, err := loadManifest(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ files, err := m.resolveEntries()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) != 3 {
+ t.Fatalf("files: %+v", files)
+ }
+ if files[2].Storage != "cdn" || files[0].Storage != "" {
+ t.Fatalf("per-entry storage lost: %+v", files)
+ }
+
+ writeManifest(t, repo, `{"project": "p", "artifacts": ["nothing/*"]}`)
+ m, err = loadManifest(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.resolveEntries(); err == nil {
+ t.Fatal("entry matching nothing must error")
+ }
+}
diff --git a/cmd/disttown/pointer.go b/cmd/disttown/pointer.go
index a459d88..1d1c854 100644
--- a/cmd/disttown/pointer.go
+++ b/cmd/disttown/pointer.go
@@ -17,7 +17,7 @@ var cmdPointer = &cli.Command{
{
Name: "list",
Usage: "list a project's pointers and the versions they target",
- ArgsUsage: "",
+ ArgsUsage: "[]",
Flags: sourceFlags(),
Action: runPointerList,
},
@@ -27,17 +27,20 @@ var cmdPointer = &cli.Command{
Usage: "point a named pointer at a published version",
ArgsUsage: " ",
Flags: []cli.Flag{
- &cli.StringFlag{Name: "project", Usage: "project name", Required: true},
+ &cli.StringFlag{Name: "project", Usage: "project name (default: the governing manifest's)"},
},
Action: func(c *cli.Context) error {
if c.Args().Len() != 2 {
return fmt.Errorf("usage: disttown pointer set ")
}
name, version := c.Args().Get(0), c.Args().Get(1)
- project := canon.NormalizeName(c.String("project"))
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectFlagOrManifest(c, "disttown pointer set --project ")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
s, err := atsession.Get(c.Context)
if err != nil {
return err
diff --git a/cmd/disttown/pointerlist.go b/cmd/disttown/pointerlist.go
index 713c6f1..74ed3d0 100644
--- a/cmd/disttown/pointerlist.go
+++ b/cmd/disttown/pointerlist.go
@@ -33,10 +33,18 @@ func runPointerList(c *cli.Context) error {
if err := checkSourceFlags(c); err != nil {
return err
}
- if c.Args().Len() != 1 {
- return fmt.Errorf("usage: disttown pointer list ")
+ if c.Args().Len() > 1 {
+ return fmt.Errorf("usage: disttown pointer list []")
+ }
+ project, note := "", ""
+ if c.Args().Len() == 1 {
+ project = canon.NormalizeName(c.Args().First())
+ } else {
+ var err error
+ if project, note, err = governingProject("disttown pointer list []"); err != nil {
+ return err
+ }
}
- project := canon.NormalizeName(c.Args().First())
if err := canon.ValidateName(project); err != nil {
return err
}
@@ -84,7 +92,7 @@ func runPointerList(c *cli.Context) error {
}
fmt.Printf("%s/%s -> %s\n", project, p.Name, version)
}
- fmt.Printf("source: %s\n", sourceLabel(sourcePDS))
+ fmt.Printf("source: %s%s\n", sourceLabel(sourcePDS), noteSuffix(note))
return nil
}
diff --git a/cmd/disttown/project.go b/cmd/disttown/project.go
index 86ae4f7..0de12d4 100644
--- a/cmd/disttown/project.go
+++ b/cmd/disttown/project.go
@@ -24,19 +24,19 @@ var cmdProject = &cli.Command{
{
Name: "create",
Usage: "create a bare project record (e.g. an umbrella; publish creates leaf projects on first release)",
- ArgsUsage: "",
+ ArgsUsage: "[]",
Flags: []cli.Flag{
&cli.StringFlag{Name: "display-name", Usage: "human-facing name"},
&cli.StringFlag{Name: "description", Usage: "brief description"},
},
Action: func(c *cli.Context) error {
- if c.Args().Len() != 1 {
- return fmt.Errorf("usage: disttown project create ")
- }
- name := canon.NormalizeName(c.Args().First())
- if err := canon.ValidateName(name); err != nil {
+ name, note, err := projectNameArg(c, "disttown project create []")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
s, err := atsession.Get(c.Context)
if err != nil {
return err
@@ -49,6 +49,10 @@ var cmdProject = &cli.Command{
return fmt.Errorf("project %q already exists", name)
}
rec := &gen.Project{LexiconTypeID: canon.NSIDProject, Name: name}
+ if from := manifestLineageFor(name); from != "" {
+ rec.DerivedFrom = &from
+ fmt.Printf(" derivedFrom: %s (from %s, an unverified claim)\n", from, manifestName)
+ }
if v := c.String("display-name"); v != "" {
rec.DisplayName = &v
}
@@ -66,7 +70,7 @@ var cmdProject = &cli.Command{
{
Name: "set",
Usage: "update a project's optional fields: only the flags you pass change, and passing an empty value clears that field",
- ArgsUsage: "",
+ ArgsUsage: "[]",
Flags: []cli.Flag{
&cli.StringFlag{Name: "display-name", Usage: "human-facing name"},
&cli.StringFlag{Name: "description", Usage: "brief description"},
@@ -74,13 +78,13 @@ var cmdProject = &cli.Command{
&cli.StringFlag{Name: "website", Usage: "project homepage, distinct from source"},
},
Action: func(c *cli.Context) error {
- if c.Args().Len() != 1 {
- return fmt.Errorf("usage: disttown project set --source ...")
- }
- name := canon.NormalizeName(c.Args().First())
- if err := canon.ValidateName(name); err != nil {
+ name, note, err := projectNameArg(c, "disttown project set [] --source ...")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
if !c.IsSet("display-name") && !c.IsSet("description") && !c.IsSet("source") && !c.IsSet("website") {
return fmt.Errorf("nothing to change: pass at least one of --display-name, --description, --source, --website")
}
@@ -109,6 +113,10 @@ var cmdProject = &cli.Command{
set("description", &rec.Description)
set("source", &rec.Source)
set("website", &rec.Website)
+ if from := manifestLineageFor(name); from != "" && rec.DerivedFrom == nil {
+ rec.DerivedFrom = &from
+ fmt.Printf(" derivedFrom: %s (from %s, an unverified claim)\n", from, manifestName)
+ }
w := updateElem(canon.NSIDProject, name, rec)
if err := applyWrites(c.Context, s, []*atproto.RepoApplyWrites_Input_Writes_Elem{w}); err != nil {
return err
diff --git a/cmd/disttown/publication.go b/cmd/disttown/publication.go
index 9074510..d3420f8 100644
--- a/cmd/disttown/publication.go
+++ b/cmd/disttown/publication.go
@@ -27,7 +27,7 @@ var cmdPublication = &cli.Command{
Name: "set",
Usage: "create a publication anchored at a project, or link a project to an existing one",
Flags: []cli.Flag{
- &cli.StringFlag{Name: "project", Usage: "anchor project (created publications) or project to link", Required: true},
+ &cli.StringFlag{Name: "project", Usage: "anchor project (created publications) or project to link (default: the governing manifest's)"},
&cli.StringFlag{Name: "name", Usage: "publication name (required when creating)"},
&cli.StringFlag{Name: "description", Usage: "brief description"},
&cli.StringFlag{Name: "icon", Usage: "square image file, at least 256x256"},
@@ -42,10 +42,13 @@ var cmdPublication = &cli.Command{
func runPublicationSet(c *cli.Context) error {
ctx := c.Context
- project := canon.NormalizeName(c.String("project"))
- if err := canon.ValidateName(project); err != nil {
+ project, note, err := projectFlagOrManifest(c, "disttown publication set --project ...")
+ if err != nil {
return err
}
+ if note != "" {
+ fmt.Println(note)
+ }
s, err := atsession.Get(ctx)
if err != nil {
return err
diff --git a/cmd/disttown/publish.go b/cmd/disttown/publish.go
index a048c97..7ac29c0 100644
--- a/cmd/disttown/publish.go
+++ b/cmd/disttown/publish.go
@@ -12,6 +12,7 @@ import (
atproto "github.com/bluesky-social/indigo/api/atproto"
cli "github.com/urfave/cli/v2"
+ "golang.org/x/term"
"dist.town/internal/artifact"
"dist.town/internal/canon"
@@ -22,9 +23,9 @@ import (
var cmdPublish = &cli.Command{
Name: "publish",
Usage: "publish a release: hash, upload, and write records atomically",
- ArgsUsage: " ",
+ ArgsUsage: "[] []",
Flags: []cli.Flag{
- &cli.StringFlag{Name: "project", Usage: "project name", Required: true},
+ &cli.StringFlag{Name: "project", Usage: "project name (default: the governing manifest's)"},
&cli.StringFlag{Name: "channel", Usage: "release channel (e.g. stable, beta); also redirects the default pointer to the channel name — latest moves only when named"},
&cli.StringFlag{Name: "license", Usage: "SPDX license identifier for this release"},
&cli.StringSliceFlag{Name: "pointer", Usage: "pointers to move to this release; naming any replaces the default entirely (default: the channel name when --channel is set, else latest)"},
@@ -36,6 +37,7 @@ var cmdPublish = &cli.Command{
&cli.BoolFlag{Name: "git", Usage: "pin the source claim to the local checkout's HEAD commit (no tag)"},
&cli.BoolFlag{Name: "attach-tangled", Usage: "attach tag-matched Tangled artifacts without prompting (for CI)"},
&cli.BoolFlag{Name: "dry-run", Usage: "print the plan — digests, records, pointer moves — uploading nothing and writing nothing"},
+ &cli.BoolFlag{Name: "yes", Usage: "skip the interactive plan confirmation"},
},
Action: runPublish,
}
@@ -115,27 +117,73 @@ func accountUploads(err error, keys []string) error {
func runPublish(c *cli.Context) error {
ctx := c.Context
+ m, err := currentManifest()
+ if err != nil {
+ return err
+ }
+
+ // Project: the flag beats the manifest (precedence: flag > env >
+ // manifest > cliconf > ask, design D5); with neither, the old
+ // required-flag error in newer words.
project := canon.NormalizeName(c.String("project"))
+ projectFrom := ""
+ if project == "" {
+ if m == nil {
+ return fmt.Errorf("no --project given and no %s governs this directory\n(usage: disttown publish --project )", manifestName)
+ }
+ project = m.Project
+ projectFrom = manifestRef(m)
+ }
if err := canon.ValidateName(project); err != nil {
return fmt.Errorf("refusing to publish: %w", err)
}
- if c.Args().Len() < 2 && c.String("tag") == "" {
- return fmt.Errorf("usage: disttown publish ")
+
+ // Version: the argument wins; in a governed repo an annotated tag
+ // at HEAD proposes one — proposed, never assumed: the plan shows
+ // it before anything is permanent (design D5). The proposing tag
+ // also pins the source claim unless --tag/--git already spoke.
+ version := c.Args().First()
+ versionFrom := ""
+ if version == "" && m != nil {
+ var tag string
+ if version, tag, err = proposeVersionFromTag(ctx); err != nil {
+ return err
+ }
+ versionFrom = "tag " + tag
+ if c.String("tag") == "" && !c.Bool("git") {
+ if err := c.Set("tag", tag); err != nil {
+ return err
+ }
+ }
}
- if c.Args().Len() < 1 {
- return fmt.Errorf("usage: disttown publish [artifact files...]")
+ if version == "" {
+ return fmt.Errorf("usage: disttown publish ")
}
- version := c.Args().First()
- files := c.Args().Tail()
// Versions are opaque strings, but an existing file in the version
// position is a near-certain missing argument: the demo-night
// mistake that published a release named after a tarball. Refuse
// before anything else does something permanent with it.
- if versionIsExistingFile(version) {
+ if versionFrom == "" && versionIsExistingFile(version) {
return fmt.Errorf("version %q is an existing file — did you forget the version argument?\n(usage: disttown publish )", version)
}
- useBlobs := c.Bool("blobs")
+ // Files: explicit arguments win; else the manifest's entries,
+ // containment-checked against its own directory.
+ var files []resolvedFile
+ filesFrom := ""
+ for _, f := range c.Args().Tail() {
+ files = append(files, resolvedFile{Path: f})
+ }
+ if len(files) == 0 && m != nil && len(m.Artifacts) > 0 {
+ if files, err = m.resolveEntries(); err != nil {
+ return err
+ }
+ filesFrom = manifestRef(m)
+ }
+ if len(files) == 0 && c.String("tag") == "" {
+ return fmt.Errorf("usage: disttown publish ")
+ }
+
// The moved-pointer set is fixed here, before anything happens,
// and the locally-knowable auth failure fails before the first
// byte is hashed (D1, D2).
@@ -148,15 +196,35 @@ func runPublish(c *cli.Context) error {
return fmt.Errorf("pointer name %q: %w", name, err)
}
}
- if err := preflightScopes(scopesForPublish(len(pointers) > 0, c.String("notes") != "", useBlobs)); err != nil {
+
+ // The session comes up front: the publisher guard compares it to
+ // the manifest's expectation before any byte moves (design D6).
+ s, err := atsession.Get(ctx)
+ if err != nil {
+ return err
+ }
+ if err := publisherGuard(m, s.DID); err != nil {
return err
}
+
cfg, err := cliconf.LoadConfig()
if err != nil {
return err
}
- if !useBlobs && len(files) > 0 && cfg.Storage.BaseURL == "" {
- return fmt.Errorf("no storage configured; run `disttown storage setup` first (or publish with --blobs to store bytes on your own PDS)")
+ // Storage routing resolves per entry before hashing: a missing
+ // profile fails here, naming the remedy (design D4).
+ placements, err := resolvePlacements(c.Bool("blobs"), m, cfg, files)
+ if err != nil {
+ return err
+ }
+ anyBlobs := false
+ for _, pl := range placements {
+ if pl.blobs {
+ anyBlobs = true
+ }
+ }
+ if err := preflightScopes(scopesForPublish(len(pointers) > 0, c.String("notes") != "", anyBlobs)); err != nil {
+ return err
}
// The source claim pins before anything uploads: hash is truth,
@@ -172,10 +240,6 @@ func runPublish(c *cli.Context) error {
}
}
- s, err := atsession.Get(ctx)
- if err != nil {
- return err
- }
projectURI := atURI(s.DID, canon.NSIDProject, project)
// Idempotency: a retry of a successful publish returns the
@@ -190,39 +254,86 @@ func runPublish(c *cli.Context) error {
return nil
}
- // The plan without the side effects (D6): preflight has run,
- // hashing runs here, nothing uploads and nothing writes.
+ exists, err := projectExists(ctx, s, project)
+ if err != nil {
+ return err
+ }
+
+ // Hash before the plan renders: digests are the plan (D5, D6).
+ plan := &publishPlan{
+ project: project, projectFrom: projectFrom,
+ version: version, versionFrom: versionFrom,
+ filesFrom: filesFrom, placements: placements,
+ pointers: pointers, channel: c.String("channel"),
+ notesFile: c.String("notes"), gitSource: gitSource,
+ createProject: !exists,
+ }
+ if m != nil && m.Project == project {
+ plan.lineage = m.DerivedFrom
+ }
+ if plan.notesFile != "" {
+ if plan.notesTitle = c.String("notes-title"); plan.notesTitle == "" {
+ plan.notesTitle = defaultTitle(project, version)
+ }
+ }
+ for _, pl := range placements {
+ info, err := artifact.HashFile(pl.path)
+ if err != nil {
+ return fmt.Errorf("hashing %s: %w", pl.path, err)
+ }
+ plan.infos = append(plan.infos, info)
+ }
+
+ // The plan without the side effects (D6): dry run renders and
+ // exits; an interactive publish renders and asks — inference may
+ // guess wrong, inference plus a shown plan is safe. Non-tty
+ // without --yes proceeds exactly as before this gate existed:
+ // scripted contexts are presumed reviewed.
if c.Bool("dry-run") {
- return dryRunPublish(ctx, c, s, cfg, project, version, files, pointers, gitSource)
+ plan.renderPlan()
+ if gitSource != nil && gitSource.Tag != nil {
+ candidates, err := findTangledArtifacts(ctx, s, gitSource)
+ if err != nil {
+ return err
+ }
+ for _, cand := range candidates {
+ fmt.Printf(" would attach (tangled, hashed at real publish): %s (%s)\n", cand.Name, cand.URI)
+ }
+ }
+ fmt.Println("dry run: nothing uploaded, nothing written")
+ return nil
+ }
+ if !c.Bool("yes") && term.IsTerminal(int(os.Stdin.Fd())) {
+ plan.renderPlan()
+ if !confirm("publish this?") {
+ return fmt.Errorf("aborted; nothing uploaded, nothing written")
+ }
}
- // Hash and place artifacts before any record exists.
+ // Upload as planned: every byte goes where the plan said.
var descriptors []*gen.Defs_Artifact
var uploadedKeys []string
- for _, path := range files {
- info, err := artifact.HashFile(path)
- if err != nil {
- return fmt.Errorf("hashing %s: %w", path, err)
- }
+ for i, pl := range placements {
+ info := plan.infos[i]
var desc *gen.Defs_Artifact
- if useBlobs {
- f, err := os.Open(path)
+ if pl.blobs {
+ f, err := os.Open(pl.path)
if err != nil {
return err
}
blob, err := uploadBlobTyped(ctx, s, f, artifact.MediaType(info.Filename))
f.Close()
if err != nil {
- return accountUploads(fmt.Errorf("uploading %s as blob: %w", path, err), uploadedKeys)
+ return accountUploads(fmt.Errorf("uploading %s as blob: %w", pl.path, err), uploadedKeys)
}
uploadedKeys = append(uploadedKeys, "pds blob "+blob.Ref.String()+" ("+info.Filename+")")
desc = artifact.BlobDescriptor(info, blob)
fmt.Printf(" %s %s (%d bytes) -> blob %s\n", info.Digest[:sha256PrefixLen], info.Filename, info.Size, blob.Ref.String())
} else {
- desc = artifact.Descriptor(info, cfg.Storage.BaseURL)
- if cfg.Storage.UploadCommand != "" {
+ desc = artifact.Descriptor(info, pl.sc.BaseURL)
+ if pl.sc.UploadCommand != "" {
key := artifact.Key(info.Digest, info.Filename)
- if err := runUploadCommand(ctx, cfg.Storage.UploadCommand, path, key); err != nil {
+ if err := runUploadCommand(ctx, pl.sc.UploadCommand, pl.path, key); err != nil {
return accountUploads(err, uploadedKeys)
}
uploadedKeys = append(uploadedKeys, key)
@@ -232,8 +343,8 @@ func runPublish(c *cli.Context) error {
// claims it.
url := desc.Storage.Defs_UrlStorage.Url
if err := verifyReachable(ctx, url, info.Size); err != nil {
- if cfg.Storage.UploadCommand == "" {
- return fmt.Errorf("%w\n(no upload command configured — upload the file yourself, e.g.:\n %s /%s )", err, path, artifact.Key(info.Digest, info.Filename))
+ if pl.sc.UploadCommand == "" {
+ return fmt.Errorf("%w\n(no upload command configured — upload the file yourself, e.g.:\n %s /%s )", err, pl.path, artifact.Key(info.Digest, info.Filename))
}
return accountUploads(err, uploadedKeys)
}
@@ -275,16 +386,19 @@ func runPublish(c *cli.Context) error {
// Assemble the atomic batch: first publish creates the project in
// the same commit (design D20).
var writes []*atproto.RepoApplyWrites_Input_Writes_Elem
- exists, err := projectExists(ctx, s, project)
- if err != nil {
- return accountUploads(err, uploadedKeys)
- }
if !exists {
fmt.Printf("project %q does not exist yet; creating it in the same commit\n", project)
- writes = append(writes, createElem(canon.NSIDProject, project, &gen.Project{
+ rec := &gen.Project{
LexiconTypeID: canon.NSIDProject,
Name: project,
- }))
+ }
+ // Manifest lineage reaches the record at creation — an
+ // unverified cross-DID claim, rendered only on this project's
+ // own page (design D7).
+ if plan.lineage != "" {
+ rec.DerivedFrom = &plan.lineage
+ }
+ writes = append(writes, createElem(canon.NSIDProject, project, rec))
// Orphans are valid (parentage is derived, never stored), but a
// missing parent is usually a typo — warn, don't refuse.
if parent := canon.ParentName(project); parent != "" {
@@ -371,61 +485,6 @@ func runPublish(c *cli.Context) error {
return nil
}
-// dryRunPublish prints the plan and touches nothing (D6): preflight
-// has already run, hashing runs here — digests are the plan — and no
-// byte leaves the machine. Tangled discovery is offered, not
-// performed: candidates list as would-attach without a download.
-func dryRunPublish(ctx context.Context, c *cli.Context, s *atsession.Session, cfg cliconf.Config, project, version string, files, pointers []string, gitSource *gen.Defs_GitSource) error {
- fmt.Printf("dry run: publish %s@%s\n", project, version)
- exists, err := projectExists(ctx, s, project)
- if err != nil {
- return err
- }
- if !exists {
- fmt.Printf(" create: project %s (first publish, same commit)\n", project)
- }
- useBlobs := c.Bool("blobs")
- for _, path := range files {
- info, err := artifact.HashFile(path)
- if err != nil {
- return fmt.Errorf("hashing %s: %w", path, err)
- }
- dest := "-> pds blob"
- if !useBlobs {
- dest = "-> " + cfg.Storage.BaseURL + "/" + artifact.Key(info.Digest, info.Filename)
- }
- fmt.Printf(" %s %s (%d bytes) %s\n", info.Digest[:sha256PrefixLen], info.Filename, info.Size, dest)
- }
- if gitSource != nil && gitSource.Tag != nil {
- candidates, err := findTangledArtifacts(ctx, s, gitSource)
- if err != nil {
- return err
- }
- for _, cand := range candidates {
- fmt.Printf(" would attach (tangled, hashed at real publish): %s (%s)\n", cand.Name, cand.URI)
- }
- }
- fmt.Printf(" create: release record for %s@%s\n", project, version)
- for _, name := range pointers {
- fmt.Printf(" move: pointer %s -> %s\n", name, version)
- }
- if len(pointers) == 0 {
- fmt.Println(" no pointer moves")
- }
- if ch := c.String("channel"); ch != "" && !slices.Contains(pointers, "latest") {
- fmt.Println(" latest would not move (channel publish; pass --pointer latest to move it)")
- }
- if notesFile := c.String("notes"); notesFile != "" {
- title := c.String("notes-title")
- if title == "" {
- title = defaultTitle(project, version)
- }
- fmt.Printf(" create: changelog document %q from %s\n", title, notesFile)
- }
- fmt.Println("dry run: nothing uploaded, nothing written")
- return nil
-}
-
const sha256PrefixLen = len("sha256:") + 12
// verifyReachable confirms the artifact URL answers an anonymous HEAD
diff --git a/cmd/disttown/publishplan.go b/cmd/disttown/publishplan.go
new file mode 100644
index 0000000..f1abce8
--- /dev/null
+++ b/cmd/disttown/publishplan.go
@@ -0,0 +1,209 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "slices"
+
+ "github.com/tailscale/hujson"
+ "golang.org/x/term"
+
+ "dist.town/internal/artifact"
+ "dist.town/internal/canon"
+ "dist.town/internal/cliconf"
+ "dist.town/internal/gen"
+)
+
+// placement routes one artifact file: to the publisher's own PDS as a
+// blob, or to the bucket a named storage profile defines. Resolution
+// happens before anything is hashed, so a missing profile fails
+// preflight naming the remedy (design D4).
+type placement struct {
+ path string
+ blobs bool
+ profile string // display name: "blobs", "default", or the profile
+ sc cliconf.StorageConfig
+}
+
+// resolvePlacements applies the routing precedence per file: the
+// --blobs flag beats everything, then the entry's own storage, then
+// the manifest's, then the machine default.
+func resolvePlacements(forceBlobs bool, m *Manifest, cfg cliconf.Config, files []resolvedFile) ([]placement, error) {
+ var out []placement
+ for _, f := range files {
+ name := f.Storage
+ if name == "" && m != nil {
+ name = m.Storage
+ }
+ if forceBlobs || name == cliconf.BlobsProfile {
+ out = append(out, placement{path: f.Path, blobs: true, profile: cliconf.BlobsProfile})
+ continue
+ }
+ sc, ok := cfg.ResolveStorage(name)
+ if !ok || sc.BaseURL == "" {
+ if name == "" || name == cliconf.DefaultProfile {
+ return nil, fmt.Errorf("no storage configured; run `disttown storage setup` first (or publish with --blobs to store bytes on your own PDS)")
+ }
+ return nil, fmt.Errorf("storage profile %q is not defined on this machine; run `disttown storage setup --profile %s` first (nothing was hashed or uploaded)", name, name)
+ }
+ if name == "" {
+ name = cliconf.DefaultProfile
+ }
+ out = append(out, placement{path: f.Path, profile: name, sc: sc})
+ }
+ return out, nil
+}
+
+// proposeVersionFromTag reads the annotated tag at HEAD and proposes
+// the version it implies: a leading "v" strips when a digit follows,
+// anything else passes verbatim — versions are opaque, so the
+// proposal is always shown before it acts (design D5).
+func proposeVersionFromTag(ctx context.Context) (version, tag string, err error) {
+ tag, err = gitOutput(ctx, "describe", "--exact-match", "HEAD")
+ if err != nil {
+ return "", "", fmt.Errorf("no version argument and no annotated tag at HEAD to propose one\npass a version (disttown publish ) or tag the release commit (git tag -a v1.2.3)")
+ }
+ return versionFromTagName(tag), tag, nil
+}
+
+// versionFromTagName maps a tag name to the version it proposes:
+// strip the "v" convention only when a digit follows, else verbatim.
+func versionFromTagName(tag string) string {
+ if len(tag) >= 2 && tag[0] == 'v' && tag[1] >= '0' && tag[1] <= '9' {
+ return tag[1:]
+ }
+ return tag
+}
+
+// publisherGuard enforces the manifest's expected publisher before
+// any byte moves (design D6). A mismatched session is the
+// multi-account save or the fork on-ramp: abort, or adopt — which
+// rewrites the manifest and captures lineage at the only moment the
+// CLI can know it. Never delegated to --yes: identity is its own
+// consent.
+func publisherGuard(m *Manifest, sessionDID string) error {
+ if m == nil || m.Publisher == "" || m.Publisher == sessionDID {
+ return nil
+ }
+ fmt.Printf("publisher mismatch:\n %s expects %s\n your session is %s\n", manifestRef(m), m.Publisher, sessionDID)
+ fmt.Println("either this is the wrong account, or this is a fork publishing under its own identity")
+ if !term.IsTerminal(int(os.Stdin.Fd())) {
+ return fmt.Errorf("refusing to publish under an identity the manifest does not expect (nothing uploaded)\nlog in as the expected publisher, or run interactively to adopt the manifest")
+ }
+ fmt.Println("adopting rewrites the manifest in your working tree: publisher becomes your DID,")
+ fmt.Println("and the original project reference moves into derivedFrom — lineage, recorded as")
+ fmt.Println("the unverified claim it is")
+ if !confirm("adopt this manifest under " + sessionDID + "?") {
+ return fmt.Errorf("aborted; nothing uploaded (log in as the expected publisher and retry)")
+ }
+ return adoptManifest(m, sessionDID)
+}
+
+// adoptManifest is the format-preserving rewrite (design D1, D6):
+// hujson patches exactly two fields and the user's comments and
+// formatting survive. The edit lands in the working tree; committing
+// it is the user's act.
+func adoptManifest(m *Manifest, sessionDID string) error {
+ orig := "at://" + m.Publisher + "/" + canon.NSIDProject + "/" + m.Project
+ b, err := os.ReadFile(m.Path)
+ if err != nil {
+ return err
+ }
+ v, err := hujson.Parse(b)
+ if err != nil {
+ return err
+ }
+ patch := fmt.Sprintf(`[{"op":"add","path":"/publisher","value":%q},{"op":"add","path":"/derivedFrom","value":%q}]`,
+ sessionDID, orig)
+ if err := v.Patch([]byte(patch)); err != nil {
+ return fmt.Errorf("rewriting %s: %w", m.Path, err)
+ }
+ if err := os.WriteFile(m.Path, v.Pack(), 0o644); err != nil {
+ return err
+ }
+ m.Publisher = sessionDID
+ m.DerivedFrom = orig
+ fmt.Printf("adopted: %s now expects %s\n derivedFrom: %s\n (the rewrite is in your working tree — commit it like any other change)\n",
+ manifestRef(m), sessionDID, orig)
+ return nil
+}
+
+// publishPlan is everything a publish will do, assembled and hashed
+// before anything uploads. Rendering it is the dry run; rendering it
+// and asking is the interactive gate (design D5) — inference may
+// guess wrong, inference plus a shown plan is safe.
+type publishPlan struct {
+ project string
+ projectFrom string // "" when explicit
+ version string
+ versionFrom string // "" when explicit, else "tag v1.2.3"
+ filesFrom string // "" when explicit
+ placements []placement
+ infos []artifact.Info // parallel to placements
+ pointers []string
+ channel string
+ notesFile string
+ notesTitle string
+ gitSource *gen.Defs_GitSource
+ createProject bool
+ lineage string // derivedFrom carried onto a created project
+}
+
+func provenance(from string) string {
+ if from == "" {
+ return ""
+ }
+ return " (from " + from + ")"
+}
+
+// renderPlan prints the whole plan, provenance included — every
+// inferred value states its source, so a wrong inference is caught by
+// eyes, not archaeology.
+func (p *publishPlan) renderPlan() {
+ fmt.Printf("publish %s@%s\n", p.project, p.version)
+ if p.projectFrom != "" {
+ fmt.Printf(" project: %s%s\n", p.project, provenance(p.projectFrom))
+ }
+ fmt.Printf(" version: %s%s\n", p.version, provenance(p.versionFrom))
+ if p.createProject {
+ fmt.Printf(" create: project %s (first publish, same commit)\n", p.project)
+ if p.lineage != "" {
+ fmt.Printf(" derivedFrom: %s (an unverified claim)\n", p.lineage)
+ }
+ }
+ for i, pl := range p.placements {
+ info := p.infos[i]
+ dest := "pds blob (" + cliconf.BlobsProfile + ")"
+ if !pl.blobs {
+ dest = pl.sc.BaseURL + "/" + artifact.Key(info.Digest, info.Filename)
+ if pl.profile != cliconf.DefaultProfile {
+ dest += " (profile " + pl.profile + ")"
+ }
+ }
+ fmt.Printf(" %s %s (%d bytes) -> %s\n", info.Digest[:sha256PrefixLen], info.Filename, info.Size, dest)
+ }
+ if p.filesFrom != "" {
+ fmt.Printf(" files resolved%s\n", provenance(p.filesFrom))
+ }
+ fmt.Printf(" create: release record for %s@%s\n", p.project, p.version)
+ for _, name := range p.pointers {
+ fmt.Printf(" move: pointer %s -> %s\n", name, p.version)
+ }
+ if len(p.pointers) == 0 {
+ fmt.Println(" no pointer moves")
+ }
+ if p.channel != "" && !slices.Contains(p.pointers, "latest") {
+ fmt.Println(" latest does not move (channel publish; pass --pointer latest to move it)")
+ }
+ if p.notesFile != "" {
+ fmt.Printf(" create: changelog document %q from %s\n", p.notesTitle, p.notesFile)
+ }
+ if p.gitSource != nil {
+ fmt.Printf(" source claim: %s", p.gitSource.Commit)
+ if p.gitSource.Ref != nil {
+ fmt.Printf(" (tag %s)", *p.gitSource.Ref)
+ }
+ fmt.Println()
+ }
+}
diff --git a/cmd/disttown/publishplan_test.go b/cmd/disttown/publishplan_test.go
new file mode 100644
index 0000000..71c8cac
--- /dev/null
+++ b/cmd/disttown/publishplan_test.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "dist.town/internal/cliconf"
+)
+
+// TestVersionFromTagName pins the proposal rule (design D5): the "v"
+// convention strips only before a digit; everything else is verbatim,
+// because versions are opaque and guesses get shown, not made.
+func TestVersionFromTagName(t *testing.T) {
+ cases := map[string]string{
+ "v0.6.0": "0.6.0",
+ "v2": "2",
+ "version-2": "version-2",
+ "vNext": "vNext",
+ "release": "release",
+ "v": "v",
+ "0.6.0": "0.6.0",
+ }
+ for tag, want := range cases {
+ if got := versionFromTagName(tag); got != want {
+ t.Errorf("versionFromTagName(%q) = %q, want %q", tag, got, want)
+ }
+ }
+}
+
+// TestResolvePlacements pins per-entry routing (design D4): entry
+// beats manifest default, "blobs" is a destination not a bucket, the
+// --blobs flag beats everything, and an undefined profile fails
+// naming the remedy before anything is hashed.
+func TestResolvePlacements(t *testing.T) {
+ t.Setenv(cliconf.EnvStorageBaseURL, "")
+ cfg := cliconf.Config{
+ Storage: cliconf.StorageConfig{BaseURL: "https://flat.example"},
+ StorageProfiles: map[string]cliconf.StorageConfig{
+ "cdn": {BaseURL: "https://cdn.example"},
+ },
+ }
+ m := &Manifest{Project: "p", Storage: "cdn"}
+ files := []resolvedFile{
+ {Path: "a"}, // manifest default: cdn
+ {Path: "b", Storage: "blobs"}, // entry override: PDS blobs
+ {Path: "c", Storage: "default"}, // entry override: flat config
+ }
+ got, err := resolvePlacements(false, m, cfg, files)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got[0].sc.BaseURL != "https://cdn.example" || got[0].profile != "cdn" {
+ t.Fatalf("manifest default not routed: %+v", got[0])
+ }
+ if !got[1].blobs {
+ t.Fatalf("entry blobs override lost: %+v", got[1])
+ }
+ if got[2].sc.BaseURL != "https://flat.example" {
+ t.Fatalf("entry default override lost: %+v", got[2])
+ }
+
+ // The flag beats the manifest entirely.
+ forced, err := resolvePlacements(true, m, cfg, files)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, pl := range forced {
+ if !pl.blobs {
+ t.Fatalf("--blobs must route everything to blobs: %+v", pl)
+ }
+ }
+
+ // Undefined profile: preflight failure, remedy named.
+ _, err = resolvePlacements(false, &Manifest{Project: "p", Storage: "nope"}, cfg, []resolvedFile{{Path: "a"}})
+ if err == nil || !strings.Contains(err.Error(), "storage setup --profile nope") {
+ t.Fatalf("missing profile must name the remedy, got: %v", err)
+ }
+
+ // No manifest, no flag: today's flat-config behavior.
+ bare, err := resolvePlacements(false, nil, cfg, []resolvedFile{{Path: "a"}})
+ if err != nil || bare[0].sc.BaseURL != "https://flat.example" {
+ t.Fatalf("bare default: %v %+v", err, bare)
+ }
+}
+
+// TestAdoptManifestPreservesFormatting pins the load-bearing reason
+// hujson is a dependency at all (design D1): adopt edits two fields
+// and the user's comments survive.
+func TestAdoptManifestPreservesFormatting(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, manifestName)
+ content := `{
+ // who publishes this
+ "project": "my-cli",
+ "publisher": "did:plc:original", // the upstream author
+ "artifacts": ["dist/*"],
+}`
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ m := &Manifest{Project: "my-cli", Publisher: "did:plc:original", Path: path, Dir: dir}
+ if err := adoptManifest(m, "did:plc:forker"); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(b)
+ if !strings.Contains(out, "// who publishes this") || !strings.Contains(out, "// the upstream author") {
+ t.Fatalf("comments vaporized:\n%s", out)
+ }
+ if !strings.Contains(out, `"did:plc:forker"`) {
+ t.Fatalf("publisher not rewritten:\n%s", out)
+ }
+ if !strings.Contains(out, `"derivedFrom"`) || !strings.Contains(out, "at://did:plc:original/town.dist.project/my-cli") {
+ t.Fatalf("lineage not captured:\n%s", out)
+ }
+ if m.Publisher != "did:plc:forker" || m.DerivedFrom != "at://did:plc:original/town.dist.project/my-cli" {
+ t.Fatalf("in-memory manifest not updated: %+v", m)
+ }
+ // The rewritten file still loads: adopt cannot corrupt.
+ reloaded, err := loadManifest(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reloaded.DerivedFrom != m.DerivedFrom {
+ t.Fatalf("reload disagrees: %+v", reloaded)
+ }
+}
+
+// TestPublisherGuardPasses: no manifest, no publisher, or a matching
+// DID all proceed silently — the guard only speaks on mismatch.
+func TestPublisherGuardPasses(t *testing.T) {
+ if err := publisherGuard(nil, "did:plc:me"); err != nil {
+ t.Fatal(err)
+ }
+ if err := publisherGuard(&Manifest{Project: "p"}, "did:plc:me"); err != nil {
+ t.Fatal(err)
+ }
+ if err := publisherGuard(&Manifest{Project: "p", Publisher: "did:plc:me"}, "did:plc:me"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestPublisherGuardNonInteractive: a mismatch off-tty refuses
+// outright — adoption is an identity decision, never delegated.
+func TestPublisherGuardNonInteractive(t *testing.T) {
+ m := &Manifest{Project: "p", Publisher: "did:plc:other", Path: filepath.Join(t.TempDir(), manifestName)}
+ err := publisherGuard(m, "did:plc:me")
+ if err == nil || !strings.Contains(err.Error(), "nothing uploaded") {
+ t.Fatalf("mismatch must refuse before any byte: %v", err)
+ }
+}
diff --git a/cmd/disttown/release.go b/cmd/disttown/release.go
index 3a47030..8d427de 100644
--- a/cmd/disttown/release.go
+++ b/cmd/disttown/release.go
@@ -55,7 +55,7 @@ var cmdRelease = &cli.Command{
{
Name: "show",
Usage: "show one release in full: artifacts, digests, locations, pointers, source, changelog",
- ArgsUsage: " ",
+ ArgsUsage: "[] ",
Flags: sourceFlags(),
Action: runReleaseShow,
},
@@ -75,12 +75,18 @@ func runReleaseList(c *cli.Context) error {
if c.Args().Len() > 1 {
return fmt.Errorf("usage: disttown release list []")
}
- project := ""
+ project, note := "", ""
if c.Args().Len() == 1 {
project = canon.NormalizeName(c.Args().First())
if err := canon.ValidateName(project); err != nil {
return err
}
+ } else if m, err := currentManifest(); err != nil {
+ return err
+ } else if m != nil {
+ // Inside a governed repo the bare listing means this project —
+ // the inference is stated beside the source line.
+ project, note = m.Project, "project "+m.Project+" (from "+manifestRef(m)+")"
}
ctx := c.Context
s, err := atsession.Get(ctx)
@@ -125,10 +131,19 @@ func runReleaseList(c *cli.Context) error {
}
fmt.Printf("%-34s %-22s %s %d artifact(s)\n", r.Project+"@"+r.Version, flags, r.PublishedAt, len(r.Artifacts))
}
- fmt.Printf("source: %s\n", sourceLabel(source))
+ fmt.Printf("source: %s%s\n", sourceLabel(source), noteSuffix(note))
return nil
}
+// noteSuffix appends an inference note to a source line; inference is
+// always stated where it acted.
+func noteSuffix(note string) string {
+ if note == "" {
+ return ""
+ }
+ return "; " + note
+}
+
func scopeSuffix(project string) string {
if project == "" {
return ""
@@ -182,14 +197,10 @@ func runReleaseShow(c *cli.Context) error {
if err := checkSourceFlags(c); err != nil {
return err
}
- if c.Args().Len() != 2 {
- return fmt.Errorf("usage: disttown release show ")
- }
- project := canon.NormalizeName(c.Args().Get(0))
- if err := canon.ValidateName(project); err != nil {
+ project, selector, note, err := projectVersionArgs(c, "disttown release show [] ")
+ if err != nil {
return err
}
- selector := c.Args().Get(1)
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
@@ -224,7 +235,7 @@ func runReleaseShow(c *cli.Context) error {
return emitJSON(showEnvelope{Source: source, Pointer: viaPointer, Release: info})
}
printRelease(info, viaPointer)
- fmt.Printf("source: %s\n", sourceLabel(source))
+ fmt.Printf("source: %s%s\n", sourceLabel(source), noteSuffix(note))
return nil
}
diff --git a/cmd/disttown/retract.go b/cmd/disttown/retract.go
index b9fdc9d..e8d837b 100644
--- a/cmd/disttown/retract.go
+++ b/cmd/disttown/retract.go
@@ -23,7 +23,7 @@ import (
var cmdReleaseRetract = &cli.Command{
Name: "retract",
Usage: "last resort: delete a mistake-release, leaving testimony — yank is the norm for anything that ever had a consumer",
- ArgsUsage: " ",
+ ArgsUsage: "[] ",
Flags: []cli.Flag{
&cli.StringFlag{Name: "kind", Usage: "machine-facing reason category (broken, mistake, ...)"},
&cli.StringFlag{Name: "reason", Usage: "human-facing explanation, preserved as the removal's permanent testimony"},
@@ -33,14 +33,13 @@ var cmdReleaseRetract = &cli.Command{
}
func runRetract(c *cli.Context) error {
- if c.Args().Len() != 2 {
- return fmt.Errorf("usage: disttown release retract ")
- }
- project := canon.NormalizeName(c.Args().Get(0))
- if err := canon.ValidateName(project); err != nil {
+ project, version, note, err := projectVersionArgs(c, "disttown release retract [] ")
+ if err != nil {
return err
}
- version := c.Args().Get(1)
+ if note != "" {
+ fmt.Println(note)
+ }
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
@@ -137,19 +136,18 @@ func retractionWrites(subject, releaseRKey, version, kind, reason string) []*atp
var cmdPointerRemove = &cli.Command{
Name: "remove",
Usage: "delete a pointer — a mutable alias nobody pins by identity; the name stops resolving",
- ArgsUsage: " ",
+ ArgsUsage: "[] ",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Usage: "consent without prompting (for automation)"},
},
Action: func(c *cli.Context) error {
- if c.Args().Len() != 2 {
- return fmt.Errorf("usage: disttown pointer remove ")
- }
- project := canon.NormalizeName(c.Args().Get(0))
- if err := canon.ValidateName(project); err != nil {
+ project, name, note, err := projectVersionArgs(c, "disttown pointer remove [] ")
+ if err != nil {
return err
}
- name := c.Args().Get(1)
+ if note != "" {
+ fmt.Println(note)
+ }
ctx := c.Context
s, err := atsession.Get(ctx)
if err != nil {
diff --git a/cmd/disttown/storage.go b/cmd/disttown/storage.go
index ae12c2f..19b797e 100644
--- a/cmd/disttown/storage.go
+++ b/cmd/disttown/storage.go
@@ -24,14 +24,28 @@ var cmdStorage = &cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{Name: "base-url", Usage: "public base URL artifacts are served from", Required: true},
&cli.StringFlag{Name: "upload-cmd", Usage: "shell command template with {file} and {key} placeholders (e.g. 'aws s3 cp {file} s3://bkt/{key}')"},
+ &cli.StringFlag{Name: "profile", Usage: "save as a named profile a repo manifest can pick (default: the machine-wide default bucket)"},
},
Action: func(c *cli.Context) error {
cfg, err := cliconf.LoadConfig()
if err != nil {
return err
}
- cfg.Storage.BaseURL = strings.TrimSuffix(c.String("base-url"), "/")
- cfg.Storage.UploadCommand = c.String("upload-cmd")
+ sc := cliconf.StorageConfig{
+ BaseURL: strings.TrimSuffix(c.String("base-url"), "/"),
+ UploadCommand: c.String("upload-cmd"),
+ }
+ switch name := c.String("profile"); name {
+ case "", cliconf.DefaultProfile:
+ cfg.Storage = sc
+ case cliconf.BlobsProfile:
+ return fmt.Errorf("%q is reserved: a manifest declaring it stores bytes as PDS blobs, no bucket involved", cliconf.BlobsProfile)
+ default:
+ if cfg.StorageProfiles == nil {
+ cfg.StorageProfiles = map[string]cliconf.StorageConfig{}
+ }
+ cfg.StorageProfiles[name] = sc
+ }
if err := cliconf.SaveConfig(cfg); err != nil {
return err
}
@@ -42,19 +56,26 @@ var cmdStorage = &cli.Command{
{
Name: "doctor",
Usage: "check the bucket against dist.town's BYOS requirements",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "profile", Usage: "check a named storage profile instead of the default bucket"},
+ },
Action: func(c *cli.Context) error {
cfg, err := cliconf.LoadConfig()
if err != nil {
return err
}
- if cfg.Storage.BaseURL == "" {
+ sc, ok := cfg.ResolveStorage(c.String("profile"))
+ if !ok || sc.BaseURL == "" {
+ if p := c.String("profile"); p != "" {
+ return fmt.Errorf("storage profile %q is not defined on this machine; run `disttown storage setup --profile %s` first", p, p)
+ }
return fmt.Errorf("no storage configured; run `disttown storage setup` first")
}
var upload bucket.UploadFunc
- if cfg.Storage.UploadCommand != "" {
- upload = uploadViaCommand(cfg.Storage.UploadCommand)
+ if sc.UploadCommand != "" {
+ upload = uploadViaCommand(sc.UploadCommand)
}
- results, ok := bucket.Doctor(c.Context, nil, cfg.Storage.BaseURL, upload)
+ results, ok := bucket.Doctor(c.Context, nil, sc.BaseURL, upload)
for _, r := range results {
mark := "✓"
if !r.OK {
diff --git a/disttown.jsonc b/disttown.jsonc
new file mode 100644
index 0000000..79fcab1
--- /dev/null
+++ b/disttown.jsonc
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://dist.town/disttown.schema.json",
+ // The publishable project in this monorepo; the appview and web
+ // deploy differently and are not releases.
+ "project": "dist-town:cli",
+ "publisher": "did:plc:57od6g2ic3e3b3kauctjmo3k",
+ // dist.sh leaves the cross-platform matrix here.
+ "artifacts": ["dist/disttown-*"],
+ // Release tarballs are small; bytes live on the publisher's PDS.
+ "storage": "blobs",
+}
diff --git a/flake.nix b/flake.nix
index 8b5c48b..2e51240 100644
--- a/flake.nix
+++ b/flake.nix
@@ -30,18 +30,30 @@
});
packages = forAllSystems (pkgs: rec {
- disttown = pkgs.buildGoModule {
+ # Version and update source stamp into the nix build too, so a
+ # flake-built binary reports honestly instead of "dev"
+ # (cli-ergonomics 5.2). +nix marks provenance: the store path
+ # is immutable, so `disttown update` reports rather than
+ # replaces.
+ disttown = pkgs.buildGoModule rec {
pname = "disttown";
- version = "0.1.0";
+ version = "0.6.0";
src = ./.;
- vendorHash = "sha256-tzsT9+be8xbGnr5E8U5H9eHxe0l9l/pBGP5ZbSJp5vc=";
+ vendorHash = "sha256-xOutx3SYutQ7DZ5i/NUUNiVAz0a/aF4UmypwDsCuweo=";
subPackages = [ "cmd/disttown" ];
+ ldflags = [
+ "-s"
+ "-w"
+ "-X main.version=${version}+nix"
+ "-X main.updateSourceDID=did:plc:57od6g2ic3e3b3kauctjmo3k"
+ "-X main.updateSourceProject=dist-town:cli"
+ ];
};
appview = pkgs.buildGoModule {
pname = "dist-town-appview";
version = "0.1.0";
src = ./.;
- vendorHash = "sha256-tzsT9+be8xbGnr5E8U5H9eHxe0l9l/pBGP5ZbSJp5vc=";
+ vendorHash = "sha256-xOutx3SYutQ7DZ5i/NUUNiVAz0a/aF4UmypwDsCuweo=";
subPackages = [ "cmd/appview" ];
};
# The operator's moderation client — deliberately not part of
@@ -50,7 +62,7 @@
pname = "disttown-admin";
version = "0.1.0";
src = ./.;
- vendorHash = "sha256-tzsT9+be8xbGnr5E8U5H9eHxe0l9l/pBGP5ZbSJp5vc=";
+ vendorHash = "sha256-xOutx3SYutQ7DZ5i/NUUNiVAz0a/aF4UmypwDsCuweo=";
subPackages = [ "cmd/disttown-admin" ];
};
# The SvelteKit UI as a runnable adapter-node build: only node
diff --git a/go.mod b/go.mod
index 3b20aed..1753a6a 100644
--- a/go.mod
+++ b/go.mod
@@ -74,6 +74,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
+ github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
diff --git a/go.sum b/go.sum
index 77a8e78..acddc35 100644
--- a/go.sum
+++ b/go.sum
@@ -41,6 +41,7 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
@@ -241,6 +242,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys=
+github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
diff --git a/internal/appview/disttown.schema.json b/internal/appview/disttown.schema.json
new file mode 100644
index 0000000..6cb3244
--- /dev/null
+++ b/internal/appview/disttown.schema.json
@@ -0,0 +1,56 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://dist.town/disttown.schema.json",
+ "title": "disttown.jsonc",
+ "description": "Committed repo facts for the disttown CLI: the project this repo publishes, its artifact entries, its storage choice, and the publisher it expects. JWCC (JSON with comments and trailing commas) is accepted.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["project"],
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+ "project": {
+ "type": "string",
+ "description": "The project this repo publishes (colons nest, e.g. \"dist-town:cli\")."
+ },
+ "publisher": {
+ "type": "string",
+ "pattern": "^did:",
+ "description": "The DID expected to publish. A session with a different DID is warned at preflight and offered adoption — the multi-account save and the fork on-ramp."
+ },
+ "derivedFrom": {
+ "type": "string",
+ "pattern": "^at://",
+ "description": "The at-uri of the project this one derives from. Written by the adopt flow; carried into the project record as an unverified claim."
+ },
+ "artifacts": {
+ "type": "array",
+ "description": "What each release contains. Globs resolve relative to this file's directory only — never absolute, never escaping upward.",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Glob shorthand, e.g. \"dist/myapp-*\"."
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["glob"],
+ "properties": {
+ "glob": { "type": "string" },
+ "storage": {
+ "type": "string",
+ "description": "Storage profile for this entry only, overriding the manifest default."
+ }
+ }
+ }
+ ]
+ }
+ },
+ "storage": {
+ "type": "string",
+ "description": "Named storage profile from this machine's `disttown storage setup --profile`, or \"blobs\" to store bytes on the publisher's own PDS. Definitions never live here — the repo picks which, the machine defines how."
+ }
+ }
+}
diff --git a/internal/appview/server.go b/internal/appview/server.go
index b363f28..17875dd 100644
--- a/internal/appview/server.go
+++ b/internal/appview/server.go
@@ -2,6 +2,7 @@ package appview
import (
"context"
+ _ "embed"
"encoding/json"
"fmt"
"net/http"
@@ -16,6 +17,12 @@ import (
"dist.town/internal/index"
)
+// manifestSchema is the published JSON Schema for disttown.jsonc,
+// served at the URL the CLI's init scaffold references.
+//
+//go:embed disttown.schema.json
+var manifestSchema []byte
+
// IdentityResolver resolves an at-identifier (handle or DID) to a
// DID plus display handle.
type IdentityResolver interface {
@@ -40,6 +47,13 @@ type Server struct {
// the Tap webhook.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
+ // The manifest's JSON Schema, at the URL every scaffold's $schema
+ // line names — editors fetch it for completion and validation.
+ mux.HandleFunc("GET /disttown.schema.json", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/schema+json")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(manifestSchema)
+ })
mux.HandleFunc("GET /xrpc/town.dist.resolveRelease", s.handleResolveRelease)
mux.HandleFunc("GET /xrpc/town.dist.listReleases", s.handleListReleases)
mux.HandleFunc("GET /xrpc/town.dist.getProject", s.handleGetProject)
@@ -218,6 +232,15 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
return
}
pv.Children = projectSummaries(st, p.Name)
+ // Resolve the lineage claim's publisher for display, best-effort:
+ // the DID stays authoritative when resolution fails.
+ if pv.DerivedFrom != nil {
+ if u, err := syntax.ParseATURI(*pv.DerivedFrom); err == nil {
+ if _, h, _, err := s.Resolver.Resolve(r.Context(), u.Authority().String()); err == nil && h != "" {
+ pv.DerivedFromHandle = &h
+ }
+ }
+ }
writeJSON(w, map[string]any{"project": pv})
}
diff --git a/internal/appview/server_test.go b/internal/appview/server_test.go
index a501bbc..92eb2d2 100644
--- a/internal/appview/server_test.go
+++ b/internal/appview/server_test.go
@@ -704,3 +704,45 @@ func TestResolveReleaseRetracted(t *testing.T) {
t.Fatalf("retracted release still listed: %s", rec.Body)
}
}
+
+// Scenarios (cli-ergonomics D7): the derivative's own page states its
+// lineage claim; the upstream page aggregates nothing inbound, no
+// matter who claims descent from it.
+func TestDerivedFromClaim(t *testing.T) {
+ srv, store, _ := seed(t)
+ ingest(t, store, "create", canon.NSIDProject, "fork-cli", map[string]any{
+ "$type": canon.NSIDProject, "name": "fork-cli",
+ "derivedFrom": "at://" + did + "/" + canon.NSIDProject + "/my-cli",
+ })
+ h := srv.Handler()
+
+ rec := get(t, h, "/xrpc/town.dist.getProject?repo="+handle+"&project=fork-cli")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
+ }
+ var out struct {
+ Project struct {
+ DerivedFrom string `json:"derivedFrom"`
+ DerivedFromHandle string `json:"derivedFromHandle"`
+ } `json:"project"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Project.DerivedFrom != "at://"+did+"/"+canon.NSIDProject+"/my-cli" {
+ t.Fatalf("derivative page missing its claim: %s", rec.Body)
+ }
+ if out.Project.DerivedFromHandle != handle {
+ t.Fatalf("handle not resolved for display: %s", rec.Body)
+ }
+
+ // The upstream page is unforgeable: no inbound aggregation, no
+ // mention of the claimant anywhere in its response.
+ rec = get(t, h, "/xrpc/town.dist.getProject?repo="+handle+"&project=my-cli")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
+ }
+ if strings.Contains(rec.Body.String(), "fork-cli") || strings.Contains(rec.Body.String(), "derivedFrom") {
+ t.Fatalf("upstream page must aggregate nothing inbound: %s", rec.Body)
+ }
+}
diff --git a/internal/appview/views.go b/internal/appview/views.go
index 5023922..d376e8e 100644
--- a/internal/appview/views.go
+++ b/internal/appview/views.go
@@ -240,6 +240,10 @@ func projectView(ctx context.Context, store *index.Store, did, handle, pds strin
if p.SourceAuthority != "" {
v.SourceAuthority = &p.SourceAuthority
}
+ // The outbound lineage claim, verbatim (cli-ergonomics D7). The
+ // inverse — aggregating who claims derivation FROM a project — is
+ // deliberately nowhere: an endorsement-forgery surface.
+ v.DerivedFrom = p.Record.DerivedFrom
for _, name := range sortedPointerNames(p) {
ptr := p.Pointers[name]
v.Pointers = append(v.Pointers, &gen.Defs_PointerView{
diff --git a/internal/cliconf/cliconf.go b/internal/cliconf/cliconf.go
index ec837e3..aa0efcb 100644
--- a/internal/cliconf/cliconf.go
+++ b/internal/cliconf/cliconf.go
@@ -20,6 +20,21 @@ const (
EnvPDS = "DISTTOWN_PDS"
EnvIdentifier = "DISTTOWN_IDENTIFIER"
EnvAppPassword = "DISTTOWN_APP_PASSWORD"
+ // Storage equivalents for file-free CI: when EnvStorageBaseURL is
+ // set, it and EnvStorageUploadCmd define the storage used,
+ // overriding any profile a manifest names (env beats manifest in
+ // the one-line precedence: flag > env > manifest > cliconf).
+ EnvStorageBaseURL = "DISTTOWN_STORAGE_BASE_URL"
+ EnvStorageUploadCmd = "DISTTOWN_STORAGE_UPLOAD_CMD"
+)
+
+// DefaultProfile names the pre-profiles flat storage config; reading
+// it as a profile is the whole migration. BlobsProfile is reserved:
+// a manifest naming it publishes artifact bytes as PDS blobs, so no
+// machine definition may claim the name.
+const (
+ DefaultProfile = "default"
+ BlobsProfile = "blobs"
)
// Config is durable, non-secret local configuration.
@@ -32,8 +47,14 @@ type Config struct {
Identifier string `json:"identifier,omitempty"`
// OAuth locates the cached OAuth session.
OAuth *OAuthConfig `json:"oauth,omitempty"`
- // Storage configures the BYOS bucket.
+ // Storage configures the BYOS bucket — read as the "default"
+ // profile when no named profile shadows it.
Storage StorageConfig `json:"storage"`
+ // StorageProfiles are named bucket configurations; a repo's
+ // manifest picks which by name, this machine defines how
+ // (cli-ergonomics D4). Secrets and upload tooling never leave the
+ // machine.
+ StorageProfiles map[string]StorageConfig `json:"storageProfiles,omitempty"`
// LastUpdateRKey is the release rkey the last self-update
// installed: TID order against it detects pointer rollback, which
// proceeds loudly rather than silently (cli-self-update D2).
@@ -110,6 +131,27 @@ func save(name string, v any) error {
return os.WriteFile(filepath.Join(d, name), b, 0o600)
}
+// ResolveStorage resolves a profile name to a storage definition.
+// The empty name and "default" read the flat config (aliased through
+// the profile map when a "default" entry exists); the env equivalents
+// override everything for file-free CI. The boolean reports whether a
+// definition was found at all — a manifest naming an undefined
+// profile must fail preflight naming the remedy, not fall through to
+// the wrong bucket.
+func (c Config) ResolveStorage(profile string) (StorageConfig, bool) {
+ if base := os.Getenv(EnvStorageBaseURL); base != "" {
+ return StorageConfig{BaseURL: base, UploadCommand: os.Getenv(EnvStorageUploadCmd)}, true
+ }
+ if profile == "" || profile == DefaultProfile {
+ if p, ok := c.StorageProfiles[DefaultProfile]; ok {
+ return p, true
+ }
+ return c.Storage, c.Storage.BaseURL != ""
+ }
+ p, ok := c.StorageProfiles[profile]
+ return p, ok
+}
+
// LoadConfig reads config, returning a zero config when none exists.
func LoadConfig() (Config, error) {
var c Config
diff --git a/internal/cliconf/storage_test.go b/internal/cliconf/storage_test.go
new file mode 100644
index 0000000..58d7e71
--- /dev/null
+++ b/internal/cliconf/storage_test.go
@@ -0,0 +1,47 @@
+package cliconf
+
+import "testing"
+
+// TestResolveStorageAliasing pins the migration: the pre-profiles
+// flat config answers as "default" with no rewrite, a "default"
+// profile entry shadows it, and undefined names report absence
+// rather than falling through to the wrong bucket.
+func TestResolveStorageAliasing(t *testing.T) {
+ t.Setenv(EnvStorageBaseURL, "")
+ flat := Config{Storage: StorageConfig{BaseURL: "https://flat.example"}}
+ for _, name := range []string{"", DefaultProfile} {
+ sc, ok := flat.ResolveStorage(name)
+ if !ok || sc.BaseURL != "https://flat.example" {
+ t.Fatalf("flat config as %q: %v %+v", name, ok, sc)
+ }
+ }
+
+ shadowed := flat
+ shadowed.StorageProfiles = map[string]StorageConfig{
+ DefaultProfile: {BaseURL: "https://named.example"},
+ "cdn": {BaseURL: "https://cdn.example"},
+ }
+ if sc, _ := shadowed.ResolveStorage(""); sc.BaseURL != "https://named.example" {
+ t.Fatalf("default profile must shadow flat: %+v", sc)
+ }
+ if sc, ok := shadowed.ResolveStorage("cdn"); !ok || sc.BaseURL != "https://cdn.example" {
+ t.Fatalf("named profile: %v %+v", ok, sc)
+ }
+ if _, ok := shadowed.ResolveStorage("nope"); ok {
+ t.Fatal("undefined profile must report absence")
+ }
+ if _, ok := (Config{}).ResolveStorage(""); ok {
+ t.Fatal("empty machine has no storage")
+ }
+}
+
+// TestResolveStorageEnv: the env equivalents define storage outright
+// for file-free CI, overriding any profile the manifest named.
+func TestResolveStorageEnv(t *testing.T) {
+ t.Setenv(EnvStorageBaseURL, "https://ci.example")
+ t.Setenv(EnvStorageUploadCmd, "upload {file} {key}")
+ sc, ok := (Config{}).ResolveStorage("cdn")
+ if !ok || sc.BaseURL != "https://ci.example" || sc.UploadCommand != "upload {file} {key}" {
+ t.Fatalf("env storage: %v %+v", ok, sc)
+ }
+}
diff --git a/internal/gen/distdefs.go b/internal/gen/distdefs.go
index 15cbc8b..755f265 100644
--- a/internal/gen/distdefs.go
+++ b/internal/gen/distdefs.go
@@ -300,15 +300,19 @@ type Defs_ProjectSummaryView struct {
// Page-shaped project state: everything the project page renders in one call.
type Defs_ProjectView struct {
// children: Direct child projects, derived by name prefix (hierarchy lives in the name; parentage is never stored). Absent when the project has no children.
- Children []*Defs_ProjectSummaryView `json:"children,omitempty" cborgen:"children,omitempty"`
- Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
- Did string `json:"did" cborgen:"did"`
- DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"`
- Handle *string `json:"handle,omitempty" cborgen:"handle,omitempty"`
- Lifecycle *Defs_LifecycleView `json:"lifecycle" cborgen:"lifecycle"`
- Name string `json:"name" cborgen:"name"`
- Pointers []*Defs_PointerView `json:"pointers" cborgen:"pointers"`
- Publication *string `json:"publication,omitempty" cborgen:"publication,omitempty"`
+ Children []*Defs_ProjectSummaryView `json:"children,omitempty" cborgen:"children,omitempty"`
+ // derivedFrom: The project record's derivedFrom, verbatim: an unverified cross-DID claim, rendered only on this project's own page and never aggregated inbound.
+ DerivedFrom *string `json:"derivedFrom,omitempty" cborgen:"derivedFrom,omitempty"`
+ // derivedFromHandle: The referenced publisher's handle, resolved for display. Absent when resolution fails; the DID in derivedFrom remains authoritative.
+ DerivedFromHandle *string `json:"derivedFromHandle,omitempty" cborgen:"derivedFromHandle,omitempty"`
+ Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
+ Did string `json:"did" cborgen:"did"`
+ DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"`
+ Handle *string `json:"handle,omitempty" cborgen:"handle,omitempty"`
+ Lifecycle *Defs_LifecycleView `json:"lifecycle" cborgen:"lifecycle"`
+ Name string `json:"name" cborgen:"name"`
+ Pointers []*Defs_PointerView `json:"pointers" cborgen:"pointers"`
+ Publication *string `json:"publication,omitempty" cborgen:"publication,omitempty"`
// releases: Releases in creation order, disputed claimants included and flagged.
Releases []*Defs_ReleaseView `json:"releases,omitempty" cborgen:"releases,omitempty"`
RenamedTo *string `json:"renamedTo,omitempty" cborgen:"renamedTo,omitempty"`
diff --git a/lexicons/town/dist/defs.json b/lexicons/town/dist/defs.json
index e63da9c..7bd80e8 100644
--- a/lexicons/town/dist/defs.json
+++ b/lexicons/town/dist/defs.json
@@ -343,6 +343,16 @@
},
"website": { "type": "string", "format": "uri" },
"renamedTo": { "type": "string", "format": "at-uri" },
+ "derivedFrom": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The project record's derivedFrom, verbatim: an unverified cross-DID claim, rendered only on this project's own page and never aggregated inbound."
+ },
+ "derivedFromHandle": {
+ "type": "string",
+ "format": "handle",
+ "description": "The referenced publisher's handle, resolved for display. Absent when resolution fails; the DID in derivedFrom remains authoritative."
+ },
"lifecycle": { "type": "ref", "ref": "town.dist.defs#lifecycleView" },
"pointers": {
"type": "array",
diff --git a/openspec/changes/cli-ergonomics/tasks.md b/openspec/changes/cli-ergonomics/tasks.md
index e0fb1d1..5e3192b 100644
--- a/openspec/changes/cli-ergonomics/tasks.md
+++ b/openspec/changes/cli-ergonomics/tasks.md
@@ -2,40 +2,40 @@
## 1. The manifest
-- [ ] 1.1 `disttown.jsonc` loading with walk-up resolution (nearest wins), read as JWCC via `hujson.Standardize` into `encoding/json`; parse the artifact entry union (string sugar, `{glob, storage}` objects, loud refusal of unknown kinds); repo-relative glob containment with no absolute paths or `..` escapes (design D1–D3)
-- [ ] 1.2 A published JSON Schema for the manifest; `init` writes a commented scaffold referencing it — the self-documenting output JWCC restores
-- [ ] 1.3 Tests: walk-up precedence, containment refusals, entry-union parsing including the unknown-kind refusal
+- [x] 1.1 `disttown.jsonc` loading with walk-up resolution (nearest wins), read as JWCC via `hujson.Standardize` into `encoding/json`; parse the artifact entry union (string sugar, `{glob, storage}` objects, loud refusal of unknown kinds); repo-relative glob containment with no absolute paths or `..` escapes (design D1–D3)
+- [x] 1.2 A published JSON Schema for the manifest; `init` writes a commented scaffold referencing it — the self-documenting output JWCC restores
+- [x] 1.3 Tests: walk-up precedence, containment refusals, entry-union parsing including the unknown-kind refusal
## 2. Storage profiles
-- [ ] 2.1 `cliconf` named profiles with the flat config read as `"default"`; `"blobs"` reserved; env equivalents for CI; `storage setup --profile ` (design D4)
-- [ ] 2.2 Publish resolves each entry's storage through the profile map; undefined profile fails preflight naming the remedy
-- [ ] 2.3 Tests: aliasing migration, per-entry routing, missing-profile preflight
+- [x] 2.1 `cliconf` named profiles with the flat config read as `"default"`; `"blobs"` reserved; env equivalents for CI; `storage setup --profile ` (design D4)
+- [x] 2.2 Publish resolves each entry's storage through the profile map; undefined profile fails preflight naming the remedy
+- [x] 2.3 Tests: aliasing migration, per-entry routing, missing-profile preflight
- [ ] 2.4 Un-defer publish-safety's archived 3.2: with a bucket profile configured, verify a retried publish reclaims accounted orphan keys
## 3. Inference and the plan gate
-- [ ] 3.1 Project inference across all commands (writes' `--project` and reads' positional relax to overrides; reads state the inference beside the source line)
-- [ ] 3.2 Version proposal from an annotated tag at HEAD: strip leading `v` before a digit, verbatim otherwise, provenance shown in the plan; no tag and no argument errors with both remedies (design D5)
-- [ ] 3.3 Interactive publish renders the full plan — inferred values with their sources, resolved files, storage routing, pointer moves — and asks in the update consent grammar; `--yes` skips; non-interactive behavior byte-identical to today (design D5)
-- [ ] 3.4 Tests: precedence chain (flag > env > manifest > cliconf > ask), tag-proposal cases, plan gating (tty prompts, `--yes` skips, non-tty unchanged)
+- [x] 3.1 Project inference across all commands (writes' `--project` and reads' positional relax to overrides; reads state the inference beside the source line)
+- [x] 3.2 Version proposal from an annotated tag at HEAD: strip leading `v` before a digit, verbatim otherwise, provenance shown in the plan; no tag and no argument errors with both remedies (design D5)
+- [x] 3.3 Interactive publish renders the full plan — inferred values with their sources, resolved files, storage routing, pointer moves — and asks in the update consent grammar; `--yes` skips; non-interactive behavior byte-identical to today (design D5)
+- [x] 3.4 Tests: precedence chain (flag > env > manifest > cliconf > ask), tag-proposal cases, plan gating (tty prompts, `--yes` skips, non-tty unchanged)
## 4. Publisher guard and lineage
-- [ ] 4.1 Preflight compares the manifest's `publisher` DID to the session; mismatch warns before any byte and offers abort or adopt (design D6)
-- [ ] 4.2 Adopt rewrites `publisher` and moves the original project reference into `derivedFrom` via hujson's format-preserving patch — the user's comments and formatting survive the edit — stating what changed (design D1, D6)
+- [x] 4.1 Preflight compares the manifest's `publisher` DID to the session; mismatch warns before any byte and offers abort or adopt (design D6)
+- [x] 4.2 Adopt rewrites `publisher` and moves the original project reference into `derivedFrom` via hujson's format-preserving patch — the user's comments and formatting survive the edit — stating what changed (design D1, D6)
- [x] 4.3 Lexicon: `derivedFrom` on the project record, regen, freeze-coordinated (ride `release-retraction`'s lexicon commit if it lands first; note in `foundation-graduation` either way) (design D7)
-- [ ] 4.4 Publish and `project create`/`set` carry manifest lineage into the record; surfaces render it claim-styled on the derivative's page only, nothing inbound (design D7)
-- [ ] 4.5 Tests: mismatch gating, adopt rewrite, lineage field round-trip; a surface test that the upstream page aggregates nothing
+- [x] 4.4 Publish and `project create`/`set` carry manifest lineage into the record; surfaces render it claim-styled on the derivative's page only, nothing inbound (design D7)
+- [x] 4.5 Tests: mismatch gating, adopt rewrite, lineage field round-trip; a surface test that the upstream page aggregates nothing
## 5. init and riders
-- [ ] 5.1 `disttown init`: propose the project name, detect `dist/`, ask storage (profile / blobs / later), write the manifest, point at `login` and `doctor` (design D8)
-- [ ] 5.2 Flake stamping: version and update-source ldflags in `buildGoModule`, closing self-update's dev-report wart
-- [ ] 5.3 Regenerate the CLI reference; flag the new prompt copy (plan gate, publisher mismatch, adopt) in the copy inventory — all three are trust-critical
+- [x] 5.1 `disttown init`: propose the project name, detect `dist/`, ask storage (profile / blobs / later), write the manifest, point at `login` and `doctor` (design D8)
+- [x] 5.2 Flake stamping: version and update-source ldflags in `buildGoModule`, closing self-update's dev-report wart
+- [x] 5.3 Regenerate the CLI reference; flag the new prompt copy (plan gate, publisher mismatch, adopt) in the copy inventory — all three are trust-critical
## 6. Dogfood and proof
- [ ] 6.1 This repo gains its own `disttown.jsonc` (`dist-town/cli`, `dist/disttown-*`, `storage: "blobs"`, `publisher`) — the next release cut is the first argumentless `disttown publish`, plan gate and all
-- [ ] 6.2 Verify the guard live with a second account or the env override; verify adopt produces a correct `derivedFrom` in a scratch clone
+- [x] 6.2 Verify the guard live with a second account or the env override; verify adopt produces a correct `derivedFrom` in a scratch clone
- [ ] 6.3 Update `start-here` and the README's publish examples to the manifest arc — flag for the copy pass
diff --git a/packages/client/src/generated/lexicons.ts b/packages/client/src/generated/lexicons.ts
index 2d00485..2ea7052 100644
--- a/packages/client/src/generated/lexicons.ts
+++ b/packages/client/src/generated/lexicons.ts
@@ -542,6 +542,18 @@ export const schemaDict = {
type: 'string',
format: 'at-uri',
},
+ derivedFrom: {
+ type: 'string',
+ format: 'at-uri',
+ description:
+ "The project record's derivedFrom, verbatim: an unverified cross-DID claim, rendered only on this project's own page and never aggregated inbound.",
+ },
+ derivedFromHandle: {
+ type: 'string',
+ format: 'handle',
+ description:
+ "The referenced publisher's handle, resolved for display. Absent when resolution fails; the DID in derivedFrom remains authoritative.",
+ },
lifecycle: {
type: 'ref',
ref: 'lex:town.dist.defs#lifecycleView',
diff --git a/packages/client/src/generated/types/town/dist/defs.ts b/packages/client/src/generated/types/town/dist/defs.ts
index b349761..0d04d9b 100644
--- a/packages/client/src/generated/types/town/dist/defs.ts
+++ b/packages/client/src/generated/types/town/dist/defs.ts
@@ -383,6 +383,10 @@ export interface ProjectView {
sourceAuthority?: 'self-authenticated' | 'claim' | (string & {})
website?: string
renamedTo?: string
+ /** The project record's derivedFrom, verbatim: an unverified cross-DID claim, rendered only on this project's own page and never aggregated inbound. */
+ derivedFrom?: string
+ /** The referenced publisher's handle, resolved for display. Absent when resolution fails; the DID in derivedFrom remains authoritative. */
+ derivedFromHandle?: string
lifecycle: LifecycleView
pointers: PointerView[]
/** Releases in creation order, disputed claimants included and flagged. */
diff --git a/web/src/lib/components/ProjectShell.svelte b/web/src/lib/components/ProjectShell.svelte
index badf7c4..b1012bb 100644
--- a/web/src/lib/components/ProjectShell.svelte
+++ b/web/src/lib/components/ProjectShell.svelte
@@ -50,6 +50,16 @@
const name = renamedTo.split('/').pop()
return name ? `/${handle}/${projectPath(name)}` : null
}
+ // The outbound lineage claim (cli-ergonomics D7): parsed for display
+ // from the at-uri, preferring the resolved handle. Rendered only on
+ // this project's own page — never aggregated on the upstream's.
+ const lineage = $derived.by(() => {
+ if (!project.derivedFrom) return null
+ const m = project.derivedFrom.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/)
+ if (!m) return null
+ const who = project.derivedFromHandle ?? m[1]
+ return { label: `@${who}/${projectPath(m[2])}`, href: `/${who}/${projectPath(m[2])}` }
+ })
@@ -78,6 +88,13 @@
{/if}
{/if}
{#if project.website}website{/if}
+ {#if lineage}
+ derived from {lineage.label}
+ {/if}
diff --git a/web/src/routes/docs/cli/+page.md b/web/src/routes/docs/cli/+page.md
index 35efa9a..ea3472b 100644
--- a/web/src/routes/docs/cli/+page.md
+++ b/web/src/routes/docs/cli/+page.md
@@ -5,6 +5,18 @@ description: Every disttown command, with flags — generated from the CLI itsel
All commands operate on your own repo via your logged-in session. Project names are accepted in both the URL form (`dist-town/cli`) and the record form (`dist-town:cli`). This page is generated from the CLI's own command definitions.
+## init
+
+```sh
+disttown init
+```
+
+Write this repo's disttown.jsonc — the committed facts publish infers from.
+
+- `--project ` — project name (default: proposed from the directory name)
+- `--storage ` — storage choice: a profile name, "blobs", or empty to decide later
+- `--yes` — accept the proposals without prompting
+
## login
```sh
@@ -71,6 +83,7 @@ Record the bucket's public base URL and optional upload command.
- `--base-url ` — public base URL artifacts are served from (required)
- `--upload-cmd ` — shell command template with {String.fromCharCode(123)}file{String.fromCharCode(125)} and {String.fromCharCode(123)}key{String.fromCharCode(125)} placeholders (e.g. 'aws s3 cp {String.fromCharCode(123)}file{String.fromCharCode(125)} s3://bkt/{String.fromCharCode(123)}key{String.fromCharCode(125)}')
+- `--profile ` — save as a named profile a repo manifest can pick (default: the machine-wide default bucket)
### storage doctor
@@ -80,16 +93,17 @@ disttown storage doctor
Check the bucket against dist.town's BYOS requirements.
+- `--profile ` — check a named storage profile instead of the default bucket
## publish
```sh
-disttown publish --project
+disttown publish [] []
```
Publish a release: hash, upload, and write records atomically.
-- `--project ` — project name (required)
+- `--project ` — project name (default: the governing manifest's)
- `--channel ` — release channel (e.g. stable, beta); also redirects the default pointer to the channel name — latest moves only when named
- `--license ` — SPDX license identifier for this release
- `--pointer ` — pointers to move to this release; naming any replaces the default entirely (default: the channel name when --channel is set, else latest); repeatable
@@ -101,6 +115,7 @@ Publish a release: hash, upload, and write records atomically.
- `--git` — pin the source claim to the local checkout's HEAD commit (no tag)
- `--attach-tangled` — attach tag-matched Tangled artifacts without prompting (for CI)
- `--dry-run` — print the plan — digests, records, pointer moves — uploading nothing and writing nothing
+- `--yes` — skip the interactive plan confirmation
## release
@@ -121,7 +136,7 @@ List releases: one project's, or the whole repo's when no project is named.
### release show
```sh
-disttown release show
+disttown release show []
```
Show one release in full: artifacts, digests, locations, pointers, source, changelog.
@@ -133,7 +148,7 @@ Show one release in full: artifacts, digests, locations, pointers, source, chang
### release history
```sh
-disttown release history
+disttown release history []
```
Print a release's full lifecycle timeline — every statement, reasons intact, the governing one marked.
@@ -143,7 +158,7 @@ Print a release's full lifecycle timeline — every statement, reasons intact, t
### release yank
```sh
-disttown release yank
+disttown release yank []
```
Mark a release do-not-newly-adopt; pins keep working.
@@ -155,7 +170,7 @@ Mark a release do-not-newly-adopt; pins keep working.
### release unyank
```sh
-disttown release unyank
+disttown release unyank []
```
Restore a yanked release to active; the yank stays in history.
@@ -166,7 +181,7 @@ Restore a yanked release to active; the yank stays in history.
### release deprecate
```sh
-disttown release deprecate
+disttown release deprecate []
```
Mark a release still-works-stop-building-on-it; it keeps resolving, with a warning.
@@ -178,7 +193,7 @@ Mark a release still-works-stop-building-on-it; it keeps resolving, with a warni
### release undeprecate
```sh
-disttown release undeprecate
+disttown release undeprecate []
```
Restore a deprecated release to active; the deprecation stays in history.
@@ -189,7 +204,7 @@ Restore a deprecated release to active; the deprecation stays in history.
### release retract
```sh
-disttown release retract
+disttown release retract []
```
Last resort: delete a mistake-release, leaving testimony — yank is the norm for anything that ever had a consumer.
@@ -205,7 +220,7 @@ Manage moving pointers (latest, next, ...)
### pointer list
```sh
-disttown pointer list
+disttown pointer list []
```
List a project's pointers and the versions they target.
@@ -217,7 +232,7 @@ List a project's pointers and the versions they target.
### pointer remove
```sh
-disttown pointer remove
+disttown pointer remove []
```
Delete a pointer — a mutable alias nobody pins by identity; the name stops resolving.
@@ -227,12 +242,12 @@ Delete a pointer — a mutable alias nobody pins by identity; the name stops res
### pointer set
```sh
-disttown pointer set --project
+disttown pointer set
```
Point a named pointer at a published version.
-- `--project ` — project name (required)
+- `--project ` — project name (default: the governing manifest's)
## changelog
@@ -241,12 +256,12 @@ Author release notes as standard.site documents.
### changelog set
```sh
-disttown changelog set --project --file
+disttown changelog set --file
```
Attach or update release notes for a published version.
-- `--project ` — project name (required)
+- `--project ` — project name (default: the governing manifest's)
- `--file ` — markdown file with the notes (plaintext fallback is derived) (required)
- `--title ` — document title (default: "<project> <version>")
- `--site ` — document site (default: the project's publication, else its dist.town page)
@@ -256,23 +271,23 @@ Attach or update release notes for a published version.
### changelog link
```sh
-disttown changelog link --project --doc
+disttown changelog link --doc
```
Adopt an existing document as a release's changelog — appends the release link, changes nothing else.
-- `--project ` — project name (required)
+- `--project ` — project name (default: the governing manifest's)
- `--doc ` — the document to adopt: an rkey or at-uri in your own repo (required)
### changelog unlink
```sh
-disttown changelog unlink --project --doc
+disttown changelog unlink --doc
```
Detach a document from a release — removes the release link, changes nothing else.
-- `--project ` — project name (required)
+- `--project ` — project name (default: the governing manifest's)
- `--doc ` — the document to detach: an rkey or at-uri in your own repo (required)
## project
@@ -294,7 +309,7 @@ List your projects, with alias and successor state.
### project create
```sh
-disttown project create
+disttown project create []
```
Create a bare project record (e.g. an umbrella; publish creates leaf projects on first release)
@@ -305,7 +320,7 @@ Create a bare project record (e.g. an umbrella; publish creates leaf projects on
### project set
```sh
-disttown project set
+disttown project set []
```
Update a project's optional fields: only the flags you pass change, and passing an empty value clears that field.
@@ -318,7 +333,7 @@ Update a project's optional fields: only the flags you pass change, and passing
### project history
```sh
-disttown project history
+disttown project history []
```
Print a project's full lifecycle timeline — every statement, reasons intact, the governing one marked.
@@ -328,7 +343,7 @@ Print a project's full lifecycle timeline — every statement, reasons intact, t
### project archive
```sh
-disttown project archive
+disttown project archive []
```
Mark a project archived; releases and pins keep working.
@@ -339,7 +354,7 @@ Mark a project archived; releases and pins keep working.
### project unarchive
```sh
-disttown project unarchive
+disttown project unarchive []
```
Restore an archived project to active; the archival stays in history.
@@ -363,12 +378,12 @@ Manage the standard.site publication a project's changelogs belong to.
### publication set
```sh
-disttown publication set --project
+disttown publication set
```
Create a publication anchored at a project, or link a project to an existing one.
-- `--project ` — anchor project (created publications) or project to link (required)
+- `--project ` — anchor project (created publications) or project to link (default: the governing manifest's)
- `--name ` — publication name (required when creating)
- `--description ` — brief description
- `--icon ` — square image file, at least 256x256