contentrain 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +112 -34
  2. package/dist/{activity-CbkxcfDf.mjs → activity-B8j9gfsK.mjs} +3 -3
  3. package/dist/{branches-D2UWex1N.mjs → branches-CvpFbyrJ.mjs} +3 -3
  4. package/dist/{cdn-build-BzC2QPNi.mjs → cdn-build-DS9FprWU.mjs} +3 -3
  5. package/dist/{cdn-init-CsmqJIaQ.mjs → cdn-init-Bm4dpEJn.mjs} +3 -3
  6. package/dist/{connect-DJo9PSiw.mjs → connect-Bb0BrZsx.mjs} +3 -3
  7. package/dist/describe-DJ5H9Cxm.mjs +106 -0
  8. package/dist/describe-format-CUAqB0C4.mjs +50 -0
  9. package/dist/{diff--halNUpG.mjs → diff-ov1j9bAb.mjs} +67 -59
  10. package/dist/doctor-MOf_LQEL.mjs +95 -0
  11. package/dist/{generate-DrUojrk8.mjs → generate-ByscV5SN.mjs} +26 -9
  12. package/dist/index.mjs +50 -12
  13. package/dist/{init-CqoDCSV5.mjs → init-540qiNQJ.mjs} +6 -6
  14. package/dist/{login-DCXkOrQV.mjs → login-xvzL-UvR.mjs} +3 -3
  15. package/dist/{logout-nPt2UAeQ.mjs → logout-DzspSatR.mjs} +2 -2
  16. package/dist/mcp-client-DVWTex2i.mjs +44 -0
  17. package/dist/merge-DdPRttF-.mjs +82 -0
  18. package/dist/{resolve-context-Cg6JoVZt.mjs → resolve-context-hKVYPniC.mjs} +1 -1
  19. package/dist/scaffold-iuvrMh78.mjs +115 -0
  20. package/dist/{serve-CNeFIQ1i.mjs → serve-CxA2pKbp.mjs} +58 -5
  21. package/dist/serve-ui/assets/{AgentPromptGroup.vue_vue_type_script_setup_true_lang-Ulb-9Tr3.js → AgentPromptGroup.vue_vue_type_script_setup_true_lang-Bftt9tx_.js} +1 -1
  22. package/dist/serve-ui/assets/AppLayout-B-fbrfMk.js +21 -0
  23. package/dist/serve-ui/assets/BranchDetailPage-B3PL9PGp.js +17 -0
  24. package/dist/serve-ui/assets/{BranchesPage-CSwikNkd.js → BranchesPage-4YpE_szf.js} +2 -2
  25. package/dist/serve-ui/assets/CardContent.vue_vue_type_script_setup_true_lang-D1L2MnBU.js +1 -0
  26. package/dist/serve-ui/assets/CollapsibleTrigger.vue_vue_type_script_setup_true_lang-B4uDtllm.js +1 -0
  27. package/dist/serve-ui/assets/{Collection-DTB4xCCE.js → Collection-9URFko-Y.js} +2 -2
  28. package/dist/serve-ui/assets/ConfigProvider-CwkmRpT5.js +1 -0
  29. package/dist/serve-ui/assets/{ContentListPage-DF-LjAaS.js → ContentListPage-BMfSfg8x.js} +2 -2
  30. package/dist/serve-ui/assets/ContentPage-BXUnNJlB.js +1 -0
  31. package/dist/serve-ui/assets/DashboardPage-1UyYgg5J.js +21 -0
  32. package/dist/serve-ui/assets/{DialogTitle.vue_vue_type_script_setup_true_lang-2sQAJn_Y.js → DialogTitle.vue_vue_type_script_setup_true_lang-M4LOzTva.js} +1 -1
  33. package/dist/serve-ui/assets/DoctorPage-WnQ8xYWy.js +1 -0
  34. package/dist/serve-ui/assets/FormatReferencePage-DoKEaawl.js +1 -0
  35. package/dist/serve-ui/assets/{Input.vue_vue_type_script_setup_true_lang-BveqfZsR.js → Input.vue_vue_type_script_setup_true_lang-tW0hgReH.js} +2 -2
  36. package/dist/serve-ui/assets/{ModelDetailPage-_TIV9itx.js → ModelDetailPage-BysqN8IL.js} +3 -3
  37. package/dist/serve-ui/assets/ModelsPage-DFZrdBIi.js +1 -0
  38. package/dist/serve-ui/assets/{NormalizePage-Cde8vdk2.js → NormalizePage-4f5Ib_ES.js} +2 -2
  39. package/dist/serve-ui/assets/{PageHeader.vue_vue_type_script_setup_true_lang-CEnjONvd.js → PageHeader.vue_vue_type_script_setup_true_lang-Bln1czg7.js} +1 -1
  40. package/dist/serve-ui/assets/Presence-CA3xTrht.js +3 -0
  41. package/dist/serve-ui/assets/{Separator.vue_vue_type_script_setup_true_lang-1pFZ2PR2.js → Separator.vue_vue_type_script_setup_true_lang-PECBq3nc.js} +1 -1
  42. package/dist/serve-ui/assets/{StudioHint.vue_vue_type_script_setup_true_lang-C6JN5tPq.js → StudioHint.vue_vue_type_script_setup_true_lang-DYTEoyjw.js} +1 -1
  43. package/dist/serve-ui/assets/{TrustBadge.vue_vue_type_script_setup_true_lang-KRiTnLVk.js → TrustBadge.vue_vue_type_script_setup_true_lang-BRXWvOYU.js} +2 -2
  44. package/dist/serve-ui/assets/ValidatePage-BMTpV2KL.js +16 -0
  45. package/dist/serve-ui/assets/{arrow-down-BAJwpryD.js → arrow-down-IJu6H7Es.js} +1 -1
  46. package/dist/serve-ui/assets/{arrow-left-DANf_wrK.js → arrow-left-D8ZjVYAI.js} +1 -1
  47. package/dist/serve-ui/assets/{book-marked-DS8FXgjz.js → book-marked-BNyyZrq3.js} +1 -1
  48. package/dist/serve-ui/assets/{book-open-DnH5T78M.js → book-open-Dw1fQtSI.js} +1 -1
  49. package/dist/serve-ui/assets/{box-UJk5SvJJ.js → box-BoGMZDZR.js} +1 -1
  50. package/dist/serve-ui/assets/{chevron-down-C1nYx5Eu.js → chevron-down-UcXxhyyY.js} +1 -1
  51. package/dist/serve-ui/assets/{chevron-right-XnJVrezx.js → chevron-right-SpdM5LBc.js} +1 -1
  52. package/dist/serve-ui/assets/circle-alert-CEwKJpYl.js +6 -0
  53. package/dist/serve-ui/assets/{content-Ctqretwl.js → content-BQEzysvq.js} +1 -1
  54. package/dist/serve-ui/assets/{database-Wd_i2bM1.js → database-U6JU8oc9.js} +1 -1
  55. package/dist/serve-ui/assets/{file-code-ScWZN40B.js → file-code-_sq1QHQo.js} +1 -1
  56. package/dist/serve-ui/assets/{file-text-J3Wzwieh.js → file-text-BiMGJckO.js} +1 -1
  57. package/dist/serve-ui/assets/{git-branch-DqEFTZDt.js → git-branch-CdZMOuSP.js} +1 -1
  58. package/dist/serve-ui/assets/{git-merge-Bmz-MVBm.js → git-merge-D8Y-BwIh.js} +1 -1
  59. package/dist/serve-ui/assets/{hash-pCwLSzjB.js → hash-CrXakbCy.js} +1 -1
  60. package/dist/serve-ui/assets/index-DL_dw3XX.js +80 -0
  61. package/dist/serve-ui/assets/index-KZ2rT4mJ.css +1 -0
  62. package/dist/serve-ui/assets/{languages-CQqNMim-.js → languages-DvyMGPwy.js} +1 -1
  63. package/dist/serve-ui/assets/{plus-Cw1OnTR2.js → plus-Ddujj7Qx.js} +1 -1
  64. package/dist/serve-ui/assets/project-Dg2BVay_.js +1 -0
  65. package/dist/serve-ui/assets/{scan-search-UJ-_oiOR.js → scan-search-CsPYGmEs.js} +1 -1
  66. package/dist/serve-ui/assets/{settings-Dnm4yTIj.js → settings-IQvevsKM.js} +1 -1
  67. package/dist/serve-ui/assets/{shield-check-B67ELGUT.js → shield-check-BmSBU8Ai.js} +1 -1
  68. package/dist/serve-ui/assets/stethoscope-DgCa_7BL.js +6 -0
  69. package/dist/serve-ui/assets/{trash-2-B1QgZW1G.js → trash-2-CNTkluuz.js} +1 -1
  70. package/dist/serve-ui/assets/useDirection-DfPL7a9d.js +1 -0
  71. package/dist/serve-ui/assets/{useId-CPFWZ4Bo.js → useId-B-RTM02R.js} +1 -1
  72. package/dist/serve-ui/assets/{useNonce-BffIzuhE.js → useNonce-DR6M3tLq.js} +1 -1
  73. package/dist/serve-ui/assets/useWatch-D4Y-epQR.js +1 -0
  74. package/dist/serve-ui/index.html +2 -2
  75. package/dist/{server-DTdmutjb.mjs → server-B1PSjXbu.mjs} +313 -112
  76. package/dist/{setup-Y1LFQ9rZ.mjs → setup-BMqKibZt.mjs} +3 -3
  77. package/dist/{skills-Cp8KF1af.mjs → skills-CZ54F-97.mjs} +3 -3
  78. package/dist/{status-Cyv-jo1y.mjs → status-BJ6Lu3J4.mjs} +3 -3
  79. package/dist/{status-CoHgz6HN.mjs → status-Bmjfrk-B.mjs} +24 -11
  80. package/dist/studio-DPVJ-2oG.mjs +24 -0
  81. package/dist/{submissions-bIG-MWvv.mjs → submissions-zH_S7JIQ.mjs} +3 -3
  82. package/dist/{usage-B2xuc2DT.mjs → usage-z6eyT64a.mjs} +3 -3
  83. package/dist/{validate-B10L7RL7.mjs → validate-BG0X5g09.mjs} +102 -4
  84. package/dist/{webhooks-Dq3zFLP7.mjs → webhooks-CBG2Bs9P.mjs} +3 -3
  85. package/dist/{whoami-Ub5RznbU.mjs → whoami-CXVfvo7x.mjs} +2 -2
  86. package/package.json +7 -6
  87. package/dist/doctor-DyKjAIKH.mjs +0 -187
  88. package/dist/serve-ui/assets/AppLayout-C91DN08z.js +0 -21
  89. package/dist/serve-ui/assets/BranchDetailPage-BokOnhE7.js +0 -17
  90. package/dist/serve-ui/assets/CollapsibleTrigger.vue_vue_type_script_setup_true_lang-BRzXVXDG.js +0 -1
  91. package/dist/serve-ui/assets/ConfigProvider-Dvd7YoEV.js +0 -1
  92. package/dist/serve-ui/assets/ContentPage-BABg8pgn.js +0 -1
  93. package/dist/serve-ui/assets/DashboardPage-BFLFEJNZ.js +0 -21
  94. package/dist/serve-ui/assets/ModelsPage-DrQemHQ-.js +0 -1
  95. package/dist/serve-ui/assets/Presence-BXKCIfUk.js +0 -3
  96. package/dist/serve-ui/assets/ValidatePage-DRFAySNR.js +0 -21
  97. package/dist/serve-ui/assets/index-CG5NtNtt.css +0 -1
  98. package/dist/serve-ui/assets/index-De_z0714.js +0 -1
  99. package/dist/serve-ui/assets/index-RDdffBTh.js +0 -80
  100. package/dist/serve-ui/assets/project-BlZcHJw6.js +0 -1
  101. package/dist/serve-ui/assets/useDirection-B4S5kPsI.js +0 -1
  102. package/dist/serve-ui/assets/useWatch-B2FLRWr8.js +0 -1
  103. package/dist/studio-CJUGiIMd.mjs +0 -24
  104. package/dist/{client-C400qHcL.mjs → client-C9E9D4mb.mjs} +1 -1
  105. package/dist/{context-An3xrvcP.mjs → context-OskMGy8y.mjs} +1 -1
  106. package/dist/{demo-DcBt6fw8.mjs → demo-BOUcAoFZ.mjs} +3 -3
  107. package/dist/{ide-_C2gUPbQ.mjs → ide-DPDVYiaq.mjs} +1 -1
  108. package/dist/{oauth-server-Beppljfz.mjs → oauth-server-smSxDoH5.mjs} +1 -1
  109. /package/dist/{ui-B5mXontH.mjs → ui-B8l_LC36.mjs} +0 -0
