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