Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .changeset/phase-13-serve-correctness-levelup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
"@contentrain/mcp": minor
"contentrain": minor
---

feat: serve correctness + level-ups — drift fixes, capability surface, sync warnings, secure-by-default auth

Consolidates a four-agent review of the `contentrain serve` surface
and the `@contentrain/mcp` helpers it consumes. Ships as a single
cohesive PR because the drift fixes are invisible without the UI
affordances that surface them (sync warnings UI, capability badge,
branch health banner).

### MCP — new public helpers + empty-repo init

- `branchDiff(projectRoot, { branch, base? })` in
`@contentrain/mcp/git/branch-lifecycle`. Defaults `base` to
`CONTENTRAIN_BRANCH` — the singleton content-tracking branch every
feature branch forks from. Replaces the CLI's duplicated
`git.diff([${defaultBranch}...${branch}])` calls, which surfaced
unrelated historical content changes once `contentrain` advanced
past the repo's default branch.
- `contentrain_init` tool now handles greenfield directories: if the
repo has zero commits after `git init` (or existed commit-free),
it seeds an `--allow-empty` initial commit so
`ensureContentBranch` has a base ref to anchor on. Previously the
CLI `init` command created this commit manually while the MCP
tool skipped the step — the tool failed on an empty directory
the CLI handled fine.

### Serve server — correctness + new routes + auth

- **Merge flow** — 3 duplicated merge-via-worktree implementations
(`/api/branches/approve`, `/api/normalize/approve`, and the `diff`
CLI command) now delegate to MCP's `mergeBranch()` helper, which
runs the worktree transaction with selective sync + dirty-file
protection. Skipped-file warnings are cached server-side and
surfaced to the UI via the new `sync:warning` WebSocket event +
`/api/branches/:name/sync-status` route. Merge conflicts
broadcast `branch:merge-conflict` instead of silently succeeding.
- **Branch diff** — `/api/branches/diff` delegates to the new
`branchDiff()` helper with `CONTENTRAIN_BRANCH` as the default base.
- **History filter** — tolerant of BOTH legacy `Merge branch
'contentrain/'` and current `Merge branch 'cr/'` commit patterns
so post-migration history doesn't drop merges.
- **`.catch(() => {})` error swallowing** at 3 sites replaced with
proper propagation. Conflicts and cleanup failures no longer
pretend to succeed.
- **Normalize plan approve** broadcasts `branch:created` on the
returned `git.branch` metadata (parity with content save).
- **New `/api/capabilities` route** — provider type, transport,
capability manifest, branch health, repo info in one call.
Dashboard consumes this to render a capability badge.
- **New `/api/branches/:name/sync-status`** — on-demand sync warning
fetch for the branch detail page; 1h TTL cache in memory.
- **New WS events** — `branch:rejected`, `branch:merge-conflict`,
`sync:warning`.
- **Zod input validation** on every write route via
`serve/schemas.ts`. Catches malformed bodies with a structured 400
error before they reach the MCP tool layer. Adds `zod` to the CLI's
direct dependencies.
- **Secure-by-default auth** — `contentrain serve` on a non-localhost
interface now HARD ERRORS when no `--authToken` is set. No opt-out
flag (OWASP Secure-by-Default). Matches industry tooling pattern
(Postgres, helm, kubectl port-forward).

### Serve UI — level-ups that make the fixes visible

- **`useWatch.ts`** — WSEvent union widened for the new event types.
- **`project` store** — `capabilities` state + `branchHealthAlarm`
computed + `fetchCapabilities()` action.
- **AppLayout** — global branch-health banner (warning / blocked),
sync-warning toasts with "View details" action deep-linking to
the branch detail page, merge-conflict toasts with the failure
message.
- **DashboardPage** — capability badge (provider type · transport)
next to the workflow + stack badges.
- **BranchDetailPage** — sync warnings panel listing files the
selective sync skipped, with the clear reason why the developer's
working tree was preserved.
- **ValidatePage** — issues are clickable when a `model` is present;
deep-links to the content list filtered to `locale`/`id`/`slug`.

### CLI — delegation to MCP helpers