@@ -1,16 +1,90 @@
1
- import { simpleGit } from "simple-git";
2
- import { CONTENTRAIN_BRANCH } from "@contentrain/types";
3
- import { tmpdir } from "node:os";
4
- import { randomUUID } from "node:crypto";
5
1
  import { join } from "node:path";
6
- import { readFile, unlink, writeFile } from "node:fs/promises";
7
- import { existsSync, readFileSync } from "node:fs";
2
+ import { readConfig } from "@contentrain/mcp/core/config";
8
3
  import { createServer } from "@contentrain/mcp/server";
9
- import { createApp, createError, createRouter, defineEventHandler, getQuery, getRouterParam, readBody, toNodeListener } from "h3";
10
- import { WebSocketServer } from "ws";
11
4
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
12
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { simpleGit } from "simple-git";
7
+ import { CONTENTRAIN_BRANCH, LOCAL_CAPABILITIES } from "@contentrain/types";
8
+ import { mergeBranch } from "@contentrain/mcp/git/transaction";
9
+ import { branchDiff, checkBranchHealth } from "@contentrain/mcp/git/branch-lifecycle";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { readFile, unlink, writeFile } from "node:fs/promises";
12
+ import { createApp, createError, createRouter, defineEventHandler, getQuery, getRouterParam, readBody, toNodeListener } from "h3";
13
+ import { WebSocketServer } from "ws";
13
14
  import { watch as watch$1 } from "chokidar";
