diff --git a/.changeset/cli-init-framework-wiring.md b/.changeset/cli-init-framework-wiring.md new file mode 100644 index 0000000..111e709 --- /dev/null +++ b/.changeset/cli-init-framework-wiring.md @@ -0,0 +1,5 @@ +--- +"contentrain": patch +--- + +`contentrain init` now prints stack-aware SDK wiring guidance after setup: for bundler stacks (Nuxt/Next/Vite/etc.) it shows the `#contentrain` subpath import, points to the `contentrain-sdk` bundler-alias skill, and recommends a `prebuild`/`predev` generate step (because `.contentrain/client/` is git-ignored and must be regenerated on fresh clones / CI). Nuxt projects also get a server-only reminder. diff --git a/.changeset/mcp-branch-lifecycle-context-validators.md b/.changeset/mcp-branch-lifecycle-context-validators.md new file mode 100644 index 0000000..b46fa3e --- /dev/null +++ b/.changeset/mcp-branch-lifecycle-context-validators.md @@ -0,0 +1,29 @@ +--- +"@contentrain/mcp": minor +"@contentrain/types": patch +--- + +Harden the git/branch lifecycle, redesign context.json handling, and fix validator false positives. + +**Git & branches** + +- Machine-generated `[contentrain]` commits now pass `--no-verify`, so repos with commitlint / husky / lefthook `commit-msg` hooks no longer reject Contentrain writes. +- Feature branches are pruned automatically: a failed save no longer leaks a dangling `cr/*` branch, and merged branches (auto-merge or `contentrain_merge`) are deleted after landing. +- Branch-health thresholds are now configurable via `config.json` — `branchWarnLimit` (default 50) and `branchBlockLimit` (default 80) — instead of being hardcoded. +- **New tools:** `contentrain_branch_list` (pending `cr/*` branches + merge status) and `contentrain_branch_delete` (remove a stale/failed branch; the `contentrain` branch is protected). +- `contentrain_merge` can now target a branch by `model` (+ optional `locale`/`latest`), not just the exact timestamped branch name. +- `contentrain_submit` with no git remote now guides you to `contentrain_merge` (local landing) instead of failing with a bare "configure a remote". +- Git/hook failures are returned as structured, ANSI-stripped errors (`{ error, stage, hook?, code?, agent_hint? }`) instead of a raw escaped color blob. + +**context.json** + +- `context.json` is no longer committed on feature branches; it is regenerated deterministically on the `contentrain` branch after merge (single-threaded). This removes the merge-conflict class that hit parallel content saves on different branches. +- `contentrain_status` now derives `stats.models`/`stats.entries` live instead of echoing a possibly-stale `context.json`. + +**Validation** + +- Non-i18n models are validated against a single locale, eliminating phantom per-locale "orphan content" warnings (and the wrong-locale meta files `--fix` used to write) in multi-locale projects. +- Polymorphic multi-relations (`relations` targeting multiple models) accept `{ model, ref }` items, matching the generated SDK type instead of being rejected as "must be a string". +- Relation-integrity resolves targets at the target model's own storage locale (with a default-locale fallback for i18n:true targets), removing false "broken relation" errors. +- `contentrain_content_save`'s inline validation now evaluates the committed/overlaid state, so freshly created locale files are no longer reported as "missing". +- `contentrain_validate --fix` lands cosmetic structural fixes via auto-merge instead of spawning a pending review branch. diff --git a/.changeset/query-document-sort-include-generator-fixes.md b/.changeset/query-document-sort-include-generator-fixes.md new file mode 100644 index 0000000..e0db2aa --- /dev/null +++ b/.changeset/query-document-sort-include-generator-fixes.md @@ -0,0 +1,17 @@ +--- +"@contentrain/query": major +--- + +Fix generated client correctness and align with the platform. + +**Breaking:** the generated document body field is now `body` (was `content`), matching `@contentrain/types` `DocumentEntry.body` and the MCP `document_save` schema. Update consumers reading `.content` on document entries to `.body`, and regenerate the client. + +Also fixed in the generated runtime + types: + +- **Dictionary interpolation was broken** in generated output — the param regex lost its escaping during emit, so `dictionary('ui').get('key', { name })` returned the raw `{name}` template. Now interpolates correctly. +- **`DocumentQuery.sort()` added** — documents can now be ordered (e.g. by `published_at`); previously only collections could sort, and calling `.sort()` on a document query threw. +- **`include()` now resolves relations across i18n boundaries** — an i18n:false relation target (e.g. `author`) is resolved whether or not `.locale()` was set, and i18n:true targets resolve when no explicit locale is passed. Previously one side silently stayed an unresolved id string. +- **Generated types corrected** — no more duplicate `slug` member when a document model declares a `slug` field; relation fields are typed as `id | ResolvedTarget` (and `include(...)` arguments are constrained to model keys) so resolved relations are no longer plain `string`. +- **String frontmatter is no longer numerically coerced** — a string-typed field like `"007"` keeps its value instead of becoming `7`. +- **`where(field, 'ne', x)` on array fields** is now the complement of `eq` (membership), matching `eq` semantics. +- Removed dead/misleading CJS proxy code; documented the required `await init()` for CommonJS. diff --git a/.changeset/rules-skills-branch-tools-docs.md b/.changeset/rules-skills-branch-tools-docs.md new file mode 100644 index 0000000..94c1910 --- /dev/null +++ b/.changeset/rules-skills-branch-tools-docs.md @@ -0,0 +1,11 @@ +--- +"@contentrain/rules": minor +"@contentrain/skills": minor +--- + +Document the new `contentrain_branch_list` / `contentrain_branch_delete` MCP tools and fix SDK wiring guidance. + +- `MCP_TOOLS` / the essential guardrails / the MCP tool reference now include the two new branch tools (19 tools total) and the model/locale/latest selector for `contentrain_merge`. +- Bundler-config snippets for Vite and Nuxt use `import.meta.url` + `fileURLToPath` instead of `__dirname` (which is undefined in ESM `vite.config.ts` / `nuxt.config.ts`), and now cover Nuxt 4's `app/` + `server/` layout. +- The generate skill documents wiring `contentrain generate` into a `prebuild`/`predev` step, since `.contentrain/client/` is git-ignored and must be regenerated on fresh clones / CI. +- Clarified the two generator invocations: `contentrain generate` (CLI) vs `npx contentrain-query generate` (the `@contentrain/query` bin). diff --git a/AGENTS.md b/AGENTS.md index f2a3dbe..4f681a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ Load `packages/rules/essential/contentrain-essentials.md` (~120 lines) for compa - Architecture (MCP = deterministic infra, Agent = intelligence) - Four model kinds (singleton, collection, document, dictionary) - Content format rules (JSON only, canonical serialization) -- 17 MCP tools with mandatory calling protocols +- 19 MCP tools with mandatory calling protocols - Git workflow (dedicated contentrain branch, worktree isolation) - Security boundaries @@ -56,7 +56,7 @@ Load `packages/rules/essential/contentrain-essentials.md` (~120 lines) for compa | Directory | npm | What it does | |-----------|-----|-------------| -| `packages/mcp` | `@contentrain/mcp` | 17 MCP tools — content operations engine | +| `packages/mcp` | `@contentrain/mcp` | 19 MCP tools — content operations engine | | `packages/cli` | `contentrain` | CLI + Serve UI + MCP stdio entrypoint | | `packages/sdk/js` | `@contentrain/query` | Generated TypeScript query SDK | | `packages/types` | `@contentrain/types` | Shared type definitions | diff --git a/CLAUDE.md b/CLAUDE.md index fb37b56..4a197a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ MIT-licensed monorepo for Contentrain's open-source packages: MCP tools, CLI, Ty ``` contentrain-ai/ ├── packages/ -│ ├── mcp/ — 17 MCP tools, stdio + HTTP transports, Local / GitHub / GitLab providers (simple-git + zod + MCP SDK) +│ ├── mcp/ — 19 MCP tools, stdio + HTTP transports, Local / GitHub / GitLab providers (simple-git + zod + MCP SDK) │ ├── cli/ — citty + tsdown (init/serve/validate/normalize/connect) │ ├── types/ — Shared TypeScript types (@contentrain/types) │ ├── rules/ — AI agent quality rules & conventions @@ -77,7 +77,7 @@ When working with Contentrain content operations (models, content, normalize, va - **Collection storage = object-map** — `{ entryId: { fields } }`, sorted by ID - **Canonical serialization** — deterministic JSON output, sorted keys, 2-space indent, trailing newline - **Dedicated contentrain branch** — content state SSOT, created at init, auto-synced with baseBranch via update-ref. Developer's working tree is never mutated during MCP operations -- **context.json** — committed with content changes (not separately), Studio/IDE reads +- **context.json** — NOT committed on feature branches; regenerated deterministically on the `contentrain` branch after merge (single-threaded), so parallel content saves never conflict on it. Studio/IDE reads it; `contentrain_status` derives live stats rather than trusting the file - **Workflow config** — `"auto-merge"` or `"review"` in config.json - **Agent-MCP split** — MCP = deterministic infra, Agent = intelligence. MCP does NOT make content decisions - **Normalize two phases** — Phase 1: Extraction (content-only), Phase 2: Reuse (source patching). Separate branches, separate reviews @@ -90,7 +90,7 @@ When working with Contentrain content operations (models, content, normalize, va | Package | Name | Description | |---|---|---| -| packages/mcp | @contentrain/mcp | 17 MCP tools, stdio + HTTP transports, Local / GitHub / GitLab providers | +| packages/mcp | @contentrain/mcp | 19 MCP tools, stdio + HTTP transports, Local / GitHub / GitLab providers | | packages/cli | contentrain | CLI (npx contentrain) | | packages/types | @contentrain/types | Shared TypeScript types | | packages/rules | @contentrain/rules | AI agent quality rules & conventions | diff --git a/README.md b/README.md index b0594af..5d40185 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ This is the strongest entry point into the product: ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ -│ AI Agent │────▶│ MCP (17 tools) │────▶│ .contentrain/│ +│ AI Agent │────▶│ MCP (19 tools) │────▶│ .contentrain/│ │ (decides) │ │ (enforces) │ │ (stores) │ └─────────────┘ └──────────────────┘ └──────┬───────┘ │ @@ -145,7 +145,7 @@ Works with Nuxt, Next.js, Astro, SvelteKit, Vue, React, Node, Go, Python, Swift, - **Git-native** — every write goes through worktree isolation + review branches - **Normalize flow** — scan codebase for hardcoded strings → extract → create i18n-ready content → patch source files -- **MCP engine** — 17 tools over stdio or HTTP transport, works with Claude Code, Cursor, Windsurf, or any MCP client +- **MCP engine** — 19 tools over stdio or HTTP transport, works with Claude Code, Cursor, Windsurf, or any MCP client - **Provider-agnostic engine** — the same tool surface runs over a local worktree, GitHub, or GitLab (self-hosted included) with zero tool-code changes. HTTP transport available for remote drivers such as Studio. - **Canonical serialization** — sorted keys, deterministic output, clean git diffs, conflict-free parallel edits - **Agent rules & skills** — behavioral policies and step-by-step workflows ship as npm packages @@ -176,7 +176,7 @@ See [`AGENTS.md`](AGENTS.md) for the full skill catalog and agent guidance. | Package | npm | Role | |---|---|---| -| [`@contentrain/mcp`](packages/mcp) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fmcp)](https://www.npmjs.com/package/@contentrain/mcp) | 17 MCP tools + stdio / HTTP transport + Local / GitHub / GitLab providers | +| [`@contentrain/mcp`](packages/mcp) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fmcp)](https://www.npmjs.com/package/@contentrain/mcp) | 19 MCP tools + stdio / HTTP transport + Local / GitHub / GitLab providers | | [`contentrain`](packages/cli) | [![npm](https://img.shields.io/npm/v/contentrain)](https://www.npmjs.com/package/contentrain) | CLI + Serve UI + MCP stdio entrypoint | | [`@contentrain/query`](packages/sdk/js) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fquery)](https://www.npmjs.com/package/@contentrain/query) | Generated TypeScript query SDK | | [`@contentrain/types`](packages/types) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Ftypes)](https://www.npmjs.com/package/@contentrain/types) | Shared type definitions + constants | diff --git a/docs/concepts.md b/docs/concepts.md index 6e14488..94d8742 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -43,7 +43,7 @@ Contentrain AI inverts the traditional CMS workflow: ### 1. MCP (Infrastructure) -17 tools that AI agents call to manage content: +19 tools that AI agents call to manage content: - **Read:** `contentrain_status`, `contentrain_describe`, `contentrain_describe_format`, `contentrain_doctor`, `contentrain_content_list` - **Project setup:** `contentrain_init`, `contentrain_scaffold` diff --git a/docs/getting-started.md b/docs/getting-started.md index afebf39..745a1ab 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -215,7 +215,7 @@ All packages are published on npm: | Package | Description | Install | |---|---|---| | [`contentrain`](https://www.npmjs.com/package/contentrain) | CLI (init, serve, generate, validate) | `npx contentrain init` | -| [`@contentrain/mcp`](https://www.npmjs.com/package/@contentrain/mcp) | 17 MCP tools for AI agents | `pnpm add @contentrain/mcp` | +| [`@contentrain/mcp`](https://www.npmjs.com/package/@contentrain/mcp) | 19 MCP tools for AI agents | `pnpm add @contentrain/mcp` | | [`@contentrain/query`](https://www.npmjs.com/package/@contentrain/query) | TypeScript query SDK (optional) | `pnpm add @contentrain/query` | | [`@contentrain/types`](https://www.npmjs.com/package/@contentrain/types) | Shared TypeScript types | `pnpm add @contentrain/types` | | [`@contentrain/rules`](https://www.npmjs.com/package/@contentrain/rules) | AI agent quality rules | `pnpm add @contentrain/rules` | @@ -253,7 +253,7 @@ Want to skip setup? Start from a production-ready template with content models, - [Core Concepts](/concepts) — Models, content kinds, domains, and the governance architecture - [Ecosystem Map](/ecosystem) — How AI packages and Studio fit together -- [MCP Tools](/packages/mcp) — All 17 tools available to your agent +- [MCP Tools](/packages/mcp) — All 19 tools available to your agent - [Normalize Flow](/guides/normalize) — Extract hardcoded strings from existing code - [i18n Workflow](/guides/i18n) — Add languages to your content - [Framework Integration](/guides/frameworks) — Platform-specific setup patterns diff --git a/docs/guides/embedding-mcp.md b/docs/guides/embedding-mcp.md index 30ba192..f4eb35e 100644 --- a/docs/guides/embedding-mcp.md +++ b/docs/guides/embedding-mcp.md @@ -332,7 +332,7 @@ A GitHub Actions job: 4. Drive it with an MCP client 5. Let `contentrain_submit` push the `cr/*` branch -All 17 tools are available because the runner has `LocalProvider`. +All 19 tools are available because the runner has `LocalProvider`. ### Scripted automation diff --git a/docs/guides/http-transport.md b/docs/guides/http-transport.md index 8fa225b..5bb857e 100644 --- a/docs/guides/http-transport.md +++ b/docs/guides/http-transport.md @@ -14,7 +14,7 @@ Typical drivers: - **CI runners** — deterministic content operations as part of a pipeline (scaffold, validate, submit). - **Remote agents** — any MCP client that wants to operate a Contentrain project without a local checkout. -All three tunnel the same 17 tools through the same `RepoProvider` contract. Which backend answers depends on how the server is wired — see [Providers & Transports](/guides/providers) for the capability matrix. +All three tunnel the same 19 tools through the same `RepoProvider` contract. Which backend answers depends on how the server is wired — see [Providers & Transports](/guides/providers) for the capability matrix. ## Starting the HTTP server diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 4fb142a..4b592cf 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -6,7 +6,7 @@ slug: providers # Providers & Transports -Contentrain MCP runs the same 17 tools over three backends: +Contentrain MCP runs the same 19 tools over three backends: - **LocalProvider** — simple-git + a temporary worktree on your disk. Default for `npx contentrain serve --stdio` and the HTTP transport when driven by the CLI. - **GitHubProvider** — Octokit over the GitHub Git Data + Repos APIs. No clone, no worktree. diff --git a/docs/packages/cli.md b/docs/packages/cli.md index 247c7fd..4df95fe 100644 --- a/docs/packages/cli.md +++ b/docs/packages/cli.md @@ -277,7 +277,7 @@ Serves REST endpoints (`/api/status`, `/api/content`, `/api/branches`, `/api/doc contentrain serve --stdio ``` -For IDE agents. Same 17 tools, stdio transport. +For IDE agents. Same 19 tools, stdio transport. #### MCP HTTP diff --git a/docs/packages/mcp.md b/docs/packages/mcp.md index a6c5c60..9988453 100644 --- a/docs/packages/mcp.md +++ b/docs/packages/mcp.md @@ -43,7 +43,7 @@ Optional parser support for higher-quality source scanning: ## Tool Catalog -The MCP server exposes **17 tools** organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so clients can distinguish safe reads from writes and destructive operations. +The MCP server exposes **19 tools** organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so clients can distinguish safe reads from writes and destructive operations. | Tool | Title | Read-only | Destructive | |------|-------|-----------|-------------| @@ -61,6 +61,8 @@ The MCP server exposes **17 tools** organized by function. Each tool includes [M | `contentrain_validate` | Validate Project | — | — | | `contentrain_submit` | Submit Branches | — | — | | `contentrain_merge` | Merge Branch | — | — | +| `contentrain_branch_list` | List Branches | Yes | — | +| `contentrain_branch_delete` | Delete Branch | — | **Yes** | | `contentrain_scan` | Scan Source Code | Yes | — | | `contentrain_apply` | Apply Normalize | — | — | | `contentrain_bulk` | Bulk Operations | — | — | @@ -89,7 +91,9 @@ The MCP server exposes **17 tools** organized by function. Each tool includes [M | `contentrain_content_delete` | Remove content | Delete specific content entries | | `contentrain_validate` | Check & fix | Validate content against schemas, optionally auto-fix structural issues | | `contentrain_submit` | Push branches | Push `cr/*` review branches to remote | -| `contentrain_merge` | Merge branches | Merge a review-mode branch into contentrain locally (no external platform needed) | +| `contentrain_merge` | Merge branches | Merge a review-mode branch into contentrain locally (by exact branch or model; no external platform needed) | +| `contentrain_branch_list` | Inspect branches | List pending `cr/*` branches with merge status and branch-health pressure | +| `contentrain_branch_delete` | Clean up branches | Delete a stale/failed `cr/*` branch (the contentrain branch is protected) | ### Normalize Tools (Scan + Apply) @@ -163,7 +167,7 @@ Agent drivers treat `capability_required` as a retry signal. See [Providers & Tr - **stdio** — `contentrain serve --stdio` or `npx contentrain-mcp`. IDE agents (Claude Code, Cursor, Windsurf) connect over stdin/stdout. - **HTTP** — `contentrain serve --mcpHttp --authToken $TOKEN` or the programmatic `startHttpMcpServer({...})` / `startHttpMcpServerWith({ provider })` exports. Streamable HTTP at `POST /mcp` with secure-by-default Bearer auth. See the [HTTP Transport guide](/guides/http-transport). -Both transports serve the same 17 tools and the same JSON response shapes. +Both transports serve the same 19 tools and the same JSON response shapes. ## Providers @@ -291,7 +295,7 @@ This auto-creates the correct MCP config file and installs AI rules/skills. -Once connected, the agent has access to all 17 MCP tools and can manage your content through natural language. +Once connected, the agent has access to all 19 MCP tools and can manage your content through natural language. ## Trust Model diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 674d319..468b707 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -186,6 +186,12 @@ export default defineCommand({ log.message(` ${pc.cyan('contentrain serve')} — open the local review and normalize UI`) } log.message(` ${pc.cyan('contentrain generate')} — generate SDK client`) + + // Stack-aware SDK wiring guidance. The generated client lives at + // .contentrain/client/ (git-ignored), so a fresh clone/CI needs both the + // #contentrain subpath import and a generate step before build. + printStackWiring(stackChoice as string) + log.message('') log.message(pc.dim(` Tip: ${pc.cyan('contentrain studio connect')} — link this project to ${pc.bold('Contentrain Studio')} for`)) log.message(pc.dim(` team review, CDN delivery, and collaboration → ${pc.underline('https://studio.contentrain.io')}`)) @@ -194,6 +200,28 @@ export default defineCommand({ }, }) +/** + * Print stack-aware guidance for wiring the generated `#contentrain` client + * into the host project. Init does not mutate the host's bundler config or + * package.json (agent = intelligence), but a fresh `contentrain init` user has + * no signal about the alias + prebuild step, so we surface it here. + */ +function printStackWiring(stack: string): void { + const bundlerStacks = new Set(['nuxt', 'next', 'astro', 'sveltekit', 'svelte', 'react', 'react-vite', 'vue', 'vite']) + if (!bundlerStacks.has(stack)) return + + log.message('') + log.message(pc.bold(` SDK wiring (${stack}):`)) + log.message(` 1. Add the subpath import to ${pc.cyan('package.json')}:`) + log.message(pc.dim(' "imports": { "#contentrain": "./.contentrain/client/index.mjs" }')) + log.message(` 2. Add a ${pc.cyan('#contentrain')} bundler alias — see the ${pc.cyan('contentrain-sdk')} skill (bundler-config).`) + log.message(` 3. ${pc.cyan('.contentrain/client/')} is git-ignored, so wire generate into build for CI/fresh clones:`) + log.message(pc.dim(' "scripts": { "prebuild": "contentrain generate", "predev": "contentrain generate" }')) + if (stack === 'nuxt') { + log.message(pc.dim(' Note (Nuxt): treat #contentrain as server-only — use it under server/ (e.g. server/api/*).')) + } +} + interface InitOptions { stack: string locales: string[] diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ccd3b2a..232e822 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -70,7 +70,7 @@ All write operations are designed around git-backed safety: ## Tool Surface -17 MCP tools with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) for client safety hints: +19 MCP tools with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) for client safety hints: | Tool | Purpose | Read-only | Destructive | | --- | --- | --- | --- | @@ -87,7 +87,9 @@ All write operations are designed around git-backed safety: | `contentrain_content_list` | Read content entries | Yes | — | | `contentrain_validate` | Validate project content, optionally auto-fix structural issues | — | — | | `contentrain_submit` | Push `cr/*` branches to remote | — | — | -| `contentrain_merge` | Merge a review-mode branch into contentrain locally | — | — | +| `contentrain_merge` | Merge a review-mode branch into contentrain locally (by exact branch or model) | — | — | +| `contentrain_branch_list` | List pending `cr/*` branches with merge status | Yes | — | +| `contentrain_branch_delete` | Delete a stale/failed `cr/*` branch (contentrain branch protected) | — | **Yes** | | `contentrain_scan` | Graph- and candidate-based hardcoded string scan | Yes | — | | `contentrain_apply` | Normalize extract/reuse execution with dry-run support | — | — | | `contentrain_bulk` | Bulk locale copy, status updates, and deletes | — | — | @@ -250,7 +252,7 @@ These are intended for Contentrain tooling and advanced integrations, not for di Key design decisions in this package: - local-first **by default** — stdio transport + LocalProvider works without any network dependency -- provider-agnostic engine — the same 17 tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single `RepoProvider` contract +- provider-agnostic engine — the same 19 tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single `RepoProvider` contract - remote provider SDKs (`@octokit/rest`, `@gitbeaker/rest`) are optional peer dependencies — pulled in only when their provider is used - JSON-only content storage - git-backed write workflow (worktree transaction locally, single atomic commit over the Git Data / REST APIs remotely) diff --git a/packages/mcp/src/core/config.ts b/packages/mcp/src/core/config.ts index b4a80ad..80079bb 100644 --- a/packages/mcp/src/core/config.ts +++ b/packages/mcp/src/core/config.ts @@ -28,6 +28,8 @@ function normaliseConfig(raw: Partial | null): ContentrainCon repository: raw.repository, assets_path: raw.assets_path, branchRetention: raw.branchRetention, + branchWarnLimit: raw.branchWarnLimit, + branchBlockLimit: raw.branchBlockLimit, } } diff --git a/packages/mcp/src/core/validator/entry.ts b/packages/mcp/src/core/validator/entry.ts index 093b082..2e07940 100644 --- a/packages/mcp/src/core/validator/entry.ts +++ b/packages/mcp/src/core/validator/entry.ts @@ -143,9 +143,26 @@ function validateField( if (def.max !== undefined && value.length > def.max) { errors.push({ severity: 'error', ...errCtx, message: `${fieldId} must have at most ${def.max} items` }) } + // Polymorphic multi-relations (model targets multiple kinds) store + // { model, ref } items — mirror the single-relation rule so content that + // conforms to the generated type isn't rejected. Single-target relations + // store plain id/slug strings. + const targets = Array.isArray(def.model) ? def.model : (def.model ? [def.model] : []) + const polymorphic = targets.length > 1 for (let i = 0; i < value.length; i++) { - if (typeof value[i] !== 'string') { - errors.push({ severity: 'error', ...errCtx, message: `${fieldId}[${i}] must be a string (entry ID or slug)` }) + const item = value[i] + const itemCtx = { ...errCtx, field: `${fieldId}[${i}]` } + if (polymorphic) { + if (typeof item !== 'object' || item === null || !('model' in item) || !('ref' in item)) { + errors.push({ severity: 'error', ...itemCtx, message: `${fieldId}[${i}] must be { model, ref } for polymorphic relations` }) + } else { + const polyItem = item as { model: string, ref: string } + if (!targets.includes(polyItem.model)) { + errors.push({ severity: 'error', ...itemCtx, message: `${fieldId}[${i}] target model "${polyItem.model}" must be one of: ${targets.join(', ')}` }) + } + } + } else if (typeof item !== 'string') { + errors.push({ severity: 'error', ...itemCtx, message: `${fieldId}[${i}] must be a string (entry ID or slug)` }) } } } diff --git a/packages/mcp/src/core/validator/project.ts b/packages/mcp/src/core/validator/project.ts index 011e532..d6dc093 100644 --- a/packages/mcp/src/core/validator/project.ts +++ b/packages/mcp/src/core/validator/project.ts @@ -64,6 +64,7 @@ async function readTextViaReader(reader: RepoReader, path: string): Promise Promise { return async (targetModelId, targetLocale) => { const targetModel = await readModel(reader, targetModelId) @@ -77,13 +78,25 @@ function buildProjectTargetResolver( if (targetModel.kind === 'singleton' || targetModel.kind === 'dictionary') { return { exists: true, content: null } } - // Collection: return empty object when the locale file is missing so - // broken-ref detection still fires (legacy parity with `checkRelation`). - const targetData = await readJsonViaReader>( + // Collection: resolve against the target model's own storage. i18n:false + // targets are locale-agnostic (data.json). For i18n:true targets, fall back + // to the default-locale file when the source locale lacks the entry — a + // relation legitimately points at an entry that may be translated under + // another locale, so the id set is merged to avoid phantom broken refs. #Y5 + const merged: Record = {} + const primary = await readJsonViaReader>( reader, contentFilePath(targetModel, targetLocale), ) - return { exists: true, content: targetData ?? {} } + if (primary) Object.assign(merged, primary) + if (targetModel.i18n && targetLocale !== config.locales.default) { + const fallback = await readJsonViaReader>( + reader, + contentFilePath(targetModel, config.locales.default), + ) + if (fallback) Object.assign(merged, fallback) + } + return { exists: true, content: merged } } } @@ -124,13 +137,16 @@ async function validateCollectionModel( ): Promise<{ entries: number; fixed: number }> { let entriesChecked = 0 let fixed = 0 - const locales = config.locales.supported + // Non-i18n models store content + meta locale-agnostically (data.json, a + // single meta set), so iterating every supported locale produces phantom + // per-locale orphan/parity warnings. Iterate just the default locale. #8/Y3 + const locales = model.i18n ? config.locales.supported : [config.locales.default] // Collect all entry IDs per locale for parity check const localeEntryIds: Record> = {} const allEntryIds = new Set() - const resolveTarget = buildProjectTargetResolver(reader) + const resolveTarget = buildProjectTargetResolver(reader, config) for (const locale of locales) { const filePath = contentFilePath(model, locale) @@ -321,7 +337,7 @@ async function validateSingletonModel( let entriesChecked = 0 let fixed = 0 - for (const locale of config.locales.supported) { + for (const locale of (model.i18n ? config.locales.supported : [config.locales.default])) { const filePath = contentFilePath(model, locale) const data = await readJsonViaReader>(reader, filePath) @@ -349,7 +365,7 @@ async function validateSingletonModel( const entryResult = validateContent(data, model.fields, model.id, locale) issues.push(...entryResult.errors) - const resolveTarget = buildProjectTargetResolver(reader) + const resolveTarget = buildProjectTargetResolver(reader, config) const relationErrors = await checkRelationIntegrity( data, model.fields, @@ -405,7 +421,7 @@ async function validateDictionaryModel( const localeKeys: Record> = {} - for (const locale of config.locales.supported) { + for (const locale of (model.i18n ? config.locales.supported : [config.locales.default])) { const filePath = contentFilePath(model, locale) const data = await readJsonViaReader>(reader, filePath) @@ -586,7 +602,10 @@ async function validateDocumentModel( if (!await reader.fileExists(cDir)) return { entries: 0, fixed: 0 } const slugs = await discoverDocumentSlugs(reader, cDir, model) - const locales = config.locales.supported + // Non-i18n models store content + meta locale-agnostically (data.json, a + // single meta set), so iterating every supported locale produces phantom + // per-locale orphan/parity warnings. Iterate just the default locale. #8/Y3 + const locales = model.i18n ? config.locales.supported : [config.locales.default] for (const slug of slugs) { if (slug.startsWith('.')) continue @@ -652,7 +671,7 @@ async function validateDocumentModel( issues.push({ ...err, slug }) } - const resolveTarget = buildProjectTargetResolver(reader) + const resolveTarget = buildProjectTargetResolver(reader, config) const relationErrors = await checkRelationIntegrity( frontmatter, fieldsWithoutBody, @@ -797,7 +816,7 @@ export async function validateProject( const model = await readModel(reader, summary.id) if (!model) continue - for (const locale of config.locales.supported) { + for (const locale of (model.i18n ? config.locales.supported : [config.locales.default])) { if (!globalValueMap[locale]) globalValueMap[locale] = new Map() const data = await readJsonViaReader>(reader, contentFilePath(model, locale)) if (!data) continue diff --git a/packages/mcp/src/git/branch-lifecycle.ts b/packages/mcp/src/git/branch-lifecycle.ts index 88f5902..294926d 100644 --- a/packages/mcp/src/git/branch-lifecycle.ts +++ b/packages/mcp/src/git/branch-lifecycle.ts @@ -125,15 +125,17 @@ export async function checkBranchHealth(projectRoot: string): Promise= 50 - const blocked = unmerged >= 80 + const warning = unmerged >= warnLimit + const blocked = unmerged >= blockLimit let message: string | undefined if (blocked) { - message = `BLOCKED: ${unmerged} active contentrain branches (limit: 80). Run cleanup or merge/delete old branches before creating new ones.` + message = `BLOCKED: ${unmerged} active contentrain branches (limit: ${blockLimit}). Run cleanup or merge/delete old branches before creating new ones.` } else if (warning) { - message = `WARNING: ${unmerged} active contentrain branches. Consider merging or deleting old branches (warning at 50, blocked at 80).` + message = `WARNING: ${unmerged} active contentrain branches. Consider merging or deleting old branches (warning at ${warnLimit}, blocked at ${blockLimit}).` } return { total, merged: mergedCount, unmerged, warning, blocked, message } diff --git a/packages/mcp/src/git/errors.ts b/packages/mcp/src/git/errors.ts new file mode 100644 index 0000000..e774e1a --- /dev/null +++ b/packages/mcp/src/git/errors.ts @@ -0,0 +1,60 @@ +/** + * Error normalization for git-backed MCP operations. + * + * `simple-git` surfaces a failed `git commit`/`push`/`merge` as a `GitError` + * whose `message` is the raw subprocess stderr — which, for hook tooling + * (commitlint, husky, lefthook), is colorized multi-line text. Returning that + * verbatim inside a JSON error produces an unreadable ANSI-escaped blob. + * + * This helper strips ANSI, preserves any structured fields the thrown error + * already carries (`code`/`agent_hint`/`developer_action`), and detects git + * hook rejections so the agent gets an actionable, structured envelope. + */ + +// Build the ANSI matcher without a literal control char so oxlint's +// no-control-regex rule stays happy. +const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +export function stripAnsi(input: string): string { + return input.replace(ANSI_RE, '') +} + +export interface NormalizedError { + error: string + stage?: string + hook?: string + code?: string + agent_hint?: string + developer_action?: string +} + +/** + * Normalize any thrown error into a structured, ANSI-free envelope for MCP + * tool responses. `stage` labels where it happened (e.g. 'content_save'). + */ +export function normalizeOperationError(err: unknown, stage?: string): NormalizedError { + const e = err as { message?: string, code?: string, agent_hint?: string, developer_action?: string } | undefined + const rawMessage = e?.message ?? String(err) + const message = stripAnsi(rawMessage).trim() + + const out: NormalizedError = { error: message } + if (stage) out.stage = stage + if (e?.code) out.code = e.code + if (e?.agent_hint) out.agent_hint = e.agent_hint + if (e?.developer_action) out.developer_action = e.developer_action + + // Detect a git hook rejection (only when the error isn't already a + // structured Contentrain error with its own code). + if (!out.code) { + const hookName = /\b(commit-msg|pre-commit|prepare-commit-msg|pre-push)\b/i.exec(message)?.[1] + const hookTool = /\b(commitlint|husky|lefthook)\b/i.exec(message)?.[1] + if (hookName || hookTool) { + out.stage = out.stage ?? 'git-commit' + out.hook = hookName ?? hookTool + out.agent_hint = out.agent_hint + ?? 'A git hook rejected the Contentrain commit. Contentrain commits with --no-verify, but a wrapping CLI or server-side hook may still run. Ask the developer to allow machine-generated "[contentrain]" commits or to exclude cr/* branches from the hook.' + } + } + + return out +} diff --git a/packages/mcp/src/git/transaction.ts b/packages/mcp/src/git/transaction.ts index 9e51384..2f5b6c7 100644 --- a/packages/mcp/src/git/transaction.ts +++ b/packages/mcp/src/git/transaction.ts @@ -1,4 +1,4 @@ -import { simpleGit } from 'simple-git' +import { simpleGit, type SimpleGit } from 'simple-git' import { join } from 'node:path' import { rm as removeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -230,6 +230,8 @@ export async function createTransaction( await wtGit.checkout(['-b', branch]) let commitHash = '' + let pendingReview = false + let savedContextUpdate: ContextUpdate | undefined return { worktree: worktreePath, @@ -240,12 +242,15 @@ export async function createTransaction( }, async commit(message, contextUpdate?) { - // Write context.json together with content (no separate commit) - if (contextUpdate) { - await writeContext(worktreePath, contextUpdate) - } + // context.json is intentionally NOT committed on the feature branch — it + // is regenerated on the contentrain branch after the merge (see + // complete()). Committing it per-branch caused cross-branch merge + // conflicts on a single mutable file. `--no-verify` keeps the repo's + // commit-msg / pre-commit hooks (commitlint, lefthook, husky) from + // rejecting these machine-generated infra commits. + savedContextUpdate = contextUpdate await wtGit.add('.') - const result = await wtGit.commit(message, { '--allow-empty': null }) + const result = await wtGit.commit(message, { '--allow-empty': null, '--no-verify': null }) commitHash = result.commit || '' return commitHash }, @@ -255,6 +260,8 @@ export async function createTransaction( if (hasRemote) { await git.push(remoteName, branch) } + // Pending-review branches must survive for a later contentrain_merge. + pendingReview = true return { action: 'pending-review', commit: commitHash } } @@ -281,6 +288,12 @@ export async function createTransaction( }) } + // Regenerate context.json on the contentrain branch (post-merge, + // single-threaded) and fold it into the tip before advancing the base. + if (savedContextUpdate) { + await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate) + } + // Get contentrain tip + old base ref + dirty files in parallel const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([ wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()), @@ -358,6 +371,13 @@ export async function createTransaction( } catch { // worktree may already be cleaned up } + // Prune the feature branch unless it is a pending-review branch that must + // survive for a later contentrain_merge. Auto-merged branches (already in + // contentrain) and failed/empty branches are both safe to delete, so + // failed saves and merged saves no longer leak dangling cr/* refs. + if (!pendingReview) { + await safeDeleteBranch(git, branch) + } }, } } @@ -416,6 +436,11 @@ export async function mergeBranch( }) } + // Regenerate context.json on contentrain post-merge (deterministic, + // single-threaded) so review-mode branches — which carry no context.json — + // still produce up-to-date stats once landed. + await regenerateContextOnContentrain(wtGit, worktreePath, { tool: 'contentrain_merge', model: '*' }) + // Get contentrain tip + old base ref + dirty files in parallel const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([ wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()), @@ -472,6 +497,9 @@ export async function mergeBranch( } } + // Prune the now-merged feature branch so merged cr/* refs don't accumulate. + await safeDeleteBranch(git, branchName) + return { action: 'merged' as const, commit: contentrainTip, @@ -494,3 +522,39 @@ export function buildBranchName(scope: string, target: string, locale?: string): parts.push(ts) return parts.join('/') } + +/** + * Force-delete a local branch, swallowing all errors. Never deletes the + * singleton `contentrain` branch. Used to prune feature branches after they + * are merged (auto-merge / contentrain_merge) or when a transaction fails + * before completing — so failed/merged `cr/*` refs do not accumulate. + */ +async function safeDeleteBranch(git: SimpleGit, branch: string): Promise { + if (!branch || branch === CONTENTRAIN_BRANCH) return + try { + await git.raw(['branch', '-D', branch]) + } catch { + // Branch may not exist, be checked out, or already be deleted — ignore. + } +} + +/** + * Regenerate `.contentrain/context.json` deterministically inside a worktree + * that is currently on the `contentrain` branch, then commit it (hooks + * bypassed). Called AFTER a feature branch is merged so context.json is only + * ever written on `contentrain`, single-threaded — eliminating the per-branch + * merge conflicts that came from committing it on every feature branch. + */ +async function regenerateContextOnContentrain( + wtGit: SimpleGit, + worktreePath: string, + contextUpdate: ContextUpdate, +): Promise { + await writeContext(worktreePath, contextUpdate) + await wtGit.add('.contentrain/context.json') + try { + await wtGit.commit('[contentrain] context: update', { '--no-verify': null }) + } catch { + // Nothing staged (context.json unchanged) — fine. + } +} diff --git a/packages/mcp/src/tools/annotations.ts b/packages/mcp/src/tools/annotations.ts index b6afb04..71d493f 100644 --- a/packages/mcp/src/tools/annotations.ts +++ b/packages/mcp/src/tools/annotations.ts @@ -103,6 +103,18 @@ export const TOOL_ANNOTATIONS: Record = { destructiveHint: false, idempotentHint: false, }, + contentrain_branch_list: { + title: 'List Branches', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + contentrain_branch_delete: { + title: 'Delete Branch', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, // ─── Normalize (mixed) ─── contentrain_scan: { diff --git a/packages/mcp/src/tools/bulk.ts b/packages/mcp/src/tools/bulk.ts index 4842838..da672bb 100644 --- a/packages/mcp/src/tools/bulk.ts +++ b/packages/mcp/src/tools/bulk.ts @@ -8,6 +8,7 @@ import { resolveContentDir, resolveJsonFilePath, deleteContent } from '../core/c import { readMeta, writeMeta } from '../core/meta-manager.js' import { createTransaction, buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' +import { normalizeOperationError } from '../git/errors.js' import { readJson, writeJson } from '../util/fs.js' import { TOOL_ANNOTATIONS } from './annotations.js' import { capabilityError } from './guards.js' @@ -165,7 +166,7 @@ export function registerBulkTools( await tx.cleanup() return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `copy_locale failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'bulk_copy_locale'), }) }], isError: true, } @@ -247,7 +248,7 @@ export function registerBulkTools( await tx.cleanup() return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `update_status failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'bulk_update_status'), }) }], isError: true, } @@ -313,7 +314,7 @@ export function registerBulkTools( await tx.cleanup() return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `delete_entries failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'bulk_delete_entries'), }) }], isError: true, } diff --git a/packages/mcp/src/tools/content.ts b/packages/mcp/src/tools/content.ts index acdecdc..7cc8ee0 100644 --- a/packages/mcp/src/tools/content.ts +++ b/packages/mcp/src/tools/content.ts @@ -8,6 +8,7 @@ import { planContentDelete, planContentSave } from '../core/ops/index.js' import { LocalProvider } from '../providers/local/index.js' import { buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' +import { normalizeOperationError } from '../git/errors.js' import { validateProject } from '../core/validator/index.js' import { OverlayReader } from '../core/overlay-reader.js' import { TOOL_ANNOTATIONS } from './annotations.js' @@ -129,7 +130,7 @@ export function registerContentTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Content save failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'content_save'), }) }], isError: true, } @@ -165,22 +166,23 @@ export function registerContentTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Content save failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'content_save'), }) }], isError: true, } } - // Post-save validation — runs against whichever provider backed the - // write. LocalProvider sees the post-commit state via its - // filesystem (the worktree transaction has already landed the - // files). Remote providers see the pre-commit state in their - // reader; wrap the reader in an OverlayReader so the validator - // evaluates the committed-but-not-yet-visible state instead of - // the pre-change base branch. - const validationResult = projectRoot - ? await validateProject(projectRoot, { model: input.model }) - : await validateProject(new OverlayReader(provider, plan.changes), { model: input.model }) + // Post-save validation — runs against an OverlayReader that layers the + // just-saved changes on top of the provider's base view. This validates + // the committed state for BOTH providers and BOTH workflow modes. The + // old local path validated `projectRoot` (the developer working tree), + // which in review mode (or before selectiveSync) had not yet received the + // feature-branch files — producing false "locale file missing" errors for + // content that was just created. #7 + const validationResult = await validateProject( + new OverlayReader(provider, plan.changes), + { model: input.model }, + ) const allAdvisories = plan.result.flatMap(r => r.advisories ?? []) @@ -267,7 +269,7 @@ export function registerContentTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Delete failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'content_delete'), }) }], isError: true, } @@ -309,7 +311,7 @@ export function registerContentTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Delete failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'content_delete'), }) }], isError: true, } @@ -369,7 +371,7 @@ export function registerContentTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `List failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'content_list'), }) }], isError: true, } diff --git a/packages/mcp/src/tools/context.ts b/packages/mcp/src/tools/context.ts index fca2b0b..5db7300 100644 --- a/packages/mcp/src/tools/context.ts +++ b/packages/mcp/src/tools/context.ts @@ -53,6 +53,28 @@ export function registerContextTools( const context = projectRoot ? await readContext(projectRoot) : await readContext(provider) const vocabulary = await readVocabulary(provider) + // Stats source depends on the provider: + // - Local (projectRoot): context.json's stats can lag (init writes 0/0, + // and the working-tree copy may be unsynced after a write), which made + // status report models:0/entries:0 alongside a non-empty models array. + // Derive both live from the just-enumerated models. #19 + // - Remote: the committed context.json is rebuilt per-commit and is the + // source of truth; the reader can't always walk content cheaply, so + // trust context.stats. + let stats: Record | null = context?.stats ?? null + if (projectRoot) { + const fullModels = await Promise.all(models.map(m => readModel(provider, m.id))) + const entryCounts = await Promise.all( + fullModels.filter((m): m is NonNullable => m !== null).map(m => countEntries(provider, m)), + ) + stats = { + models: models.length, + entries: entryCounts.reduce((acc, c) => acc + c.total, 0), + locales: config?.locales.supported ?? context?.stats?.locales ?? ['en'], + ...(context?.stats?.lastSync ? { lastSync: context.stats.lastSync } : {}), + } + } + const errors: string[] = [] if (!config) errors.push('.contentrain/config.json missing') @@ -66,9 +88,9 @@ export function registerContextTools( repository: config.repository, } : null, models, - context: context ? { - lastOperation: context.lastOperation, - stats: context.stats, + context: (context || stats) ? { + lastOperation: context?.lastOperation ?? null, + stats, } : null, vocabulary: vocabulary && Object.keys(vocabulary.terms).length > 0 ? { size: Object.keys(vocabulary.terms).length, terms: vocabulary.terms } diff --git a/packages/mcp/src/tools/model.ts b/packages/mcp/src/tools/model.ts index 7de0f6f..54e9b69 100644 --- a/packages/mcp/src/tools/model.ts +++ b/packages/mcp/src/tools/model.ts @@ -10,6 +10,7 @@ import { planModelDelete, planModelSave } from '../core/ops/index.js' import { LocalProvider } from '../providers/local/index.js' import { buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' +import { normalizeOperationError } from '../git/errors.js' import { TOOL_ANNOTATIONS } from './annotations.js' import { commitThroughProvider } from './commit-plan.js' @@ -112,7 +113,7 @@ export function registerModelTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Model save failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'model_save'), }) }], isError: true, } @@ -246,7 +247,7 @@ export function registerModelTools( } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Delete failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'model_delete'), }) }], isError: true, } diff --git a/packages/mcp/src/tools/setup.ts b/packages/mcp/src/tools/setup.ts index d251c44..ad99212 100644 --- a/packages/mcp/src/tools/setup.ts +++ b/packages/mcp/src/tools/setup.ts @@ -13,6 +13,7 @@ import { writeContent, type ContentEntry } from '../core/content-manager.js' import { getTemplate, listTemplates } from '../templates/index.js' import { createTransaction, buildBranchName, ensureContentBranch } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' +import { normalizeOperationError } from '../git/errors.js' import { TOOL_ANNOTATIONS } from './annotations.js' import { capabilityError } from './guards.js' @@ -68,7 +69,7 @@ export function registerSetupTools( } const hasAnyCommit = await git.raw(['rev-list', '-n', '1', '--all']).then(out => out.trim().length > 0).catch(() => false) if (!hasAnyCommit) { - await git.commit('initial commit', { '--allow-empty': null }) + await git.commit('initial commit', { '--allow-empty': null, '--no-verify': null }) } // Branch health gate @@ -172,7 +173,7 @@ export function registerSetupTools( await tx.cleanup() return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Init failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'init'), }) }], isError: true, } @@ -288,7 +289,7 @@ export function registerSetupTools( await tx.cleanup() return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Scaffold failed: ${error instanceof Error ? error.message : String(error)}`, + ...normalizeOperationError(error, 'scaffold'), }) }], isError: true, } diff --git a/packages/mcp/src/tools/workflow.ts b/packages/mcp/src/tools/workflow.ts index 7a44a3b..e39857b 100644 --- a/packages/mcp/src/tools/workflow.ts +++ b/packages/mcp/src/tools/workflow.ts @@ -1,15 +1,61 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { CONTENTRAIN_BRANCH } from '@contentrain/types' import { z } from 'zod' -import { simpleGit } from 'simple-git' +import { simpleGit, type SimpleGit } from 'simple-git' import type { ToolProvider } from '../server.js' import { validateProject } from '../core/validator/index.js' import { readConfig } from '../core/config.js' import { createTransaction, buildBranchName, mergeBranch } from '../git/transaction.js' import { checkBranchHealth, cleanupMergedBranches } from '../git/branch-lifecycle.js' +import { isMerged } from '../providers/local/branch-ops.js' +import { normalizeOperationError } from '../git/errors.js' import { TOOL_ANNOTATIONS } from './annotations.js' import { capabilityError } from './guards.js' +/** + * Resolve a merge target from either an exact branch name or a + * model/locale/latest selector. Returns the concrete branch, or an error with + * candidate branches when the selector is ambiguous or matches nothing. + */ +async function resolveMergeBranch( + git: SimpleGit, + input: { branch?: string, model?: string, locale?: string, latest?: boolean }, +): Promise<{ branch: string } | { error: string, candidates: string[] }> { + const summary = await git.branchLocal() + const crBranches = summary.all.filter(b => b.startsWith('cr/') && b !== CONTENTRAIN_BRANCH) + + if (input.branch) { + if (summary.all.includes(input.branch)) return { branch: input.branch } + return { error: `Branch "${input.branch}" not found locally.`, candidates: crBranches } + } + + if (!input.model) { + return { error: 'Provide either "branch" (exact name) or "model" (to resolve the branch).', candidates: crBranches } + } + + const prefix = input.locale + ? `cr/content/${input.model}/${input.locale}/` + : `cr/content/${input.model}/` + let matches = crBranches.filter(b => b.startsWith(prefix)) + if (matches.length === 0) { + // Fall back to any scope whose path contains the model segment (fix/bulk/...) + matches = crBranches.filter(b => b.split('/').includes(input.model!)) + } + if (matches.length === 0) { + return { error: `No pending cr/* branch found for model "${input.model}".`, candidates: crBranches } + } + if (matches.length === 1) return { branch: matches[0]! } + if (!input.latest) { + return { error: `Multiple branches match model "${input.model}". Pass latest:true or an exact branch.`, candidates: matches } + } + const withTimes = await Promise.all(matches.map(async (b) => { + const t = await git.raw(['log', '-1', '--format=%ct', b]).then(s => Number(s.trim())).catch(() => 0) + return { b, t } + })) + const newest = withTimes.toSorted((left, right) => right.t - left.t)[0]! + return { branch: newest.b } +} + export function registerWorkflowTools( server: McpServer, provider: ToolProvider, @@ -58,9 +104,12 @@ export function registerWorkflowTools( } } - // Use git transaction for fixes + // Use git transaction for fixes. Force auto-merge: structural fixes + // (canonical sort, orphan meta, missing locale files) are cosmetic + // infra repairs and should land directly on contentrain rather than + // spawn a pending cr/fix/validate review branch. const branch = buildBranchName('fix', 'validate') - const tx = await createTransaction(projectRoot, branch) + const tx = await createTransaction(projectRoot, branch, { workflowOverride: 'auto-merge' }) try { await tx.write(async (wt) => { @@ -175,11 +224,21 @@ export function registerWorkflowTools( } if (!hasRemote) { + const summary = await git.branchLocal().catch(() => ({ all: [] as string[] })) + const pending = summary.all.filter(b => b.startsWith('cr/') && b !== CONTENTRAIN_BRANCH) return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `No remote "${remoteName}" found. Configure a git remote first.`, - next_steps: [`git remote add ${remoteName} `], - }) }], + error: `No remote "${remoteName}" configured — contentrain_submit (push) is unavailable.`, + stage: 'submit', + agent_hint: 'This project has no git remote. In a local/solo workflow, land pending review branches with contentrain_merge instead of submit, or set workflow:"auto-merge" in config.json so saves merge locally without a remote.', + pending_branches: pending, + next_steps: [ + pending.length > 0 + ? `Merge a pending branch locally: contentrain_merge { branch: "${pending[0]}", confirm: true }` + : 'Make changes with contentrain_content_save — auto-merge lands them locally without a remote', + `Or add a remote to enable submit: git remote add ${remoteName} `, + ], + }, null, 2) }], isError: true, } } @@ -291,9 +350,12 @@ export function registerWorkflowTools( // ─── contentrain_merge ─── server.tool( 'contentrain_merge', - 'Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, and selectively syncs .contentrain/ files to the working tree.', + 'Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, selectively syncs .contentrain/ files to the working tree, and prunes the merged branch. Target by exact "branch" name, or resolve by "model" (+ optional "locale"/"latest").', { - branch: z.string().describe('Branch name to merge (e.g. cr/normalize/extract/...)'), + branch: z.string().optional().describe('Exact branch name to merge (e.g. cr/content/blog-post/...). Omit to resolve by model.'), + model: z.string().optional().describe('Resolve the branch by model id (e.g. "blog-post").'), + locale: z.string().optional().describe('Narrow model resolution to a locale.'), + latest: z.boolean().optional().describe('When multiple branches match the model, merge the most recently committed one.'), confirm: z.literal(true).describe('Must be true to confirm the merge'), }, TOOL_ANNOTATIONS['contentrain_merge']!, @@ -313,29 +375,35 @@ export function registerWorkflowTools( } try { - // Verify branch exists + // Resolve the target branch from exact name or model/locale/latest selector const git = simpleGit(projectRoot) - const branches = await git.branchLocal() - if (!branches.all.includes(input.branch)) { + const resolved = await resolveMergeBranch(git, input) + if ('error' in resolved) { return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Branch "${input.branch}" not found locally.`, - next_steps: ['Check branch name with git branch -l', 'Use contentrain_submit to push first if needed'], - }) }], + error: resolved.error, + candidates: resolved.candidates, + next_steps: [ + 'Pass an exact { branch } or { model, latest: true }', + 'List pending branches with contentrain_branch_list', + ], + }, null, 2) }], isError: true, } } + const targetBranch = resolved.branch - const result = await mergeBranch(projectRoot, input.branch) + const result = await mergeBranch(projectRoot, targetBranch) return { content: [{ type: 'text' as const, text: JSON.stringify({ status: 'merged', + branch: targetBranch, action: result.action, commit: result.commit, sync: result.sync, next_steps: [ - 'Run `npx contentrain generate` to update SDK client', + 'Run `contentrain generate` (or `npx contentrain-query generate`) to update the SDK client', 'Run contentrain_validate to verify content integrity', result.sync.skipped.length > 0 ? `${result.sync.skipped.length} file(s) skipped due to local changes — resolve manually` @@ -344,13 +412,115 @@ export function registerWorkflowTools( }, null, 2) }], } } catch (error) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(normalizeOperationError(error, 'merge'), null, 2) }], + isError: true, + } + } + }, + ) + + // ─── contentrain_branch_list ─── + server.tool( + 'contentrain_branch_list', + 'List pending contentrain (cr/*) branches with their merge status against the contentrain branch. Use this to discover branch names for contentrain_merge / contentrain_branch_delete, and to monitor branch-health limits (warning at 50, blocked at 80 unmerged).', + { + unmerged_only: z.boolean().optional().describe('Only list branches not yet merged into contentrain. Default: false'), + }, + TOOL_ANNOTATIONS['contentrain_branch_list']!, + async (input) => { + if (!provider.capabilities.localWorktree || !projectRoot) { + return capabilityError('contentrain_branch_list', 'localWorktree') + } + try { + const git = simpleGit(projectRoot) + const summary = await git.branchLocal() + const crBranches = summary.all.filter(b => b.startsWith('cr/') && b !== CONTENTRAIN_BRANCH) + + const branches = await Promise.all(crBranches.map(async (name) => { + const [merged, ts] = await Promise.all([ + isMerged(projectRoot, name, CONTENTRAIN_BRANCH), + git.raw(['log', '-1', '--format=%cI', name]).then(s => s.trim()).catch(() => ''), + ]) + return { name, sha: summary.branches[name]?.commit ?? '', merged, lastCommit: ts } + })) + const ordered = branches.toSorted((left, right) => right.lastCommit.localeCompare(left.lastCommit)) + const filtered = input.unmerged_only ? ordered.filter(b => !b.merged) : ordered + const health = await checkBranchHealth(projectRoot) + return { content: [{ type: 'text' as const, text: JSON.stringify({ - error: `Merge failed: ${error instanceof Error ? error.message : String(error)}`, + total: ordered.length, + unmerged: ordered.filter(b => !b.merged).length, + branches: filtered, + health: { warning: health.warning, blocked: health.blocked, message: health.message }, + next_steps: [ + 'Merge a branch: contentrain_merge { branch: "...", confirm: true }', + 'Delete a stale branch: contentrain_branch_delete { branch: "...", confirm: true }', + ], + }, null, 2) }], + } + } catch (error) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(normalizeOperationError(error, 'branch_list'), null, 2) }], + isError: true, + } + } + }, + ) + + // ─── contentrain_branch_delete ─── + server.tool( + 'contentrain_branch_delete', + 'Delete a pending contentrain (cr/*) branch that will not be merged — e.g. a branch left behind by a failed operation, or a superseded draft. Only cr/* branches can be deleted; the contentrain branch is protected. This is destructive: the branch and its unmerged commits are removed.', + { + branch: z.string().describe('The cr/* branch to delete'), + confirm: z.literal(true).describe('Must be true to confirm deletion'), + }, + TOOL_ANNOTATIONS['contentrain_branch_delete']!, + async (input) => { + if (!provider.capabilities.localWorktree || !projectRoot) { + return capabilityError('contentrain_branch_delete', 'localWorktree') + } + if (!input.branch.startsWith('cr/') || input.branch === CONTENTRAIN_BRANCH) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `Refusing to delete "${input.branch}". Only cr/* feature branches can be deleted (the contentrain branch is protected).`, }) }], isError: true, } } + try { + const git = simpleGit(projectRoot) + const summary = await git.branchLocal() + if (!summary.all.includes(input.branch)) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `Branch "${input.branch}" not found locally.`, + next_steps: ['List branches with contentrain_branch_list'], + }) }], + isError: true, + } + } + const merged = await isMerged(projectRoot, input.branch, CONTENTRAIN_BRANCH) + // Force-delete (-D): the branch may carry unmerged commits the caller + // has explicitly decided to discard. + await git.raw(['branch', '-D', input.branch]) + + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + status: 'deleted', + branch: input.branch, + was_merged: merged, + warning: merged ? undefined : 'Branch was not merged — its commits were discarded.', + }, null, 2) }], + } + } catch (error) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(normalizeOperationError(error, 'branch_delete'), null, 2) }], + isError: true, + } + } }, ) } diff --git a/packages/mcp/tests/core/validator/entry.test.ts b/packages/mcp/tests/core/validator/entry.test.ts index 06e0a98..a16ccc8 100644 --- a/packages/mcp/tests/core/validator/entry.test.ts +++ b/packages/mcp/tests/core/validator/entry.test.ts @@ -233,4 +233,34 @@ describe('validateContent', () => { expect(err?.locale).toBe('tr') expect(err?.entry).toBe('my-entry') }) + + it('single-target relations accept string id arrays', () => { + const fields: Record = { + tags: { type: 'relations', model: 'tag' }, + } + const result = validateContent({ tags: ['t1', 't2'] }, fields, 'post', 'en', 'p1') + expect(result.errors.filter(e => e.field?.startsWith('tags'))).toEqual([]) + }) + + it('polymorphic relations accept { model, ref } items (matches generated type)', () => { + const fields: Record = { + blocks: { type: 'relations', model: ['blog-post', 'page'] }, + } + const ok = validateContent( + { blocks: [{ model: 'blog-post', ref: 'a1' }, { model: 'page', ref: 'p2' }] }, + fields, 'home', 'en', 'h1', + ) + expect(ok.errors.filter(e => e.field?.startsWith('blocks'))).toEqual([]) + + // A plain string is invalid for a polymorphic multi-relation + const bad = validateContent({ blocks: ['a1'] }, fields, 'home', 'en', 'h1') + expect(bad.errors.some(e => e.field === 'blocks[0]' && e.severity === 'error')).toBe(true) + + // An item targeting a model outside the union is rejected + const wrong = validateContent( + { blocks: [{ model: 'author', ref: 'x' }] }, + fields, 'home', 'en', 'h1', + ) + expect(wrong.errors.some(e => e.field === 'blocks[0]' && e.message.includes('must be one of'))).toBe(true) + }) }) diff --git a/packages/mcp/tests/git/transaction.test.ts b/packages/mcp/tests/git/transaction.test.ts index 409a502..3f69b9f 100644 --- a/packages/mcp/tests/git/transaction.test.ts +++ b/packages/mcp/tests/git/transaction.test.ts @@ -118,13 +118,18 @@ describe('createTransaction', () => { const hash = await tx.commit('[contentrain] create: new-model', { tool: 'test', model: 'new-model' }) expect(hash).toBeTruthy() - // Verify context.json was committed (not excluded) + // context.json is NOT committed on the feature branch — it is regenerated + // on the contentrain branch post-merge (avoids cross-branch conflicts). const wtGit = simpleGit(tx.worktree) const show = await wtGit.show(['HEAD', '--name-only', '--format=']) - expect(show).toContain('context.json') + expect(show).not.toContain('context.json') const result = await tx.complete() expect(result.action).toBe('auto-merged') + + // After complete(), context.json exists on the contentrain branch tip. + const ctxOnContentrain = await simpleGit(testDir).show(['contentrain:.contentrain/context.json']) + expect(ctxOnContentrain).toContain('"lastOperation"') expect(result.sync).toBeDefined() expect(result.sync!.synced).toBeDefined() @@ -162,8 +167,8 @@ describe('createTransaction', () => { }) }) -describe('context.json committed with content', () => { - it('includes context.json in the commit', async () => { +describe('context.json regenerated on contentrain (not feature branch)', () => { + it('keeps context.json out of the feature-branch commit and regenerates it on merge', async () => { const tx = await createTransaction(testDir, 'cr/model/test/ctx-1') await tx.write(async (wt) => { await mkdir(join(wt, '.contentrain', 'models'), { recursive: true }) @@ -171,13 +176,33 @@ describe('context.json committed with content', () => { }) await tx.commit('[contentrain] test context', { tool: 'test', model: 'ctx-test' }) - // Verify context.json was committed (not excluded) + // Feature-branch commit excludes context.json const wtGit = simpleGit(tx.worktree) const show = await wtGit.show(['HEAD', '--name-only', '--format=']) - expect(show).toContain('context.json') + expect(show).not.toContain('context.json') + + await tx.complete() + + // contentrain branch tip has a regenerated context.json + const ctx = await simpleGit(testDir).show(['contentrain:.contentrain/context.json']) + expect(ctx).toContain('"version"') await tx.cleanup() }) + + it('prunes the feature branch after auto-merge cleanup', async () => { + const tx = await createTransaction(testDir, 'cr/model/test/prune-1') + await tx.write(async (wt) => { + await mkdir(join(wt, '.contentrain', 'models'), { recursive: true }) + await writeFile(join(wt, '.contentrain', 'models', 'prune-test.json'), '{}') + }) + await tx.commit('[contentrain] prune test', { tool: 'test', model: 'prune-test' }) + await tx.complete() + await tx.cleanup() + + const branches = await simpleGit(testDir).branchLocal() + expect(branches.all).not.toContain('cr/model/test/prune-1') + }) }) describe('selectiveSync', () => { diff --git a/packages/mcp/tests/tools/content.test.ts b/packages/mcp/tests/tools/content.test.ts index 8ed1176..154e7c6 100644 --- a/packages/mcp/tests/tools/content.test.ts +++ b/packages/mcp/tests/tools/content.test.ts @@ -267,19 +267,27 @@ describe('contentrain_content_save', () => { expect(data['error']).toContain('not found') }) - it('blocks new writes when 80 active contentrain branches exist', async () => { + it('blocks new writes when the unmerged branch limit is reached', async () => { client = await createModel(client, 'hero', 'singleton', 'marketing', { title: { type: 'string', required: true }, }) - const git = simpleGit(testDir) - const baseBranch = (await git.raw(['branch', '--show-current'])).trim() + // Lower the configurable block limit so the test doesn't have to create 80 + // real branches (hundreds of git subprocesses that lock up the machine). + const configPath = join(testDir, '.contentrain', 'config.json') + const config = await readJson>(configPath) + config!['branchBlockLimit'] = 3 + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n') - for (let i = 1; i <= 80; i++) { - const branchName = `cr/test/block-${String(i).padStart(3, '0')}` - await git.checkoutBranch(branchName, baseBranch) - await git.commit(`branch ${i}`, undefined, { '--allow-empty': null }) - await git.checkout(baseBranch) + // Create 3 unmerged cr/* branches cheaply: one divergent commit object + // (commit-tree touches neither the index nor the working tree), then point + // each branch ref at it — no checkouts, no per-branch commits. + const git = simpleGit(testDir) + const head = (await git.raw(['rev-parse', 'HEAD'])).trim() + const tree = (await git.raw(['rev-parse', 'HEAD^{tree}'])).trim() + const divergent = (await git.raw(['commit-tree', tree, '-p', head, '-m', 'divergent'])).trim() + for (let i = 1; i <= 3; i++) { + await git.raw(['branch', `cr/test/block-${i}`, divergent]) } client = await createTestClient(testDir) @@ -294,8 +302,9 @@ describe('contentrain_content_save', () => { expect(result.isError).toBe(true) const data = parseResult(result) - expect(data['error']).toContain('80') - }, 120000) + expect(data['action']).toBe('blocked') + expect(data['error']).toContain('BLOCKED') + }, 60000) it('handles two writes to the same model in the same second without branch collision', async () => { client = await createModel(client, 'hero', 'singleton', 'marketing', { diff --git a/packages/rules/essential/contentrain-essentials.md b/packages/rules/essential/contentrain-essentials.md index b0a0e40..be72fed 100644 --- a/packages/rules/essential/contentrain-essentials.md +++ b/packages/rules/essential/contentrain-essentials.md @@ -52,7 +52,9 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l | `contentrain_apply` | Apply normalize (extract/reuse) | | `contentrain_validate` | Validate content against schemas | | `contentrain_submit` | Push branches to remote | -| `contentrain_merge` | Merge a review-mode branch into contentrain locally | +| `contentrain_merge` | Merge a review-mode branch into contentrain locally (by exact branch or model) | +| `contentrain_branch_list` | List pending `cr/*` branches + merge status | +| `contentrain_branch_delete` | Delete a stale/failed `cr/*` branch (the contentrain branch is protected) | | `contentrain_bulk` | Batch operations (copy_locale/update_status/delete_entries) | | `contentrain_doctor` | Project health report (env + structure + orphan content + branch pressure + SDK freshness) | diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 77dfbf9..fa5432d 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -23,7 +23,7 @@ export type FieldType = (typeof FIELD_TYPES)[number] export const MODEL_KINDS = ['singleton', 'collection', 'document', 'dictionary'] as const export type ModelKind = (typeof MODEL_KINDS)[number] -// ─── MCP Tools (17 tools) ─── +// ─── MCP Tools (19 tools) ─── export const MCP_TOOLS = [ 'contentrain_status', 'contentrain_describe', 'contentrain_describe_format', @@ -33,6 +33,7 @@ export const MCP_TOOLS = [ 'contentrain_scan', 'contentrain_apply', 'contentrain_validate', 'contentrain_submit', 'contentrain_merge', + 'contentrain_branch_list', 'contentrain_branch_delete', 'contentrain_bulk', 'contentrain_doctor', ] as const diff --git a/packages/rules/tests/validate-rules.test.ts b/packages/rules/tests/validate-rules.test.ts index bea3cc7..cfdccf0 100644 --- a/packages/rules/tests/validate-rules.test.ts +++ b/packages/rules/tests/validate-rules.test.ts @@ -25,7 +25,7 @@ describe('essential rules', () => { describe('constants', () => { it('FIELD_TYPES has 27 entries', () => { expect(FIELD_TYPES).toHaveLength(27) }) it('MODEL_KINDS has 4 entries', () => { expect(MODEL_KINDS).toHaveLength(4) }) - it('MCP_TOOLS has 17 entries', () => { expect(MCP_TOOLS).toHaveLength(17) }) + it('MCP_TOOLS has 19 entries', () => { expect(MCP_TOOLS).toHaveLength(19) }) it('all MCP tools match pattern', () => { for (const t of MCP_TOOLS) expect(t).toMatch(/^contentrain_/) }) diff --git a/packages/sdk/js/skills/contentrain-query/references/bundler-config.md b/packages/sdk/js/skills/contentrain-query/references/bundler-config.md index 9222113..0171893 100644 --- a/packages/sdk/js/skills/contentrain-query/references/bundler-config.md +++ b/packages/sdk/js/skills/contentrain-query/references/bundler-config.md @@ -20,14 +20,14 @@ The `#contentrain` import requires subpath imports configuration in `package.jso ## Vite (Vue, React, Svelte) ```typescript -// vite.config.ts -import { resolve } from 'node:path' +// vite.config.ts (ESM — __dirname is not defined here) +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' export default defineConfig({ resolve: { alias: { - '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs') + '#contentrain': fileURLToPath(new URL('.contentrain/client/index.mjs', import.meta.url)) } } }) @@ -47,20 +47,22 @@ export default { } ``` -## Nuxt 3 +## Nuxt 3 / Nuxt 4 ```typescript -// nuxt.config.ts -import { resolve } from 'node:path' +// nuxt.config.ts (ESM — use import.meta.url, not __dirname) +import { fileURLToPath } from 'node:url' export default defineNuxtConfig({ alias: { - '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs') + '#contentrain': fileURLToPath(new URL('.contentrain/client/index.mjs', import.meta.url)) } }) ``` -**Important:** Treat `#contentrain` as **server-only** in Nuxt. Use in server routes, server plugins, and `useAsyncData` callbacks only. +**Important:** Treat `#contentrain` as **server-only** in Nuxt. Use it in server routes (`server/`), server plugins, and `useAsyncData` callbacks only. + +**Nuxt 4 note:** Nuxt 4 moves app code under `app/` and keeps server code under `server/`. The alias above resolves relative to `nuxt.config.ts` (project root), so it is unchanged — keep your `#contentrain` calls in `server/` (e.g. `server/api/*`), never in client-side `app/` components. ## SvelteKit diff --git a/packages/sdk/js/src/cdn/document-query.ts b/packages/sdk/js/src/cdn/document-query.ts index 3305bd6..9b7ba25 100644 --- a/packages/sdk/js/src/cdn/document-query.ts +++ b/packages/sdk/js/src/cdn/document-query.ts @@ -6,6 +6,8 @@ export class CdnDocumentQuery { private _source: DocumentDataSource private _locale: string = 'en' private _filters: WhereClause[] = [] + private _sortField: string | null = null + private _sortOrder: 'asc' | 'desc' = 'asc' constructor(source: DocumentDataSource, defaultLocale?: string) { this._source = source @@ -22,6 +24,12 @@ export class CdnDocumentQuery { return this } + sort(field: string, order: 'asc' | 'desc' = 'asc'): this { + this._sortField = field + this._sortOrder = order + return this + } + async all(): Promise { let items = await this._source.getIndex(this._locale) @@ -29,6 +37,19 @@ export class CdnDocumentQuery { items = items.filter(item => applyWhere(item, clause)) } + if (this._sortField) { + const sf = this._sortField + const dir = this._sortOrder === 'asc' ? 1 : -1 + items = items.toSorted((a, b) => { + const va = (a as Record)[sf] as number | string | null | undefined + const vb = (b as Record)[sf] as number | string | null | undefined + if (va == null && vb == null) return 0 + if (va == null) return dir + if (vb == null) return -dir + return va < vb ? -dir : va > vb ? dir : 0 + }) + } + return items } diff --git a/packages/sdk/js/src/generator/data-emitter.ts b/packages/sdk/js/src/generator/data-emitter.ts index a885cff..1892173 100644 --- a/packages/sdk/js/src/generator/data-emitter.ts +++ b/packages/sdk/js/src/generator/data-emitter.ts @@ -64,9 +64,11 @@ async function emitSingleModule( case 'document': { const rawText = await readText(ref.filePath) if (!rawText) return null - const { frontmatter, body } = parseFrontmatter(rawText) + const { frontmatter, body } = parseFrontmatter(rawText, stringLikeFieldKeys(model)) const slug = ref.slug ?? model.id - const data = { slug, ...frontmatter, content: body } + // Canonical document field is `body` (matches @contentrain/types + // DocumentEntry.body and the MCP document_save schema). + const data = { slug, ...frontmatter, body } return { fileName: `${model.id}--${slug}${localeSuffix}.mjs`, content: `export default ${canonicalStringify(data)}\n` } } @@ -75,8 +77,26 @@ async function emitSingleModule( } } +// Field types that map to `string` in the generated types — their frontmatter +// values must NOT be numerically coerced (e.g. a string SKU "007"). +const STRING_LIKE_TYPES = new Set([ + 'string', 'text', 'email', 'url', 'slug', 'color', 'phone', 'code', 'icon', + 'markdown', 'richtext', 'date', 'datetime', 'image', 'video', 'file', + 'select', 'relation', +]) + +function stringLikeFieldKeys(model: ModelDefinition): Set { + const keys = new Set() + if (model.fields) { + for (const [name, field] of Object.entries(model.fields)) { + if (STRING_LIKE_TYPES.has(field.type)) keys.add(name) + } + } + return keys +} + // Minimal frontmatter parser (replicated from MCP pattern) -function parseFrontmatter(text: string): { frontmatter: Record; body: string } { +function parseFrontmatter(text: string, stringKeys: Set = new Set()): { frontmatter: Record; body: string } { const normalized = text.replace(/\r\n/g, '\n') const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) if (!match) return { frontmatter: {}, body: normalized } @@ -143,20 +163,23 @@ function parseFrontmatter(text: string): { frontmatter: Record; continue } - current[key] = parseValue(rawValue) + // Only top-level keys are matched against the model's declared field types. + const forceString = current === frontmatter && stringKeys.has(key) + current[key] = parseValue(rawValue, forceString) } return { frontmatter, body } } -function parseValue(raw: string): unknown { +function parseValue(raw: string, forceString = false): unknown { + const isQuoted = (raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")) + const unquoted = isQuoted ? raw.slice(1, -1) : raw + if (forceString) return unquoted + if (isQuoted) return unquoted if (raw === 'true') return true if (raw === 'false') return false if (raw === 'null') return null if (/^-?\d+$/.test(raw)) return parseInt(raw, 10) if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw) - if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) { - return raw.slice(1, -1) - } return raw } diff --git a/packages/sdk/js/src/generator/runtime-emitter.ts b/packages/sdk/js/src/generator/runtime-emitter.ts index 274858b..6bf3281 100644 --- a/packages/sdk/js/src/generator/runtime-emitter.ts +++ b/packages/sdk/js/src/generator/runtime-emitter.ts @@ -52,17 +52,23 @@ export function emitRuntimeModule(models: ModelDefinition[], dataModules: DataMo lines.push('// ─── Relation Resolver ───') lines.push('') lines.push('function _resolveEntry(model, id, locale) {') - lines.push(' const localeKey = locale ?? \'_default\'') + // Try the requested locale first, then fall back to the locale-agnostic + // '_default' key. i18n:true targets are keyed by locale string ('en'), + // i18n:false targets are keyed '_default' — this fallback resolves both + // regardless of the source query's active locale. + lines.push(' const localeKeys = locale ? [locale, \'_default\'] : [\'_default\']') + lines.push(' for (const localeKey of localeKeys) {') // Search in collection registry if (collections.length > 0) { - lines.push(' const colData = _collectionRegistry[model]?.get(localeKey)') - lines.push(' if (colData) { const e = colData.find(x => x.id === id); if (e) return e; }') + lines.push(' const colData = _collectionRegistry[model]?.get(localeKey)') + lines.push(' if (colData) { const e = colData.find(x => x.id === id); if (e) return e; }') } // Search in document registry if (documents.length > 0) { - lines.push(' const docData = _documentRegistry[model]?.get(localeKey)') - lines.push(' if (docData) { const e = docData.find(x => x.slug === id); if (e) return e; }') + lines.push(' const docData = _documentRegistry[model]?.get(localeKey)') + lines.push(' if (docData) { const e = docData.find(x => x.slug === id); if (e) return e; }') } + lines.push(' }') lines.push(' return undefined') lines.push('}') lines.push('') @@ -219,20 +225,15 @@ export function emitCjsWrapper(models: ModelDefinition[]): string { return `/* eslint-disable */ /* oxlint-disable */ // Auto-generated CJS proxy — delegates to ESM via dynamic import() -// Sync usage: const client = require('#contentrain'); client.query('model') -// Async usage: const client = await require('#contentrain').init() +// The generated client is ESM-first. From CommonJS, await init() once before +// accessing exports: +// const client = await require('#contentrain').init() +// client.query('model') +// Prefer native ESM (import) where possible. 'use strict' let _mod = null let _promise = null -function _ensure() { - if (_mod) return _mod - throw new Error( - 'Contentrain client not initialized. Call .init() first, then access exports.\\n' - + 'Example: require("#contentrain").init().then(c => c.query("model"))' - ) -} - module.exports.init = function() { if (!_promise) _promise = import('./index.mjs').then(function(m) { _mod = m @@ -324,7 +325,7 @@ function _applyWhere(item, clause) { const val = item[clause.field]; switch (clause.op) { case 'eq': return Array.isArray(val) ? val.includes(clause.value) : val === clause.value; - case 'ne': return val !== clause.value; + case 'ne': return Array.isArray(val) ? !val.includes(clause.value) : val !== clause.value; case 'gt': return val > clause.value; case 'gte': return val >= clause.value; case 'lt': return val < clause.value; @@ -361,11 +362,12 @@ class QueryBuilder { first() { return this.all()[0]; } _resolveIncludes(item) { const resolved = { ...item }; + const _loc = this._locale ?? this._defaultLocale; for (const field of this._includes) { const meta = this._relationMeta[field]; if (!meta) continue; const targets = Array.isArray(meta.target) ? meta.target : [meta.target]; - if (meta.multi) { const ids = item[field]; if (Array.isArray(ids)) { resolved[field] = ids.map(id => { if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, this._locale); if (r) return r; } return id; } if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, this._locale); if (r) return r; } return id; }); } } - else { const id = item[field]; if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, this._locale); if (r) { resolved[field] = r; break; } } } else if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, this._locale); if (r) resolved[field] = r; } } + if (meta.multi) { const ids = item[field]; if (Array.isArray(ids)) { resolved[field] = ids.map(id => { if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, _loc); if (r) return r; } return id; } if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, _loc); if (r) return r; } return id; }); } } + else { const id = item[field]; if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, _loc); if (r) { resolved[field] = r; break; } } } else if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, _loc); if (r) resolved[field] = r; } } } return resolved; } @@ -403,15 +405,16 @@ class DictionaryAccessor { if (key === undefined) return dict; const val = dict[key]; if (val === undefined) return undefined; - if (params) return val.replace(/{(w+)}/g, (m, k) => { const v = params[k]; return v !== undefined ? String(v) : m; }); + if (params) return val.replace(/\\{(\\w+)\\}/g, (m, k) => { const v = params[k]; return v !== undefined ? String(v) : m; }); return val; } } class DocumentQuery { - constructor(data, relationMeta, resolver, defaultLocale) { this._data = data; this._filters = []; this._locale = null; this._includes = []; this._relationMeta = relationMeta || {}; this._resolver = resolver || null; this._defaultLocale = defaultLocale || null; } + constructor(data, relationMeta, resolver, defaultLocale) { this._data = data; this._filters = []; this._sortField = null; this._sortOrder = 'asc'; this._locale = null; this._includes = []; this._relationMeta = relationMeta || {}; this._resolver = resolver || null; this._defaultLocale = defaultLocale || null; } locale(lang) { this._locale = lang; return this; } where(field, opOrValue, value) { if (value !== undefined) { this._filters.push({ field, op: opOrValue, value }); } else { this._filters.push({ field, op: 'eq', value: opOrValue }); } return this; } + sort(field, order = 'asc') { this._sortField = field; this._sortOrder = order; return this; } include(...fields) { this._includes.push(...fields); return this; } bySlug(slug) { const items = this._resolveData(); const item = items.find(x => x.slug === slug); @@ -420,15 +423,16 @@ class DocumentQuery { } count() { return this.all().length; } first() { return this.all()[0]; } - all() { let items = this._resolveData(); for (const clause of this._filters) items = items.filter(item => _applyWhere(item, clause)); if (this._includes.length > 0 && this._resolver) { items = items.map(item => this._resolveIncludes(item)); } return items; } + all() { let items = this._resolveData(); for (const clause of this._filters) items = items.filter(item => _applyWhere(item, clause)); if (this._sortField) { const sf = this._sortField; const d = this._sortOrder === 'asc' ? 1 : -1; items.sort((a, b) => { const va = a[sf], vb = b[sf]; if (va == null && vb == null) return 0; if (va == null) return d; if (vb == null) return -d; return va < vb ? -d : va > vb ? d : 0; }); } if (this._includes.length > 0 && this._resolver) { items = items.map(item => this._resolveIncludes(item)); } return items; } _resolveData() { let key; if (this._locale) { key = this._locale; } else if (this._defaultLocale && this._data.has(this._defaultLocale)) { key = this._defaultLocale; } else { key = this._data.keys().next().value; } return [...(this._data.get(key) ?? [])]; } _resolveIncludes(item) { const resolved = { ...item }; + const _loc = this._locale ?? this._defaultLocale; for (const field of this._includes) { const meta = this._relationMeta[field]; if (!meta) continue; const targets = Array.isArray(meta.target) ? meta.target : [meta.target]; - if (meta.multi) { const ids = item[field]; if (Array.isArray(ids)) { resolved[field] = ids.map(id => { if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, this._locale); if (r) return r; } return id; } if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, this._locale); if (r) return r; } return id; }); } } - else { const id = item[field]; if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, this._locale); if (r) { resolved[field] = r; break; } } } else if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, this._locale); if (r) resolved[field] = r; } } + if (meta.multi) { const ids = item[field]; if (Array.isArray(ids)) { resolved[field] = ids.map(id => { if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, _loc); if (r) return r; } return id; } if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, _loc); if (r) return r; } return id; }); } } + else { const id = item[field]; if (typeof id === 'string') { for (const t of targets) { const r = this._resolver(t, id, _loc); if (r) { resolved[field] = r; break; } } } else if (typeof id === 'object' && id !== null && 'model' in id && 'ref' in id) { const r = this._resolver(id.model, id.ref, _loc); if (r) resolved[field] = r; } } } return resolved; } diff --git a/packages/sdk/js/src/generator/type-emitter.ts b/packages/sdk/js/src/generator/type-emitter.ts index 5be9621..3ca9a6c 100644 --- a/packages/sdk/js/src/generator/type-emitter.ts +++ b/packages/sdk/js/src/generator/type-emitter.ts @@ -18,17 +18,23 @@ export function emitTypes(models: ModelDefinition[]): string { lines.push(`export type ${kebabToPascal(model.id)} = Record`) } else { lines.push(`export interface ${kebabToPascal(model.id)} {`) - // System fields by kind + // System fields by kind. Track their names so a model-defined field of + // the same name is not emitted twice (the base field wins). + const baseNames = new Set() if (model.kind === 'collection') { lines.push(' id: string') + baseNames.add('id') } if (model.kind === 'document') { lines.push(' slug: string') - lines.push(' content: string') + lines.push(' body: string') + baseNames.add('slug') + baseNames.add('body') } // User fields if (model.fields) { for (const [name, field] of Object.entries(model.fields)) { + if (baseNames.has(name)) continue const tsType = fieldToTS(field) const optional = field.required ? '' : '?' lines.push(` ${name}${optional}: ${tsType}`) @@ -47,7 +53,7 @@ export function emitTypes(models: ModelDefinition[]): string { sort(field: K, order?: 'asc' | 'desc'): QueryBuilder limit(n: number): QueryBuilder offset(n: number): QueryBuilder - include(...fields: string[]): QueryBuilder + include(...fields: K[]): QueryBuilder count(): number first(): T | undefined all(): T[] @@ -56,7 +62,7 @@ export function emitTypes(models: ModelDefinition[]): string { lines.push(`export interface SingletonAccessor { locale(lang: string): SingletonAccessor - include(...fields: string[]): SingletonAccessor + include(...fields: K[]): SingletonAccessor get(): T }`) lines.push('') @@ -73,7 +79,8 @@ export function emitTypes(models: ModelDefinition[]): string { locale(lang: string): DocumentQuery where(field: K, value: T[K]): DocumentQuery where(field: K, op: WhereOp, value: unknown): DocumentQuery - include(...fields: string[]): DocumentQuery + sort(field: K, order?: 'asc' | 'desc'): DocumentQuery + include(...fields: K[]): DocumentQuery bySlug(slug: string): T | undefined count(): number first(): T | undefined @@ -148,19 +155,25 @@ function fieldToTS(field: FieldDef): string { case 'markdown': case 'richtext': case 'date': case 'datetime': case 'image': case 'video': case 'file': - case 'relation': + case 'relation': { if (Array.isArray(field.model) && field.model.length > 1) { const union = field.model.map(m => `'${m}'`).join(' | ') return `{ model: ${union}; ref: string }` } - return 'string' + // Single-target relation: stored as an id string, but include() resolves + // it to the target entry, so the field can be either at read time. + const target = Array.isArray(field.model) ? field.model[0] : field.model + return target ? `string | ${kebabToPascal(target)}` : 'string' + } - case 'relations': + case 'relations': { if (Array.isArray(field.model) && field.model.length > 1) { const union = field.model.map(m => `'${m}'`).join(' | ') return `Array<{ model: ${union}; ref: string }>` } - return 'string[]' + const target = Array.isArray(field.model) ? field.model[0] : field.model + return target ? `Array` : 'string[]' + } case 'number': case 'integer': case 'decimal': case 'percent': case 'rating': return 'number' diff --git a/packages/sdk/js/src/runtime/document.ts b/packages/sdk/js/src/runtime/document.ts index 81420b7..93e6a30 100644 --- a/packages/sdk/js/src/runtime/document.ts +++ b/packages/sdk/js/src/runtime/document.ts @@ -6,6 +6,8 @@ export class DocumentQuery { private _data: Map private _locale: string | null = null private _filters: WhereClause[] = [] + private _sortField: string | null = null + private _sortOrder: 'asc' | 'desc' = 'asc' private _includes: string[] = [] private _relationMeta: Record private _resolver: RelationResolver | null @@ -39,6 +41,12 @@ export class DocumentQuery { return this } + sort(field: K, order: 'asc' | 'desc' = 'asc'): this { + this._sortField = field + this._sortOrder = order + return this + } + include(...fields: string[]): this { this._includes.push(...fields) return this @@ -62,6 +70,18 @@ export class DocumentQuery { for (const clause of this._filters) { items = items.filter(item => applyWhere(item, clause)) } + if (this._sortField) { + const sf = this._sortField + const dir = this._sortOrder === 'asc' ? 1 : -1 + items = items.toSorted((a, b) => { + const va = (a as Record)[sf] as number | string | null | undefined + const vb = (b as Record)[sf] as number | string | null | undefined + if (va == null && vb == null) return 0 + if (va == null) return dir + if (vb == null) return -dir + return va < vb ? -dir : va > vb ? dir : 0 + }) + } if (this._includes.length > 0 && this._resolver) { items = items.map(item => this._resolveIncludes(item)) } @@ -124,8 +144,9 @@ export class DocumentQuery { } private _resolveId(targets: string[], id: string): Record | undefined { + const loc = this._locale ?? this._defaultLocale for (const target of targets) { - const result = this._resolver!(target, id, this._locale) + const result = this._resolver!(target, id, loc) if (result) return result } return undefined diff --git a/packages/sdk/js/src/runtime/query.ts b/packages/sdk/js/src/runtime/query.ts index 27d6047..efcf051 100644 --- a/packages/sdk/js/src/runtime/query.ts +++ b/packages/sdk/js/src/runtime/query.ts @@ -170,8 +170,9 @@ export class QueryBuilder { } private _resolveId(targets: string[], id: string): Record | undefined { + const loc = this._locale ?? this._defaultLocale for (const target of targets) { - const result = this._resolver!(target, id, this._locale) + const result = this._resolver!(target, id, loc) if (result) return result } return undefined diff --git a/packages/sdk/js/src/shared/where.ts b/packages/sdk/js/src/shared/where.ts index e84516a..5a0b84a 100644 --- a/packages/sdk/js/src/shared/where.ts +++ b/packages/sdk/js/src/shared/where.ts @@ -13,7 +13,10 @@ export function applyWhere(item: T, clause: WhereClause): boolean { if (Array.isArray(val)) return val.includes(clause.value) return val === clause.value } - case 'ne': return val !== clause.value + case 'ne': { + if (Array.isArray(val)) return !val.includes(clause.value) + return val !== clause.value + } case 'gt': return (val as number) > (clause.value as number) case 'gte': return (val as number) >= (clause.value as number) case 'lt': return (val as number) < (clause.value as number) diff --git a/packages/sdk/js/tests/generator/data-emitter.test.ts b/packages/sdk/js/tests/generator/data-emitter.test.ts index 2919704..8c72201 100644 --- a/packages/sdk/js/tests/generator/data-emitter.test.ts +++ b/packages/sdk/js/tests/generator/data-emitter.test.ts @@ -74,7 +74,9 @@ describe('data-emitter', () => { expect(content).toContain('export default') expect(content).toContain('"slug": "welcome-post"') expect(content).toContain('"title": "Welcome to Contentrain"') - expect(content).toContain('"content": "# Welcome') + // Canonical document body key is `body` (matches @contentrain/types) + expect(content).toContain('"body": "# Welcome') + expect(content).not.toContain('"content":') // Keys should be sorted (canonical) const jsonStr = content.replace('export default ', '').trim() const parsed = JSON.parse(jsonStr) @@ -149,4 +151,46 @@ Body.`, 'utf-8') await rm(tempRoot, { recursive: true, force: true }) } }) + + it('does not numerically coerce string-typed frontmatter (preserves leading zeros)', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'contentrain-sdk-coerce-')) + + try { + const docPath = join(tempRoot, 'product.md') + await mkdir(tempRoot, { recursive: true }) + await writeFile(docPath, `--- +title: Widget +sku: 007 +views: 42 +--- +# Product`, 'utf-8') + + const models: ModelDefinition[] = [{ + id: 'product', + name: 'Product', + kind: 'document', + domain: 'shop', + i18n: false, + fields: { + title: { type: 'string', required: true }, + sku: { type: 'string' }, // declared string → must stay "007" + views: { type: 'integer' }, // declared number → may coerce to 42 + }, + }] + + const refs: ContentFileRef[] = [{ + modelId: 'product', locale: null, filePath: docPath, kind: 'document', slug: 'widget', + }] + + const modules = await emitDataModules(models, refs) + const parsed = JSON.parse(modules[0]!.content.replace('export default ', '').trim()) as Record + + expect(parsed['sku']).toBe('007') + expect(parsed['views']).toBe(42) + // Body uses the canonical key + expect(parsed['body']).toBe('# Product') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) }) diff --git a/packages/sdk/js/tests/generator/type-emitter.test.ts b/packages/sdk/js/tests/generator/type-emitter.test.ts index 0cc0274..3bacdc5 100644 --- a/packages/sdk/js/tests/generator/type-emitter.test.ts +++ b/packages/sdk/js/tests/generator/type-emitter.test.ts @@ -99,7 +99,7 @@ describe('type-emitter', () => { it('includes relation resolution API on SingletonAccessor', () => { const result = emitTypes([]) - expect(result).toContain('include(...fields: string[]): SingletonAccessor') + expect(result).toContain('include(...fields: K[]): SingletonAccessor') }) it('generates dictionary overloads', () => { @@ -116,8 +116,13 @@ describe('type-emitter', () => { it('generates include() in QueryBuilder and DocumentQuery interfaces', () => { const result = emitTypes([]) - expect(result).toContain('include(...fields: string[]): QueryBuilder') - expect(result).toContain('include(...fields: string[]): DocumentQuery') + expect(result).toContain('include(...fields: K[]): QueryBuilder') + expect(result).toContain('include(...fields: K[]): DocumentQuery') + }) + + it('generates sort() on the DocumentQuery interface (parity with QueryBuilder)', () => { + const result = emitTypes([]) + expect(result).toContain("sort(field: K, order?: 'asc' | 'desc'): DocumentQuery") }) it('generates fallback string overload for query', () => { @@ -254,8 +259,9 @@ describe('type-emitter', () => { expect(result).toContain('f_boolean?: boolean') expect(result).toContain('f_date?: string') expect(result).toContain('f_image?: string') - expect(result).toContain('f_relation?: string') - expect(result).toContain('f_relations?: string[]') + // Single-target relations are typed as `id | ResolvedTarget` to reflect include() + expect(result).toContain('f_relation?: string | Author') + expect(result).toContain('f_relations?: Array') }) it('emits object shape for polymorphic relation fields', () => { @@ -272,4 +278,28 @@ describe('type-emitter', () => { const result = emitTypes(models) expect(result).toContain("target?: { model: 'blog-post' | 'page'; ref: string }") }) + + it('emits document body as `body` and does not duplicate a model-defined slug field', () => { + const models: ModelDefinition[] = [{ + id: 'blog-article', + name: 'Blog Article', + kind: 'document', + domain: 'blog', + i18n: true, + fields: { + // A model that explicitly declares slug/body must NOT produce duplicate + // interface members — the base field wins. + slug: { type: 'slug', required: true }, + title: { type: 'string', required: true }, + }, + }] + const result = emitTypes(models) + const iface = result.slice(result.indexOf('export interface BlogArticle {')) + const body = iface.slice(0, iface.indexOf('}')) + // Canonical body field name + expect(body).toContain('body: string') + expect(body).not.toContain('content: string') + // slug appears exactly once + expect(body.match(/slug/g)?.length).toBe(1) + }) }) diff --git a/packages/sdk/js/tests/integration/generate.test.ts b/packages/sdk/js/tests/integration/generate.test.ts index b8a2e3c..c2a4e42 100644 --- a/packages/sdk/js/tests/integration/generate.test.ts +++ b/packages/sdk/js/tests/integration/generate.test.ts @@ -56,17 +56,17 @@ describe('generate (integration)', () => { // Document expect(types).toContain('export interface BlogArticle') expect(types).toContain('slug: string') - expect(types).toContain('content: string') + expect(types).toContain('body: string') // Relation model types expect(types).toContain('export interface Author') expect(types).toContain('export interface Tag') // Query interfaces with include() expect(types).toContain('export interface QueryBuilder') - expect(types).toContain('include(...fields: string[]): QueryBuilder') + expect(types).toContain('include(...fields: K[]): QueryBuilder') expect(types).toContain('export interface SingletonAccessor') expect(types).toContain('export interface DictionaryAccessor') expect(types).toContain('export interface DocumentQuery') - expect(types).toContain('include(...fields: string[]): DocumentQuery') + expect(types).toContain('include(...fields: K[]): DocumentQuery') // Overloaded entry points — typed overloads expect(types).toContain("export declare function query(model: 'blog-post'): QueryBuilder") expect(types).toContain("export declare function query(model: 'author'): QueryBuilder") @@ -158,7 +158,7 @@ describe('generate (integration)', () => { const docData = await readFile(join(clientDir, 'data', 'blog-article--welcome-post.en.mjs'), 'utf-8') expect(docData).toContain('export default') expect(docData).toContain('"slug": "welcome-post"') - expect(docData).toContain('"content"') + expect(docData).toContain('"body"') // Canonical JSON: keys sorted const jsonStr = docData.replace('export default ', '').trim() const parsed = JSON.parse(jsonStr) @@ -248,6 +248,69 @@ describe('generate (integration)', () => { } }) + it('interpolates dictionary params in the generated runtime (regex survives emit)', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'contentrain-sdk-dict-')) + try { + await cp(FIXTURE, tempRoot, { recursive: true }) + // Add an interpolated value to the error-messages dictionary (en) + const dictEn = join(tempRoot, '.contentrain', 'content', 'system', 'error-messages', 'en.json') + const raw = JSON.parse(await readFile(dictEn, 'utf-8')) as Record + raw['greeting'] = 'Hello {name}, you have {count} messages' + await writeFile(dictEn, JSON.stringify(raw, null, 2) + '\n', 'utf-8') + + await generate({ projectRoot: tempRoot }) + const clientPath = pathToFileURL(join(tempRoot, '.contentrain', 'client', 'index.mjs')).href + const client = await import(clientPath) + + const out = client.dictionary('error-messages').get('greeting', { name: 'Ada', count: 3 }) + expect(out).toBe('Hello Ada, you have 3 messages') + // No-params call returns the raw template untouched + expect(client.dictionary('error-messages').get('greeting')).toBe('Hello {name}, you have {count} messages') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('DocumentQuery.sort() orders documents in the generated runtime', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'contentrain-sdk-docsort-')) + try { + await cp(FIXTURE, tempRoot, { recursive: true }) + await generate({ projectRoot: tempRoot }) + const clientPath = pathToFileURL(join(tempRoot, '.contentrain', 'client', 'index.mjs')).href + const client = await import(clientPath) + + const asc = client.document('blog-article').sort('title', 'asc').all().map((d: { title: string }) => d.title) + const desc = client.document('blog-article').sort('title', 'desc').all().map((d: { title: string }) => d.title) + expect(asc).toEqual([...asc].toSorted()) + expect(desc).toEqual([...asc].toReversed()) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('include() resolves an i18n:false relation target from an i18n:true source', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'contentrain-sdk-include-')) + try { + await cp(FIXTURE, tempRoot, { recursive: true }) + await generate({ projectRoot: tempRoot }) + const clientPath = pathToFileURL(join(tempRoot, '.contentrain', 'client', 'index.mjs')).href + const client = await import(clientPath) + + // blog-post is i18n:true, author is i18n:false (stored under '_default'). + // Without an explicit .locale(): effective locale = default → must still resolve. + const noLocale = client.query('blog-post').include('author').first() + expect(typeof noLocale.author).toBe('object') + expect(noLocale.author.name).toBeTruthy() + + // With an explicit .locale('en'): must also resolve the '_default' author. + const withLocale = client.query('blog-post').locale('en').include('author').first() + expect(typeof withLocale.author).toBe('object') + expect(withLocale.author.name).toBeTruthy() + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + it('exposes usable query exports via CommonJS require + init()', async () => { const tempRoot = await mkdtemp(join(tmpdir(), 'contentrain-sdk-cjs-')) diff --git a/packages/sdk/js/tests/shared/where.test.ts b/packages/sdk/js/tests/shared/where.test.ts index bf7afcd..04cef3e 100644 --- a/packages/sdk/js/tests/shared/where.test.ts +++ b/packages/sdk/js/tests/shared/where.test.ts @@ -19,6 +19,13 @@ describe('applyWhere', () => { expect(applyWhere(item, { field: 'name', op: 'ne', value: 'Alice' })).toBe(false) }) + it('ne — array is the complement of eq (membership)', () => { + // 'admin' IS a member → ne must be false (mirrors eq array semantics) + expect(applyWhere(item, { field: 'tags', op: 'ne', value: 'admin' })).toBe(false) + // 'guest' is NOT a member → ne must be true + expect(applyWhere(item, { field: 'tags', op: 'ne', value: 'guest' })).toBe(true) + }) + it('gt — greater than', () => { expect(applyWhere(item, { field: 'age', op: 'gt', value: 25 })).toBe(true) expect(applyWhere(item, { field: 'age', op: 'gt', value: 30 })).toBe(false) diff --git a/packages/skills/skills/contentrain-generate/SKILL.md b/packages/skills/skills/contentrain-generate/SKILL.md index f181c6f..c5877c0 100644 --- a/packages/skills/skills/contentrain-generate/SKILL.md +++ b/packages/skills/skills/contentrain-generate/SKILL.md @@ -54,6 +54,10 @@ Execute the generation command: npx contentrain generate ``` +> **Two ways to invoke the generator:** +> - `contentrain generate` — via the `contentrain` CLI (recommended; requires the `contentrain` package installed). This is what all examples in this skill use. +> - `npx contentrain-query generate` — directly via the `@contentrain/query` package's own bin, for projects that depend only on `@contentrain/query` (programmatic / build-tool flows). Both run the same generator. + If the project uses a non-standard root directory, specify it: ```bash @@ -170,6 +174,25 @@ For convenience, suggest adding a script to `package.json`: } ``` +### 9b. Wire generate into build/CI (REQUIRED for fresh clones) + +`.contentrain/client/` is git-ignored (it is generated output, like Prisma's client). That means a **fresh clone or CI checkout has no client**, so any `#contentrain` import fails at typecheck/build until `generate` runs. + +Wire generation into the build lifecycle so it always runs before a build: + +```json +{ + "scripts": { + "prebuild": "contentrain generate", + "predev": "contentrain generate" + } +} +``` + +- `prebuild`/`predev` run automatically before `build`/`dev` (npm/pnpm lifecycle), so CI and teammates regenerate the client without a manual step. +- Alternatively use a `postinstall` hook, but `prebuild`/`predev` are preferred (they don't run on every dependency install and stay close to when the client is actually needed). +- If you only commit content occasionally, the watch script above covers local dev; the `prebuild` hook covers CI and fresh clones. + ### 10. Final Summary Report to the user: diff --git a/packages/skills/skills/contentrain-generate/references/generated-client.md b/packages/skills/skills/contentrain-generate/references/generated-client.md index 39222b2..3a16734 100644 --- a/packages/skills/skills/contentrain-generate/references/generated-client.md +++ b/packages/skills/skills/contentrain-generate/references/generated-client.md @@ -12,12 +12,12 @@ The `#contentrain` subpath import works natively in Node.js 22+ but **does NOT r ### Vite (Vue, React, Svelte, Astro) ```ts -// vite.config.ts -import { resolve } from 'node:path' +// vite.config.ts (ESM — __dirname is not defined; use import.meta.url) +import { fileURLToPath } from 'node:url' export default defineConfig({ resolve: { alias: { - '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'), + '#contentrain': fileURLToPath(new URL('.contentrain/client/index.mjs', import.meta.url)), }, }, }) @@ -60,9 +60,9 @@ Add the same `tsconfig.json` paths entry: } ``` -### Nuxt 3 +### Nuxt 3 / Nuxt 4 -Nuxt provides a top-level `alias` option — no Vite config needed: +Nuxt provides a top-level `alias` option — no Vite config needed (the relative string avoids `__dirname` entirely): ```ts // nuxt.config.ts diff --git a/packages/skills/skills/contentrain-sdk/references/bundler-config.md b/packages/skills/skills/contentrain-sdk/references/bundler-config.md index 9222113..8c3e74b 100644 --- a/packages/skills/skills/contentrain-sdk/references/bundler-config.md +++ b/packages/skills/skills/contentrain-sdk/references/bundler-config.md @@ -20,14 +20,14 @@ The `#contentrain` import requires subpath imports configuration in `package.jso ## Vite (Vue, React, Svelte) ```typescript -// vite.config.ts -import { resolve } from 'node:path' +// vite.config.ts (ESM — __dirname is not defined here) +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' export default defineConfig({ resolve: { alias: { - '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs') + '#contentrain': fileURLToPath(new URL('.contentrain/client/index.mjs', import.meta.url)) } } }) @@ -47,20 +47,22 @@ export default { } ``` -## Nuxt 3 +## Nuxt 3 / Nuxt 4 ```typescript -// nuxt.config.ts -import { resolve } from 'node:path' +// nuxt.config.ts (ESM — use import.meta.url, not __dirname) +import { fileURLToPath } from 'node:url' export default defineNuxtConfig({ alias: { - '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs') + '#contentrain': fileURLToPath(new URL('.contentrain/client/index.mjs', import.meta.url)) } }) ``` -**Important:** Treat `#contentrain` as **server-only** in Nuxt. Use in server routes, server plugins, and `useAsyncData` callbacks only. +**Important:** Treat `#contentrain` as **server-only** in Nuxt. Use it in server routes (`server/`), server plugins, and `useAsyncData` callbacks only. + +**Nuxt 4 note:** Nuxt 4 moves app code under `app/` and keeps server code under `server/`. The alias above is resolved relative to `nuxt.config.ts` (project root), so it is unchanged — but make sure your `#contentrain` calls live in `server/` (e.g. `server/api/*`), never in `app/` components that run on the client. ## SvelteKit diff --git a/packages/skills/skills/contentrain/SKILL.md b/packages/skills/skills/contentrain/SKILL.md index 6791441..5433ff5 100644 --- a/packages/skills/skills/contentrain/SKILL.md +++ b/packages/skills/skills/contentrain/SKILL.md @@ -16,7 +16,7 @@ Contentrain consists of 6 packages that work together: | Package | Role | How agent uses it | |---|---|---| -| @contentrain/mcp | 17 MCP tools (scan, apply, validate, merge, doctor...) | MCP tool calls | +| @contentrain/mcp | 19 MCP tools (scan, apply, validate, merge, doctor...) | MCP tool calls | | contentrain (CLI) | init, serve, generate, doctor, diff, status | Shell commands | | @contentrain/types | Shared TypeScript contracts | Type safety | | @contentrain/query | Generated SDK client (Prisma-pattern) | `import from '#contentrain'` | diff --git a/packages/skills/skills/contentrain/references/mcp-tools.md b/packages/skills/skills/contentrain/references/mcp-tools.md index c300b98..d1f4fca 100644 --- a/packages/skills/skills/contentrain/references/mcp-tools.md +++ b/packages/skills/skills/contentrain/references/mcp-tools.md @@ -278,13 +278,42 @@ Push `cr/*` feature branches to remote. ### contentrain_merge -Merge a single review-mode `cr/*` branch into the content-tracking `contentrain` branch and advance the base branch via `update-ref`. Runs the worktree transaction with selective sync — dirty files in the developer's working tree are preserved rather than overwritten. +Merge a single review-mode `cr/*` branch into the content-tracking `contentrain` branch and advance the base branch via `update-ref`. Runs the worktree transaction with selective sync — dirty files in the developer's working tree are preserved rather than overwritten. The merged feature branch is pruned afterward. + +Target the branch by exact name, or resolve it by model: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `branch` | string | No* | Exact feature branch name (must start with `cr/`) | +| `model` | string | No* | Resolve the branch by model id (e.g. `blog-post`) | +| `locale` | string | No | Narrow model resolution to a locale | +| `latest` | boolean | No | When multiple branches match the model, merge the most recently committed one | +| `confirm` | `true` | Yes | Must be `true` to confirm | + +\* Provide either `branch` or `model`. Ambiguous model matches return the candidate branches so you can pick one (or pass `latest: true`). + +Returns `{ branch, action, commit, sync }` — `sync.skipped[]` lists files the selective sync skipped because the developer has uncommitted changes. The CLI surfaces this as a warning. + +### contentrain_branch_list + +List pending `cr/*` branches with their merge status against the `contentrain` branch. Use it to discover branch names for `contentrain_merge` / `contentrain_branch_delete` and to monitor branch-health pressure (warning at 50, blocked at 80 unmerged). Read-only. Local-filesystem only (`localWorktree`). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `unmerged_only` | boolean | No | Only list branches not yet merged into `contentrain`. Default `false` | + +Returns `{ total, unmerged, branches: [{ name, sha, merged, lastCommit }], health }`. + +### contentrain_branch_delete + +Delete a pending `cr/*` branch that will not be merged — e.g. one left by a failed operation, or a superseded draft. Only `cr/*` branches can be deleted; the `contentrain` branch is protected. Destructive: the branch and its unmerged commits are removed. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `branch` | string | Yes | Feature branch name (must start with `cr/`) | +| `branch` | string | Yes | The `cr/*` branch to delete | +| `confirm` | `true` | Yes | Must be `true` to confirm deletion | -Returns `{ action, commit, sync }` — `sync.skipped[]` lists files the selective sync skipped because the developer has uncommitted changes. The CLI surfaces this as a warning. +Returns `{ status: 'deleted', branch, was_merged }`. Note: in normal operation Contentrain prunes feature branches automatically after auto-merge / `contentrain_merge`, so this tool is for cleanup of leftover branches only. ## Doctor Tools diff --git a/packages/skills/workflows/contentrain-generate.md b/packages/skills/workflows/contentrain-generate.md index 66ae779..04f1b6d 100644 --- a/packages/skills/workflows/contentrain-generate.md +++ b/packages/skills/workflows/contentrain-generate.md @@ -103,12 +103,12 @@ The `#contentrain` subpath import works natively in Node.js 22+ but **does NOT r #### Vite (Vue, React, Svelte, Astro) ```ts -// vite.config.ts -import { resolve } from 'node:path' +// vite.config.ts (ESM — __dirname is not defined; use import.meta.url) +import { fileURLToPath } from 'node:url' export default defineConfig({ resolve: { alias: { - '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'), + '#contentrain': fileURLToPath(new URL('.contentrain/client/index.mjs', import.meta.url)), }, }, }) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 67d5d24..7970431 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -97,7 +97,12 @@ export interface ContentrainConfig { } domains: string[] assets_path?: string + /** Days a merged `cr/*` branch is kept before lazy cleanup. Default: 30. */ branchRetention?: number + /** Unmerged `cr/*` branch count that triggers a warning. Default: 50. */ + branchWarnLimit?: number + /** Unmerged `cr/*` branch count that blocks new writes. Default: 80. */ + branchBlockLimit?: number } // ─── Vocabulary ───