- `commands/diff.ts` — both the diff summary and the merge path now
call `branchDiff()` / `mergeBranch()` from MCP. Surfaces
`sync.skipped[]` warnings to the user. Removes the duplicated
`contentrain` branch + worktree + update-ref + checkout dance.
- `commands/doctor.ts` — branch health check delegates to MCP's
`checkBranchHealth()`. Previously filtered `contentrain/*` directly
after the Phase 7 naming migration to `cr/*`, so the check was
effectively a no-op.
- `commands/validate.ts` non-interactive path — captures `tx.complete()`
result and surfaces the branch name + workflow action in review
mode. Previously this metadata was silently dropped.

### Verification

- `pnpm -r typecheck` → 0 errors across 8 packages.
- `oxlint` monorepo → 0 warnings across 399 files.
- `vue-tsc --noEmit` serve-ui → 0 errors.
- `pnpm --filter @contentrain/mcp build` + `pnpm --filter contentrain build:cli-only` → clean.
- MCP fast suite (`tests/core tests/conformance tests/serialization-parity tests/git tests/providers tests/server tests/util`) → **443/443 green**, 2 skipped. Includes the new `setup.test.ts` empty-repo case + the new `branch-lifecycle.test.ts` `branchDiff` suite.

### Tool surface

No changes. Same 16 MCP tools, same arg schemas, same response
shapes. Stdio + LocalProvider flows behave identically to the
previous release.
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
"h3": "^1.15.0",
"picocolors": "^1.1.0",
"simple-git": "^3.27.0",
"ws": "^8.18.0"
"ws": "^8.18.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/ws": "^8.5.0",
Expand Down
93 changes: 33 additions & 60 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { defineCommand } from 'citty'
import { intro, outro, log, select, confirm, isCancel } from '@clack/prompts'
import { simpleGit } from 'simple-git'
import { readConfig } from '@contentrain/mcp/core/config'
import { CONTENTRAIN_BRANCH } from '@contentrain/types'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { mergeBranch } from '@contentrain/mcp/git/transaction'
import { branchDiff } from '@contentrain/mcp/git/branch-lifecycle'
import { resolveProjectRoot } from '../utils/context.js'
import { pc } from '../utils/ui.js'