15
+ import { z } from "zod";
16
+ //#region src/serve/schemas.ts
17
+ /**
18
+ * Zod schemas for `contentrain serve` HTTP write routes.
19
+ *
20
+ * Each schema validates the request body BEFORE it reaches the MCP
21
+ * tool layer. Catches malformed payloads (wrong types, extra fields,
22
+ * path-traversal candidates) with structured 400 errors instead of
23
+ * propagating them into git operations where they would be much
24
+ * harder to diagnose.
25
+ *
26
+ * Schemas are intentionally conservative — they match the loosest
27
+ * shape the underlying MCP tool accepts. A request that passes the
28
+ * schema is guaranteed to at least REACH the MCP tool; whether the
29
+ * tool accepts it (e.g. for domain-specific validation) is a separate
30
+ * concern surfaced as an MCP-level error.
31
+ */
32
+ const branchNameRegex = /^cr\/[a-z0-9-_/]+(?:\/[a-z0-9-]+)*\/\d+-[a-z0-9]+$/u;
33
+ /** `cr/*` feature branch name. Legacy `contentrain/*` is explicitly rejected — old branches are auto-migrated on init. */
34
+ const BranchNameSchema = z.string().min(1, "Branch name is required").regex(/^cr\//u, "Branch must start with \"cr/\"").refine((n) => branchNameRegex.test(n) || n.startsWith("cr/"), { message: "Invalid branch name format" });
35
+ /** Body for `/api/content/:modelId/:entryId/fix`. */
36
+ const ContentFixBodySchema = z.object({
37
+ locale: z.string().min(1).optional(),
38
+ data: z.record(z.string(), z.unknown())
39
+ });
40
+ /** Body for `/api/branches/approve` and `/api/branches/reject` and `/api/normalize/approve`. */
41
+ const BranchActionBodySchema = z.object({ branch: BranchNameSchema });
42
+ /** Body for `/api/normalize/apply` — mirrors contentrain_apply tool input, slimmed. */
43
+ const NormalizeApplyBodySchema = z.object({
44
+ mode: z.enum(["extract", "reuse"]),
45
+ dry_run: z.boolean().optional().default(true),
46
+ extractions: z.array(z.unknown()).optional(),
47
+ scope: z.object({
48
+ model: z.string().optional(),
49
+ domain: z.string().optional()
50
+ }).optional(),
51
+ patches: z.array(z.unknown()).optional()
52
+ }).passthrough();
53
+ /** Body for `/api/normalize/plan/approve`. */
54
+ const NormalizePlanApproveBodySchema = z.object({ models: z.array(z.string()).optional() });
55
+ /**
56
+ * Body for `/api/normalize/plan/reject`. Currently the route only
57
+ * deletes the plan file, but we validate the body shape anyway so
58
+ * any future caller that wants to record a rejection reason has a
59
+ * well-defined contract. Both an empty body and `{ reason? }` pass.
60
+ */
61
+ const NormalizePlanRejectBodySchema = z.object({ reason: z.string().max(500).optional() }).optional();
62
+ /** Query params for `/api/normalize/file-context`. */
63
+ const FileContextQuerySchema = z.object({
64
+ file: z.string().min(1).refine((p) => !p.includes(".."), { message: "Path traversal segments (\"..\") are not allowed" }).refine((p) => !p.startsWith("/") && !/^[a-zA-Z]:/u.test(p), { message: "Absolute paths are not allowed" }),
65
+ line: z.coerce.number().int().min(1).optional(),
66
+ range: z.coerce.number().int().min(0).max(50).optional()
67
+ });
68
+ /**
69
+ * Parse a body with a schema and either return the typed value or
70
+ * throw an h3-compatible 400 error. Keeps route handlers readable.
71
+ *
72
+ * Generic constraint is `z.ZodTypeAny` so the inferred type propagates
73
+ * as `z.infer<T>` rather than the looser `T` that `z.ZodType<T>`
74
+ * infers to at call sites.
75
+ */
76
+ function parseOrThrow(schema, value, kind = "body") {
77
+ const result = schema.safeParse(value);
78
+ if (!result.success) {
79
+ const details = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
80
+ const err = /* @__PURE__ */ new Error(`Invalid ${kind}: ${details}`);
81
+ err.statusCode = 400;
82
+ err.data = { issues: result.error.issues };
83
+ throw err;
84
+ }
85
+ return result.data;
86
+ }
87
+ //#endregion
14
88
  //#region src/serve/server.ts
15
89
  function apiError(error) {
16
90
  if (error && typeof error === "object" && "statusCode" in error) {
@@ -36,6 +110,8 @@ async function createServeApp(options) {
36
110
  } catch {}
37
111
  return "main";
38
112
  }
113
+ const syncWarnings = /* @__PURE__ */ new Map();
114
+ const SYNC_WARNING_TTL_MS = 3600 * 1e3;
39
115
  const mcpServer = createServer(projectRoot);
40
116
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
41
117
  const mcpClient = new Client({
@@ -137,21 +213,79 @@ async function createServeApp(options) {
137
213
  modelId: parts[1],
138
214
  locale: parts[2]?.replace(".json", "")
139
215
  };
216
+ } else if (rel.startsWith("meta/") && rel.endsWith(".json")) {
217
+ const parts = rel.replace("meta/", "").split("/");
218
+ const locale = parts[parts.length - 1]?.replace(".json", "");
219
+ const modelId = parts[0];
220
+ const entryId = parts.length === 3 ? parts[1] : void 0;
221
+ event = entryId ? {
222
+ type: "meta:changed",
223
+ modelId,
224
+ entryId,
225
+ locale
226
+ } : {
227
+ type: "meta:changed",
228
+ modelId,
229
+ locale
230
+ };
140
231
  } else return;
141
232
  pendingEvents.push(event);
142
233
  if (debounceTimer) clearTimeout(debounceTimer);
143
234
  debounceTimer = setTimeout(flush, 300);
144
235
  });
236
+ watcher.on("error", (err) => {
237
+ broadcast({
238
+ type: "file-watch:error",
239
+ message: err instanceof Error ? err.message : String(err),
240
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
241
+ });
242
+ });
145
243
  }
