@contentrain/mcp 1.5.2 → 1.8.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 (72) hide show
  1. package/README.md +8 -7
  2. package/dist/{apply-manager-BJ3nKEls.mjs → apply-manager-BRmB27H5.mjs} +5 -5
  3. package/dist/{apply-manager-BJ3nKEls.mjs.map → apply-manager-BRmB27H5.mjs.map} +1 -1
  4. package/dist/branch-lifecycle-7dQoF22r.d.mts +141 -0
  5. package/dist/branch-lifecycle-7dQoF22r.d.mts.map +1 -0
  6. package/dist/branch-lifecycle-D7tXOE4f.mjs +444 -0
  7. package/dist/branch-lifecycle-D7tXOE4f.mjs.map +1 -0
  8. package/dist/{config-Lw7vpK9n.mjs → config-DlFcTxFG.mjs} +3 -2
  9. package/dist/config-DlFcTxFG.mjs.map +1 -0
  10. package/dist/{context-DS40WOdw.mjs → context-CYN__o3Q.mjs} +24 -11
  11. package/dist/context-CYN__o3Q.mjs.map +1 -0
  12. package/dist/core/apply-manager.mjs +6 -6
  13. package/dist/core/config.d.mts.map +1 -1
  14. package/dist/core/config.mjs +1 -1
  15. package/dist/core/context.d.mts +20 -2
  16. package/dist/core/context.d.mts.map +1 -1
  17. package/dist/core/context.mjs +2 -2
  18. package/dist/core/doctor.mjs +3 -3
  19. package/dist/core/ops/index.d.mts +7 -0
  20. package/dist/core/ops/index.d.mts.map +1 -1
  21. package/dist/core/ops/index.mjs +1 -1
  22. package/dist/core/validator/index.mjs +2 -2
  23. package/dist/{detect-DL9mFmaS.mjs → detect-eWS6lvoH.mjs} +1 -1
  24. package/dist/{detect-DL9mFmaS.mjs.map → detect-eWS6lvoH.mjs.map} +1 -1
  25. package/dist/{doctor-Dxu_SzZy.mjs → doctor-qiUKCy9b.mjs} +34 -15
  26. package/dist/doctor-qiUKCy9b.mjs.map +1 -0
  27. package/dist/git/branch-lifecycle.d.mts +2 -57
  28. package/dist/git/branch-lifecycle.mjs +3 -3
  29. package/dist/git/transaction.d.mts +2 -0
  30. package/dist/git/transaction.d.mts.map +1 -1
  31. package/dist/git/transaction.mjs +5 -4
  32. package/dist/{id-CIO9s0x_.mjs → id-DV_T9Ic8.mjs} +1 -1
  33. package/dist/{id-CIO9s0x_.mjs.map → id-DV_T9Ic8.mjs.map} +1 -1
  34. package/dist/index.mjs +12 -12
  35. package/dist/{local-BJEAd4Ub.mjs → local-BZEUWBoC.mjs} +9 -12
  36. package/dist/local-BZEUWBoC.mjs.map +1 -0
  37. package/dist/{ops-DjEWi5Wa.mjs → ops-Dh2T3u53.mjs} +90 -5
  38. package/dist/ops-Dh2T3u53.mjs.map +1 -0
  39. package/dist/providers/github/index.d.mts +23 -2
  40. package/dist/providers/github/index.d.mts.map +1 -1
  41. package/dist/providers/github/index.mjs +112 -51
  42. package/dist/providers/github/index.mjs.map +1 -1
  43. package/dist/providers/gitlab/index.d.mts +3 -1
  44. package/dist/providers/gitlab/index.d.mts.map +1 -1
  45. package/dist/providers/gitlab/index.mjs +21 -5
  46. package/dist/providers/gitlab/index.mjs.map +1 -1
  47. package/dist/providers/local/index.mjs +7 -6
  48. package/dist/server/http/index.d.mts +1 -1
  49. package/dist/server/http/index.mjs +12 -12
  50. package/dist/{server-WqGx6ait.d.mts → server-DZBcZWhE.d.mts} +1 -1
  51. package/dist/{server-WqGx6ait.d.mts.map → server-DZBcZWhE.d.mts.map} +1 -1
  52. package/dist/{server-RwV1Ig5l.mjs → server-tTuinKHD.mjs} +60 -25
  53. package/dist/server-tTuinKHD.mjs.map +1 -0
  54. package/dist/server.d.mts +1 -1
  55. package/dist/server.mjs +12 -12
  56. package/dist/{transaction-LiTjan9Q.mjs → transaction-qliKvBRL.mjs} +104 -60
  57. package/dist/transaction-qliKvBRL.mjs.map +1 -0
  58. package/dist/util/detect.mjs +1 -1
  59. package/dist/util/id.mjs +1 -1
  60. package/dist/{validator-Bo06QPVB.mjs → validator-Z3DGNWQ6.mjs} +2 -2
  61. package/dist/{validator-Bo06QPVB.mjs.map → validator-Z3DGNWQ6.mjs.map} +1 -1
  62. package/package.json +2 -2
  63. package/dist/branch-lifecycle-DjX3MXNG.mjs +0 -149
  64. package/dist/branch-lifecycle-DjX3MXNG.mjs.map +0 -1
  65. package/dist/config-Lw7vpK9n.mjs.map +0 -1
  66. package/dist/context-DS40WOdw.mjs.map +0 -1
  67. package/dist/doctor-Dxu_SzZy.mjs.map +0 -1
  68. package/dist/git/branch-lifecycle.d.mts.map +0 -1
  69. package/dist/local-BJEAd4Ub.mjs.map +0 -1
  70. package/dist/ops-DjEWi5Wa.mjs.map +0 -1
  71. package/dist/server-RwV1Ig5l.mjs.map +0 -1
  72. package/dist/transaction-LiTjan9Q.mjs.map +0 -1
@@ -216,6 +216,89 @@ async function pushAllMetaForModel(reader, modelId, changes) {
216
216
  });
217
217
  }
218
218
  //#endregion