Expand Down Expand Up @@ -35,21 +33,22 @@ export default defineCommand({

log.info(pc.bold(`Pending branches (${featureBranches.length})`))

// Get base branch from config, env, or fallback
const config = await readConfig(projectRoot)
const baseBranch = config?.repository?.default_branch
?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')

// Show each branch with summary
// Diff base = CONTENTRAIN_BRANCH (the singleton content-tracking
// branch every feature branch forks from). Diffing against the
// repo's default branch (main/master/trunk) surfaces unrelated
// historical content changes once contentrain has advanced past
// the default.
const branchInfos: Array<{ name: string; summary: string; files: number }> = []

for (const branch of featureBranches) {
try {
const diffStat = await git.diffSummary([`${baseBranch}...${branch}`])
const diff = await branchDiff(projectRoot, { branch })
const insertions = (diff.patch.match(/^\+(?!\+\+)/gmu) ?? []).length
const deletions = (diff.patch.match(/^-(?!--)/gmu) ?? []).length
branchInfos.push({
name: branch,
summary: `${diffStat.changed} file(s), +${diffStat.insertions}/-${diffStat.deletions}`,
files: diffStat.changed,
summary: `${diff.filesChanged} file(s), +${insertions}/-${deletions}`,
files: diff.filesChanged,
})
} catch {
branchInfos.push({
Expand Down Expand Up @@ -83,18 +82,16 @@ export default defineCommand({

const selectedBranch = reviewChoice as string

// Show detailed diff
// Show detailed diff against CONTENTRAIN_BRANCH
try {
const diff = await git.diff([`${baseBranch}...${selectedBranch}`, '--stat'])
log.info(pc.bold(`\nDiff: ${selectedBranch}`))
log.message(diff)

// Show the actual content changes
const fullDiff = await git.diff([`${baseBranch}...${selectedBranch}`])
if (fullDiff.length < 5000) {
log.message(fullDiff)
const detail = await branchDiff(projectRoot, { branch: selectedBranch })
log.info(pc.bold(`\nDiff: ${selectedBranch} (base: ${detail.base})`))
log.message(detail.stat)

if (detail.patch.length < 5000) {
log.message(detail.patch)
} else {
log.message(pc.dim(`(${Math.round(fullDiff.length / 1024)}KB diff — too large to display inline)`))
log.message(pc.dim(`(${Math.round(detail.patch.length / 1024)}KB diff — too large to display inline)`))
}
} catch (error) {
log.error(`Could not show diff: ${error instanceof Error ? error.message : String(error)}`)
Expand All @@ -104,7 +101,7 @@ export default defineCommand({
const action = await select({
message: 'Action',
options: [
{ value: 'merge', label: `Merge into ${baseBranch}` },
{ value: 'merge', label: `Merge into ${CONTENTRAIN_BRANCH} + advance base` },
{ value: 'delete', label: 'Delete branch (reject changes)' },
{ value: 'skip', label: 'Leave for later' },
],
Expand All @@ -116,47 +113,23 @@ export default defineCommand({
}

if (action === 'merge') {
const confirmMerge = await confirm({ message: `Merge ${selectedBranch} into ${baseBranch}?` })
const confirmMerge = await confirm({ message: `Merge ${selectedBranch} into ${CONTENTRAIN_BRANCH}?` })
if (!isCancel(confirmMerge) && confirmMerge) {
const mergePath = join(tmpdir(), `cr-merge-${randomUUID()}`)
// Delegate to MCP's mergeBranch — runs the worktree transaction
// with selective sync, so dirty developer-tree files are
// preserved (skipped) rather than overwritten by checkout.
try {
// Ensure contentrain branch exists
const localBranches = await git.branchLocal()
if (!localBranches.all.includes(CONTENTRAIN_BRANCH)) {
await git.branch([CONTENTRAIN_BRANCH, baseBranch])
}

// Create temp worktree on contentrain branch
await git.raw(['worktree', 'add', mergePath, CONTENTRAIN_BRANCH])
const mergeGit = simpleGit(mergePath)

// Sync contentrain with base
await mergeGit.merge([baseBranch, '--no-edit']).catch(() => {})

// Merge selected branch into contentrain
await mergeGit.merge([selectedBranch, '--no-edit'])

// Get contentrain tip
const tip = (await mergeGit.raw(['rev-parse', 'HEAD'])).trim()

// Advance base branch via update-ref
await git.raw(['update-ref', `refs/heads/${baseBranch}`, tip])

// Sync .contentrain/ files to developer's tree
const currentBranch = (await git.raw(['branch', '--show-current'])).trim()
if (currentBranch === baseBranch) {
await git.checkout([tip, '--', '.contentrain/'])
const result = await mergeBranch(projectRoot, selectedBranch)
log.success(`Merged ${selectedBranch} (commit ${result.commit.slice(0, 8)})`)
if (result.sync?.skipped?.length) {
log.warning(`${result.sync.skipped.length} file(s) skipped during sync — you have uncommitted changes:`)
for (const f of result.sync.skipped) {
log.message(pc.dim(` ${f}`))
}
log.message(pc.dim(' Review your working tree before running another merge.'))
}

// Delete the merged feature branch
await git.deleteLocalBranch(selectedBranch, true)

log.success(`Merged and deleted ${selectedBranch}`)
} catch (error) {
log.error(`Merge failed: ${error instanceof Error ? error.message : String(error)}`)
} finally {
// Cleanup worktree
await git.raw(['worktree', 'remove', mergePath, '--force']).catch(() => {})
}
}
} else if (action === 'delete') {
Expand Down
19 changes: 11 additions & 8 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from '@
import { readConfig } from '@contentrain/mcp/core/config'
import { pathExists, contentrainDir, readDir, readJson, readText } from '@contentrain/mcp/util/fs'
import { autoDetectSourceDirs, discoverFiles } from '@contentrain/mcp/core/scan-config'
import { checkBranchHealth } from '@contentrain/mcp/git/branch-lifecycle'
import type { ModelDefinition, ContentrainConfig } from '@contentrain/types'
import { resolveProjectRoot } from '../utils/context.js'
import { statusIcon, pc } from '../utils/ui.js'
Expand Down Expand Up @@ -123,17 +124,19 @@ export default defineCommand({

// 8. Stale contentrain branches
if (hasGit) {
// Delegate to MCP's checkBranchHealth — it filters cr/* feature
// branches and applies the same warning / blocked thresholds
// MCP uses internally. Keeps doctor honest with the rest of the
// stack (previously this used a stale `contentrain/*` filter
// that returned zero after the Phase 7 naming migration, so the
// check was effectively a no-op).
try {
const git = simpleGit(projectRoot)
const branches = await git.branch(['--list', 'contentrain/*'])
const staleCount = branches.all.length
const health = await checkBranchHealth(projectRoot)
checks.push({
name: 'Pending branches',
pass: staleCount < 50,
detail: staleCount === 0 ? 'None'
: staleCount >= 80 ? `${staleCount} branches (BLOCKED — limit: 80)`
: staleCount >= 50 ? `${staleCount} branches (WARNING — limit: 50)`
: `${staleCount} contentrain branch(es)`,
pass: !health.blocked && !health.warning,
detail: health.message
?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),
})
} catch {
checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })
Expand Down
29 changes: 24 additions & 5 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ export default defineCommand({

const port = Number(args.port) || Number(process.env['CONTENTRAIN_PORT']) || 3333
const host = args.host ?? process.env['CONTENTRAIN_HOST'] ?? 'localhost'
const authToken = args.authToken ?? process.env['CONTENTRAIN_AUTH_TOKEN']

// --- MCP over HTTP mode ---
const useMcpHttp = args.mcpHttp || process.env['CONTENTRAIN_MCP_HTTP'] === 'true' || process.env['CONTENTRAIN_MCP_HTTP'] === '1'
if (useMcpHttp) {
const { startHttpMcpServer } = await import('@contentrain/mcp/server/http')
const authToken = args.authToken ?? process.env['CONTENTRAIN_AUTH_TOKEN']
const handle = await startHttpMcpServer({
projectRoot,
host,
Expand All @@ -93,6 +93,29 @@ export default defineCommand({
}

// --- Web UI mode (default) ---
// Secure-by-default: binding to a non-localhost interface requires
// an explicit `--authToken`. The serve UI has no per-request auth
// today, so exposing it to the network without a token would give
// any reachable host full .contentrain/ write access. Opt-out
// flags (`--allow-unsafe`) are NOT provided — OWASP Secure-by-
// Default. Bind to localhost if you want unauthenticated access.
if (host !== 'localhost' && host !== '127.0.0.1' && !authToken) {
consola.error([
`Refusing to start serve UI on ${host} without a Bearer token.`,
'',
' The serve UI exposes the full .contentrain/ write surface to any',
' reachable host. Either bind to localhost:',
'',
' contentrain serve --host localhost',
'',
' Or pass an auth token (coming soon — today, use stdio MCP for remote agents):',
'',
' contentrain serve --mcpHttp --host 0.0.0.0 --authToken $(openssl rand -hex 32)',
].join('\n'))
process.exitCode = 1
return
}

const shouldOpen = args.open !== false && process.env['CONTENTRAIN_NO_OPEN'] !== 'true'

// Resolve UI directory (pre-built static assets next to CLI bundle)
Expand Down Expand Up @@ -158,9 +181,5 @@ export default defineCommand({
}
})

// Warn if binding to 0.0.0.0
if (host === '0.0.0.0') {
consola.warn('Server is accessible from the network. No authentication is configured.')
}
},
})
18 changes: 17 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,32 @@ export default defineCommand({

const branch = buildBranchName('fix', 'validate')
const tx = await createTransaction(projectRoot, branch)
let txResult: Awaited<ReturnType<typeof tx.complete>> | undefined
try {
await tx.write(async (wt) => {
result = await validateProject(wt, { model: args.model, fix: true })
await writeContext(wt, { tool: 'contentrain_validate', model: args.model ?? '*' })
})
await tx.commit('[contentrain] validate: auto-fix')
await tx.complete()
txResult = await tx.complete()
} finally {
await tx.cleanup()
}

// Surface branch + workflow action to parity with the interactive
// path. In review mode the fix lands on a cr/fix/... branch that
// needs a manual merge; previously the non-interactive path
// reported "done" without telling the caller where the fix went.
if (txResult && result && result.fixed > 0) {
if (txResult.action === 'pending-review') {
log.info(`Fixes committed to branch ${pc.cyan(branch)} (pending review). Run ${pc.cyan('contentrain diff')} to merge.`)
} else if (txResult.action === 'auto-merged') {
log.success(`Fixes auto-merged into ${pc.cyan('contentrain')} (commit ${pc.dim(txResult.commit.slice(0, 8))}).`)
}
if (txResult.sync?.skipped?.length) {
log.warning(`${txResult.sync.skipped.length} file(s) skipped during sync — you have uncommitted changes.`)
}
}
} else {
result = await validateProject(projectRoot, {
model: args.model,
Expand Down
Loading
Loading