146
244
  router.add("/api/status", defineEventHandler(async () => {
147
245
  return await callTool("contentrain_status");
148
246
  }));
247
+ router.add("/api/capabilities", defineEventHandler(async () => {
248
+ const config = await readConfig(projectRoot).catch(() => null);
249
+ const health = await checkBranchHealth(projectRoot).catch(() => null);
250
+ return {
251
+ version: 1,
252
+ provider: {
253
+ type: "local",
254
+ repo: config?.repository ?? null
255
+ },
256
+ transport: "stdio",
257
+ capabilities: LOCAL_CAPABILITIES,
258
+ contentBranch: CONTENTRAIN_BRANCH,
259
+ defaultBranch: getDefaultBranch(),
260
+ branchHealth: health
261
+ };
262
+ }));
263
+ router.add("/api/branches/:name/sync-status", defineEventHandler(async (event) => {
264
+ const name = getRouterParam(event, "name");
265
+ if (!name) throw createError({
266
+ statusCode: 400,
267
+ message: "Missing branch name"
268
+ });
269
+ const warning = syncWarnings.get(name);
270
+ if (warning && Date.now() - warning.recordedAt > SYNC_WARNING_TTL_MS) {
271
+ syncWarnings.delete(name);
272
+ return { warning: null };
273
+ }
274
+ return { warning: warning ?? null };
275
+ }));
149
276
  router.add("/api/describe/:modelId", defineEventHandler(async (event) => {
150
277
  return await callTool("contentrain_describe", {
151
278
  model: getRouterParam(event, "modelId"),
152
279
  include_sample: true
153
280
  });
154
281
  }));