219
+ //#region src/core/media/media-rewrite.ts
220
+ /**
221
+ * Media path → delivery URL rewriting for MCP content writes.
222
+ *
223
+ * Ported from Studio's `server/utils/media-rewrite.ts`, with one deliberate
224
+ * difference: Studio derives the delivery base from a `projectId` plus its
225
+ * runtime config, whereas MCP receives the fully-qualified per-project base
226
+ * directly via `RepoProvider.mediaBaseUrl`
227
+ * (`{siteUrl}/api/cdn/v1/{projectId}` — the project segment is already
228
+ * included). So here `toDeliveryUrl` is a single `{base}/{path}` join.
229
+ *
230
+ * Why this exists: Studio stores uploaded media in CDN/R2 and references it
231
+ * from content by a relative storage path (`media/...`). That path renders
232
+ * nowhere on its own — it must become an absolute, public delivery URL before
233
+ * it reaches a browser. When an external agent writes through MCP Cloud, the
234
+ * write bypasses Studio's content-engine and goes straight through
235
+ * `planContentSave`, so this is where the normalization has to happen: the
236
+ * git-committed value is then already a ready-to-use URL for any consumer
237
+ * (`@contentrain/query`, raw markdown, a plain landing page) with no SDK.
238
+ *
239
+ * Only image/video/file fields (resolved via the model schema, including those
240
+ * nested in object/array fields) and `media/...` src/href targets in
241
+ * markdown/HTML are rewritten. External URLs (`http(s)://`, `//`, `data:`) and
242
+ * already-absolute delivery URLs pass through untouched, so every entry point
243
+ * is idempotent and safe to call repeatedly. In local mode (no base) media
244
+ * stays a relative path — the OSS file model.
245
+ */
246
+ const MEDIA_FIELD_TYPES = new Set([
247
+ "image",
248
+ "video",
249
+ "file"
250
+ ]);
251
+ /**
252
+ * Whether a stored field value is a relative media-storage path (`media/...`)
253
+ * rather than an already-absolute URL or external link. Only these are
254
+ * rewritten to delivery URLs — `http(s)://`, `//`, and `data:` are left as-is.
255
+ */
256
+ function isStoredMediaPath(value) {
257
+ return typeof value === "string" && value.startsWith("media/");
258
+ }
259
+ /** Join a relative media path onto the per-project delivery base. */
260
+ function toDeliveryUrl(base, path) {
261
+ return `${base.replace(/\/+$/, "")}/${path}`;
262
+ }
263
+ /**
264
+ * Rewrite a stored media path to its absolute delivery URL. Non-media values
265
+ * (external URLs, empty, non-strings) pass through untouched, so this is safe
266
+ * to call on any field value.
267
+ */
268
+ function rewriteMediaUrl(base, value) {
269
+ return isStoredMediaPath(value) ? toDeliveryUrl(base, value) : value;
270
+ }
271
+ /**
272
+ * Rewrite media paths within a single field value, guided by its FieldDef so
273
+ * only media fields — including those nested inside object/array fields — are
274
+ * touched. Non-media values pass through.
275
+ */
276
+ function rewriteFieldMedia(value, field, base) {
277
+ if (value == null) return value;
278
+ if (MEDIA_FIELD_TYPES.has(field.type)) return rewriteMediaUrl(base, value);
279
+ if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) return rewriteEntryMedia(value, field.fields, base);
280
+ if (field.type === "array" && Array.isArray(value)) {
281
+ const itemDef = typeof field.items === "string" ? { type: field.items } : field.items ?? null;
282
+ if (!itemDef) return value;
283
+ return value.map((v) => rewriteFieldMedia(v, itemDef, base));
284
+ }
285
+ return value;
286
+ }
287
+ /**
288
+ * Rewrite media paths across one entry/frontmatter object using a field map.
289
+ * Returns a shallow copy (the input is never mutated); a no-op when the object
290
+ * has no media fields.
291
+ */
292
+ function rewriteEntryMedia(entry, fields, base) {
293
+ const out = { ...entry };
294
+ for (const [fieldId, field] of Object.entries(fields)) if (fieldId in out) out[fieldId] = rewriteFieldMedia(out[fieldId], field, base);
295
+ return out;
296
+ }
297
+ /** Rewrite `media/...` markdown image/link targets and inline src/href attrs. */
298
+ function rewriteMarkdownMedia(body, base) {
299
+ return body.replace(/(\]\()(media\/[^)\s"]+)/g, (_m, open, p) => `${open}${toDeliveryUrl(base, p)}`).replace(/(\s(?:src|href)=)(["'])(media\/[^"']+)\2/gi, (_m, attr, quote, p) => `${attr}${quote}${toDeliveryUrl(base, p)}${quote}`);
300
+ }
301
+ //#endregion
219
302
  //#region src/core/ops/content-save.ts
220
303
  /**
221
304
  * Build the FileChange[] required to save a batch of content entries. The
@@ -227,9 +310,10 @@ async function pushAllMetaForModel(reader, modelId, changes) {
227
310
  * so repeated reads of the same path during a plan are one IO.
228
311
  */
