diff --git a/CHANGELOG.md b/CHANGELOG.md index fe63e4b..8a03488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`positronick research`**: the agent-facing "what's new" feed over positronick.com's published blog posts, mirrored GitHub releases, and mirrored news links — newest first, so agents avoid stale knowledge. Filter with `--kind` (article/release/link), `--category`, `--tag`, or a free-text query, and poll just the delta with `--since `; the printed `latest` timestamp is the value to pass back next time. Read-only and unauthenticated, like the soul/listing reads. Backed by the public `GET /api/research` endpoint. - **`positronick blog`**: read the positronick.com blog from the terminal — `blog list` (newest first, optional `--kind` article/release/link) and `blog show `, with `--raw` printing the markdown body verbatim and did-you-mean hints (exit 3) on a missing slug. Read-only and unauthenticated, mirroring the soul/listing reads. Backed by the public `GET /api/blog`, `/api/blog/{slug}`, and `/api/blog/{slug}.md` endpoints. - **`positronick feed` (admin)**: manage the blog feed sources (GitHub release / RSS mirroring) the ingestor polls — `feed list`, `feed create --label --feed-url --kind github_release|rss --category ` (`--author`/`--listing` attribution, repeatable `--tag`, `--auto-publish`, `--enabled`), `feed update ` (`--enabled=false` pauses a feed — there is no delete verb), and `feed sync ` (ingest one feed now, surfacing the fetch summary; a fetch/parse failure maps the API's 502 to a clear error). Backed by the `/api/admin/feeds` API. Ingesting every feed on a schedule stays the cron's job — no CLI subcommand carries that admin key. +- **`positronick post` (admin)**: create, update and list blog posts — `post create --file ` (YAML frontmatter + markdown body, field flags override), `post update ` and `post list`. New posts default to **draft** — an agent post never self-publishes — and there is no delete verb (unpublish with `--status draft`). Editing a feed-mirrored post takes `api` ownership so the ingestor stops refreshing it (`--source feed` hands it back). Hidden and revealed only for a cached admin; the server is the authorization authority (401/403 → exit 4). Backed by the new `POST`/`GET`/`PATCH /api/admin/posts` API. - **API type sync**: the wire types now mirror the latest `src/lib/types.ts` field-for-field, so `--json` no longer silently drops fields the server sends. Souls and listings gain `chargeCount` (the "energy boost" count); listings also gain `profileTier` (the author's seal), `hasAsset`/`assetVersion`/`assetContentHash` (hosted SKILL.md asset metadata — lets a client skip identical re-downloads), and `LoopData`/`SkillData` gain `bundles`. Human detail views surface `CHARGES`, a skill's `ASSET VERSION`, and loop/skill `BUNDLES`. ## [0.1.2] - 2026-06-16 diff --git a/internal/api/admin.go b/internal/api/admin.go index dbc7fb7..4ed7c18 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -36,6 +36,17 @@ type AdminProfile struct { Source string `json:"source"` } +// AdminPost is a blog post as the admin API returns it: the full public Post +// (markdown body included) plus Source, the ownership marker. For posts the +// values are "feed" (mirrored from a feed source by the ingestor, which keeps +// it fresh) and "api" (authored or edited through the write API, which the +// ingestor leaves alone). Editing a feed-owned post flips it to api, reported +// as tookOwnership. +type AdminPost struct { + Post + Source string `json:"source"` +} + // CreateSoul creates a soul: POST /api/admin/souls. fields is sent verbatim // as the JSON body — the server validates with the seed's own validator and // answers 422 with the validator message, 409 on a slug conflict. The id is @@ -231,3 +242,60 @@ func (c *Client) SyncFeed(ctx context.Context, id string) (*FeedSyncSummary, err } return nil, err } + +// CreatePost creates a blog post: POST /api/admin/posts. fields is sent +// verbatim as the JSON body — the server validates it and answers 422 with the +// validator message, 409 on a slug conflict. The id is server-assigned (the +// server rejects a client-supplied one) and status defaults to "draft": an +// agent-authored post cannot self-publish, it must be promoted deliberately. +func (c *Client) CreatePost(ctx context.Context, fields map[string]any) (*AdminPost, error) { + var out struct { + Post AdminPost `json:"post"` + } + if err := c.do(ctx, http.MethodPost, "/api/admin/posts", nil, fields, &out); err != nil { + return nil, err + } + return &out.Post, nil +} + +// AdminPost fetches one post by id, any status: GET /api/admin/posts/{id}. +func (c *Client) AdminPost(ctx context.Context, id string) (*AdminPost, error) { + var out struct { + Post AdminPost `json:"post"` + } + path := "/api/admin/posts/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil { + return nil, err + } + return &out.Post, nil +} + +// AdminPosts lists every post (any status, source included): GET +// /api/admin/posts. Admin only — the server answers 401/403 for non-admins. +// Named AdminPosts (not Posts) to leave the public, published-only blog gallery +// reader Posts(ctx, kind) untouched. +func (c *Client) AdminPosts(ctx context.Context) ([]AdminPost, error) { + var out struct { + Posts []AdminPost `json:"posts"` + } + if err := c.do(ctx, http.MethodGet, "/api/admin/posts", nil, nil, &out); err != nil { + return nil, err + } + return out.Posts, nil +} + +// UpdatePost patches a post: PATCH /api/admin/posts/{id}. patch carries only +// the fields to change — {"status":"draft"} unpublishes (there is no delete +// verb). tookOwnership is true when the row was feed-owned and this edit flipped +// it to api-owned: the ingestor will no longer refresh it from its source. +func (c *Client) UpdatePost(ctx context.Context, id string, patch map[string]any) (*AdminPost, bool, error) { + var out struct { + Post AdminPost `json:"post"` + TookOwnership bool `json:"tookOwnership"` + } + path := "/api/admin/posts/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodPatch, path, nil, patch, &out); err != nil { + return nil, false, err + } + return &out.Post, out.TookOwnership, nil +} diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 7a8a233..c80ad36 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -51,6 +51,7 @@ func registerAdminCommands(root *cobra.Command) { root.AddCommand(newListingCmd()) root.AddCommand(newProfileCmd()) root.AddCommand(newFeedCmd()) + root.AddCommand(newPostCmd()) revealAdminCommands(root) } diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 07abd80..b85e1aa 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -170,6 +170,10 @@ var adminCommandPaths = [][]string{ {"feed", "create"}, {"feed", "update"}, {"feed", "sync"}, + {"post"}, + {"post", "create"}, + {"post", "update"}, + {"post", "list"}, } // The admin commands are hidden by default and flip visible from the CACHED @@ -253,6 +257,7 @@ func TestRevealAdminCommands(t *testing.T) { "positronick profile create", "positronick profile list", "positronick feed list", "positronick feed create", "positronick feed update", "positronick feed sync", + "positronick post create", "positronick post update", "positronick post list", } { if !strings.Contains(stdout, "## "+want) { t.Errorf("agent-docs for an admin must document %q", want) diff --git a/internal/cli/agentdocs.go b/internal/cli/agentdocs.go index adf3bab..5fb2970 100644 --- a/internal/cli/agentdocs.go +++ b/internal/cli/agentdocs.go @@ -48,8 +48,8 @@ const agentDocsIntro = "`positronick` is the command-line client for positronick "driven by coding agents — pass `--json` to any command for stable machine-readable JSON on " + "stdout, read progress and errors from stderr, and branch on the exit code. Read commands " + "never prompt. Hidden admin commands (create/update for souls and listings, create/list for " + - "profiles, and list/create/update/sync for blog feed sources) exist and appear in help and " + - "in these docs after logging in with an admin account." + "profiles, list/create/update/sync for blog feed sources, and create/update/list for blog " + + "posts) exist and appear in help and in these docs after logging in with an admin account." func newAgentDocsCmd() *cobra.Command { return &cobra.Command{ diff --git a/internal/cli/post.go b/internal/cli/post.go new file mode 100644 index 0000000..61286a5 --- /dev/null +++ b/internal/cli/post.go @@ -0,0 +1,336 @@ +package cli + +import ( + "context" + "os" + "sort" + + "github.com/positronick/cli/internal/api" + "github.com/positronick/cli/internal/output" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// This file owns the hidden admin `post` command group (create/update/list) +// against /api/admin/posts — the blog "agent write API". Like the other admin +// commands they are Hidden + annotated (revealed for cached admins) and map a +// 401/403 to exit 4 via adminAPIError. Two things are deliberate and matter: +// create defaults status to "draft" (the server's default — an agent post must +// be promoted to publish, never self-publishes), and there is no delete verb +// (unpublish is `--status draft`). Ownership mirrors souls/listings but with +// feed semantics: editing a feed-mirrored post takes "api" ownership so the +// ingestor stops refreshing it; `--source feed` hands it back. + +// postCreateResult is the `post create --json` contract. +type postCreateResult struct { + Post api.AdminPost `json:"post"` + Created bool `json:"created"` +} + +// postUpdateResult is the `post update --json` contract — the server's response +// shape, tookOwnership included so agents can react to the feed→api flip. +type postUpdateResult struct { + Post api.AdminPost `json:"post"` + TookOwnership bool `json:"tookOwnership"` +} + +// postListResult is the `post list --json` contract. +type postListResult struct { + Count int `json:"count"` + Posts []api.AdminPost `json:"posts"` +} + +// postFieldFlagMap maps the post string-field flags to their API field names +// (the repeatable --tag is handled separately). +var postFieldFlagMap = [][2]string{ + {"slug", "slug"}, + {"title", "title"}, + {"excerpt", "excerpt"}, + {"description", "description"}, + {"category", "category"}, + {"kind", "kind"}, + {"version", "version"}, + {"author-handle", "authorHandle"}, + {"listing-slug", "listingSlug"}, + {"canonical-url", "canonicalUrl"}, + {"published-at", "publishedAt"}, +} + +func newPostCmd() *cobra.Command { + cmd := markAdmin(&cobra.Command{ + Use: "post", + Short: "Create, update and list blog posts (admin)", + Long: "Write access to the positronick.com blog — the agent write API. Create a post from a " + + "markdown file, patch one in place, or list every post (any status). New posts are drafts " + + "unless you set --status: an agent post never self-publishes, it must be promoted " + + "deliberately. There is no delete verb — unpublish with --status draft. Posts mirrored from " + + "a feed source are feed-owned; editing one takes api ownership so the ingestor stops " + + "refreshing it (hand it back with --source feed)." + adminNote, + }) + cmd.AddCommand(newPostCreateCmd(), newPostUpdateCmd(), newPostListCmd()) + return cmd +} + +// addPostFieldFlags registers the per-field override flags shared by post +// create and post update. +func addPostFieldFlags(cmd *cobra.Command) { + f := cmd.Flags() + f.String("slug", "", "url slug — /blog/ (overrides frontmatter)") + f.String("title", "", "post title (overrides frontmatter)") + f.String("excerpt", "", "short summary for cards and the RSS feed (overrides frontmatter)") + f.String("description", "", "longer SEO description (overrides frontmatter)") + f.String("category", "", "post category (overrides frontmatter)") + f.String("kind", "", "post kind: article, release or link (overrides frontmatter)") + f.String("version", "", "semver version (overrides frontmatter)") + f.String("author-handle", "", "author profile handle (overrides frontmatter)") + f.String("listing-slug", "", "related registry listing slug (overrides frontmatter)") + f.String("canonical-url", "", "canonical backlink to the original release/item (overrides frontmatter)") + f.String("published-at", "", "publish instant, ISO-8601 (overrides frontmatter)") + f.StringArray("tag", nil, "post tag, repeatable (overrides frontmatter)") +} + +// applyPostFieldFlags copies every explicitly set field flag over fields — +// flags always beat frontmatter. +func applyPostFieldFlags(cmd *cobra.Command, fields map[string]any) error { + for _, pair := range postFieldFlagMap { + if !cmd.Flags().Changed(pair[0]) { + continue + } + v, err := cmd.Flags().GetString(pair[0]) + if err != nil { + return err + } + fields[pair[1]] = v + } + if cmd.Flags().Changed("tag") { + v, err := cmd.Flags().GetStringArray("tag") + if err != nil { + return err + } + fields["tags"] = v + } + return nil +} + +// postServerAssigned are the read-only/derived post fields stripped from a +// --file's frontmatter so a post copied straight out of the blog content +// source just works: ids and timestamps are server-assigned, the content hash +// is derived from the body, the view count is runtime, and author*/listingName +// are denormalized server-side from authorHandle/listingSlug. +var postServerAssigned = []string{ + "id", "createdAt", "updatedAt", "contentHash", "viewCount", + "authorName", "authorAvatar", "authorTier", "listingName", +} + +// parsePostFile parses a post markdown file into the admin-API field map: the +// YAML frontmatter keys verbatim, minus the server-assigned/derived fields, +// plus the body as "content". Unknown keys are passed through on purpose — the +// server's validator is the single authority and its 422 is surfaced verbatim. +// It mirrors parseSoulFile over the shared splitFrontmatter, with the post +// drop set. +func parsePostFile(path, raw string) (map[string]any, error) { + yamlText, body, err := splitFrontmatter(raw) + if err != nil { + return nil, err + } + fields := map[string]any{} + if yamlText != "" { + if err := yaml.Unmarshal([]byte(yamlText), &fields); err != nil { + return nil, output.Errorf("parsing frontmatter in %s: %v", path, err) + } + } + for _, key := range postServerAssigned { + delete(fields, key) + } + fields["content"] = body + return fields, nil +} + +// postFieldsFromFile reads and parses the --file markdown into the field map. +func postFieldsFromFile(cmd *cobra.Command) (map[string]any, error) { + path, err := cmd.Flags().GetString("file") + if err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, output.Errorf("reading %s: %v", path, err) + } + return parsePostFile(path, string(raw)) +} + +func newPostCreateCmd() *cobra.Command { + cmd := markAdmin(&cobra.Command{ + Use: "create --file POST.md", + Short: "Create a blog post on positronick.com (admin)", + Long: "Create a post from a markdown file (YAML frontmatter + markdown body). Required " + + "frontmatter: slug, title, excerpt, category; the body becomes the post content. Field " + + "flags override frontmatter. The post is a DRAFT unless --status says otherwise — an " + + "agent post does not self-publish. Any id/createdAt/updatedAt/contentHash in the " + + "frontmatter is ignored (the server assigns them) and the post is api-owned, so the feed " + + "ingestor never touches it. Validation is the server's; a 422 carries its message " + + "verbatim." + adminNote, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + fields, err := postFieldsFromFile(cmd) + if err != nil { + return err + } + if err := applyPostFieldFlags(cmd, fields); err != nil { + return err + } + if err := applyStringFlag(cmd, "status", fields); err != nil { + return err + } + + post, err := client.CreatePost(cmd.Context(), fields) + if err != nil { + return adminAPIError(err) + } + if p.Mode.JSON { + return p.EmitJSON(postCreateResult{Post: *post, Created: true}) + } + p.Human("Created post %s (id %s, status %s)\n", post.Slug, post.ID, post.Status) + p.Human("Public path: /blog/%s\n", post.Slug) + if post.Status == "draft" { + p.Status("hint: still a draft — publish with `positronick post update %s --status published`\n", post.ID) + } + return nil + }, + }) + cmd.Flags().String("file", "", "path to the post markdown file (YAML frontmatter + markdown body)") + _ = cmd.MarkFlagRequired("file") + addPostFieldFlags(cmd) + cmd.Flags().String("status", "", "publication status: draft, pending or published (default: draft)") + return cmd +} + +func newPostUpdateCmd() *cobra.Command { + cmd := markAdmin(&cobra.Command{ + Use: "update ", + Short: "Update a blog post on positronick.com (admin)", + Long: "Patch a post in place: only the fields you provide change. --file replaces the " + + "frontmatter fields and the whole markdown body; field flags override either way. " + + "Unpublish with --status draft (there is no delete verb). The argument is the post id, or " + + "a published slug (drafts are only addressable by id). Editing a feed-mirrored post takes " + + "api ownership — the ingestor stops refreshing it; pass --source feed to hand it back." + + adminNote, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + patch := map[string]any{} + if cmd.Flags().Changed("file") { + fileFields, err := postFieldsFromFile(cmd) + if err != nil { + return err + } + for k, v := range fileFields { + patch[k] = v + } + } + if err := applyPostFieldFlags(cmd, patch); err != nil { + return err + } + for _, flag := range []string{"status", "source"} { + if err := applyStringFlag(cmd, flag, patch); err != nil { + return err + } + } + if len(patch) == 0 { + return output.Errorf("nothing to update — provide --file, field flags, --status or --source") + } + + ref := args[0] + post, took, err := client.UpdatePost(cmd.Context(), ref, patch) + if api.IsNotFound(err) && !isULID(ref) { + // Not an id and not shaped like one: resolve it as a slug via the + // public blog detail endpoint and retry. + var id string + if id, err = postIDForSlug(cmd.Context(), client, ref); err != nil { + return err + } + post, took, err = client.UpdatePost(cmd.Context(), id, patch) + } + if err != nil { + return adminAPIError(err) + } + if p.Mode.JSON { + return p.EmitJSON(postUpdateResult{Post: *post, TookOwnership: took}) + } + p.Human("Updated post %s (id %s, status %s)\n", post.Slug, post.ID, post.Status) + postOwnershipWarning(p, took) + return nil + }, + }) + cmd.Flags().String("file", "", "post markdown replacing the frontmatter fields and the whole body") + addPostFieldFlags(cmd) + cmd.Flags().String("status", "", "publication status: draft (= unpublish), pending or published") + cmd.Flags().String("source", "", "ownership: api (default on any change) or feed (hand back to the ingestor)") + return cmd +} + +func newPostListCmd() *cobra.Command { + return markAdmin(&cobra.Command{ + Use: "list", + Short: "List all blog posts, any status (admin)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + posts, err := client.AdminPosts(cmd.Context()) + if err != nil { + return adminAPIError(err) + } + // Stable order for human + golden output; the API returns newest-first. + sort.Slice(posts, func(i, j int) bool { return posts[i].Slug < posts[j].Slug }) + if p.Mode.JSON { + return p.EmitJSON(postListResult{Count: len(posts), Posts: posts}) + } + renderPostTable(p, posts) + return nil + }, + }) +} + +// postIDForSlug resolves a published slug to its post id via the public blog +// detail endpoint. +func postIDForSlug(ctx context.Context, client *api.Client, slug string) (string, error) { + post, err := client.Post(ctx, slug) + if api.IsNotFound(err) { + return "", output.NotFoundError("post \""+slug+"\" not found", + "pass the post id — drafts are only addressable by id. Run: positronick post list") + } + if err != nil { + return "", err + } + return post.ID, nil +} + +// postOwnershipWarning prints the feed→api flip warning. It rides Status +// (stderr, suppressed in JSON mode where the tookOwnership field says the same +// thing). Posts flip from the feed ingestor, not the git seed, so the wording +// differs from ownershipWarning. +func postOwnershipWarning(p *output.Printer, took bool) { + if !took { + return + } + p.Status("WARNING: took ownership from the feed — the ingestor will no longer refresh this post " + + "from its source (update with --source feed to hand it back).\n") +} + +func renderPostTable(p *output.Printer, posts []api.AdminPost) { + rows := make([][]string, len(posts)) + for i, post := range posts { + rows[i] = []string{post.Slug, post.Kind, post.Status, post.Source, deref(post.AuthorHandle), truncateCell(post.Title, taglineWidth)} + } + output.RenderTable(p.Out, []string{"SLUG", "KIND", "STATUS", "SOURCE", "AUTHOR", "TITLE"}, rows) +} diff --git a/internal/cli/post_test.go b/internal/cli/post_test.go new file mode 100644 index 0000000..a938b47 --- /dev/null +++ b/internal/cli/post_test.go @@ -0,0 +1,317 @@ +package cli + +import ( + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/positronick/cli/internal/mockapi" + "github.com/positronick/cli/internal/output" +) + +// post create from a markdown fixture: 201, the --json shape and the human +// summary (public path included) pinned as goldens. The default status is the +// server's draft — an agent post never self-publishes. +func TestPostCreateGolden(t *testing.T) { + t.Run("json", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "create", "--file", "testdata/new-post.md", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "post-create.json", stdout) + if !strings.Contains(stdout, `"status": "draft"`) { + t.Errorf("stdout = %q, want the post defaulting to draft", stdout) + } + }) + + t.Run("human", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) // fresh state: the slug is free again + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "create", "--file", "testdata/new-post.md") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "post-create.txt", stdout) + if !strings.Contains(stdout, "/blog/the-positronick-manifesto") { + t.Errorf("stdout = %q, want the public URL path", stdout) + } + // The draft hint rides stderr so the stdout golden stays clean. + if !strings.Contains(stderr, "still a draft") { + t.Errorf("stderr = %q, want the publish-it hint for a draft", stderr) + } + }) +} + +// status defaults to draft on create unless --status is given — the body the +// CLI sends never forces publish, so the server's default stands. +func TestPostCreateDefaultsDraft(t *testing.T) { + adminEnv(t) + srv, last := newCaptureServer(t) + _, stderr, code := executeAgainst(t, srv.URL, + "post", "create", "--file", "testdata/new-post.md", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + var sent map[string]any + if err := json.Unmarshal(last.body, &sent); err != nil { + t.Fatalf("sent body is not JSON: %v", err) + } + if _, forced := sent["status"]; forced { + t.Errorf("sent = %v, want no status field so the server's draft default stands", sent) + } +} + +// Flags override frontmatter, the server-assigned fields are stripped, and the +// body rides as content — asserted on the exact JSON the mock received. +func TestPostCreateFlagsOverrideFrontmatter(t *testing.T) { + adminEnv(t) + srv, last := newCaptureServer(t) + + _, stderr, code := executeAgainst(t, srv.URL, + "post", "create", "--file", "testdata/new-post.md", + "--title", "A New Title", "--slug", "a-new-slug", "--kind", "link", + "--tag", "news", "--author-handle", "nsollazzo", "--status", "published", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if last.method != http.MethodPost || last.path != "/api/admin/posts" { + t.Fatalf("request = %s %s, want POST /api/admin/posts", last.method, last.path) + } + var sent map[string]any + if err := json.Unmarshal(last.body, &sent); err != nil { + t.Fatalf("sent body is not JSON: %v", err) + } + if sent["title"] != "A New Title" || sent["slug"] != "a-new-slug" || + sent["kind"] != "link" || sent["status"] != "published" || sent["authorHandle"] != "nsollazzo" { + t.Errorf("sent = %v, want the flag values to beat the frontmatter", sent) + } + if !reflect.DeepEqual(sent["tags"], []any{"news"}) { + t.Errorf("tags = %v, want the --tag override", sent["tags"]) + } + if c, _ := sent["content"].(string); !strings.Contains(c, "# The Positronick Manifesto") { + t.Errorf("content = %q, want the markdown body", c) + } + // Untouched frontmatter passes through; server-assigned fields never do. + if sent["excerpt"] == nil || sent["category"] != "Announcements" { + t.Errorf("sent = %v, want unflagged frontmatter fields verbatim", sent) + } + for _, dropped := range []string{"id", "createdAt", "updatedAt", "contentHash", "viewCount"} { + if _, ok := sent[dropped]; ok { + t.Errorf("sent body must not carry server-assigned %q", dropped) + } + } +} + +// A 422 is the server validator speaking: its message must reach the user +// verbatim. An invalid --kind is the cheapest way to provoke one. +func TestPostCreate422Verbatim(t *testing.T) { + const msg = `admin post create: invalid kind "bogus" (expected one of: article, release, link)` + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "create", "--file", "testdata/new-post.md", "--kind", "bogus", "--json") + if code != output.ExitError { + t.Fatalf("exit code = %d, want %d", code, output.ExitError) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty on error", stdout) + } + want := `{"error":{"code":"invalid_input","message":"` + strings.ReplaceAll(msg, `"`, `\"`) + `"}}` + "\n" + if stderr != want { + t.Errorf("stderr = %q, want %q", stderr, want) + } +} + +// 401 (no credentials) and 403 (authenticated non-admin) both map to exit 4 +// with the admin-login hint — the server is the authorization authority. +func TestPostAdminAuthErrors(t *testing.T) { + t.Run("401 logged out", func(t *testing.T) { + isolateAuth(t) + srv := newMockServer(t) + _, stderr, code := executeAgainst(t, srv.URL, + "post", "create", "--file", "testdata/new-post.md", "--json") + if code != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", code, output.ExitAuth) + } + want := `{"error":{"code":"auth_required","message":"authentication required — run ` + + "`positronick login`" + `","hint":"run positronick login with an admin account"}}` + "\n" + if stderr != want { + t.Errorf("stderr = %q, want %q", stderr, want) + } + }) + + t.Run("403 non-admin", func(t *testing.T) { + dir := isolateAuth(t) + srv := newMockServer(t) + seedLogin(t, dir, srv.URL, mockapi.PlebToken) + _, stderr, code := executeAgainst(t, srv.URL, + "post", "list", "--json") + if code != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", code, output.ExitAuth) + } + want := `{"error":{"code":"auth_required","message":"admin access required",` + + `"hint":"run positronick login with an admin account"}}` + "\n" + if stderr != want { + t.Errorf("stderr = %q, want %q", stderr, want) + } + }) +} + +// Updating a feed-mirrored post by SLUG: the ref 404s as an id, resolves +// through the public blog detail endpoint, and the patch flips ownership — +// tookOwnership in the JSON contract, a WARNING on stderr for humans. +func TestPostUpdateBySlugTookOwnership(t *testing.T) { + t.Run("json", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "update", "positronick-cli-v0-1-0", "--excerpt", "Now with admin write commands.", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "post-update-ownership.json", stdout) + if !strings.Contains(stdout, `"tookOwnership": true`) { + t.Errorf("stdout = %q, want tookOwnership true for a feed-owned row", stdout) + } + }) + + t.Run("human warning", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "update", "positronick-cli-v0-1-0", "--excerpt", "Now with admin write commands.") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "post-update-ownership.txt", stdout) + if !strings.Contains(stderr, "WARNING: took ownership from the feed") { + t.Errorf("stderr = %q, want the ownership warning", stderr) + } + if !strings.Contains(stderr, "--source feed") { + t.Errorf("stderr = %q, want the hand-back hint", stderr) + } + }) + + t.Run("explicit --source feed does not take ownership", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "update", "positronick-cli-v0-1-0", "--excerpt", "tweak", "--source", "feed") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if strings.Contains(stderr, "WARNING") { + t.Errorf("stderr = %q, want no warning when ownership stays with the feed", stderr) + } + if !strings.Contains(stdout, "Updated post positronick-cli-v0-1-0") { + t.Errorf("stdout = %q, want the update summary", stdout) + } + }) +} + +// An update by id PATCHes directly — no slug resolution round-trip — and a ref +// that is neither id nor known slug is exit 3. +func TestPostUpdateResolution(t *testing.T) { + t.Run("by id", func(t *testing.T) { + adminEnv(t) + srv, last := newCaptureServer(t) + _, stderr, code := executeAgainst(t, srv.URL, + "post", "update", "01POSTINTRO0000000000000XX", "--title", "Introducing Positronick (v2)", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if last.path != "/api/admin/posts/01POSTINTRO0000000000000XX" { + t.Errorf("path = %q, want the id PATCHed directly", last.path) + } + if string(last.body) != `{"title":"Introducing Positronick (v2)"}` { + t.Errorf("body = %s, want only the provided field", last.body) + } + }) + + t.Run("unknown ref is exit 3", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "update", "no-such-post", "--title", "x", "--json") + if code != output.ExitNotFound { + t.Fatalf("exit code = %d, want %d", code, output.ExitNotFound) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty on error", stdout) + } + if !strings.Contains(stderr, `"code":"not_found"`) || + !strings.Contains(stderr, `post \"no-such-post\" not found`) { + t.Errorf("stderr = %q, want the not-found envelope", stderr) + } + }) + + t.Run("no fields is a client-side error", func(t *testing.T) { + adminEnv(t) + // Unreachable server: the validation must fire before any request. + _, stderr, code := executeAgainst(t, "http://127.0.0.1:1", "post", "update", "positronick-cli-v0-1-0") + if code != output.ExitError { + t.Fatalf("exit code = %d, want %d", code, output.ExitError) + } + if !strings.Contains(stderr, "nothing to update") { + t.Errorf("stderr = %q, want the nothing-to-update error", stderr) + } + }) +} + +// There is no delete verb: unpublishing is PATCH {"status":"draft"}, by slug. +// The article post is api-owned, so this does not flip ownership. +func TestPostUnpublishViaStatusDraft(t *testing.T) { + adminEnv(t) + srv, last := newCaptureServer(t) + + stdout, stderr, code := executeAgainst(t, srv.URL, + "post", "update", "introducing-positronick", "--status", "draft", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if last.path != "/api/admin/posts/01POSTINTRO0000000000000XX" { + t.Errorf("path = %q, want the slug resolved to the post id", last.path) + } + if string(last.body) != `{"status":"draft"}` { + t.Errorf("body = %s, want exactly the status patch", last.body) + } + if !strings.Contains(stdout, `"status": "draft"`) { + t.Errorf("stdout = %q, want the post unpublished", stdout) + } + if strings.Contains(stderr, "WARNING") { + t.Errorf("stderr = %q, want no ownership warning for an api-owned post", stderr) + } +} + +// post list returns every post, any status, with source — the --json and human +// table both pinned as goldens. +func TestPostListGolden(t *testing.T) { + t.Run("json", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, "post", "list", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "post-list.json", stdout) + if !strings.Contains(stdout, `"source": "feed"`) { + t.Errorf("stdout = %q, want the source marker in the list", stdout) + } + }) + + t.Run("human", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, "post", "list") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "post-list.txt", stdout) + }) +} diff --git a/internal/cli/testdata/golden/agent-docs.txt b/internal/cli/testdata/golden/agent-docs.txt index b1dea07..5235a86 100644 --- a/internal/cli/testdata/golden/agent-docs.txt +++ b/internal/cli/testdata/golden/agent-docs.txt @@ -1,6 +1,6 @@ # positronick — agent manual -`positronick` is the command-line client for positronick.com. It discovers agent capabilities: souls (installable SOUL.md personality files), a registry of official tooling (harnesses, CLIs, MCP servers, agents, skills, plugins, loops), and a `research` feed of what's new (articles, releases, links) so agents avoid stale knowledge. It is built to be driven by coding agents — pass `--json` to any command for stable machine-readable JSON on stdout, read progress and errors from stderr, and branch on the exit code. Read commands never prompt. Hidden admin commands (create/update for souls and listings, create/list for profiles, and list/create/update/sync for blog feed sources) exist and appear in help and in these docs after logging in with an admin account. +`positronick` is the command-line client for positronick.com. It discovers agent capabilities: souls (installable SOUL.md personality files), a registry of official tooling (harnesses, CLIs, MCP servers, agents, skills, plugins, loops), and a `research` feed of what's new (articles, releases, links) so agents avoid stale knowledge. It is built to be driven by coding agents — pass `--json` to any command for stable machine-readable JSON on stdout, read progress and errors from stderr, and branch on the exit code. Read commands never prompt. Hidden admin commands (create/update for souls and listings, create/list for profiles, list/create/update/sync for blog feed sources, and create/update/list for blog posts) exist and appear in help and in these docs after logging in with an admin account. Exit codes: diff --git a/internal/cli/testdata/golden/post-create.json b/internal/cli/testdata/golden/post-create.json new file mode 100644 index 0000000..aba4dd3 --- /dev/null +++ b/internal/cli/testdata/golden/post-create.json @@ -0,0 +1,33 @@ +{ + "post": { + "id": "01BCREATED0000000000000001", + "slug": "the-positronick-manifesto", + "slugHistory": [], + "kind": "article", + "title": "The Positronick Manifesto", + "excerpt": "Why we built a provider- and framework-agnostic marketplace for everything AI.", + "description": null, + "contentHash": "98f4a115d808889e77d8aba32252a72ebe95f8f15aa97a6d4faa5471c0874a3e", + "version": "1.0.0", + "category": "Announcements", + "tags": [ + "manifesto", + "launch" + ], + "authorHandle": null, + "authorName": null, + "authorAvatar": null, + "authorTier": null, + "listingSlug": null, + "listingName": null, + "canonicalUrl": null, + "status": "draft", + "viewCount": 0, + "publishedAt": null, + "createdAt": "2026-06-09T09:00:00.000Z", + "updatedAt": "2026-06-09T09:00:00.000Z", + "content": "# The Positronick Manifesto\n\nPublish once — discover and use anywhere.", + "source": "api" + }, + "created": true +} diff --git a/internal/cli/testdata/golden/post-create.txt b/internal/cli/testdata/golden/post-create.txt new file mode 100644 index 0000000..354a129 --- /dev/null +++ b/internal/cli/testdata/golden/post-create.txt @@ -0,0 +1,2 @@ +Created post the-positronick-manifesto (id 01BCREATED0000000000000001, status draft) +Public path: /blog/the-positronick-manifesto diff --git a/internal/cli/testdata/golden/post-list.json b/internal/cli/testdata/golden/post-list.json new file mode 100644 index 0000000..119ddea --- /dev/null +++ b/internal/cli/testdata/golden/post-list.json @@ -0,0 +1,95 @@ +{ + "count": 3, + "posts": [ + { + "id": "01POSTINTRO0000000000000XX", + "slug": "introducing-positronick", + "slugHistory": [], + "kind": "article", + "title": "Introducing Positronick", + "excerpt": "Publish once, discover and use anywhere — the marketplace for everything AI.", + "description": "Why we built a provider- and framework-agnostic registry for souls, MCP servers, CLIs, agents, skills and more.", + "contentHash": "1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa", + "version": "1.0.0", + "category": "Announcements", + "tags": [ + "launch", + "registry" + ], + "authorHandle": "positronick", + "authorName": "Positronick", + "authorAvatar": "https://example.com/positronick.png", + "authorTier": "official", + "listingSlug": null, + "listingName": null, + "canonicalUrl": null, + "status": "published", + "viewCount": 128, + "publishedAt": "2026-05-20T12:00:00.000Z", + "createdAt": "2026-05-20T12:00:00.000Z", + "updatedAt": "2026-05-21T09:00:00.000Z", + "content": "# Introducing Positronick\n\nPublish once — discover and use anywhere.\n", + "source": "api" + }, + { + "id": "01POSTOPENCLAWLINK0000000X", + "slug": "openclaw-joins-the-registry", + "slugHistory": [], + "kind": "link", + "title": "OpenClaw joins the registry", + "excerpt": "A new open-source agent harness is now listed on Positronick.", + "description": null, + "contentHash": "3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc", + "version": "1.0.0", + "category": "Community", + "tags": [ + "openclaw", + "news" + ], + "authorHandle": null, + "authorName": null, + "authorAvatar": null, + "authorTier": null, + "listingSlug": null, + "listingName": null, + "canonicalUrl": "https://example.com/openclaw-joins", + "status": "published", + "viewCount": 9, + "publishedAt": "2026-06-10T08:00:00.000Z", + "createdAt": "2026-06-10T08:00:00.000Z", + "updatedAt": "2026-06-10T08:00:00.000Z", + "content": "OpenClaw, an open-source agent harness, is now listed on Positronick.\n", + "source": "feed" + }, + { + "id": "01POSTCLIRELEASE000000000X", + "slug": "positronick-cli-v0-1-0", + "slugHistory": [], + "kind": "release", + "title": "Positronick CLI v0.1.0", + "excerpt": "Install souls and browse the registry from your terminal.", + "description": null, + "contentHash": "2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb", + "version": "1.0.0", + "category": "Releases", + "tags": [ + "cli", + "release" + ], + "authorHandle": "nsollazzo", + "authorName": "Nicholas Sollazzo", + "authorAvatar": null, + "authorTier": "verified", + "listingSlug": "positronick-cli", + "listingName": "Positronick CLI", + "canonicalUrl": "https://github.com/positronick/cli/releases/tag/v0.1.0", + "status": "published", + "viewCount": 42, + "publishedAt": "2026-06-01T09:00:00.000Z", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z", + "content": "# Positronick CLI v0.1.0\n\nThe first public release of the command-line client.\n", + "source": "feed" + } + ] +} diff --git a/internal/cli/testdata/golden/post-list.txt b/internal/cli/testdata/golden/post-list.txt new file mode 100644 index 0000000..72a9163 --- /dev/null +++ b/internal/cli/testdata/golden/post-list.txt @@ -0,0 +1,4 @@ +SLUG KIND STATUS SOURCE AUTHOR TITLE +introducing-positronick article published api positronick Introducing Positronick +openclaw-joins-the-registry link published feed OpenClaw joins the registry +positronick-cli-v0-1-0 release published feed nsollazzo Positronick CLI v0.1.0 diff --git a/internal/cli/testdata/golden/post-update-ownership.json b/internal/cli/testdata/golden/post-update-ownership.json new file mode 100644 index 0000000..8b2b14e --- /dev/null +++ b/internal/cli/testdata/golden/post-update-ownership.json @@ -0,0 +1,33 @@ +{ + "post": { + "id": "01POSTCLIRELEASE000000000X", + "slug": "positronick-cli-v0-1-0", + "slugHistory": [], + "kind": "release", + "title": "Positronick CLI v0.1.0", + "excerpt": "Now with admin write commands.", + "description": null, + "contentHash": "2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb", + "version": "1.0.0", + "category": "Releases", + "tags": [ + "cli", + "release" + ], + "authorHandle": "nsollazzo", + "authorName": "Nicholas Sollazzo", + "authorAvatar": null, + "authorTier": "verified", + "listingSlug": "positronick-cli", + "listingName": "Positronick CLI", + "canonicalUrl": "https://github.com/positronick/cli/releases/tag/v0.1.0", + "status": "published", + "viewCount": 42, + "publishedAt": "2026-06-01T09:00:00.000Z", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-09T10:00:00.000Z", + "content": "# Positronick CLI v0.1.0\n\nThe first public release of the command-line client.\n", + "source": "api" + }, + "tookOwnership": true +} diff --git a/internal/cli/testdata/golden/post-update-ownership.txt b/internal/cli/testdata/golden/post-update-ownership.txt new file mode 100644 index 0000000..4a3d345 --- /dev/null +++ b/internal/cli/testdata/golden/post-update-ownership.txt @@ -0,0 +1 @@ +Updated post positronick-cli-v0-1-0 (id 01POSTCLIRELEASE000000000X, status published) diff --git a/internal/cli/testdata/new-post.md b/internal/cli/testdata/new-post.md new file mode 100644 index 0000000..3655fca --- /dev/null +++ b/internal/cli/testdata/new-post.md @@ -0,0 +1,13 @@ +--- +slug: the-positronick-manifesto +title: The Positronick Manifesto +excerpt: Why we built a provider- and framework-agnostic marketplace for everything AI. +category: Announcements +kind: article +tags: [manifesto, launch] +version: 1.0.0 +--- + +# The Positronick Manifesto + +Publish once — discover and use anywhere. diff --git a/internal/mockapi/admin.go b/internal/mockapi/admin.go index 3385792..7ffada7 100644 --- a/internal/mockapi/admin.go +++ b/internal/mockapi/admin.go @@ -65,6 +65,13 @@ var ( // defaultCategory; patch validates only the keys present). feedFields = []string{"label", "feedUrl", "kind", "authorHandle", "listingSlug", "defaultCategory", "defaultTags", "autoPublish", "enabled"} + // postPatchFields mirrors POST_PATCH_FIELDS in the product repo; create + // accepts the same minus "source". The derived/denormalized columns + // (contentHash, viewCount, author*/listingName) are server-owned and not + // patchable. + postPatchFields = []string{"slug", "slugHistory", "kind", "title", "excerpt", + "description", "category", "tags", "version", "authorHandle", "listingSlug", + "canonicalUrl", "publishedAt", "content", "status", "source"} ) // Seeded feed source ids — stable so the feed list/sync/update golden + tests @@ -82,10 +89,12 @@ type adminState struct { listings []listingRow profiles []profileRow feeds []api.FeedSource + posts []postRow soulSeq int listingSeq int profileSeq int feedSeq int + postSeq int } type soulRow struct { @@ -103,6 +112,11 @@ type profileRow struct { Source string } +type postRow struct { + api.Post + Source string +} + // registerAdmin mounts the admin write API on mux with a fresh copy of the // fixtures. func registerAdmin(mux *http.ServeMux) { @@ -115,6 +129,17 @@ func registerAdmin(mux *http.ServeMux) { } st.profiles = seedProfiles() st.feeds = seedFeeds() + // Posts seed from the public blog fixtures. A mirrored post (one with a + // canonical backlink to a release/item) is feed-owned, exactly like a post + // the ingestor produced; a native editorial post is api-owned — so the first + // edit of a mirrored post flips ownership just like production. + for _, p := range Posts { + source := "api" + if p.CanonicalURL != nil { + source = "feed" + } + st.posts = append(st.posts, postRow{Post: p, Source: source}) + } mux.HandleFunc("POST /api/admin/souls", st.createSoul) mux.HandleFunc("GET /api/admin/souls/{id}", st.getSoul) @@ -129,6 +154,10 @@ func registerAdmin(mux *http.ServeMux) { mux.HandleFunc("GET /api/admin/feeds/{id}", st.getFeed) mux.HandleFunc("PATCH /api/admin/feeds/{id}", st.patchFeed) mux.HandleFunc("POST /api/admin/feeds/{id}/sync", st.syncFeed) + mux.HandleFunc("POST /api/admin/posts", st.createPost) + mux.HandleFunc("GET /api/admin/posts", st.listPosts) + mux.HandleFunc("GET /api/admin/posts/{id}", st.getPost) + mux.HandleFunc("PATCH /api/admin/posts/{id}", st.patchPost) } // seedProfiles builds the fixture's curated authors from the listing handles @@ -1262,3 +1291,309 @@ func isGitHubRepoURL(u string) bool { parts := strings.Split(strings.Trim(strings.TrimPrefix(u, prefix), "/"), "/") return len(parts) >= 2 && parts[0] != "" && parts[1] != "" } + +// ── Posts ──────────────────────────────────────────────────────────────────── + +func (st *adminState) createPost(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + body := readBody(w, r) + if body == nil { + return + } + if _, ok := body["id"]; ok { + invalid(w, `ids are server-assigned — omit "id"`) + return + } + const ctx = "admin post create" + if key, ok := unknownKey(body, without(postPatchFields, "source")); ok { + invalid(w, fmt.Sprintf("%s: unknown field %q", ctx, key)) + return + } + if msg, ok := validatePostPatch(ctx, body); !ok { + invalid(w, msg) + return + } + for _, key := range []string{"slug", "title", "excerpt", "category"} { + if blank(body[key]) { + invalid(w, fmt.Sprintf("%s: missing required field %q", ctx, key)) + return + } + } + if normalizeBody(toStr(body["content"])) == "" { + invalid(w, ctx+": post body is empty") + return + } + + st.mu.Lock() + defer st.mu.Unlock() + slug := toStr(body["slug"]) + if st.postBySlug(slug) != nil { + writeError(w, http.StatusConflict, "conflict", fmt.Sprintf("slug %q is already taken", slug)) + return + } + st.postSeq++ + content := normalizeBody(toStr(body["content"])) + // status defaults to draft so an agent post cannot self-publish; kind and + // version take the server's defaults when omitted. + status := "draft" + if v, ok := body["status"]; ok { + status = toStr(v) + } + kind := "article" + if v, ok := body["kind"]; ok && !blank(v) { + kind = toStr(v) + } + version := "1.0.0" + if v, ok := body["version"]; ok && !blank(v) { + version = toStr(v) + } + authorHandle, authorName, authorAvatar, authorTier := st.denormAuthor(body["authorHandle"]) + listingSlug, listingName := st.denormListing(body["listingSlug"]) + row := postRow{ + Post: api.Post{ + PostCard: api.PostCard{ + ID: fmt.Sprintf("01BCREATED%016d", st.postSeq), + Slug: slug, + SlugHistory: toStrSlice(body["slugHistory"]), + Kind: kind, + Title: toStr(body["title"]), + Excerpt: toStr(body["excerpt"]), + Description: toStrPtr(body["description"]), + ContentHash: bodyHash(content), + Version: version, + Category: toStr(body["category"]), + Tags: toStrSlice(body["tags"]), + AuthorHandle: authorHandle, + AuthorName: authorName, + AuthorAvatar: authorAvatar, + AuthorTier: authorTier, + ListingSlug: listingSlug, + ListingName: listingName, + CanonicalURL: toStrPtr(body["canonicalUrl"]), + Status: status, + ViewCount: 0, + PublishedAt: toStrPtr(body["publishedAt"]), + CreatedAt: createdStamp, + UpdatedAt: createdStamp, + }, + Content: content, + }, + Source: "api", + } + st.posts = append(st.posts, row) + writeJSON(w, http.StatusCreated, map[string]any{"post": adminPostJSON(row)}) +} + +func (st *adminState) listPosts(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + st.mu.Lock() + defer st.mu.Unlock() + out := make([]map[string]any, len(st.posts)) + for i := range st.posts { + out[i] = adminPostJSON(st.posts[i]) + } + writeJSON(w, http.StatusOK, map[string]any{"posts": out}) +} + +func (st *adminState) getPost(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + st.mu.Lock() + defer st.mu.Unlock() + id := r.PathValue("id") + for i := range st.posts { + if st.posts[i].ID == id { + writeJSON(w, http.StatusOK, map[string]any{"post": adminPostJSON(st.posts[i])}) + return + } + } + writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("post id %q not found", id)) +} + +func (st *adminState) patchPost(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + body := readBody(w, r) + if body == nil { + return + } + const ctx = "admin post update" + if key, ok := unknownKey(body, postPatchFields); ok { + invalid(w, fmt.Sprintf("%s: unknown field %q", ctx, key)) + return + } + + st.mu.Lock() + defer st.mu.Unlock() + id := r.PathValue("id") + var row *postRow + for i := range st.posts { + if st.posts[i].ID == id { + row = &st.posts[i] + break + } + } + if row == nil { + writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("post id %q not found", id)) + return + } + if msg, ok := validatePostPatch(ctx, body); !ok { + invalid(w, msg) + return + } + source, took, ok := resolvePostSource(w, ctx, row.Source, body) + if !ok { + return + } + for _, key := range []string{"slug", "title", "excerpt", "category", "content"} { + if v, present := body[key]; present && blank(v) { + invalid(w, fmt.Sprintf("%s: missing required field %q", ctx, key)) + return + } + } + + if v, ok := body["slug"]; ok { + slug := toStr(v) + if slug != row.Slug { + if st.postBySlug(slug) != nil { + writeError(w, http.StatusConflict, "conflict", fmt.Sprintf("slug %q is already taken", slug)) + return + } + if _, explicit := body["slugHistory"]; !explicit { + row.SlugHistory = append(slices.Clone(row.SlugHistory), row.Slug) + } + row.Slug = slug + } + } + if v, ok := body["slugHistory"]; ok { + row.SlugHistory = toStrSlice(v) + } + setStr := func(key string, dst *string) { + if v, ok := body[key]; ok { + *dst = toStr(v) + } + } + setPtr := func(key string, dst **string) { + if v, ok := body[key]; ok { + *dst = toStrPtr(v) + } + } + setStr("kind", &row.Kind) + setStr("title", &row.Title) + setStr("excerpt", &row.Excerpt) + setPtr("description", &row.Description) + setStr("category", &row.Category) + setStr("version", &row.Version) + setStr("status", &row.Status) + setPtr("canonicalUrl", &row.CanonicalURL) + setPtr("publishedAt", &row.PublishedAt) + if v, ok := body["tags"]; ok { + row.Tags = toStrSlice(v) + } + // authorHandle and listingSlug re-denormalize their derived columns; an + // explicit blank clears the attribution. + if v, ok := body["authorHandle"]; ok { + row.AuthorHandle, row.AuthorName, row.AuthorAvatar, row.AuthorTier = st.denormAuthor(v) + } + if v, ok := body["listingSlug"]; ok { + row.ListingSlug, row.ListingName = st.denormListing(v) + } + if v, ok := body["content"]; ok { + row.Content = normalizeBody(toStr(v)) + row.ContentHash = bodyHash(row.Content) + } + row.Source = source + row.UpdatedAt = updatedStamp + writeJSON(w, http.StatusOK, map[string]any{"post": adminPostJSON(*row), "tookOwnership": took}) +} + +// validatePostPatch checks the enum-valued post fields present in body. Post +// categories are free-form (unlike souls/listings), so only kind and status +// are constrained. +func validatePostPatch(ctx string, body map[string]any) (string, bool) { + if v, ok := body["status"]; ok && !slices.Contains(statuses, toStr(v)) { + return fmt.Sprintf("%s: invalid status %q (expected one of: %s)", + ctx, toStr(v), strings.Join(statuses, ", ")), false + } + if v, ok := body["kind"]; ok && !blank(v) && !slices.Contains(api.PostKinds, toStr(v)) { + return fmt.Sprintf("%s: invalid kind %q (expected one of: %s)", + ctx, toStr(v), strings.Join(api.PostKinds, ", ")), false + } + return "", true +} + +// resolvePostSource applies the post ownership rules: an explicit source wins +// (and must be "feed" or "api"); otherwise any change takes "api" ownership. A +// feed→api flip is the tookOwnership signal. +func resolvePostSource(w http.ResponseWriter, ctx, existing string, body map[string]any) (source string, tookOwnership, ok bool) { + source = "api" + if v, present := body["source"]; present { + source = toStr(v) + if source != "feed" && source != "api" { + invalid(w, fmt.Sprintf(`%s: invalid source %q (expected "feed" or "api")`, ctx, source)) + return "", false, false + } + } + return source, existing == "feed" && source == "api", true +} + +// denormAuthor resolves the denormalized author columns from an authorHandle +// JSON value: a blank value is authorless (all nil); a handle that resolves to +// a profile stamps its name/avatar/tier, an unknown one keeps just the handle. +func (st *adminState) denormAuthor(v any) (handle, name, avatar, tier *string) { + if blank(v) { + return nil, nil, nil, nil + } + h := toStr(v) + if pr := st.profileByHandle(h); pr != nil { + return ptr(h), ptr(pr.Name), pr.AvatarURL, postAuthorTier(pr) + } + return ptr(h), nil, nil, nil +} + +// denormListing resolves the denormalized listing columns from a listingSlug +// JSON value: a blank value clears the link; a slug that resolves to a listing +// stamps its name, an unknown one keeps just the slug. +func (st *adminState) denormListing(v any) (slug, name *string) { + if blank(v) { + return nil, nil + } + s := toStr(v) + if l := st.listingBySlug(s); l != nil { + return ptr(s), ptr(l.Name) + } + return ptr(s), nil +} + +// postAuthorTier maps a profile's seal to the post's denormalized authorTier. +func postAuthorTier(pr *profileRow) *string { + switch { + case pr.Official: + return ptr("official") + case pr.Verified: + return ptr("verified") + default: + return nil + } +} + +func (st *adminState) postBySlug(slug string) *postRow { + for i := range st.posts { + if st.posts[i].Slug == slug { + return &st.posts[i] + } + } + return nil +} + +// adminPostJSON renders a row the way the admin API does: the public post shape +// plus source. +func adminPostJSON(row postRow) map[string]any { + return withSource(row.Post, row.Source) +}