282
+ router.add("/api/describe-format", defineEventHandler(async () => {
283
+ return await callTool("contentrain_describe_format");
284
+ }));
285
+ router.add("/api/doctor", defineEventHandler(async (event) => {
286
+ const query = getQuery(event);
287
+ return await callTool("contentrain_doctor", { usage: query["usage"] === "true" || query["usage"] === "1" });
288
+ }));
155
289
  router.add("/api/content/:modelId", defineEventHandler(async (event) => {
156
290
  const modelId = getRouterParam(event, "modelId");
157
291
  const query = getQuery(event);
@@ -179,7 +313,7 @@ async function createServeApp(options) {
179
313
  });
180
314
  const modelId = getRouterParam(event, "modelId");
181
315
  const entryId = getRouterParam(event, "entryId");
182
- const body = await readBody(event);
316
+ const body = parseOrThrow(ContentFixBodySchema, await readBody(event));
183
317
  const result = await callTool("contentrain_content_save", {
184
318
  model: modelId,
185
319
  entries: [{
@@ -221,13 +355,86 @@ async function createServeApp(options) {
221
355
  statusCode: 400,
222
356
  message: "Invalid branch name"
223
357
  });
358
+ const result = await branchDiff(projectRoot, { branch: branchName });
359
+ return {
360
+ branch: result.branch,
361
+ base: result.base,
362
+ stat: result.stat,
363
+ diff: result.patch,
364
+ filesChanged: result.filesChanged
365
+ };
366
+ }));
367
+ router.add("/api/preview/merge", defineEventHandler(async (event) => {
368
+ const branchName = getQuery(event)["branch"];
369
+ if (!branchName || !branchName.startsWith("cr/")) throw createError({
370
+ statusCode: 400,
371
+ message: "Missing or invalid `branch` query (must start with \"cr/\")"
372
+ });
224
373
  const git = simpleGit(projectRoot);
225
- const defaultBranch = getDefaultBranch();
374
+ if (!(await git.branchLocal()).all.includes(branchName)) throw createError({
375
+ statusCode: 404,
376
+ message: `Branch "${branchName}" does not exist locally`
377
+ });
378
+ let alreadyMerged = false;
379
+ let canFastForward = false;
380
+ try {
381
+ await git.raw([
382
+ "merge-base",
383
+ "--is-ancestor",
384
+ branchName,
385
+ CONTENTRAIN_BRANCH
386
+ ]);
387
+ alreadyMerged = true;
388
+ } catch {}
389
+ if (!alreadyMerged) try {
390
+ await git.raw([
391
+ "merge-base",
392
+ "--is-ancestor",
393
+ CONTENTRAIN_BRANCH,
394
+ branchName
395
+ ]);
396
+ canFastForward = true;
397
+ } catch {}
398
+ let conflicts = null;
399
+ try {
400
+ const mergeBase = (await git.raw([
401
+ "merge-base",
402
+ CONTENTRAIN_BRANCH,
403
+ branchName
404
+ ])).trim();
405
+ if (mergeBase) {
406
+ const out = await git.raw([
407
+ "merge-tree",
408
+ mergeBase,
409
+ CONTENTRAIN_BRANCH,
410
+ branchName
411
+ ]);
412
+ const paths = /* @__PURE__ */ new Set();
413
+ const conflictBlockRegex = /^(?:changed in both|added in both|removed in (?:local|remote))\b[^\n]*\n((?:[ \t][^\n]*\n)+)/gmu;
414
+ let match;
415
+ while ((match = conflictBlockRegex.exec(out)) !== null) {
416
+ const block = match[1] ?? "";
417
+ const pathMatch = /\b100644\s+[0-9a-f]{4,}\s+(\S+)/u.exec(block);
418
+ if (pathMatch?.[1]) paths.add(pathMatch[1]);
419
+ }
420
+ conflicts = [...paths];
421
+ }
422
+ } catch {}
423
+ let stat = "";
424
+ let filesChanged = 0;
425
+ if (!alreadyMerged) try {
426
+ const diff = await branchDiff(projectRoot, { branch: branchName });
427
+ stat = diff.stat;
428
+ filesChanged = diff.filesChanged;
429
+ } catch {}
226
430
  return {
227
431
  branch: branchName,
228
- base: defaultBranch,
229
- stat: await git.diff([`${defaultBranch}...${branchName}`, "--stat"]),
230
- diff: await git.diff([`${defaultBranch}...${branchName}`])
432
+ base: CONTENTRAIN_BRANCH,
433
+ alreadyMerged,
434
+ canFastForward,
435
+ conflicts,
436
+ filesChanged,
437
+ stat
231
438
  };
232
439
  }));
233
440
  router.add("/api/branches/approve", defineEventHandler(async (event) => {
@@ -235,37 +442,22 @@ async function createServeApp(options) {
235
442
  statusCode: 405,
236
443
  message: "Method not allowed"
237
444
  });
238
- const branchName = (await readBody(event)).branch;
239
- if (!branchName?.startsWith("cr/")) throw createError({
240
- statusCode: 400,
241
- message: "Invalid branch name"
242
- });
243
- const git = simpleGit(projectRoot);
244
- const baseBranch = getDefaultBranch();
245
- if (!(await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
246
- const mergePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
247
- await git.raw([
248
- "worktree",
249
- "add",
250
- mergePath,
251
- CONTENTRAIN_BRANCH
252
- ]);
253
- const mergeGit = simpleGit(mergePath);
445
+ const { branch: branchName } = parseOrThrow(BranchActionBodySchema, await readBody(event));
254
446
  try {
255
- await mergeGit.merge([baseBranch, "--no-edit"]).catch(() => {});
256
- await mergeGit.merge([branchName, "--no-edit"]);
257
- const tip = (await mergeGit.raw(["rev-parse", "HEAD"])).trim();
258
- await git.raw([
259
- "update-ref",
260
- `refs/heads/${baseBranch}`,
261
- tip
262
- ]);
263
- if ((await git.raw(["branch", "--show-current"])).trim() === baseBranch) await git.checkout([
264
- tip,
265
- "--",
266
- ".contentrain/"
267
- ]);
268
- await git.deleteLocalBranch(branchName, true);
447
+ const result = await mergeBranch(projectRoot, branchName);
448
+ if (result.sync?.skipped?.length) {
449
+ syncWarnings.set(branchName, {
450
+ branch: branchName,
451
+ skipped: result.sync.skipped,
452
+ synced: result.sync.synced,
453
+ recordedAt: Date.now()
454
+ });
455
+ broadcast({
456
+ type: "sync:warning",
457
+ branch: branchName,
458
+ skippedCount: result.sync.skipped.length
459
+ });
460
+ }
269
461
  broadcast({
270
462
  type: "branch:merged",
271
463
  branch: branchName
@@ -273,15 +465,21 @@ async function createServeApp(options) {
273
465
  return {
274
466
  status: "merged",
275
467
  branch: branchName,
276
- into: baseBranch
468
+ commit: result.commit,
469
+ action: result.action,
470
+ sync: result.sync
277
471
  };
278
- } finally {
279
- await git.raw([
280
- "worktree",
281
- "remove",
282
- mergePath,
283
- "--force"
284
- ]).catch(() => {});
472
+ } catch (error) {
473
+ const message = error instanceof Error ? error.message : String(error);
474
+ broadcast({
475
+ type: "branch:merge-conflict",
476
+ branch: branchName,
477
+ message
478
+ });
479
+ throw createError({
480
+ statusCode: 409,
481
+ message: `Merge failed: ${message}`
482
+ });
285
483
  }
286
484
  }));
287
485
  router.add("/api/branches/reject", defineEventHandler(async (event) => {
@@ -289,12 +487,13 @@ async function createServeApp(options) {
289
487
  statusCode: 405,
290
488
  message: "Method not allowed"
291
489
  });
292
- const branchName = (await readBody(event)).branch;
293
- if (!branchName?.startsWith("cr/")) throw createError({
294
- statusCode: 400,
295
- message: "Invalid branch name"
296
- });
490
+ const { branch: branchName } = parseOrThrow(BranchActionBodySchema, await readBody(event));
297
491
  await simpleGit(projectRoot).deleteLocalBranch(branchName, true);
492
+ syncWarnings.delete(branchName);
493
+ broadcast({
494
+ type: "branch:rejected",
495
+ branch: branchName
496
+ });
298
497
  return {
299
498
  status: "deleted",
300
499
  branch: branchName
@@ -303,7 +502,7 @@ async function createServeApp(options) {
303
502
  router.add("/api/history", defineEventHandler(async (event) => {
304
503
  const query = getQuery(event);
305
504
  const limit = Number(query["limit"]) || 50;
306
- const entries = (await simpleGit(projectRoot).log({
505
+ const log = await simpleGit(projectRoot).log({
307
506
  maxCount: limit * 2,
308
507
  format: {
309
508
  hash: "%h",
@@ -314,9 +513,11 @@ async function createServeApp(options) {
314
513
  date: "%aI",
315
514
  relativeDate: "%ar"
316
515
  }
317
- })).all.filter((c) => {
516
+ });
517
+ const mergeBranchPattern = /^Merge branch '(cr\/|contentrain\/)/u;
518
+ const entries = log.all.filter((c) => {
318
519
  const msg = c.message ?? "";
319
- return msg.startsWith("[contentrain]") || msg.startsWith("Merge branch 'contentrain/") || msg.includes("contentrain");
520
+ return msg.startsWith("[contentrain]") || mergeBranchPattern.test(msg);
320
521
  }).slice(0, limit).map((c) => {
321
522
  const msg = c.message ?? "";
322
523
  let type = "other";
@@ -330,13 +531,13 @@ async function createServeApp(options) {
330
531
  } else if (msg.startsWith("[contentrain] content:")) {
331
532
  type = "content_save";
332
533
  target = msg.replace("[contentrain] content: ", "");
333
- } else if (msg.startsWith("[contentrain] deleted:")) {
534
+ } else if (msg.startsWith("[contentrain] deleted:") || msg.startsWith("[contentrain] delete")) {
334
535
  type = "delete";
335
- target = msg.replace("[contentrain] deleted: ", "");
536
+ target = msg.replace(/^\[contentrain\] delete(?:d)?:?\s*/u, "");
336
537
  } else if (msg.startsWith("[contentrain] update context")) type = "context_update";
337
- else if (msg.startsWith("Merge branch 'contentrain/")) {
538
+ else if (mergeBranchPattern.test(msg)) {
338
539
  type = "merge";
339
- target = msg.replace("Merge branch '", "").replace("'", "");
540
+ target = msg.replace(/^Merge branch '/u, "").replace(/'$/u, "");
340
541
  } else if (msg.startsWith("[contentrain]")) {
341
542
  type = "operation";
342
543
  target = msg.replace("[contentrain] ", "");
@@ -466,6 +667,10 @@ async function createServeApp(options) {
466
667
  statusCode: 405,
467
668
  message: "Method not allowed"
468
669
  });
670
+ if (event.method === "POST") {
671
+ const raw = await readBody(event).catch(() => void 0);
672
+ if (raw !== void 0) parseOrThrow(NormalizePlanRejectBodySchema, raw);
673
+ }
469
674
  const planPath = join(crDir, "normalize-plan.json");
470
675
  if (existsSync(planPath)) await unlink(planPath);
471
676
  broadcast({ type: "normalize:plan-updated" });
@@ -481,9 +686,9 @@ async function createServeApp(options) {
481
686
  statusCode: 404,
482
687
  message: "No normalize plan found"
483
688
  });
484
- const raw = await readFile(planPath, "utf-8");
485
- const plan = JSON.parse(raw);
486
- const selectedModels = (await readBody(event))?.models;
689
+ const rawPlan = await readFile(planPath, "utf-8");
690
+ const plan = JSON.parse(rawPlan);
691
+ const selectedModels = parseOrThrow(NormalizePlanApproveBodySchema, await readBody(event)).models;
487
692
  plan.status = "approved";
488
693
  plan.approved_at = (/* @__PURE__ */ new Date()).toISOString();
489
694
  if (selectedModels) plan.approved_models = selectedModels;
@@ -523,6 +728,11 @@ async function createServeApp(options) {
523
728
  extractions: applyExtractions
524
729
  });
525
730
  if (existsSync(planPath)) await unlink(planPath);
731
+ const git = result?.["git"];
732
+ if (git?.["branch"] && git?.["action"] === "pending-review") broadcast({
733
+ type: "branch:created",
734
+ branch: String(git["branch"])
735
+ });
526
736
  broadcast({ type: "normalize:plan-updated" });
527
737
  return result;
528
738
  }));
@@ -531,44 +741,29 @@ async function createServeApp(options) {
531
741
  statusCode: 405,
532
742
  message: "Method not allowed"
533
743
  });
534
- return await callTool("contentrain_apply", await readBody(event));
744
+ return await callTool("contentrain_apply", parseOrThrow(NormalizeApplyBodySchema, await readBody(event)));
535
745
  }));
536
746
  router.add("/api/normalize/approve", defineEventHandler(async (event) => {
537
747
  if (event.method !== "POST") throw createError({
538
748
  statusCode: 405,
539
749
  message: "Method not allowed"
540
750
  });
541
- const branchName = (await readBody(event)).branch;
542
- if (!branchName?.startsWith("cr/")) throw createError({
543
- statusCode: 400,
544
- message: "Invalid branch name"
545
- });
546
- const git = simpleGit(projectRoot);
547
- const baseBranch = getDefaultBranch();
548
- if (!(await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
549
- const mergePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
550
- await git.raw([
551
- "worktree",
552
- "add",
553
- mergePath,
554
- CONTENTRAIN_BRANCH
555
- ]);
556
- const mergeGit = simpleGit(mergePath);
751
+ const { branch: branchName } = parseOrThrow(BranchActionBodySchema, await readBody(event));
557
752
  try {
558
- await mergeGit.merge([baseBranch, "--no-edit"]).catch(() => {});
559
- await mergeGit.merge([branchName, "--no-edit"]);
560
- const tip = (await mergeGit.raw(["rev-parse", "HEAD"])).trim();
561
- await git.raw([
562
- "update-ref",
563
- `refs/heads/${baseBranch}`,
564
- tip
565
- ]);
566
- if ((await git.raw(["branch", "--show-current"])).trim() === baseBranch) await git.checkout([
567
- tip,
568
- "--",
569
- ".contentrain/"
570
- ]);
571
- await git.deleteLocalBranch(branchName, true);
753
+ const result = await mergeBranch(projectRoot, branchName);
754
+ if (result.sync?.skipped?.length) {
755
+ syncWarnings.set(branchName, {
756
+ branch: branchName,
757
+ skipped: result.sync.skipped,
758
+ synced: result.sync.synced,
759
+ recordedAt: Date.now()
760
+ });
761
+ broadcast({
762
+ type: "sync:warning",
763
+ branch: branchName,
764
+ skippedCount: result.sync.skipped.length
765
+ });
766
+ }
572
767
  broadcast({
573
768
  type: "branch:merged",
574
769
  branch: branchName
@@ -576,15 +771,21 @@ async function createServeApp(options) {
576
771
  return {
577
772
  status: "merged",
578
773
  branch: branchName,
579
- into: baseBranch
774
+ commit: result.commit,
775
+ action: result.action,
776
+ sync: result.sync
580
777
  };
581
- } finally {
582
- await git.raw([
583
- "worktree",
584
- "remove",
585
- mergePath,
586
- "--force"
587
- ]).catch(() => {});
778
+ } catch (error) {
779
+ const message = error instanceof Error ? error.message : String(error);
780
+ broadcast({
781
+ type: "branch:merge-conflict",
782
+ branch: branchName,
783
+ message
784
+ });
785
+ throw createError({
786
+ statusCode: 409,
787
+ message: `Merge failed: ${message}`
788
+ });
588
789
  }
589
790
  }));
590
791
  router.add("/api/normalize/sources", defineEventHandler(async () => {
@@ -598,15 +799,15 @@ async function createServeApp(options) {
598
799
  }
599
800
  }));
600
801
  router.add("/api/normalize/file-context", defineEventHandler(async (event) => {
601
- const query = getQuery(event);
602
- const filePath = query["file"];
603
- const line = Number(query["line"]) || 1;
604
- const range = Number(query["range"]) || 5;
605
- if (!filePath || filePath.includes("..") || filePath.startsWith("/")) throw createError({
802
+ const { file: filePath, line = 1, range = 5 } = parseOrThrow(FileContextQuerySchema, getQuery(event), "query");
803
+ const fullPath = join(projectRoot, filePath);
804
+ const { resolve: resolvePath, sep } = await import("node:path");
805
+ const resolvedRoot = resolvePath(projectRoot);
806
+ const resolvedFull = resolvePath(fullPath);
807
+ if (!resolvedFull.startsWith(resolvedRoot + sep) && resolvedFull !== resolvedRoot) throw createError({
606
808
  statusCode: 400,
607
- message: "Invalid file path"
809
+ message: "Path escapes project root"
608
810
  });
609
- const fullPath = join(projectRoot, filePath);
610
811
  if (!existsSync(fullPath)) throw createError({
611
812
  statusCode: 404,
612
813
  message: "File not found"
@@ -1,6 +1,6 @@
1
- import { r as resolveProjectRoot, t as loadProjectContext } from "./context-An3xrvcP.mjs";
2
- import { i as pc } from "./ui-B5mXontH.mjs";
3
- import { a as createPackageResolver, c as writeMcpConfig, n as IDE_CONFIGS, o as detectIdes, r as MCP_CONFIGS, s as installIdeRulesAndSkills } from "./ide-_C2gUPbQ.mjs";
1
+ import { i as pc } from "./ui-B8l_LC36.mjs";
2
+ import { r as resolveProjectRoot, t as loadProjectContext } from "./context-OskMGy8y.mjs";
3
+ import { a as createPackageResolver, c as writeMcpConfig, n as IDE_CONFIGS, o as detectIdes, r as MCP_CONFIGS, s as installIdeRulesAndSkills } from "./ide-DPDVYiaq.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/commands/setup.ts
@@ -1,6 +1,6 @@
1
- import { r as resolveProjectRoot } from "./context-An3xrvcP.mjs";
2
- import { i as pc } from "./ui-B5mXontH.mjs";
3
- import { a as createPackageResolver, n as IDE_CONFIGS, o as detectIdes, s as installIdeRulesAndSkills, t as AGENT_SKILL_NAMES } from "./ide-_C2gUPbQ.mjs";
1
+ import { i as pc } from "./ui-B8l_LC36.mjs";
2
+ import { r as resolveProjectRoot } from "./context-OskMGy8y.mjs";
3
+ import { a as createPackageResolver, n as IDE_CONFIGS, o as detectIdes, s as installIdeRulesAndSkills, t as AGENT_SKILL_NAMES } from "./ide-DPDVYiaq.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  import { join } from "node:path";
@@ -1,6 +1,6 @@
1
- import { i as pc, r as formatTable, t as formatCount } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-C400qHcL.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-Cg6JoVZt.mjs";
1
+ import { i as pc, r as formatTable, t as formatCount } from "./ui-B8l_LC36.mjs";
2
+ import { n as resolveStudioClient } from "./client-C9E9D4mb.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-hKVYPniC.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/status.ts
@@ -1,10 +1,11 @@
1
- import { n as requireInitialized, r as resolveProjectRoot, t as loadProjectContext } from "./context-An3xrvcP.mjs";
2
- import { i as pc, n as formatPercent, r as formatTable, t as formatCount } from "./ui-B5mXontH.mjs";
1
+ import { i as pc, n as formatPercent, r as formatTable, t as formatCount } from "./ui-B8l_LC36.mjs";
2
+ import { n as requireInitialized, r as resolveProjectRoot, t as loadProjectContext } from "./context-OskMGy8y.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { intro, log, outro } from "@clack/prompts";
5
+ import { countEntries, readModel } from "@contentrain/mcp/core/model-manager";
5
6
  import { simpleGit } from "simple-git";
6
7
  import { CONTENTRAIN_BRANCH } from "@contentrain/types";
7
- import { countEntries, readModel } from "@contentrain/mcp/core/model-manager";
8
+ import { checkBranchHealth } from "@contentrain/mcp/git/branch-lifecycle";
8
9
  import { validateProject } from "@contentrain/mcp/core/validator";
9
10
  //#region src/commands/status.ts
10
11
  var status_default = defineCommand({
@@ -48,6 +49,17 @@ var status_default = defineCommand({
48
49
  const branches = await git.branch(["--list", "cr/*"]);
49
50
  const allLocal = await git.branchLocal();
50
51
  jsonResult["pending_branches"] = branches.all.filter((b) => b !== CONTENTRAIN_BRANCH);
52
+ try {
53
+ const health = await checkBranchHealth(projectRoot);
54
+ jsonResult["branch_health"] = {
55
+ total: health.total,
56
+ merged: health.merged,
57
+ unmerged: health.unmerged,
58
+ warning: health.warning,
59
+ blocked: health.blocked,
60
+ message: health.message ?? null
61
+ };
62
+ } catch {}
51
63
  const contentBranchExists = allLocal.all.includes(CONTENTRAIN_BRANCH);
52
64
  const contentBranchInfo = { exists: contentBranchExists };
53
65
  if (contentBranchExists) try {
@@ -126,14 +138,15 @@ var status_default = defineCommand({
126
138
  } catch {}
127
139
  const featureBranches = (await git.branch(["--list", "cr/*"])).all.filter((b) => b !== CONTENTRAIN_BRANCH);
128
140
  if (featureBranches.length > 0) {
129
- const count = featureBranches.length;
130
- if (count >= 80) {
131
- log.error(pc.bold(`\nBLOCKED: ${count} active contentrain branches (limit: 80)`));
132
- log.message(` New writes are blocked. Merge or delete old branches with ${pc.cyan("contentrain diff")}.`);
133
- } else if (count >= 50) {
134
- log.warning(pc.bold(`\nWARNING: ${count} active contentrain branches (limit: 50)`));
135
- log.message(` Consider merging or deleting old branches with ${pc.cyan("contentrain diff")}.`);
136
- } else log.info(pc.bold(`\nPending branches (${count})`));
141
+ const health = await checkBranchHealth(projectRoot).catch(() => null);
142
+ const headerLabel = health ? `\nPending branches (${health.unmerged} unmerged / ${health.total} total)` : `\nPending branches (${featureBranches.length})`;
143
+ if (health?.blocked) {
144
+ log.error(pc.bold(headerLabel));
145
+ log.message(` ${health.message}`);
146
+ } else if (health?.warning) {
147
+ log.warning(pc.bold(headerLabel));
148
+ log.message(` ${health.message}`);
149
+ } else log.info(pc.bold(headerLabel));
137
150
  for (const branch of featureBranches) log.message(` ${pc.yellow("●")} ${branch}`);
138
151
  log.message(` Run ${pc.cyan("contentrain diff")} to review.`);
139
152
  }