229
312
  async function planContentSave(reader, input) {
230
- const { model, entries, config, vocabulary } = input;
313
+ const { model, entries, config, vocabulary, mediaBaseUrl } = input;
231
314
  const result = [];
232
315
  const advisories = [];
316
+ const normalizeMedia = (data) => mediaBaseUrl && model.fields ? rewriteEntryMedia(data, model.fields, mediaBaseUrl) : data;
233
317
  const contentByPath = /* @__PURE__ */ new Map();
234
318
  const metaByPath = /* @__PURE__ */ new Map();
235
319
  const markdownChanges = /* @__PURE__ */ new Map();
@@ -257,7 +341,7 @@ async function planContentSave(reader, input) {
257
341
  case "singleton": {
258
342
  const cPath = contentFilePath(model, locale);
259
343
  const mPath = metaFilePath(model, locale);
260
- contentByPath.set(cPath, entry.data);
344
+ contentByPath.set(cPath, normalizeMedia(entry.data));
261
345
  metaByPath.set(mPath, defaultMeta(entry.data));
262
346
  result.push({
263
347
  action: "updated",
@@ -272,7 +356,7 @@ async function planContentSave(reader, input) {
272
356
  const mPath = metaFilePath(model, locale);
273
357
  const existing = contentByPath.get(cPath) ?? await readJsonOrEmpty(cPath);
274
358
  const action = isNew || !(id in existing) ? "created" : "updated";
275
- existing[id] = entry.data;
359
+ existing[id] = normalizeMedia(entry.data);
276
360
  const sorted = {};
277
361
  for (const key of Object.keys(existing).toSorted()) sorted[key] = existing[key];
278
362
  contentByPath.set(cPath, sorted);
@@ -337,7 +421,8 @@ async function planContentSave(reader, input) {
337
421
  existingRaw = await reader.readFile(dPath);
338
422
  } catch {}
339
423
  const action = existingRaw ? "updated" : "created";
340
- markdownChanges.set(dPath, serializeMarkdownFrontmatter$1(fmData, bodyContent));
424
+ const normalizedBody = mediaBaseUrl ? rewriteMarkdownMedia(bodyContent, mediaBaseUrl) : bodyContent;
425
+ markdownChanges.set(dPath, serializeMarkdownFrontmatter$1(normalizeMedia(fmData), normalizedBody));
341
426
  metaByPath.set(mPath, defaultMeta(entry.data));
342
427
  result.push({
343
428
  action,
@@ -483,4 +568,4 @@ async function planModelSave(reader, input) {
483
568
  //#endregion
484
569
  export { applyChangesToWorktree as a, planContentDelete as i, planModelDelete as n, planContentSave as r, planModelSave as t };
485
570
 
486
- //# sourceMappingURL=ops-DjEWi5Wa.mjs.map
571
+ //# sourceMappingURL=ops-Dh2T3u53.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ops-Dh2T3u53.mjs","names":["canonicalStringify","serializeMarkdownFrontmatter","canonicalStringify","canonicalStringify"],"sources":["../src/core/ops/apply-to-worktree.ts","../src/core/ops/content-delete.ts","../src/core/media/media-rewrite.ts","../src/core/ops/content-save.ts","../src/core/ops/model-delete.ts","../src/core/ops/model-save.ts"],"sourcesContent":["import { mkdir, rm, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport type { FileChange } from '../contracts/index.js'\n\n/**\n * Phase 2 shim — applies a `FileChange[]` to a local worktree directory.\n *\n * Phase 3 replaces this with `LocalProvider.applyPlan` (same logic, owned by\n * the provider) while a `GitHubProvider.applyPlan` produces the equivalent\n * commit through the Git Data API. Until then, tool handlers call this shim\n * inside the existing `createTransaction` flow so Phase 2 gets plan/apply\n * semantics without rewriting the transaction machinery.\n *\n * - `content: string` → `writeFile` (creates parent dirs).\n * - `content: null` → `rm`; missing files are tolerated.\n * - Changes are applied in their given order; callers should sort for\n * determinism.\n */\nexport async function applyChangesToWorktree(worktree: string, changes: readonly FileChange[]): Promise<void> {\n for (const change of changes) {\n const absolute = join(worktree, change.path)\n if (change.content === null) {\n await rm(absolute, { force: true }).catch(() => { /* tolerate missing */ })\n continue\n }\n await mkdir(dirname(absolute), { recursive: true })\n await writeFile(absolute, change.content, 'utf-8')\n }\n}\n","import type { EntryMeta, ModelDefinition } from '@contentrain/types'\nimport { validateSlug } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport { canonicalStringify } from '../serialization/index.js'\nimport { contentFilePath, documentFilePath, metaFilePath } from './paths.js'\nimport type { OpPlan } from './types.js'\n\nexport interface ContentDeleteInput {\n model: ModelDefinition\n id?: string\n slug?: string\n locale?: string\n keys?: string[]\n}\n\nexport type ContentDeletePlan = OpPlan<string[]>\n\nfunction contentDir(model: Pick<ModelDefinition, 'domain' | 'id' | 'content_path'>): string {\n return model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`\n}\n\nasync function tryReadJson<T>(reader: RepoReader, path: string): Promise<T | null> {\n try {\n return JSON.parse(await reader.readFile(path)) as T\n } catch {\n return null\n }\n}\n\nasync function discoverCollectionLocales(\n reader: RepoReader,\n model: ModelDefinition,\n): Promise<string[]> {\n const dir = contentDir(model)\n const files = await reader.listDirectory(dir)\n const strategy = model.locale_strategy ?? 'file'\n if (strategy === 'directory') {\n return files\n }\n return files\n .filter(f => f.endsWith('.json'))\n .map(f => f.replace('.json', '').replace(`${model.id}.`, ''))\n}\n\n/**\n * Build the FileChange[] required to delete content. Pure — touches no disk.\n *\n * Semantics preserved from legacy `deleteContent`:\n * - Collection: remove the entry ID from each locale's content file and its\n * meta file. Meta file is deleted outright if the removal empties it.\n * - Singleton: delete the locale's content + meta file (locale-scoped when\n * i18n, non-i18n removes whatever meta lives under `.contentrain/meta/{id}/`).\n * - Dictionary: two modes — key-scoped removal updates the file in place,\n * or locale-scoped removes the content + meta pair.\n * - Document: strategy-aware delete (file/suffix/directory/none), plus\n * meta removal under `.contentrain/meta/{id}/{slug}/`.\n */\nexport async function planContentDelete(\n reader: RepoReader,\n input: ContentDeleteInput,\n): Promise<ContentDeletePlan> {\n const { model } = input\n const changes: FileChange[] = []\n const removed: string[] = []\n\n switch (model.kind) {\n case 'collection': {\n if (!input.id) throw new Error('Collection delete requires an entry ID')\n\n const locales = input.locale\n ? [input.locale]\n : await discoverCollectionLocales(reader, model)\n\n for (const loc of locales) {\n const cPath = contentFilePath(model, loc)\n const data = await tryReadJson<Record<string, unknown>>(reader, cPath)\n if (!data || !(input.id in data)) continue\n delete data[input.id]\n const sorted: Record<string, unknown> = {}\n for (const k of Object.keys(data).toSorted()) sorted[k] = data[k]\n changes.push({ path: cPath, content: canonicalStringify(sorted) })\n removed.push(`content/${model.domain}/${model.id}/${loc}.json#${input.id}`)\n }\n\n for (const loc of locales) {\n const mPath = metaFilePath(model, loc)\n const meta = await tryReadJson<Record<string, EntryMeta>>(reader, mPath)\n if (!meta || !(input.id in meta)) continue\n delete meta[input.id]\n if (Object.keys(meta).length > 0) {\n changes.push({ path: mPath, content: canonicalStringify(meta) })\n } else {\n changes.push({ path: mPath, content: null })\n }\n }\n break\n }\n\n case 'singleton': {\n if (model.i18n && !input.locale) {\n throw new Error('Singleton delete requires a locale when i18n is enabled')\n }\n const locale = input.locale ?? 'data'\n const cPath = contentFilePath(model, locale)\n changes.push({ path: cPath, content: null })\n removed.push(model.i18n\n ? `content/${model.domain}/${model.id}/${locale}.json`\n : `content/${model.domain}/${model.id}/data.json`)\n\n if (model.i18n) {\n changes.push({ path: metaFilePath(model, locale), content: null })\n } else {\n await pushAllMetaForModel(reader, model.id, changes)\n }\n break\n }\n\n case 'dictionary': {\n if (model.i18n && !input.locale) {\n throw new Error('Dictionary delete requires a locale when i18n is enabled')\n }\n const locale = input.locale ?? 'data'\n const cPath = contentFilePath(model, locale)\n\n if (input.keys?.length) {\n const existing = (await tryReadJson<Record<string, string>>(reader, cPath)) ?? {}\n const notFound: string[] = []\n for (const key of input.keys) {\n if (key in existing) delete existing[key]\n else notFound.push(key)\n }\n if (notFound.length > 0) {\n throw new Error(`Dictionary \"${model.id}\" (${locale}): keys not found — [${notFound.join(', ')}]`)\n }\n changes.push({ path: cPath, content: canonicalStringify(existing) })\n removed.push(...input.keys.map(k => `${model.id}/${locale}:${k}`))\n } else {\n changes.push({ path: cPath, content: null })\n removed.push(model.i18n\n ? `content/${model.domain}/${model.id}/${locale}.json`\n : `content/${model.domain}/${model.id}/data.json`)\n if (model.i18n) {\n changes.push({ path: metaFilePath(model, locale), content: null })\n } else {\n await pushAllMetaForModel(reader, model.id, changes)\n }\n }\n break\n }\n\n case 'document': {\n if (!input.slug) throw new Error('Document delete requires a slug')\n const slugErr = validateSlug(input.slug)\n if (slugErr) throw new Error(slugErr)\n\n const dir = contentDir(model)\n const strategy = model.locale_strategy ?? 'file'\n\n if (!model.i18n) {\n changes.push({ path: `${dir}/${input.slug}.md`, content: null })\n } else if (strategy === 'file') {\n const slugDir = `${dir}/${input.slug}`\n const files = await reader.listDirectory(slugDir)\n for (const f of files) changes.push({ path: `${slugDir}/${f}`, content: null })\n } else if (input.locale) {\n changes.push({ path: documentFilePath(model, input.locale, input.slug), content: null })\n } else if (strategy === 'suffix') {\n const files = await reader.listDirectory(dir)\n for (const f of files) {\n if (f.startsWith(`${input.slug}.`) && f.endsWith('.md')) {\n changes.push({ path: `${dir}/${f}`, content: null })\n }\n }\n } else if (strategy === 'directory') {\n const subdirs = await reader.listDirectory(dir)\n for (const d of subdirs) {\n const filePath = `${dir}/${d}/${input.slug}.md`\n if (await reader.fileExists(filePath)) {\n changes.push({ path: filePath, content: null })\n }\n }\n } else if (strategy === 'none') {\n const files = await reader.listDirectory(dir)\n if (files.includes(`${input.slug}.md`)) {\n changes.push({ path: `${dir}/${input.slug}.md`, content: null })\n }\n }\n\n removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${input.slug}`)\n\n const metaSlugDir = `.contentrain/meta/${model.id}/${input.slug}`\n if (input.locale) {\n changes.push({ path: `${metaSlugDir}/${input.locale}.json`, content: null })\n } else {\n const metaFiles = await reader.listDirectory(metaSlugDir)\n for (const f of metaFiles) changes.push({ path: `${metaSlugDir}/${f}`, content: null })\n }\n break\n }\n }\n\n changes.sort((a, b) => a.path.localeCompare(b.path))\n return { changes, result: removed, advisories: [] }\n}\n\nasync function pushAllMetaForModel(reader: RepoReader, modelId: string, changes: FileChange[]): Promise<void> {\n const metaDir = `.contentrain/meta/${modelId}`\n const files = await reader.listDirectory(metaDir)\n for (const f of files) changes.push({ path: `${metaDir}/${f}`, content: null })\n}\n","import type { FieldDef, FieldType } from '@contentrain/types'\n\n/**\n * Media path → delivery URL rewriting for MCP content writes.\n *\n * Ported from Studio's `server/utils/media-rewrite.ts`, with one deliberate\n * difference: Studio derives the delivery base from a `projectId` plus its\n * runtime config, whereas MCP receives the fully-qualified per-project base\n * directly via `RepoProvider.mediaBaseUrl`\n * (`{siteUrl}/api/cdn/v1/{projectId}` — the project segment is already\n * included). So here `toDeliveryUrl` is a single `{base}/{path}` join.\n *\n * Why this exists: Studio stores uploaded media in CDN/R2 and references it\n * from content by a relative storage path (`media/...`). That path renders\n * nowhere on its own — it must become an absolute, public delivery URL before\n * it reaches a browser. When an external agent writes through MCP Cloud, the\n * write bypasses Studio's content-engine and goes straight through\n * `planContentSave`, so this is where the normalization has to happen: the\n * git-committed value is then already a ready-to-use URL for any consumer\n * (`@contentrain/query`, raw markdown, a plain landing page) with no SDK.\n *\n * Only image/video/file fields (resolved via the model schema, including those\n * nested in object/array fields) and `media/...` src/href targets in\n * markdown/HTML are rewritten. External URLs (`http(s)://`, `//`, `data:`) and\n * already-absolute delivery URLs pass through untouched, so every entry point\n * is idempotent and safe to call repeatedly. In local mode (no base) media\n * stays a relative path — the OSS file model.\n */\n\nconst MEDIA_FIELD_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\n/**\n * Whether a stored field value is a relative media-storage path (`media/...`)\n * rather than an already-absolute URL or external link. Only these are\n * rewritten to delivery URLs — `http(s)://`, `//`, and `data:` are left as-is.\n */\nexport function isStoredMediaPath(value: unknown): value is string {\n return typeof value === 'string' && value.startsWith('media/')\n}\n\n/** Join a relative media path onto the per-project delivery base. */\nexport function toDeliveryUrl(base: string, path: string): string {\n return `${base.replace(/\\/+$/, '')}/${path}`\n}\n\n/**\n * Rewrite a stored media path to its absolute delivery URL. Non-media values\n * (external URLs, empty, non-strings) pass through untouched, so this is safe\n * to call on any field value.\n */\nexport function rewriteMediaUrl(base: string, value: unknown): unknown {\n return isStoredMediaPath(value) ? toDeliveryUrl(base, value) : value\n}\n\n/**\n * Rewrite media paths within a single field value, guided by its FieldDef so\n * only media fields — including those nested inside object/array fields — are\n * touched. Non-media values pass through.\n */\nexport function rewriteFieldMedia(value: unknown, field: FieldDef, base: string): unknown {\n if (value == null) return value\n\n if (MEDIA_FIELD_TYPES.has(field.type))\n return rewriteMediaUrl(base, value)\n\n if (field.type === 'object' && field.fields && typeof value === 'object' && !Array.isArray(value))\n return rewriteEntryMedia(value as Record<string, unknown>, field.fields, base)\n\n if (field.type === 'array' && Array.isArray(value)) {\n const itemDef: FieldDef | null = typeof field.items === 'string'\n ? { type: field.items as FieldType }\n : field.items ?? null\n if (!itemDef) return value\n return value.map(v => rewriteFieldMedia(v, itemDef, base))\n }\n\n return value\n}\n\n/**\n * Rewrite media paths across one entry/frontmatter object using a field map.\n * Returns a shallow copy (the input is never mutated); a no-op when the object\n * has no media fields.\n */\nexport function rewriteEntryMedia(\n entry: Record<string, unknown>,\n fields: Record<string, FieldDef>,\n base: string,\n): Record<string, unknown> {\n const out = { ...entry }\n for (const [fieldId, field] of Object.entries(fields)) {\n if (fieldId in out)\n out[fieldId] = rewriteFieldMedia(out[fieldId], field, base)\n }\n return out\n}\n\n/** Rewrite `media/...` markdown image/link targets and inline src/href attrs. */\nexport function rewriteMarkdownMedia(body: string, base: string): string {\n return body\n // markdown image/link target: ](media/...) — stops at whitespace, ) or \"\n .replace(/(\\]\\()(media\\/[^)\\s\"]+)/g, (_m, open, p) => `${open}${toDeliveryUrl(base, p)}`)\n // inline HTML src=/href=\"media/...\"\n .replace(/(\\s(?:src|href)=)([\"'])(media\\/[^\"']+)\\2/gi,\n (_m, attr, quote, p) => `${attr}${quote}${toDeliveryUrl(base, p)}${quote}`)\n}\n","import type { ContentrainConfig, EntryMeta, ModelDefinition, Vocabulary } from '@contentrain/types'\nimport { generateEntryId, validateEntryId, validateLocale, validateSlug } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport type { ContentEntry } from '../content-manager.js'\nimport { canonicalStringify, serializeMarkdownFrontmatter } from '../serialization/index.js'\nimport { rewriteEntryMedia, rewriteMarkdownMedia } from '../media/media-rewrite.js'\nimport type { ContentSaveEntryResult, ContentSavePlan } from './types.js'\nimport { contentFilePath, documentFilePath, metaFilePath } from './paths.js'\n\ninterface PlanInput {\n model: ModelDefinition\n entries: ContentEntry[]\n config: ContentrainConfig\n vocabulary?: Vocabulary | null\n /**\n * Per-project public media delivery base (`RepoProvider.mediaBaseUrl`). When\n * set (hosted/cloud mode), relative `media/...` references in media fields\n * and markdown bodies are normalized to absolute delivery URLs before the\n * value is committed. Undefined in local mode — paths are kept verbatim.\n */\n mediaBaseUrl?: string\n}\n\n/**\n * Build the FileChange[] required to save a batch of content entries. The\n * plan is deterministic, pure, and does not touch disk — it reads existing\n * state through `reader` and returns changes for a single atomic commit.\n *\n * Grouping: multiple entries targeting the same (model, locale) share a\n * single content file and a single meta file. Reads are cached in-memory\n * so repeated reads of the same path during a plan are one IO.\n */\nexport async function planContentSave(reader: RepoReader, input: PlanInput): Promise<ContentSavePlan> {\n const { model, entries, config, vocabulary, mediaBaseUrl } = input\n const result: ContentSaveEntryResult[] = []\n const advisories: string[] = []\n\n // Hosted/cloud mode: normalize relative `media/...` references to absolute\n // delivery URLs so the committed value renders anywhere with no SDK. A no-op\n // in local mode (no base) or for field-less models (e.g. dictionaries).\n const normalizeMedia = (data: Record<string, unknown>): Record<string, unknown> =>\n mediaBaseUrl && model.fields ? rewriteEntryMedia(data, model.fields, mediaBaseUrl) : data\n\n // In-memory accumulators keyed by content-root-relative path.\n const contentByPath = new Map<string, unknown>()\n const metaByPath = new Map<string, unknown>()\n const markdownChanges = new Map<string, string>()\n\n async function readJsonOrEmpty<T>(path: string): Promise<T> {\n try {\n return JSON.parse(await reader.readFile(path)) as T\n } catch {\n return {} as T\n }\n }\n\n const defaultLocale = config.locales.default\n\n for (const entry of entries) {\n const locale = entry.locale ?? defaultLocale\n\n const localeErr = validateLocale(locale, config)\n if (localeErr) throw new Error(localeErr)\n if (entry.id) {\n const err = validateEntryId(entry.id)\n if (err) throw new Error(err)\n }\n if (entry.slug) {\n const err = validateSlug(entry.slug)\n if (err) throw new Error(err)\n }\n\n switch (model.kind) {\n case 'singleton': {\n const cPath = contentFilePath(model, locale)\n const mPath = metaFilePath(model, locale)\n contentByPath.set(cPath, normalizeMedia(entry.data))\n metaByPath.set(mPath, defaultMeta(entry.data))\n result.push({ action: 'updated', locale })\n break\n }\n\n case 'collection': {\n const isNew = !entry.id\n const id = entry.id ?? generateEntryId()\n const cPath = contentFilePath(model, locale)\n const mPath = metaFilePath(model, locale)\n\n const existing = (contentByPath.get(cPath) as Record<string, unknown> | undefined)\n ?? await readJsonOrEmpty<Record<string, unknown>>(cPath)\n\n const action: 'created' | 'updated' = isNew || !(id in existing) ? 'created' : 'updated'\n existing[id] = normalizeMedia(entry.data)\n\n const sorted: Record<string, unknown> = {}\n for (const key of Object.keys(existing).toSorted()) {\n sorted[key] = existing[key]\n }\n contentByPath.set(cPath, sorted)\n\n const existingMeta = (metaByPath.get(mPath) as Record<string, EntryMeta> | undefined)\n ?? await readJsonOrEmpty<Record<string, EntryMeta>>(mPath)\n existingMeta[id] = defaultMeta(entry.data)\n metaByPath.set(mPath, existingMeta)\n\n result.push({ action, id, locale })\n break\n }\n\n case 'dictionary': {\n const cPath = contentFilePath(model, locale)\n const mPath = metaFilePath(model, locale)\n\n const existing = (contentByPath.get(cPath) as Record<string, string> | undefined)\n ?? await readJsonOrEmpty<Record<string, string>>(cPath)\n\n const newData = entry.data as Record<string, string>\n const collisions = Object.keys(newData).filter(\n k => k in existing && existing[k] !== newData[k],\n )\n if (collisions.length > 0) {\n throw new Error(\n `Dictionary \"${model.id}\" (${locale}): ${collisions.length} key collision(s) — [${collisions.join(', ')}] already exist with different values. `\n + 'Read existing keys with contentrain_content_list first, or include all keys in a single save call.',\n )\n }\n\n const entryAdvisories: string[] = []\n const reverseMap = new Map<string, string>()\n for (const [k, v] of Object.entries(existing)) reverseMap.set(v, k)\n for (const [newKey, newValue] of Object.entries(newData)) {\n if (newKey in existing) continue\n const existingKey = reverseMap.get(newValue)\n if (existingKey && existingKey !== newKey) {\n entryAdvisories.push(\n `Value \"${newValue}\" already exists as key \"${existingKey}\". Consider reusing instead of creating \"${newKey}\".`,\n )\n }\n }\n\n if (vocabulary && Object.keys(vocabulary.terms).length > 0) {\n outer:\n for (const [newKey, newValue] of Object.entries(newData)) {\n if (newKey in existing) continue\n for (const translations of Object.values(vocabulary.terms)) {\n if (Object.values(translations).includes(newValue)) {\n entryAdvisories.push(\n `Value \"${newValue}\" matches a vocabulary term. Use the canonical form for consistency.`,\n )\n continue outer\n }\n }\n }\n }\n\n advisories.push(...entryAdvisories)\n\n contentByPath.set(cPath, { ...existing, ...newData })\n metaByPath.set(mPath, defaultMeta(entry.data))\n\n result.push({\n action: 'updated',\n locale,\n ...(entryAdvisories.length > 0 ? { advisories: entryAdvisories } : {}),\n })\n break\n }\n\n case 'document': {\n const slug = entry.slug ?? (entry.data['slug'] as string | undefined)\n if (!slug) throw new Error('Document entries require a slug')\n const slugErr = validateSlug(slug)\n if (slugErr) throw new Error(slugErr)\n\n const fmData = { ...entry.data }\n const bodyContent = (fmData['body'] as string | undefined) ?? ''\n delete fmData['body']\n if (!fmData['slug']) fmData['slug'] = slug\n\n const dPath = documentFilePath(model, locale, slug)\n const mPath = metaFilePath(model, locale, slug)\n\n let existingRaw: string | null = null\n try { existingRaw = await reader.readFile(dPath) }\n catch { /* not yet */ }\n const action: 'created' | 'updated' = existingRaw ? 'updated' : 'created'\n\n // Frontmatter media fields go through the schema-guided rewrite; the\n // markdown body has its `media/...` image/link targets rewritten too.\n const normalizedBody = mediaBaseUrl ? rewriteMarkdownMedia(bodyContent, mediaBaseUrl) : bodyContent\n markdownChanges.set(dPath, serializeMarkdownFrontmatter(normalizeMedia(fmData), normalizedBody))\n metaByPath.set(mPath, defaultMeta(entry.data))\n\n result.push({ action, slug, locale })\n break\n }\n }\n }\n\n const changes: FileChange[] = []\n for (const [path, data] of contentByPath) {\n changes.push({ path, content: canonicalStringify(data) })\n }\n for (const [path, meta] of metaByPath) {\n changes.push({ path, content: canonicalStringify(meta) })\n }\n for (const [path, content] of markdownChanges) {\n changes.push({ path, content })\n }\n changes.sort((a, b) => a.path.localeCompare(b.path))\n\n return { changes, result, advisories }\n}\n\nfunction defaultMeta(data?: Record<string, unknown>): EntryMeta {\n const meta: EntryMeta = {\n status: 'draft',\n source: 'agent',\n updated_by: 'contentrain-mcp',\n }\n if (data?.['publish_at'] !== undefined) meta.publish_at = data['publish_at'] as string\n if (data?.['expire_at'] !== undefined) meta.expire_at = data['expire_at'] as string\n return meta\n}\n","import type { ModelDefinition } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport type { OpPlan } from './types.js'\n\nexport interface ModelDeleteInput {\n model: ModelDefinition\n}\n\nexport type ModelDeletePlan = OpPlan<string[]>\n\n/**\n * Build the FileChange[] required to delete a model and its associated\n * content/meta files.\n *\n * The schema is at most two directory levels deep (e.g. document meta at\n * `.contentrain/meta/{id}/{slug}/{locale}.json`). The helper below walks\n * that much, which is enough for every model kind + locale strategy and\n * keeps us free of a general-purpose recursive lister for now.\n *\n * `result` is the legacy three-entry summary:\n * - `models/{id}.json`\n * - `content/{domain}/{id}/` (only if any content files existed)\n * - `meta/{id}/` (only if any meta files existed)\n */\nexport async function planModelDelete(reader: RepoReader, input: ModelDeleteInput): Promise<ModelDeletePlan> {\n const { model } = input\n const changes: FileChange[] = []\n const removed: string[] = []\n\n changes.push({ path: `.contentrain/models/${model.id}.json`, content: null })\n removed.push(`models/${model.id}.json`)\n\n const contentDir = model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`\n const contentFiles = await enumerateTwoLevels(reader, contentDir)\n for (const path of contentFiles) {\n changes.push({ path, content: null })\n }\n if (contentFiles.length > 0) {\n removed.push(model.content_path ?? `content/${model.domain}/${model.id}/`)\n }\n\n const metaDir = `.contentrain/meta/${model.id}`\n const metaFiles = await enumerateTwoLevels(reader, metaDir)\n for (const path of metaFiles) {\n changes.push({ path, content: null })\n }\n if (metaFiles.length > 0) {\n removed.push(`meta/${model.id}/`)\n }\n\n changes.sort((a, b) => a.path.localeCompare(b.path))\n return { changes, result: removed, advisories: [] }\n}\n\n/** List files up to two directory levels deep. Filters to `.json` and `.md`. */\nasync function enumerateTwoLevels(reader: RepoReader, dir: string): Promise<string[]> {\n const results: string[] = []\n const entries = await reader.listDirectory(dir)\n for (const entry of entries) {\n const path = `${dir}/${entry}`\n if (entry.endsWith('.json') || entry.endsWith('.md')) {\n results.push(path)\n continue\n }\n const subEntries = await reader.listDirectory(path)\n for (const sub of subEntries) {\n if (sub.endsWith('.json') || sub.endsWith('.md')) {\n results.push(`${path}/${sub}`)\n }\n }\n }\n return results\n}\n","import type { ModelDefinition } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport { canonicalStringify } from '../serialization/index.js'\nimport type { OpPlan } from './types.js'\n\n/** Field order for canonical model JSON — preserves reader ergonomics. */\nconst MODEL_FIELD_ORDER = [\n 'id',\n 'name',\n 'kind',\n 'domain',\n 'i18n',\n 'description',\n 'content_path',\n 'locale_strategy',\n 'fields',\n]\n\nexport interface ModelSaveInput {\n model: ModelDefinition\n}\n\nexport interface ModelSaveResult {\n action: 'created' | 'updated'\n id: string\n}\n\nexport type ModelSavePlan = OpPlan<ModelSaveResult>\n\n/**\n * Build the FileChange[] required to create or update a model definition.\n *\n * The only disk change is the model JSON file itself. Content and meta\n * directories are created on-demand when their first write lands — git does\n * not track empty directories, and MCP's write helpers `mkdir -p` before\n * every file write, so there is no operational reason to emit an empty-dir\n * ensure step here.\n */\nexport async function planModelSave(reader: RepoReader, input: ModelSaveInput): Promise<ModelSavePlan> {\n const { model } = input\n const modelPath = `.contentrain/models/${model.id}.json`\n\n let action: 'created' | 'updated' = 'created'\n try {\n await reader.readFile(modelPath)\n action = 'updated'\n } catch { /* not yet exists */ }\n\n const changes: FileChange[] = [\n { path: modelPath, content: canonicalStringify(model, MODEL_FIELD_ORDER) },\n ]\n\n return {\n changes,\n result: { action, id: model.id },\n advisories: [],\n }\n}\n"],"mappings":"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"}
@@ -136,7 +136,9 @@ declare class GitHubProvider implements RepoProvider {
136
136
  createBranch(name: string, fromRef?: string): Promise<void>;
137
137
  deleteBranch(name: string): Promise<void>;
138
138
  getBranchDiff(branch: string, base?: string): Promise<FileDiff[]>;
139
- mergeBranch(branch: string, into: string): Promise<MergeResult>;
139
+ mergeBranch(branch: string, into: string, opts?: {
140
+ removeSourceBranch?: boolean;
141
+ }): Promise<MergeResult>;
140
142
  isMerged(branch: string, into?: string): Promise<boolean>;
141
143
  getDefaultBranch(): Promise<string>;
142
144
  }
@@ -187,12 +189,31 @@ declare function createGitHubProvider(opts: {
187
189
  * commit SHA. When omitted, GitHub resolves to the repository's default
188
190
  * branch — which is usually wrong for Contentrain flows, so callers
189
191
  * should always pass the explicit `contentrain` tracking branch.
192
+ *
193
+ * Pass `{ memoize: true }` to dedupe reads within one operation: a repeated
194
+ * `(path, ref)` returns the in-flight/cached promise instead of a fresh
195
+ * `getContent`. This is OPT-IN and only safe for a SHORT-LIVED, read-only
196
+ * reader — a long-lived reader that outlives a write would serve stale
197
+ * results, so the provider's own reader never enables it. Failed reads are
198
+ * evicted so a transient error is retried, not cached forever.
190
199
  */
191
200
  declare class GitHubReader implements RepoReader {
192
201
  private readonly client;
193
202
  private readonly repo;
194
- constructor(client: GitHubClient, repo: RepoRef);
203
+ private readonly opts?;
204
+ private readonly fileMemo;
205
+ private readonly listMemo;
206
+ private readonly existsMemo;
207
+ constructor(client: GitHubClient, repo: RepoRef, opts?: {
208
+ memoize?: boolean;
209
+ } | undefined);
210
+ /**
211
+ * Run `fetch` through the given memo when memoization is enabled, keyed by
212
+ * `(ref, repoPath)`. A rejected promise is evicted so the next call retries.
213
+ */
214
+ private memoized;
195
215
  readFile(path: string, ref?: string): Promise<string>;
216
+ private readFileUncached;
196
217
  listDirectory(path: string, ref?: string): Promise<string[]>;
197
218
  fileExists(path: string, ref?: string): Promise<boolean>;
198
219
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/providers/github/app-auth.ts","../../../src/providers/github/capabilities.ts","../../../src/providers/github/client.ts","../../../src/providers/github/types.ts","../../../src/providers/github/provider.ts","../../../src/providers/github/factory.ts","../../../src/providers/github/reader.ts"],"mappings":";;;;;;;;AAuBA;;;;;;;;;AASA;;;;;AAkBA;;UA3BiB,aAAA;EA2BsB;EAzBrC,KAAA;EAyBsC;EAvBtC,UAAA;EAuB4E;EArB5E,cAAA;AAAA;AAAA,UAGe,uBAAA;;EAEf,KAAA;EA4C6C;EA1C7C,SAAA;AAAA;AAAA,iBAcc,UAAA,CAAW,MAAA,EAAQ,IAAA,CAAK,aAAA;;;;;;;;iBA0BlB,yBAAA,CACpB,MAAA,EAAQ,aAAA,EACR,IAAA;EAAQ,OAAA;EAAkB,SAAA,UAAmB,UAAA,CAAW,KAAA;AAAA,IACvD,OAAA,CAAQ,uBAAA;;;;;;AAxDX;;;;;;;;;AASA;cCjBa,mBAAA,EAAqB,oBAAA;;;;;;ADQlC;;;;;;KEZY,YAAA,GAAe,OAAA;;;;;;AFY3B;;;;;UGbiB,OAAA;EACf,KAAA;EACA,IAAA;EACA,WAAA;AAAA;;;;;AHqCF;;;;;KGzBY,UAAA;EACN,IAAA;EAAa,KAAA;AAAA;EAEb,IAAA;EACA,KAAA;EACA,UAAA;EACA,cAAA;AAAA;;cAIO,qBAAA,EAAuB,YAAA;;;;AHZpC;;;;;;;;cIUa,cAAA,YAA0B,YAAA;EAAA,iBAKlB,MAAA;EAAA,SACD,IAAA,EAAM,OAAA;EAAA,SALf,YAAA,EAAc,oBAAA;EAAA,iBACN,MAAA;cAGE,MAAA,EAAQ,YAAA,EACT,IAAA,EAAM,OAAA;EAKxB,QAAA,CAAS,IAAA,UAAc,GAAA,YAAe,OAAA;EAGtC,aAAA,CAAc,IAAA,UAAc,GAAA,YAAe,OAAA;EAG3C,UAAA,CAAW,IAAA,UAAc,GAAA,YAAe,OAAA;EAIxC,SAAA,CAAU,KAAA,EAAO,cAAA,GAAiB,OAAA,CAAQ,MAAA;EAI1C,YAAA,CAAa,MAAA,YAAkB,OAAA,CAAQ,MAAA;EAGvC,YAAA,CAAa,IAAA,UAAc,OAAA,YAAmB,OAAA;EAI9C,YAAA,CAAa,IAAA,WAAe,OAAA;EAG5B,aAAA,CAAc,MAAA,UAAgB,IAAA,YAAgB,OAAA,CAAQ,QAAA;EAItD,WAAA,CAAY,MAAA,UAAgB,IAAA,WAAe,OAAA,CAAQ,WAAA;EAGnD,QAAA,CAAS,MAAA,UAAgB,IAAA,YAAgB,OAAA;EAIzC,gBAAA,CAAA,GAAoB,OAAA;AAAA;;;;AJxDtB;;;;;;;;;AASA;;;;;AAkBA;;;;;iBKzBsB,kBAAA,CAAmB,IAAA,EAAM,UAAA,GAAa,OAAA,CAAQ,YAAA;;;;ALmDpE;;;iBKhBsB,oBAAA,CAAqB,IAAA;EAAQ,IAAA,EAAM,UAAA;EAAY,IAAA,EAAM,OAAA;AAAA,IAAY,OAAA,CAAQ,cAAA;;;;ALrC/F;;;;;;;;;AASA;;;cMda,YAAA,YAAwB,UAAA;EAAA,iBAEhB,MAAA;EAAA,iBACA,IAAA;cADA,MAAA,EAAQ,YAAA,EACR,IAAA,EAAM,OAAA;EAGnB,QAAA,CAAS,IAAA,UAAc,GAAA,YAAe,OAAA;EAmCtC,aAAA,CAAc,IAAA,UAAc,GAAA,YAAe,OAAA;EAkB3C,UAAA,CAAW,IAAA,UAAc,GAAA,YAAe,OAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/providers/github/app-auth.ts","../../../src/providers/github/capabilities.ts","../../../src/providers/github/client.ts","../../../src/providers/github/types.ts","../../../src/providers/github/provider.ts","../../../src/providers/github/factory.ts","../../../src/providers/github/reader.ts"],"mappings":";;;;;;;;AAuBA;;;;;;;;;AASA;;;;;AAkBA;;UA3BiB,aAAA;EA2BsB;EAzBrC,KAAA;EAyBsC;EAvBtC,UAAA;EAuB4E;EArB5E,cAAA;AAAA;AAAA,UAGe,uBAAA;;EAEf,KAAA;EA4C6C;EA1C7C,SAAA;AAAA;AAAA,iBAcc,UAAA,CAAW,MAAA,EAAQ,IAAA,CAAK,aAAA;;;;;;;;iBA0BlB,yBAAA,CACpB,MAAA,EAAQ,aAAA,EACR,IAAA;EAAQ,OAAA;EAAkB,SAAA,UAAmB,UAAA,CAAW,KAAA;AAAA,IACvD,OAAA,CAAQ,uBAAA;;;;;;AAxDX;;;;;;;;;AASA;cCjBa,mBAAA,EAAqB,oBAAA;;;;;;ADQlC;;;;;;KEZY,YAAA,GAAe,OAAA;;;;;;AFY3B;;;;;UGbiB,OAAA;EACf,KAAA;EACA,IAAA;EACA,WAAA;AAAA;;;;;AHqCF;;;;;KGzBY,UAAA;EACN,IAAA;EAAa,KAAA;AAAA;EAEb,IAAA;EACA,KAAA;EACA,UAAA;EACA,cAAA;AAAA;;cAIO,qBAAA,EAAuB,YAAA;;;;AHZpC;;;;;;;;cIUa,cAAA,YAA0B,YAAA;EAAA,iBAKlB,MAAA;EAAA,SACD,IAAA,EAAM,OAAA;EAAA,SALf,YAAA,EAAc,oBAAA;EAAA,iBACN,MAAA;cAGE,MAAA,EAAQ,YAAA,EACT,IAAA,EAAM,OAAA;EAKxB,QAAA,CAAS,IAAA,UAAc,GAAA,YAAe,OAAA;EAGtC,aAAA,CAAc,IAAA,UAAc,GAAA,YAAe,OAAA;EAG3C,UAAA,CAAW,IAAA,UAAc,GAAA,YAAe,OAAA;EAIxC,SAAA,CAAU,KAAA,EAAO,cAAA,GAAiB,OAAA,CAAQ,MAAA;EAI1C,YAAA,CAAa,MAAA,YAAkB,OAAA,CAAQ,MAAA;EAGvC,YAAA,CAAa,IAAA,UAAc,OAAA,YAAmB,OAAA;EAI9C,YAAA,CAAa,IAAA,WAAe,OAAA;EAG5B,aAAA,CAAc,MAAA,UAAgB,IAAA,YAAgB,OAAA,CAAQ,QAAA;EAItD,WAAA,CAAY,MAAA,UAAgB,IAAA,UAAc,IAAA;IAAS,kBAAA;EAAA,IAAiC,OAAA,CAAQ,WAAA;EAG5F,QAAA,CAAS,MAAA,UAAgB,IAAA,YAAgB,OAAA;EAIzC,gBAAA,CAAA,GAAoB,OAAA;AAAA;;;;AJxDtB;;;;;;;;;AASA;;;;;AAkBA;;;;;iBKzBsB,kBAAA,CAAmB,IAAA,EAAM,UAAA,GAAa,OAAA,CAAQ,YAAA;;;;ALmDpE;;;iBKhBsB,oBAAA,CAAqB,IAAA;EAAQ,IAAA,EAAM,UAAA;EAAY,IAAA,EAAM,OAAA;AAAA,IAAY,OAAA,CAAQ,cAAA;;;;ALrC/F;;;;;;;;;AASA;;;;;AAkBA;;;;;cMzBa,YAAA,YAAwB,UAAA;EAAA,iBAMhB,MAAA;EAAA,iBACA,IAAA;EAAA,iBACA,IAAA;EAAA,iBAPF,QAAA;EAAA,iBACA,QAAA;EAAA,iBACA,UAAA;cAGE,MAAA,EAAQ,YAAA,EACR,IAAA,EAAM,OAAA,EACN,IAAA;IAAS,OAAA;EAAA;EN8C3B;;;;EAAA,QMvCO,QAAA;EAWF,QAAA,CAAS,IAAA,UAAc,GAAA,YAAe,OAAA;EAAA,QAK9B,gBAAA;EAkCR,aAAA,CAAc,IAAA,UAAc,GAAA,YAAe,OAAA;EAoB3C,UAAA,CAAW,IAAA,UAAc,GAAA,YAAe,OAAA;AAAA"}
@@ -95,8 +95,12 @@ const GITHUB_CAPABILITIES = {
95
95
  * branch, or the HEAD of `input.base` (or the repo's default branch)
96
96
  * when the target branch does not yet exist.
97
97
  * 2. Read the base tree SHA from that commit.
98
- * 3. Walk `input.changes` in parallel — create a blob per non-null
99
- * content, emit a tree entry with `sha: null` for each deletion.
98
+ * 3. Map `input.changes` to tree entries — `content` inline for each write,
99
+ * `sha: null` for each deletion. No per-file blob round trip: GitHub
100
+ * creates the blobs as part of `createTree`, which keeps the write to a
101
+ * fixed 3 mutations (tree + commit + ref) regardless of file count and
102
+ * stays under the mutation-rate secondary limit. Mirrors the GitLab
103
+ * provider, which already inlines content in its commit actions.
100
104
  * 4. Create a new tree layered on top of the base tree with the collected
101
105
  * entries.
102
106
  * 5. Create the commit (tree, parents, author).
@@ -113,7 +117,7 @@ async function applyPlanToGitHub(client, repo, input) {
113
117
  repo: repo.name,
114
118
  commit_sha: baseSha
115
119
  })).data.tree.sha;
116
- const treeEntries = await Promise.all(input.changes.map((change) => buildTreeEntry(client, repo, change)));
120
+ const treeEntries = input.changes.map((change) => buildTreeEntry(repo, change));
117
121
  const tree = await client.rest.git.createTree({
118
122
  owner: repo.owner,
119
123
  repo: repo.name,
@@ -155,7 +159,7 @@ async function applyPlanToGitHub(client, repo, input) {
155
159
  timestamp: commit.data.author?.date ?? timestamp
156
160
  };
157
161
  }
158
- async function buildTreeEntry(client, repo, change) {
162
+ function buildTreeEntry(repo, change) {
159
163
  const path = resolveRepoPath(repo.contentRoot, change.path);
160
164
  if (change.content === null) return {
161
165
  path,
@@ -167,12 +171,7 @@ async function buildTreeEntry(client, repo, change) {
167
171
  path,
168
172
  mode: "100644",
169
173
  type: "blob",
170
- sha: (await client.rest.git.createBlob({
171
- owner: repo.owner,
172
- repo: repo.name,
173
- content: change.content,
174
- encoding: "utf-8"
175
- })).data.sha
174
+ content: change.content
176
175
  };
177
176
  }
178
177
  async function resolveBaseSha(client, repo, branch, base) {
@@ -264,27 +263,57 @@ async function getBranchDiff(client, repo, branch, base) {
264
263
  after: null
265
264
  }));
266
265
  }
267
- async function mergeBranch(client, repo, branch, into) {
266
+ async function mergeBranch(client, repo, branch, into, opts) {
268
267
  try {
268
+ const response = await client.rest.repos.merge({
269
+ owner: repo.owner,
270
+ repo: repo.name,
271
+ base: into,
272
+ head: branch
273
+ });
274
+ const remote = await cleanupSourceBranch(client, repo, branch, opts);
269
275
  return {
270
276
  merged: true,
271
- sha: (await client.rest.repos.merge({
272
- owner: repo.owner,
273
- repo: repo.name,
274
- base: into,
275
- head: branch
276
- })).data.sha,
277
- pullRequestUrl: null
277
+ sha: response.data.sha,
278
+ pullRequestUrl: null,
279
+ ...remote ? { remote } : {}
278
280
  };
279
281
  } catch (error) {
280
- if (isNotModified(error)) return {
281
- merged: true,
282
- sha: null,
283
- pullRequestUrl: null
284
- };
282
+ if (isNotModified(error)) {
283
+ const remote = await cleanupSourceBranch(client, repo, branch, opts);
284
+ return {
285
+ merged: true,
286
+ sha: null,
287
+ pullRequestUrl: null,
288
+ ...remote ? { remote } : {}
289
+ };
290
+ }
285
291
  throw error;
286
292
  }
287
293
  }
294
+ /**
295
+ * Best-effort post-merge deletion of the source branch so merged cr/*
296
+ * branches don't pile up on the remote. Opt out per call with
297
+ * `removeSourceBranch: false` (e.g. a driver that owns cleanup separately).
298
+ * Never throws — the merge itself already succeeded.
299
+ */
300
+ async function cleanupSourceBranch(client, repo, branch, opts) {
301
+ if (opts?.removeSourceBranch === false) return void 0;
302
+ try {
303
+ await deleteBranch(client, repo, branch);
304
+ return { deleted: true };
305
+ } catch (error) {
306
+ const status = error.status;
307
+ if (status === 404 || status === 422) return {
308
+ deleted: false,
309
+ skipped: "not-found"
310
+ };
311
+ return {
312
+ deleted: false,
313
+ warning: `Could not delete "${branch}": ${error instanceof Error ? error.message : String(error)}`
314
+ };
315
+ }
316
+ }
288
317
  async function isMerged(client, repo, branch, into) {
289
318
  return (await client.rest.repos.compareCommits({
290
319
  owner: repo.owner,
@@ -315,14 +344,42 @@ function isNotModified(error) {
315
344
  * commit SHA. When omitted, GitHub resolves to the repository's default
316
345
  * branch — which is usually wrong for Contentrain flows, so callers
317
346
  * should always pass the explicit `contentrain` tracking branch.
347
+ *
348
+ * Pass `{ memoize: true }` to dedupe reads within one operation: a repeated
349
+ * `(path, ref)` returns the in-flight/cached promise instead of a fresh
350
+ * `getContent`. This is OPT-IN and only safe for a SHORT-LIVED, read-only
351
+ * reader — a long-lived reader that outlives a write would serve stale
352
+ * results, so the provider's own reader never enables it. Failed reads are
353
+ * evicted so a transient error is retried, not cached forever.
318
354
  */
319
355
  var GitHubReader = class {
320
- constructor(client, repo) {
356
+ fileMemo = /* @__PURE__ */ new Map();
357
+ listMemo = /* @__PURE__ */ new Map();
358
+ existsMemo = /* @__PURE__ */ new Map();
359
+ constructor(client, repo, opts) {
321
360
  this.client = client;
322
361
  this.repo = repo;
362
+ this.opts = opts;
363
+ }
364
+ /**
365
+ * Run `fetch` through the given memo when memoization is enabled, keyed by
366
+ * `(ref, repoPath)`. A rejected promise is evicted so the next call retries.
367
+ */
368
+ memoized(memo, repoPath, ref, fetch) {
369
+ if (!this.opts?.memoize) return fetch();
370
+ const key = `${ref ?? ""}:${repoPath}`;
371
+ const cached = memo.get(key);
372
+ if (cached) return cached;
373
+ const promise = fetch();
374
+ memo.set(key, promise);
375
+ promise.catch(() => memo.delete(key));
376
+ return promise;
323
377
  }
324
378
  async readFile(path, ref) {
325
379
  const repoPath = resolveRepoPath(this.repo.contentRoot, path);
380
+ return this.memoized(this.fileMemo, repoPath, ref, () => this.readFileUncached(path, repoPath, ref));
381
+ }
382
+ async readFileUncached(path, repoPath, ref) {
326
383
  const data = (await this.client.rest.repos.getContent({
327
384
  owner: this.repo.owner,
328
385
  repo: this.repo.name,
@@ -345,34 +402,38 @@ var GitHubReader = class {
345
402
  }
346
403
  async listDirectory(path, ref) {
347
404
  const repoPath = resolveRepoPath(this.repo.contentRoot, path);
348
- try {
349
- const data = (await this.client.rest.repos.getContent({
350
- owner: this.repo.owner,
351
- repo: this.repo.name,
352
- path: repoPath,
353
- ref
354
- })).data;
355
- if (!Array.isArray(data)) return [];
356
- return data.map((entry) => entry.name);
357
- } catch (error) {
358
- if (isNotFoundError(error)) return [];
359
- throw error;
360
- }
405
+ return this.memoized(this.listMemo, repoPath, ref, async () => {
406
+ try {
407
+ const data = (await this.client.rest.repos.getContent({
408
+ owner: this.repo.owner,
409
+ repo: this.repo.name,
410
+ path: repoPath,
411
+ ref
412
+ })).data;
413
+ if (!Array.isArray(data)) return [];
414
+ return data.map((entry) => entry.name);
415
+ } catch (error) {
416
+ if (isNotFoundError(error)) return [];
417
+ throw error;
418
+ }
419
+ });
361
420
  }
362
421
  async fileExists(path, ref) {
363
422
  const repoPath = resolveRepoPath(this.repo.contentRoot, path);
364
- try {
365
- await this.client.rest.repos.getContent({
366
- owner: this.repo.owner,
367
- repo: this.repo.name,
368
- path: repoPath,
369
- ref
370
- });
371
- return true;
372
- } catch (error) {
373
- if (isNotFoundError(error)) return false;
374
- throw error;
375
- }
423
+ return this.memoized(this.existsMemo, repoPath, ref, async () => {
424
+ try {
425
+ await this.client.rest.repos.getContent({
426
+ owner: this.repo.owner,
427
+ repo: this.repo.name,
428
+ path: repoPath,
429
+ ref
430
+ });
431
+ return true;
432
+ } catch (error) {
433
+ if (isNotFoundError(error)) return false;
434
+ throw error;
435
+ }
436
+ });
376
437
  }
377
438
  };
378
439
  //#endregion
@@ -420,8 +481,8 @@ var GitHubProvider = class {
420
481
  const resolved = base ?? "main";
421
482
  return getBranchDiff(this.client, this.repo, branch, resolved);
422
483
  }
423
- mergeBranch(branch, into) {
424
- return mergeBranch(this.client, this.repo, branch, into);
484
+ mergeBranch(branch, into, opts) {
485
+ return mergeBranch(this.client, this.repo, branch, into, opts);
425
486
  }
426
487
  isMerged(branch, into) {
427
488
  const resolved = into ?? "main";