@contentrain/mcp 1.5.1 → 1.6.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.
@@ -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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"}
@@ -486,7 +486,7 @@ async function createGitHubProvider(opts) {
486
486
  /** Default author used when a call does not provide one. */
487
487
  const DEFAULT_GITHUB_AUTHOR = {
488
488
  name: "Contentrain",
489
- email: "mcp@contentrain.io"
489
+ email: "ai@contentrain.io"
490
490
  };
491
491
  //#endregion
492
492
  export { DEFAULT_GITHUB_AUTHOR, GITHUB_CAPABILITIES, GitHubProvider, GitHubReader, createGitHubClient, createGitHubProvider, exchangeInstallationToken, signAppJwt };
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../../../src/providers/github/app-auth.ts","../../../src/providers/github/capabilities.ts","../../../src/providers/github/apply-plan.ts","../../../src/providers/github/branch-ops.ts","../../../src/providers/github/reader.ts","../../../src/providers/github/provider.ts","../../../src/providers/github/factory.ts","../../../src/providers/github/types.ts"],"sourcesContent":["import { createPrivateKey, createSign } from 'node:crypto'\n\n/**\n * GitHub App authentication helpers.\n *\n * Two entry points:\n *\n * - {@link signAppJwt} — mint a short-lived (10 min) GitHub App JWT from\n * `appId` + `privateKey`. Used to authenticate app-level endpoints\n * (listing installations, creating installation tokens).\n *\n * - {@link exchangeInstallationToken} — exchange an app JWT for a\n * per-installation access token that can be passed straight to\n * `Octokit({ auth: token })`. Installation tokens last ~1 hour and\n * must be refreshed.\n *\n * Both helpers are pure — they never import `@octokit/rest` or\n * `@octokit/auth-app`. Callers that want auto-refresh behaviour should\n * either wire `@octokit/auth-app` as the `authStrategy` when constructing\n * their own Octokit (see the embedding guide), or call\n * `exchangeInstallationToken` on their own schedule.\n */\n\nexport interface AppAuthConfig {\n /** GitHub App ID (numeric, from the app's settings page). */\n appId: number\n /** PEM-encoded private key — the contents of the `.pem` the app issued. */\n privateKey: string\n /** Installation the token should be scoped to. */\n installationId: number\n}\n\nexport interface InstallationTokenResult {\n /** Opaque bearer token — pass to `new Octokit({ auth: token })`. */\n token: string\n /** ISO 8601 expiry. Installation tokens expire after ~1 hour. */\n expiresAt: string\n}\n\n/**\n * Sign a GitHub App JWT.\n *\n * GitHub's spec: RS256 (RSASSA-PKCS1-v1_5), 10-minute max lifetime,\n * `iat` 60 seconds in the past to cover small clock skew, `iss`\n * set to the numeric app ID.\n */\nfunction base64UrlEncode(obj: unknown): string {\n return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url')\n}\n\nexport function signAppJwt(config: Pick<AppAuthConfig, 'appId' | 'privateKey'>): string {\n const now = Math.floor(Date.now() / 1000)\n const header = { alg: 'RS256', typ: 'JWT' }\n const payload = {\n iat: now - 60,\n exp: now + 9 * 60,\n iss: String(config.appId),\n }\n\n const toSign = `${base64UrlEncode(header)}.${base64UrlEncode(payload)}`\n const keyObject = createPrivateKey({ key: config.privateKey, format: 'pem' })\n const signer = createSign('RSA-SHA256')\n signer.update(toSign)\n signer.end()\n const signature = signer.sign(keyObject).toString('base64url')\n\n return `${toSign}.${signature}`\n}\n\n/**\n * Exchange an App JWT for an installation-scoped access token by calling\n * GitHub's `POST /app/installations/{id}/access_tokens` endpoint.\n *\n * Uses `fetch` (Node ≥22 has it native) so the helper stays dependency-\n * free. Throws on non-2xx responses with the GitHub-returned message.\n */\nexport async function exchangeInstallationToken(\n config: AppAuthConfig,\n opts: { baseUrl?: string, fetchImpl?: typeof globalThis.fetch } = {},\n): Promise<InstallationTokenResult> {\n const jwt = signAppJwt(config)\n const baseUrl = opts.baseUrl ?? 'https://api.github.com'\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch\n\n const url = `${baseUrl}/app/installations/${config.installationId}/access_tokens`\n const response = await fetchImpl(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${jwt}`,\n Accept: 'application/vnd.github+json',\n 'X-GitHub-Api-Version': '2022-11-28',\n },\n })\n\n if (!response.ok) {\n const body = await response.text().catch(() => '')\n throw new Error(\n `GitHub installation-token exchange failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ''}`,\n )\n }\n\n const data = await response.json() as { token: string, expires_at: string }\n return { token: data.token, expiresAt: data.expires_at }\n}\n","import type { ProviderCapabilities } from '../../core/contracts/index.js'\n\n/**\n * Capability set for GitHubProvider.\n *\n * GitHub over the Git Data API has no working tree, so local worktree\n * features, source-file access and AST scans are unavailable. Push /\n * PR operations are free because every commit goes straight to the\n * remote. Branch protection detection uses the Repos API.\n *\n * Tools that require `astScan`, `sourceRead` or `sourceWrite` must\n * gracefully reject (with `capability_required`) when running against\n * a GitHubProvider — this is the mechanism behind phase 6's normalize\n * capability-gate.\n */\nexport const GITHUB_CAPABILITIES: ProviderCapabilities = {\n localWorktree: false,\n sourceRead: false,\n sourceWrite: false,\n pushRemote: true,\n branchProtection: true,\n pullRequestFallback: true,\n astScan: false,\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitHubClient } from './client.js'\nimport type { RepoRef } from './types.js'\n\ninterface TreeEntry {\n path: string\n mode: '100644' | '100755' | '040000' | '160000' | '120000'\n type: 'blob' | 'tree' | 'commit'\n sha: string | null\n}\n\n/**\n * Apply a plan to a GitHub repository as a single atomic commit via the\n * Git Data API. High-level flow:\n *\n * 1. Resolve the base commit SHA — either the current HEAD of the target\n * branch, or the HEAD of `input.base` (or the repo's default branch)\n * when the target branch does not yet exist.\n * 2. Read the base tree SHA from that commit.\n * 3. Walk `input.changes` in parallel — create a blob per non-null\n * content, emit a tree entry with `sha: null` for each deletion.\n * 4. Create a new tree layered on top of the base tree with the collected\n * entries.\n * 5. Create the commit (tree, parents, author).\n * 6. Update an existing branch ref or create a new one pointing at the\n * commit.\n *\n * No working tree, no transaction — the commit is durable as soon as the\n * final ref update returns.\n */\nexport async function applyPlanToGitHub(\n client: GitHubClient,\n repo: RepoRef,\n input: ApplyPlanInput,\n): Promise<Commit> {\n const { baseSha, branchExists } = await resolveBaseSha(client, repo, input.branch, input.base)\n\n const baseCommit = await client.rest.git.getCommit({\n owner: repo.owner,\n repo: repo.name,\n commit_sha: baseSha,\n })\n const baseTreeSha = baseCommit.data.tree.sha\n\n const treeEntries = await Promise.all(\n input.changes.map(change => buildTreeEntry(client, repo, change)),\n )\n\n const tree = await client.rest.git.createTree({\n owner: repo.owner,\n repo: repo.name,\n base_tree: baseTreeSha,\n tree: treeEntries,\n })\n\n const timestamp = new Date().toISOString()\n const commit = await client.rest.git.createCommit({\n owner: repo.owner,\n repo: repo.name,\n message: input.message,\n tree: tree.data.sha,\n parents: [baseSha],\n author: {\n name: input.author.name,\n email: input.author.email,\n date: timestamp,\n },\n })\n\n if (branchExists) {\n await client.rest.git.updateRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${input.branch}`,\n sha: commit.data.sha,\n })\n } else {\n await client.rest.git.createRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `refs/heads/${input.branch}`,\n sha: commit.data.sha,\n })\n }\n\n return {\n sha: commit.data.sha,\n message: commit.data.message,\n author: {\n name: commit.data.author?.name ?? input.author.name,\n email: commit.data.author?.email ?? input.author.email,\n },\n timestamp: commit.data.author?.date ?? timestamp,\n }\n}\n\nasync function buildTreeEntry(\n client: GitHubClient,\n repo: RepoRef,\n change: FileChange,\n): Promise<TreeEntry> {\n const path = resolveRepoPath(repo.contentRoot, change.path)\n if (change.content === null) {\n return { path, mode: '100644', type: 'blob', sha: null }\n }\n const blob = await client.rest.git.createBlob({\n owner: repo.owner,\n repo: repo.name,\n content: change.content,\n encoding: 'utf-8',\n })\n return { path, mode: '100644', type: 'blob', sha: blob.data.sha }\n}\n\nasync function resolveBaseSha(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n base: string | undefined,\n): Promise<{ baseSha: string, branchExists: boolean }> {\n try {\n const ref = await client.rest.git.getRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${branch}`,\n })\n return { baseSha: ref.data.object.sha, branchExists: true }\n } catch (error) {\n if (!isNotFoundError(error)) throw error\n }\n\n // Invariant: feature branches always fork from the Contentrain\n // content-tracking branch. Callers that genuinely want to bypass this\n // must pass `base` explicitly. The repository's default branch\n // (main / master / trunk) is NOT the fallback — that would create a\n // split-brain where remote writes derive from a different ref than\n // local writes, which the LocalProvider transaction path forbids.\n const baseRefName = base ?? CONTENTRAIN_BRANCH\n\n const baseRef = await client.rest.git.getRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${baseRefName}`,\n })\n return { baseSha: baseRef.data.object.sha, branchExists: false }\n}\n","import type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport type { GitHubClient } from './client.js'\nimport type { RepoRef } from './types.js'\n\n/**\n * Branch/merge/diff helpers backed by the Repos and Git Data APIs.\n *\n * Pure functions so they can be composed into `GitHubProvider` or used\n * standalone. All throw on non-404 errors; 404s collapse to empty-ish\n * results where that matches the `RepoProvider` contract (e.g. a\n * missing branch prefix yields `[]` rather than raising).\n */\n\nexport async function getDefaultBranch(client: GitHubClient, repo: RepoRef): Promise<string> {\n const response = await client.rest.repos.get({ owner: repo.owner, repo: repo.name })\n return response.data.default_branch\n}\n\nexport async function listBranches(\n client: GitHubClient,\n repo: RepoRef,\n prefix?: string,\n): Promise<Branch[]> {\n const branches: Branch[] = []\n const iterator = client.paginate.iterator(client.rest.repos.listBranches, {\n owner: repo.owner,\n repo: repo.name,\n per_page: 100,\n })\n for await (const page of iterator) {\n for (const b of page.data) {\n if (prefix && !b.name.startsWith(prefix)) continue\n branches.push({ name: b.name, sha: b.commit.sha, protected: b.protected })\n }\n }\n return branches\n}\n\nexport async function createBranch(\n client: GitHubClient,\n repo: RepoRef,\n name: string,\n fromRef: string,\n): Promise<void> {\n const base = await client.rest.git.getRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${fromRef}`,\n })\n await client.rest.git.createRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `refs/heads/${name}`,\n sha: base.data.object.sha,\n })\n}\n\nexport async function deleteBranch(\n client: GitHubClient,\n repo: RepoRef,\n name: string,\n): Promise<void> {\n await client.rest.git.deleteRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${name}`,\n })\n}\n\nexport async function getBranchDiff(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const response = await client.rest.repos.compareCommits({\n owner: repo.owner,\n repo: repo.name,\n base,\n head: branch,\n })\n const files = response.data.files ?? []\n return files.map(f => ({\n path: f.filename,\n status: normaliseStatus(f.status),\n before: null,\n after: null,\n }))\n}\n\nexport async function mergeBranch(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n try {\n const response = await client.rest.repos.merge({\n owner: repo.owner,\n repo: repo.name,\n base: into,\n head: branch,\n })\n return { merged: true, sha: response.data.sha, pullRequestUrl: null }\n } catch (error) {\n if (isNotModified(error)) {\n return { merged: true, sha: null, pullRequestUrl: null }\n }\n throw error\n }\n}\n\nexport async function isMerged(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n into: string,\n): Promise<boolean> {\n const response = await client.rest.repos.compareCommits({\n owner: repo.owner,\n repo: repo.name,\n base: into,\n head: branch,\n })\n return response.data.ahead_by === 0\n}\n\nfunction normaliseStatus(status: string): FileDiff['status'] {\n if (status === 'added') return 'added'\n if (status === 'removed') return 'removed'\n return 'modified'\n}\n\nfunction isNotModified(error: unknown): boolean {\n return typeof error === 'object' && error !== null && (error as { status?: number }).status === 204\n}\n","import type { RepoReader } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitHubClient } from './client.js'\nimport type { RepoRef } from './types.js'\n\n/**\n * GitHubReader — `RepoReader` backed by the GitHub Repos + Git Data APIs.\n *\n * Reads pass through `repos.getContent`; directories return a list of\n * names, files return decoded UTF-8 text. Files larger than ~1 MB are\n * fetched by blob SHA through `git.getBlob` because `getContent` omits\n * the body in that case.\n *\n * `ref` is forwarded verbatim and may be a branch name, tag name or\n * commit SHA. When omitted, GitHub resolves to the repository's default\n * branch — which is usually wrong for Contentrain flows, so callers\n * should always pass the explicit `contentrain` tracking branch.\n */\nexport class GitHubReader implements RepoReader {\n constructor(\n private readonly client: GitHubClient,\n private readonly repo: RepoRef,\n ) {}\n\n async readFile(path: string, ref?: string): Promise<string> {\n const repoPath = resolveRepoPath(this.repo.contentRoot, path)\n const response = await this.client.rest.repos.getContent({\n owner: this.repo.owner,\n repo: this.repo.name,\n path: repoPath,\n ref,\n })\n const data = response.data\n if (Array.isArray(data)) {\n throw new Error(`GitHubReader: path \"${path}\" is a directory, not a file`)\n }\n if (data.type !== 'file') {\n throw new Error(`GitHubReader: path \"${path}\" is a ${data.type}, not a file`)\n }\n\n // GitHub omits content for files > 1 MB — fall back to blob fetch.\n if (data.content === '' && data.size > 0) {\n const blob = await this.client.rest.git.getBlob({\n owner: this.repo.owner,\n repo: this.repo.name,\n file_sha: data.sha,\n })\n if (blob.data.encoding !== 'base64') {\n throw new Error(`GitHubReader: unexpected blob encoding \"${blob.data.encoding}\" for ${path}`)\n }\n return Buffer.from(blob.data.content, 'base64').toString('utf-8')\n }\n\n if (data.encoding !== 'base64') {\n throw new Error(`GitHubReader: unexpected encoding \"${data.encoding}\" for ${path}`)\n }\n return Buffer.from(data.content, 'base64').toString('utf-8')\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const repoPath = resolveRepoPath(this.repo.contentRoot, path)\n try {\n const response = await this.client.rest.repos.getContent({\n owner: this.repo.owner,\n repo: this.repo.name,\n path: repoPath,\n ref,\n })\n const data = response.data\n if (!Array.isArray(data)) return []\n return data.map(entry => entry.name)\n } catch (error) {\n if (isNotFoundError(error)) return []\n throw error\n }\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const repoPath = resolveRepoPath(this.repo.contentRoot, path)\n try {\n await this.client.rest.repos.getContent({\n owner: this.repo.owner,\n repo: this.repo.name,\n path: repoPath,\n ref,\n })\n return true\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n }\n}\n","import type {\n ApplyPlanInput,\n Branch,\n Commit,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { applyPlanToGitHub } from './apply-plan.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { GITHUB_CAPABILITIES } from './capabilities.js'\nimport type { GitHubClient } from './client.js'\nimport { GitHubReader } from './reader.js'\nimport type { RepoRef } from './types.js'\n\n/**\n * GitHubProvider — `RepoProvider` backed by the Octokit-driven GitHub\n * REST + Git Data APIs.\n *\n * The provider is transport-agnostic; it only talks to an `Octokit`\n * instance passed into the constructor. The `createGitHubProvider`\n * helper in `factory.ts` wraps the dynamic import so consumers never\n * have to touch Octokit directly.\n */\nexport class GitHubProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = GITHUB_CAPABILITIES\n private readonly reader: GitHubReader\n\n constructor(\n private readonly client: GitHubClient,\n public readonly repo: RepoRef,\n ) {\n this.reader = new GitHubReader(client, repo)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n applyPlan(input: ApplyPlanInput): Promise<Commit> {\n return applyPlanToGitHub(this.client, this.repo, input)\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.client, this.repo, prefix)\n }\n createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? 'main'\n return createBranchOp(this.client, this.repo, name, resolved)\n }\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.client, this.repo, name)\n }\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? 'main'\n return getBranchDiffOp(this.client, this.repo, branch, resolved)\n }\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.client, this.repo, branch, into)\n }\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? 'main'\n return isMergedOp(this.client, this.repo, branch, resolved)\n }\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.client, this.repo)\n }\n}\n","import type { GitHubClient } from './client.js'\nimport { exchangeInstallationToken } from './app-auth.js'\nimport { GitHubProvider } from './provider.js'\nimport type { GitHubAuth, RepoRef } from './types.js'\n\n/**\n * Create an Octokit-backed `GitHubClient` from an auth configuration.\n *\n * The `@octokit/rest` module is imported dynamically so it stays a pure\n * optional peer dependency — self-hosted MCP on stdio can run without\n * it. If the module is not installed, the import throws with a helpful\n * hint pointing the operator at the peer dependency.\n *\n * Two auth modes:\n *\n * - `pat` — personal access token or fine-grained PAT. Simplest for\n * self-hosted MCP or CI runners.\n * - `app` — GitHub App installation auth. The factory mints a short-\n * lived JWT, exchanges it for an installation token via\n * `exchangeInstallationToken`, and instantiates Octokit with the\n * resulting bearer. The returned token expires in ~1 hour; callers\n * that need auto-refresh should instead inject their own Octokit\n * built with `@octokit/auth-app`'s auth strategy and construct\n * `GitHubProvider` directly. See the embedding guide for trade-offs.\n */\nexport async function createGitHubClient(auth: GitHubAuth): Promise<GitHubClient> {\n let OctokitCtor: typeof import('@octokit/rest').Octokit\n try {\n ({ Octokit: OctokitCtor } = await import('@octokit/rest'))\n } catch (error) {\n throw new Error(\n '@octokit/rest is required for the GitHubProvider but could not be loaded. '\n + 'Install it as a peer dependency: pnpm add @octokit/rest.',\n { cause: error },\n )\n }\n\n if (auth.type === 'pat') {\n return new OctokitCtor({ auth: auth.token })\n }\n\n if (auth.type === 'app') {\n const { token } = await exchangeInstallationToken({\n appId: auth.appId,\n privateKey: auth.privateKey,\n installationId: auth.installationId,\n })\n return new OctokitCtor({ auth: token })\n }\n\n const unknown = auth as { type: string }\n throw new Error(`GitHub auth type \"${unknown.type}\" is not supported. Use \"pat\" or \"app\".`)\n}\n\n/**\n * Factory for the full provider — instantiates an Octokit client and\n * wraps it in a `GitHubProvider`. Consumers who already hold an Octokit\n * instance (HTTP server injecting shared clients, tests, etc.) should\n * instantiate `GitHubProvider` directly instead.\n */\nexport async function createGitHubProvider(opts: { auth: GitHubAuth, repo: RepoRef }): Promise<GitHubProvider> {\n const client = await createGitHubClient(opts.auth)\n return new GitHubProvider(client, opts.repo)\n}\n","import type { CommitAuthor } from '../../core/contracts/index.js'\n\n/**\n * A reference to a GitHub repository.\n *\n * `contentRoot` is the repo-relative directory prefix where Contentrain\n * content lives. For a flat content repo it stays `''`; for a monorepo\n * where Contentrain is embedded (e.g. `apps/web/.contentrain/`) it holds\n * that prefix. All reader/writer paths are joined against it.\n */\nexport interface RepoRef {\n owner: string\n name: string\n contentRoot?: string\n}\n\n/**\n * Authentication options for the GitHub provider.\n *\n * - `pat` — a personal access token or fine-grained PAT. Simplest for\n * self-hosted MCP or CI runners. Phase 5.1 ships with this mode only.\n * - `app` — GitHub App installation auth (JWT + installation token\n * exchange). Planned for Phase 5.2; see `.internal/refactor/\n * 02-studio-handoff.md` S6 for how Studio's hosted MCP plugs in.\n */\nexport type GitHubAuth =\n | { type: 'pat', token: string }\n | {\n type: 'app'\n appId: number\n privateKey: string\n installationId: number\n }\n\n/** Default author used when a call does not provide one. */\nexport const DEFAULT_GITHUB_AUTHOR: CommitAuthor = {\n name: 'Contentrain',\n email: 'mcp@contentrain.io',\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../../../src/providers/github/app-auth.ts","../../../src/providers/github/capabilities.ts","../../../src/providers/github/apply-plan.ts","../../../src/providers/github/branch-ops.ts","../../../src/providers/github/reader.ts","../../../src/providers/github/provider.ts","../../../src/providers/github/factory.ts","../../../src/providers/github/types.ts"],"sourcesContent":["import { createPrivateKey, createSign } from 'node:crypto'\n\n/**\n * GitHub App authentication helpers.\n *\n * Two entry points:\n *\n * - {@link signAppJwt} — mint a short-lived (10 min) GitHub App JWT from\n * `appId` + `privateKey`. Used to authenticate app-level endpoints\n * (listing installations, creating installation tokens).\n *\n * - {@link exchangeInstallationToken} — exchange an app JWT for a\n * per-installation access token that can be passed straight to\n * `Octokit({ auth: token })`. Installation tokens last ~1 hour and\n * must be refreshed.\n *\n * Both helpers are pure — they never import `@octokit/rest` or\n * `@octokit/auth-app`. Callers that want auto-refresh behaviour should\n * either wire `@octokit/auth-app` as the `authStrategy` when constructing\n * their own Octokit (see the embedding guide), or call\n * `exchangeInstallationToken` on their own schedule.\n */\n\nexport interface AppAuthConfig {\n /** GitHub App ID (numeric, from the app's settings page). */\n appId: number\n /** PEM-encoded private key — the contents of the `.pem` the app issued. */\n privateKey: string\n /** Installation the token should be scoped to. */\n installationId: number\n}\n\nexport interface InstallationTokenResult {\n /** Opaque bearer token — pass to `new Octokit({ auth: token })`. */\n token: string\n /** ISO 8601 expiry. Installation tokens expire after ~1 hour. */\n expiresAt: string\n}\n\n/**\n * Sign a GitHub App JWT.\n *\n * GitHub's spec: RS256 (RSASSA-PKCS1-v1_5), 10-minute max lifetime,\n * `iat` 60 seconds in the past to cover small clock skew, `iss`\n * set to the numeric app ID.\n */\nfunction base64UrlEncode(obj: unknown): string {\n return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url')\n}\n\nexport function signAppJwt(config: Pick<AppAuthConfig, 'appId' | 'privateKey'>): string {\n const now = Math.floor(Date.now() / 1000)\n const header = { alg: 'RS256', typ: 'JWT' }\n const payload = {\n iat: now - 60,\n exp: now + 9 * 60,\n iss: String(config.appId),\n }\n\n const toSign = `${base64UrlEncode(header)}.${base64UrlEncode(payload)}`\n const keyObject = createPrivateKey({ key: config.privateKey, format: 'pem' })\n const signer = createSign('RSA-SHA256')\n signer.update(toSign)\n signer.end()\n const signature = signer.sign(keyObject).toString('base64url')\n\n return `${toSign}.${signature}`\n}\n\n/**\n * Exchange an App JWT for an installation-scoped access token by calling\n * GitHub's `POST /app/installations/{id}/access_tokens` endpoint.\n *\n * Uses `fetch` (Node ≥22 has it native) so the helper stays dependency-\n * free. Throws on non-2xx responses with the GitHub-returned message.\n */\nexport async function exchangeInstallationToken(\n config: AppAuthConfig,\n opts: { baseUrl?: string, fetchImpl?: typeof globalThis.fetch } = {},\n): Promise<InstallationTokenResult> {\n const jwt = signAppJwt(config)\n const baseUrl = opts.baseUrl ?? 'https://api.github.com'\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch\n\n const url = `${baseUrl}/app/installations/${config.installationId}/access_tokens`\n const response = await fetchImpl(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${jwt}`,\n Accept: 'application/vnd.github+json',\n 'X-GitHub-Api-Version': '2022-11-28',\n },\n })\n\n if (!response.ok) {\n const body = await response.text().catch(() => '')\n throw new Error(\n `GitHub installation-token exchange failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ''}`,\n )\n }\n\n const data = await response.json() as { token: string, expires_at: string }\n return { token: data.token, expiresAt: data.expires_at }\n}\n","import type { ProviderCapabilities } from '../../core/contracts/index.js'\n\n/**\n * Capability set for GitHubProvider.\n *\n * GitHub over the Git Data API has no working tree, so local worktree\n * features, source-file access and AST scans are unavailable. Push /\n * PR operations are free because every commit goes straight to the\n * remote. Branch protection detection uses the Repos API.\n *\n * Tools that require `astScan`, `sourceRead` or `sourceWrite` must\n * gracefully reject (with `capability_required`) when running against\n * a GitHubProvider — this is the mechanism behind phase 6's normalize\n * capability-gate.\n */\nexport const GITHUB_CAPABILITIES: ProviderCapabilities = {\n localWorktree: false,\n sourceRead: false,\n sourceWrite: false,\n pushRemote: true,\n branchProtection: true,\n pullRequestFallback: true,\n astScan: false,\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitHubClient } from './client.js'\nimport type { RepoRef } from './types.js'\n\ninterface TreeEntry {\n path: string\n mode: '100644' | '100755' | '040000' | '160000' | '120000'\n type: 'blob' | 'tree' | 'commit'\n sha: string | null\n}\n\n/**\n * Apply a plan to a GitHub repository as a single atomic commit via the\n * Git Data API. High-level flow:\n *\n * 1. Resolve the base commit SHA — either the current HEAD of the target\n * branch, or the HEAD of `input.base` (or the repo's default branch)\n * when the target branch does not yet exist.\n * 2. Read the base tree SHA from that commit.\n * 3. Walk `input.changes` in parallel — create a blob per non-null\n * content, emit a tree entry with `sha: null` for each deletion.\n * 4. Create a new tree layered on top of the base tree with the collected\n * entries.\n * 5. Create the commit (tree, parents, author).\n * 6. Update an existing branch ref or create a new one pointing at the\n * commit.\n *\n * No working tree, no transaction — the commit is durable as soon as the\n * final ref update returns.\n */\nexport async function applyPlanToGitHub(\n client: GitHubClient,\n repo: RepoRef,\n input: ApplyPlanInput,\n): Promise<Commit> {\n const { baseSha, branchExists } = await resolveBaseSha(client, repo, input.branch, input.base)\n\n const baseCommit = await client.rest.git.getCommit({\n owner: repo.owner,\n repo: repo.name,\n commit_sha: baseSha,\n })\n const baseTreeSha = baseCommit.data.tree.sha\n\n const treeEntries = await Promise.all(\n input.changes.map(change => buildTreeEntry(client, repo, change)),\n )\n\n const tree = await client.rest.git.createTree({\n owner: repo.owner,\n repo: repo.name,\n base_tree: baseTreeSha,\n tree: treeEntries,\n })\n\n const timestamp = new Date().toISOString()\n const commit = await client.rest.git.createCommit({\n owner: repo.owner,\n repo: repo.name,\n message: input.message,\n tree: tree.data.sha,\n parents: [baseSha],\n author: {\n name: input.author.name,\n email: input.author.email,\n date: timestamp,\n },\n })\n\n if (branchExists) {\n await client.rest.git.updateRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${input.branch}`,\n sha: commit.data.sha,\n })\n } else {\n await client.rest.git.createRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `refs/heads/${input.branch}`,\n sha: commit.data.sha,\n })\n }\n\n return {\n sha: commit.data.sha,\n message: commit.data.message,\n author: {\n name: commit.data.author?.name ?? input.author.name,\n email: commit.data.author?.email ?? input.author.email,\n },\n timestamp: commit.data.author?.date ?? timestamp,\n }\n}\n\nasync function buildTreeEntry(\n client: GitHubClient,\n repo: RepoRef,\n change: FileChange,\n): Promise<TreeEntry> {\n const path = resolveRepoPath(repo.contentRoot, change.path)\n if (change.content === null) {\n return { path, mode: '100644', type: 'blob', sha: null }\n }\n const blob = await client.rest.git.createBlob({\n owner: repo.owner,\n repo: repo.name,\n content: change.content,\n encoding: 'utf-8',\n })\n return { path, mode: '100644', type: 'blob', sha: blob.data.sha }\n}\n\nasync function resolveBaseSha(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n base: string | undefined,\n): Promise<{ baseSha: string, branchExists: boolean }> {\n try {\n const ref = await client.rest.git.getRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${branch}`,\n })\n return { baseSha: ref.data.object.sha, branchExists: true }\n } catch (error) {\n if (!isNotFoundError(error)) throw error\n }\n\n // Invariant: feature branches always fork from the Contentrain\n // content-tracking branch. Callers that genuinely want to bypass this\n // must pass `base` explicitly. The repository's default branch\n // (main / master / trunk) is NOT the fallback — that would create a\n // split-brain where remote writes derive from a different ref than\n // local writes, which the LocalProvider transaction path forbids.\n const baseRefName = base ?? CONTENTRAIN_BRANCH\n\n const baseRef = await client.rest.git.getRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${baseRefName}`,\n })\n return { baseSha: baseRef.data.object.sha, branchExists: false }\n}\n","import type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport type { GitHubClient } from './client.js'\nimport type { RepoRef } from './types.js'\n\n/**\n * Branch/merge/diff helpers backed by the Repos and Git Data APIs.\n *\n * Pure functions so they can be composed into `GitHubProvider` or used\n * standalone. All throw on non-404 errors; 404s collapse to empty-ish\n * results where that matches the `RepoProvider` contract (e.g. a\n * missing branch prefix yields `[]` rather than raising).\n */\n\nexport async function getDefaultBranch(client: GitHubClient, repo: RepoRef): Promise<string> {\n const response = await client.rest.repos.get({ owner: repo.owner, repo: repo.name })\n return response.data.default_branch\n}\n\nexport async function listBranches(\n client: GitHubClient,\n repo: RepoRef,\n prefix?: string,\n): Promise<Branch[]> {\n const branches: Branch[] = []\n const iterator = client.paginate.iterator(client.rest.repos.listBranches, {\n owner: repo.owner,\n repo: repo.name,\n per_page: 100,\n })\n for await (const page of iterator) {\n for (const b of page.data) {\n if (prefix && !b.name.startsWith(prefix)) continue\n branches.push({ name: b.name, sha: b.commit.sha, protected: b.protected })\n }\n }\n return branches\n}\n\nexport async function createBranch(\n client: GitHubClient,\n repo: RepoRef,\n name: string,\n fromRef: string,\n): Promise<void> {\n const base = await client.rest.git.getRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${fromRef}`,\n })\n await client.rest.git.createRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `refs/heads/${name}`,\n sha: base.data.object.sha,\n })\n}\n\nexport async function deleteBranch(\n client: GitHubClient,\n repo: RepoRef,\n name: string,\n): Promise<void> {\n await client.rest.git.deleteRef({\n owner: repo.owner,\n repo: repo.name,\n ref: `heads/${name}`,\n })\n}\n\nexport async function getBranchDiff(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const response = await client.rest.repos.compareCommits({\n owner: repo.owner,\n repo: repo.name,\n base,\n head: branch,\n })\n const files = response.data.files ?? []\n return files.map(f => ({\n path: f.filename,\n status: normaliseStatus(f.status),\n before: null,\n after: null,\n }))\n}\n\nexport async function mergeBranch(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n try {\n const response = await client.rest.repos.merge({\n owner: repo.owner,\n repo: repo.name,\n base: into,\n head: branch,\n })\n return { merged: true, sha: response.data.sha, pullRequestUrl: null }\n } catch (error) {\n if (isNotModified(error)) {\n return { merged: true, sha: null, pullRequestUrl: null }\n }\n throw error\n }\n}\n\nexport async function isMerged(\n client: GitHubClient,\n repo: RepoRef,\n branch: string,\n into: string,\n): Promise<boolean> {\n const response = await client.rest.repos.compareCommits({\n owner: repo.owner,\n repo: repo.name,\n base: into,\n head: branch,\n })\n return response.data.ahead_by === 0\n}\n\nfunction normaliseStatus(status: string): FileDiff['status'] {\n if (status === 'added') return 'added'\n if (status === 'removed') return 'removed'\n return 'modified'\n}\n\nfunction isNotModified(error: unknown): boolean {\n return typeof error === 'object' && error !== null && (error as { status?: number }).status === 204\n}\n","import type { RepoReader } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitHubClient } from './client.js'\nimport type { RepoRef } from './types.js'\n\n/**\n * GitHubReader — `RepoReader` backed by the GitHub Repos + Git Data APIs.\n *\n * Reads pass through `repos.getContent`; directories return a list of\n * names, files return decoded UTF-8 text. Files larger than ~1 MB are\n * fetched by blob SHA through `git.getBlob` because `getContent` omits\n * the body in that case.\n *\n * `ref` is forwarded verbatim and may be a branch name, tag name or\n * commit SHA. When omitted, GitHub resolves to the repository's default\n * branch — which is usually wrong for Contentrain flows, so callers\n * should always pass the explicit `contentrain` tracking branch.\n */\nexport class GitHubReader implements RepoReader {\n constructor(\n private readonly client: GitHubClient,\n private readonly repo: RepoRef,\n ) {}\n\n async readFile(path: string, ref?: string): Promise<string> {\n const repoPath = resolveRepoPath(this.repo.contentRoot, path)\n const response = await this.client.rest.repos.getContent({\n owner: this.repo.owner,\n repo: this.repo.name,\n path: repoPath,\n ref,\n })\n const data = response.data\n if (Array.isArray(data)) {\n throw new Error(`GitHubReader: path \"${path}\" is a directory, not a file`)\n }\n if (data.type !== 'file') {\n throw new Error(`GitHubReader: path \"${path}\" is a ${data.type}, not a file`)\n }\n\n // GitHub omits content for files > 1 MB — fall back to blob fetch.\n if (data.content === '' && data.size > 0) {\n const blob = await this.client.rest.git.getBlob({\n owner: this.repo.owner,\n repo: this.repo.name,\n file_sha: data.sha,\n })\n if (blob.data.encoding !== 'base64') {\n throw new Error(`GitHubReader: unexpected blob encoding \"${blob.data.encoding}\" for ${path}`)\n }\n return Buffer.from(blob.data.content, 'base64').toString('utf-8')\n }\n\n if (data.encoding !== 'base64') {\n throw new Error(`GitHubReader: unexpected encoding \"${data.encoding}\" for ${path}`)\n }\n return Buffer.from(data.content, 'base64').toString('utf-8')\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const repoPath = resolveRepoPath(this.repo.contentRoot, path)\n try {\n const response = await this.client.rest.repos.getContent({\n owner: this.repo.owner,\n repo: this.repo.name,\n path: repoPath,\n ref,\n })\n const data = response.data\n if (!Array.isArray(data)) return []\n return data.map(entry => entry.name)\n } catch (error) {\n if (isNotFoundError(error)) return []\n throw error\n }\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const repoPath = resolveRepoPath(this.repo.contentRoot, path)\n try {\n await this.client.rest.repos.getContent({\n owner: this.repo.owner,\n repo: this.repo.name,\n path: repoPath,\n ref,\n })\n return true\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n }\n}\n","import type {\n ApplyPlanInput,\n Branch,\n Commit,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { applyPlanToGitHub } from './apply-plan.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { GITHUB_CAPABILITIES } from './capabilities.js'\nimport type { GitHubClient } from './client.js'\nimport { GitHubReader } from './reader.js'\nimport type { RepoRef } from './types.js'\n\n/**\n * GitHubProvider — `RepoProvider` backed by the Octokit-driven GitHub\n * REST + Git Data APIs.\n *\n * The provider is transport-agnostic; it only talks to an `Octokit`\n * instance passed into the constructor. The `createGitHubProvider`\n * helper in `factory.ts` wraps the dynamic import so consumers never\n * have to touch Octokit directly.\n */\nexport class GitHubProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = GITHUB_CAPABILITIES\n private readonly reader: GitHubReader\n\n constructor(\n private readonly client: GitHubClient,\n public readonly repo: RepoRef,\n ) {\n this.reader = new GitHubReader(client, repo)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n applyPlan(input: ApplyPlanInput): Promise<Commit> {\n return applyPlanToGitHub(this.client, this.repo, input)\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.client, this.repo, prefix)\n }\n createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? 'main'\n return createBranchOp(this.client, this.repo, name, resolved)\n }\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.client, this.repo, name)\n }\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? 'main'\n return getBranchDiffOp(this.client, this.repo, branch, resolved)\n }\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.client, this.repo, branch, into)\n }\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? 'main'\n return isMergedOp(this.client, this.repo, branch, resolved)\n }\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.client, this.repo)\n }\n}\n","import type { GitHubClient } from './client.js'\nimport { exchangeInstallationToken } from './app-auth.js'\nimport { GitHubProvider } from './provider.js'\nimport type { GitHubAuth, RepoRef } from './types.js'\n\n/**\n * Create an Octokit-backed `GitHubClient` from an auth configuration.\n *\n * The `@octokit/rest` module is imported dynamically so it stays a pure\n * optional peer dependency — self-hosted MCP on stdio can run without\n * it. If the module is not installed, the import throws with a helpful\n * hint pointing the operator at the peer dependency.\n *\n * Two auth modes:\n *\n * - `pat` — personal access token or fine-grained PAT. Simplest for\n * self-hosted MCP or CI runners.\n * - `app` — GitHub App installation auth. The factory mints a short-\n * lived JWT, exchanges it for an installation token via\n * `exchangeInstallationToken`, and instantiates Octokit with the\n * resulting bearer. The returned token expires in ~1 hour; callers\n * that need auto-refresh should instead inject their own Octokit\n * built with `@octokit/auth-app`'s auth strategy and construct\n * `GitHubProvider` directly. See the embedding guide for trade-offs.\n */\nexport async function createGitHubClient(auth: GitHubAuth): Promise<GitHubClient> {\n let OctokitCtor: typeof import('@octokit/rest').Octokit\n try {\n ({ Octokit: OctokitCtor } = await import('@octokit/rest'))\n } catch (error) {\n throw new Error(\n '@octokit/rest is required for the GitHubProvider but could not be loaded. '\n + 'Install it as a peer dependency: pnpm add @octokit/rest.',\n { cause: error },\n )\n }\n\n if (auth.type === 'pat') {\n return new OctokitCtor({ auth: auth.token })\n }\n\n if (auth.type === 'app') {\n const { token } = await exchangeInstallationToken({\n appId: auth.appId,\n privateKey: auth.privateKey,\n installationId: auth.installationId,\n })\n return new OctokitCtor({ auth: token })\n }\n\n const unknown = auth as { type: string }\n throw new Error(`GitHub auth type \"${unknown.type}\" is not supported. Use \"pat\" or \"app\".`)\n}\n\n/**\n * Factory for the full provider — instantiates an Octokit client and\n * wraps it in a `GitHubProvider`. Consumers who already hold an Octokit\n * instance (HTTP server injecting shared clients, tests, etc.) should\n * instantiate `GitHubProvider` directly instead.\n */\nexport async function createGitHubProvider(opts: { auth: GitHubAuth, repo: RepoRef }): Promise<GitHubProvider> {\n const client = await createGitHubClient(opts.auth)\n return new GitHubProvider(client, opts.repo)\n}\n","import type { CommitAuthor } from '../../core/contracts/index.js'\n\n/**\n * A reference to a GitHub repository.\n *\n * `contentRoot` is the repo-relative directory prefix where Contentrain\n * content lives. For a flat content repo it stays `''`; for a monorepo\n * where Contentrain is embedded (e.g. `apps/web/.contentrain/`) it holds\n * that prefix. All reader/writer paths are joined against it.\n */\nexport interface RepoRef {\n owner: string\n name: string\n contentRoot?: string\n}\n\n/**\n * Authentication options for the GitHub provider.\n *\n * - `pat` — a personal access token or fine-grained PAT. Simplest for\n * self-hosted MCP or CI runners. Phase 5.1 ships with this mode only.\n * - `app` — GitHub App installation auth (JWT + installation token\n * exchange). Planned for Phase 5.2; see `.internal/refactor/\n * 02-studio-handoff.md` S6 for how Studio's hosted MCP plugs in.\n */\nexport type GitHubAuth =\n | { type: 'pat', token: string }\n | {\n type: 'app'\n appId: number\n privateKey: string\n installationId: number\n }\n\n/** Default author used when a call does not provide one. */\nexport const DEFAULT_GITHUB_AUTHOR: CommitAuthor = {\n name: 'Contentrain',\n email: 'ai@contentrain.io',\n}\n"],"mappings":";;;;;;;;;;;AA8CA,SAAS,gBAAgB,KAAsB;AAC7C,QAAO,OAAO,KAAK,KAAK,UAAU,IAAI,EAAE,OAAO,CAAC,SAAS,YAAY;;AAGvE,SAAgB,WAAW,QAA6D;CACtF,MAAM,MAAM,KAAK,MAAM,KAAK,KAAK,GAAG,IAAK;CACzC,MAAM,SAAS;EAAE,KAAK;EAAS,KAAK;EAAO;CAC3C,MAAM,UAAU;EACd,KAAK,MAAM;EACX,KAAK,MAAM;EACX,KAAK,OAAO,OAAO,MAAM;EAC1B;CAED,MAAM,SAAS,GAAG,gBAAgB,OAAO,CAAC,GAAG,gBAAgB,QAAQ;CACrE,MAAM,YAAY,iBAAiB;EAAE,KAAK,OAAO;EAAY,QAAQ;EAAO,CAAC;CAC7E,MAAM,SAAS,WAAW,aAAa;AACvC,QAAO,OAAO,OAAO;AACrB,QAAO,KAAK;AAGZ,QAAO,GAAG,OAAO,GAFC,OAAO,KAAK,UAAU,CAAC,SAAS,YAAY;;;;;;;;;AAYhE,eAAsB,0BACpB,QACA,OAAkE,EAAE,EAClC;CAClC,MAAM,MAAM,WAAW,OAAO;CAC9B,MAAM,UAAU,KAAK,WAAW;CAIhC,MAAM,WAAW,OAHC,KAAK,aAAa,WAAW,OAEnC,GAAG,QAAQ,qBAAqB,OAAO,eAAe,iBAC5B;EACpC,QAAQ;EACR,SAAS;GACP,eAAe,UAAU;GACzB,QAAQ;GACR,wBAAwB;GACzB;EACF,CAAC;AAEF,KAAI,CAAC,SAAS,IAAI;EAChB,MAAM,OAAO,MAAM,SAAS,MAAM,CAAC,YAAY,GAAG;AAClD,QAAM,IAAI,MACR,8CAA8C,SAAS,OAAO,GAAG,SAAS,aAAa,OAAO,MAAM,SAAS,KAC9G;;CAGH,MAAM,OAAO,MAAM,SAAS,MAAM;AAClC,QAAO;EAAE,OAAO,KAAK;EAAO,WAAW,KAAK;EAAY;;;;;;;;;;;;;;;;;ACvF1D,MAAa,sBAA4C;CACvD,eAAe;CACf,YAAY;CACZ,aAAa;CACb,YAAY;CACZ,kBAAkB;CAClB,qBAAqB;CACrB,SAAS;CACV;;;;;;;;;;;;;;;;;;;;;;ACSD,eAAsB,kBACpB,QACA,MACA,OACiB;CACjB,MAAM,EAAE,SAAS,iBAAiB,MAAM,eAAe,QAAQ,MAAM,MAAM,QAAQ,MAAM,KAAK;CAO9F,MAAM,eALa,MAAM,OAAO,KAAK,IAAI,UAAU;EACjD,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,YAAY;EACb,CAAC,EAC6B,KAAK,KAAK;CAEzC,MAAM,cAAc,MAAM,QAAQ,IAChC,MAAM,QAAQ,KAAI,WAAU,eAAe,QAAQ,MAAM,OAAO,CAAC,CAClE;CAED,MAAM,OAAO,MAAM,OAAO,KAAK,IAAI,WAAW;EAC5C,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,WAAW;EACX,MAAM;EACP,CAAC;CAEF,MAAM,6BAAY,IAAI,MAAM,EAAC,aAAa;CAC1C,MAAM,SAAS,MAAM,OAAO,KAAK,IAAI,aAAa;EAChD,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,SAAS,MAAM;EACf,MAAM,KAAK,KAAK;EAChB,SAAS,CAAC,QAAQ;EAClB,QAAQ;GACN,MAAM,MAAM,OAAO;GACnB,OAAO,MAAM,OAAO;GACpB,MAAM;GACP;EACF,CAAC;AAEF,KAAI,aACF,OAAM,OAAO,KAAK,IAAI,UAAU;EAC9B,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,KAAK,SAAS,MAAM;EACpB,KAAK,OAAO,KAAK;EAClB,CAAC;KAEF,OAAM,OAAO,KAAK,IAAI,UAAU;EAC9B,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,KAAK,cAAc,MAAM;EACzB,KAAK,OAAO,KAAK;EAClB,CAAC;AAGJ,QAAO;EACL,KAAK,OAAO,KAAK;EACjB,SAAS,OAAO,KAAK;EACrB,QAAQ;GACN,MAAM,OAAO,KAAK,QAAQ,QAAQ,MAAM,OAAO;GAC/C,OAAO,OAAO,KAAK,QAAQ,SAAS,MAAM,OAAO;GAClD;EACD,WAAW,OAAO,KAAK,QAAQ,QAAQ;EACxC;;AAGH,eAAe,eACb,QACA,MACA,QACoB;CACpB,MAAM,OAAO,gBAAgB,KAAK,aAAa,OAAO,KAAK;AAC3D,KAAI,OAAO,YAAY,KACrB,QAAO;EAAE;EAAM,MAAM;EAAU,MAAM;EAAQ,KAAK;EAAM;AAQ1D,QAAO;EAAE;EAAM,MAAM;EAAU,MAAM;EAAQ,MANhC,MAAM,OAAO,KAAK,IAAI,WAAW;GAC5C,OAAO,KAAK;GACZ,MAAM,KAAK;GACX,SAAS,OAAO;GAChB,UAAU;GACX,CAAC,EACqD,KAAK;EAAK;;AAGnE,eAAe,eACb,QACA,MACA,QACA,MACqD;AACrD,KAAI;AAMF,SAAO;GAAE,UALG,MAAM,OAAO,KAAK,IAAI,OAAO;IACvC,OAAO,KAAK;IACZ,MAAM,KAAK;IACX,KAAK,SAAS;IACf,CAAC,EACoB,KAAK,OAAO;GAAK,cAAc;GAAM;UACpD,OAAO;AACd,MAAI,CAAC,gBAAgB,MAAM,CAAE,OAAM;;CASrC,MAAM,cAAc,QAAQ;AAO5B,QAAO;EAAE,UALO,MAAM,OAAO,KAAK,IAAI,OAAO;GAC3C,OAAO,KAAK;GACZ,MAAM,KAAK;GACX,KAAK,SAAS;GACf,CAAC,EACwB,KAAK,OAAO;EAAK,cAAc;EAAO;;;;;;;;;;;;ACrIlE,eAAsB,iBAAiB,QAAsB,MAAgC;AAE3F,SADiB,MAAM,OAAO,KAAK,MAAM,IAAI;EAAE,OAAO,KAAK;EAAO,MAAM,KAAK;EAAM,CAAC,EACpE,KAAK;;AAGvB,eAAsB,aACpB,QACA,MACA,QACmB;CACnB,MAAM,WAAqB,EAAE;CAC7B,MAAM,WAAW,OAAO,SAAS,SAAS,OAAO,KAAK,MAAM,cAAc;EACxE,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,UAAU;EACX,CAAC;AACF,YAAW,MAAM,QAAQ,SACvB,MAAK,MAAM,KAAK,KAAK,MAAM;AACzB,MAAI,UAAU,CAAC,EAAE,KAAK,WAAW,OAAO,CAAE;AAC1C,WAAS,KAAK;GAAE,MAAM,EAAE;GAAM,KAAK,EAAE,OAAO;GAAK,WAAW,EAAE;GAAW,CAAC;;AAG9E,QAAO;;AAGT,eAAsB,aACpB,QACA,MACA,MACA,SACe;CACf,MAAM,OAAO,MAAM,OAAO,KAAK,IAAI,OAAO;EACxC,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,KAAK,SAAS;EACf,CAAC;AACF,OAAM,OAAO,KAAK,IAAI,UAAU;EAC9B,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,KAAK,cAAc;EACnB,KAAK,KAAK,KAAK,OAAO;EACvB,CAAC;;AAGJ,eAAsB,aACpB,QACA,MACA,MACe;AACf,OAAM,OAAO,KAAK,IAAI,UAAU;EAC9B,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,KAAK,SAAS;EACf,CAAC;;AAGJ,eAAsB,cACpB,QACA,MACA,QACA,MACqB;AAQrB,UAPiB,MAAM,OAAO,KAAK,MAAM,eAAe;EACtD,OAAO,KAAK;EACZ,MAAM,KAAK;EACX;EACA,MAAM;EACP,CAAC,EACqB,KAAK,SAAS,EAAE,EAC1B,KAAI,OAAM;EACrB,MAAM,EAAE;EACR,QAAQ,gBAAgB,EAAE,OAAO;EACjC,QAAQ;EACR,OAAO;EACR,EAAE;;AAGL,eAAsB,YACpB,QACA,MACA,QACA,MACsB;AACtB,KAAI;AAOF,SAAO;GAAE,QAAQ;GAAM,MANN,MAAM,OAAO,KAAK,MAAM,MAAM;IAC7C,OAAO,KAAK;IACZ,MAAM,KAAK;IACX,MAAM;IACN,MAAM;IACP,CAAC,EACmC,KAAK;GAAK,gBAAgB;GAAM;UAC9D,OAAO;AACd,MAAI,cAAc,MAAM,CACtB,QAAO;GAAE,QAAQ;GAAM,KAAK;GAAM,gBAAgB;GAAM;AAE1D,QAAM;;;AAIV,eAAsB,SACpB,QACA,MACA,QACA,MACkB;AAOlB,SANiB,MAAM,OAAO,KAAK,MAAM,eAAe;EACtD,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,MAAM;EACN,MAAM;EACP,CAAC,EACc,KAAK,aAAa;;AAGpC,SAAS,gBAAgB,QAAoC;AAC3D,KAAI,WAAW,QAAS,QAAO;AAC/B,KAAI,WAAW,UAAW,QAAO;AACjC,QAAO;;AAGT,SAAS,cAAc,OAAyB;AAC9C,QAAO,OAAO,UAAU,YAAY,UAAU,QAAS,MAA8B,WAAW;;;;;;;;;;;;;;;;;ACpHlG,IAAa,eAAb,MAAgD;CAC9C,YACE,QACA,MACA;AAFiB,OAAA,SAAA;AACA,OAAA,OAAA;;CAGnB,MAAM,SAAS,MAAc,KAA+B;EAC1D,MAAM,WAAW,gBAAgB,KAAK,KAAK,aAAa,KAAK;EAO7D,MAAM,QANW,MAAM,KAAK,OAAO,KAAK,MAAM,WAAW;GACvD,OAAO,KAAK,KAAK;GACjB,MAAM,KAAK,KAAK;GAChB,MAAM;GACN;GACD,CAAC,EACoB;AACtB,MAAI,MAAM,QAAQ,KAAK,CACrB,OAAM,IAAI,MAAM,uBAAuB,KAAK,8BAA8B;AAE5E,MAAI,KAAK,SAAS,OAChB,OAAM,IAAI,MAAM,uBAAuB,KAAK,SAAS,KAAK,KAAK,cAAc;AAI/E,MAAI,KAAK,YAAY,MAAM,KAAK,OAAO,GAAG;GACxC,MAAM,OAAO,MAAM,KAAK,OAAO,KAAK,IAAI,QAAQ;IAC9C,OAAO,KAAK,KAAK;IACjB,MAAM,KAAK,KAAK;IAChB,UAAU,KAAK;IAChB,CAAC;AACF,OAAI,KAAK,KAAK,aAAa,SACzB,OAAM,IAAI,MAAM,2CAA2C,KAAK,KAAK,SAAS,QAAQ,OAAO;AAE/F,UAAO,OAAO,KAAK,KAAK,KAAK,SAAS,SAAS,CAAC,SAAS,QAAQ;;AAGnE,MAAI,KAAK,aAAa,SACpB,OAAM,IAAI,MAAM,sCAAsC,KAAK,SAAS,QAAQ,OAAO;AAErF,SAAO,OAAO,KAAK,KAAK,SAAS,SAAS,CAAC,SAAS,QAAQ;;CAG9D,MAAM,cAAc,MAAc,KAAiC;EACjE,MAAM,WAAW,gBAAgB,KAAK,KAAK,aAAa,KAAK;AAC7D,MAAI;GAOF,MAAM,QANW,MAAM,KAAK,OAAO,KAAK,MAAM,WAAW;IACvD,OAAO,KAAK,KAAK;IACjB,MAAM,KAAK,KAAK;IAChB,MAAM;IACN;IACD,CAAC,EACoB;AACtB,OAAI,CAAC,MAAM,QAAQ,KAAK,CAAE,QAAO,EAAE;AACnC,UAAO,KAAK,KAAI,UAAS,MAAM,KAAK;WAC7B,OAAO;AACd,OAAI,gBAAgB,MAAM,CAAE,QAAO,EAAE;AACrC,SAAM;;;CAIV,MAAM,WAAW,MAAc,KAAgC;EAC7D,MAAM,WAAW,gBAAgB,KAAK,KAAK,aAAa,KAAK;AAC7D,MAAI;AACF,SAAM,KAAK,OAAO,KAAK,MAAM,WAAW;IACtC,OAAO,KAAK,KAAK;IACjB,MAAM,KAAK,KAAK;IAChB,MAAM;IACN;IACD,CAAC;AACF,UAAO;WACA,OAAO;AACd,OAAI,gBAAgB,MAAM,CAAE,QAAO;AACnC,SAAM;;;;;;;;;;;;;;;ACxDZ,IAAa,iBAAb,MAAoD;CAClD,eAA8C;CAC9C;CAEA,YACE,QACA,MACA;AAFiB,OAAA,SAAA;AACD,OAAA,OAAA;AAEhB,OAAK,SAAS,IAAI,aAAa,QAAQ,KAAK;;CAG9C,SAAS,MAAc,KAA+B;AACpD,SAAO,KAAK,OAAO,SAAS,MAAM,IAAI;;CAExC,cAAc,MAAc,KAAiC;AAC3D,SAAO,KAAK,OAAO,cAAc,MAAM,IAAI;;CAE7C,WAAW,MAAc,KAAgC;AACvD,SAAO,KAAK,OAAO,WAAW,MAAM,IAAI;;CAG1C,UAAU,OAAwC;AAChD,SAAO,kBAAkB,KAAK,QAAQ,KAAK,MAAM,MAAM;;CAGzD,aAAa,QAAoC;AAC/C,SAAOA,aAAe,KAAK,QAAQ,KAAK,MAAM,OAAO;;CAEvD,aAAa,MAAc,SAAiC;EAC1D,MAAM,WAAW,WAAW;AAC5B,SAAOC,aAAe,KAAK,QAAQ,KAAK,MAAM,MAAM,SAAS;;CAE/D,aAAa,MAA6B;AACxC,SAAOC,aAAe,KAAK,QAAQ,KAAK,MAAM,KAAK;;CAErD,cAAc,QAAgB,MAAoC;EAChE,MAAM,WAAW,QAAQ;AACzB,SAAOC,cAAgB,KAAK,QAAQ,KAAK,MAAM,QAAQ,SAAS;;CAElE,YAAY,QAAgB,MAAoC;AAC9D,SAAOC,YAAc,KAAK,QAAQ,KAAK,MAAM,QAAQ,KAAK;;CAE5D,SAAS,QAAgB,MAAiC;EACxD,MAAM,WAAW,QAAQ;AACzB,SAAOC,SAAW,KAAK,QAAQ,KAAK,MAAM,QAAQ,SAAS;;CAE7D,mBAAoC;AAClC,SAAOC,iBAAmB,KAAK,QAAQ,KAAK,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;ACvDrD,eAAsB,mBAAmB,MAAyC;CAChF,IAAI;AACJ,KAAI;AACF,GAAC,CAAE,SAAS,eAAgB,MAAM,OAAO;UAClC,OAAO;AACd,QAAM,IAAI,MACR,sIAEA,EAAE,OAAO,OAAO,CACjB;;AAGH,KAAI,KAAK,SAAS,MAChB,QAAO,IAAI,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC;AAG9C,KAAI,KAAK,SAAS,OAAO;EACvB,MAAM,EAAE,UAAU,MAAM,0BAA0B;GAChD,OAAO,KAAK;GACZ,YAAY,KAAK;GACjB,gBAAgB,KAAK;GACtB,CAAC;AACF,SAAO,IAAI,YAAY,EAAE,MAAM,OAAO,CAAC;;CAGzC,MAAM,UAAU;AAChB,OAAM,IAAI,MAAM,qBAAqB,QAAQ,KAAK,yCAAyC;;;;;;;;AAS7F,eAAsB,qBAAqB,MAAoE;AAE7G,QAAO,IAAI,eADI,MAAM,mBAAmB,KAAK,KAAK,EAChB,KAAK,KAAK;;;;;AC3B9C,MAAa,wBAAsC;CACjD,MAAM;CACN,OAAO;CACR"}
@@ -353,7 +353,7 @@ async function createGitLabProvider(opts) {
353
353
  /** Default author used when a call does not provide one. */
354
354
  const DEFAULT_GITLAB_AUTHOR = {
355
355
  name: "Contentrain",
356
- email: "mcp@contentrain.io"
356
+ email: "ai@contentrain.io"
357
357
  };
358
358
  //#endregion
359
359
  export { DEFAULT_GITLAB_AUTHOR, GITLAB_CAPABILITIES, GitLabProvider, GitLabReader, createGitLabClient, createGitLabProvider };
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["listBranchesOp","getDefaultBranchOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp"],"sources":["../../../src/providers/gitlab/capabilities.ts","../../../src/providers/gitlab/apply-plan.ts","../../../src/providers/gitlab/branch-ops.ts","../../../src/providers/gitlab/reader.ts","../../../src/providers/gitlab/provider.ts","../../../src/providers/gitlab/factory.ts","../../../src/providers/gitlab/types.ts"],"sourcesContent":["import type { ProviderCapabilities } from '../../core/contracts/index.js'\n\n/**\n * Capability set for GitLabProvider.\n *\n * GitLab over REST has no working tree, so local worktree features,\n * source-file access and AST scans are unavailable. Push, merge and\n * branch-protection detection all work over the API. GitLab enforces\n * merges through merge requests, so `pullRequestFallback` is `true` —\n * `mergeBranch` opens an MR and auto-accepts it to match GitHub's\n * `repos.merge` semantics while still leaving an audit trail.\n */\nexport const GITLAB_CAPABILITIES: ProviderCapabilities = {\n localWorktree: false,\n sourceRead: false,\n sourceWrite: false,\n pushRemote: true,\n branchProtection: true,\n pullRequestFallback: true,\n astScan: false,\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitLabClient } from './client.js'\nimport type { ProjectRef } from './types.js'\n\ntype CommitActionType = 'create' | 'update' | 'delete'\n\ninterface CommitAction {\n action: CommitActionType\n filePath: string\n content?: string\n encoding?: 'text' | 'base64'\n}\n\n/**\n * Apply a plan to a GitLab repository as a single atomic commit via the\n * Commits API. High-level flow:\n *\n * 1. Check whether the target branch exists. If it does not, GitLab's\n * `startBranch` option forks a new branch from `input.base` (or the\n * project's default branch) as part of the same commit.\n * 2. For each `FileChange`, figure out the right GitLab action verb\n * (`create` vs `update` vs `delete`) by probing the path against the\n * resolved ref. GitLab validates this server-side and returns a 400\n * for mismatches — we avoid the round trip by getting it right up\n * front.\n * 3. POST `/repository/commits` once with the full action set. No\n * working tree, no multi-call tree-assembly dance — GitLab exposes\n * a richer primitive than the GitHub Git Data API for this case.\n *\n * The commit is durable as soon as the call returns; GitLab's ref\n * update happens inside the same request.\n */\nexport async function applyPlanToGitLab(\n client: GitLabClient,\n project: ProjectRef,\n input: ApplyPlanInput,\n): Promise<Commit> {\n const branchExists = await branchHasRef(client, project, input.branch)\n // Invariant: fork from the Contentrain content-tracking branch, not\n // the repo's default branch. See ApplyPlanInput.base docstring in\n // @contentrain/types.\n const baseBranch = input.base ?? CONTENTRAIN_BRANCH\n\n // Actions are computed against the HEAD of the feature branch when it\n // exists, otherwise against the fork point (baseBranch). GitLab\n // treats `create` / `update` / `delete` as a strict check against the\n // path's existence at that ref.\n const refForActionResolution = branchExists ? input.branch : baseBranch\n const rawActions = await Promise.all(\n input.changes.map(change => resolveAction(client, project, refForActionResolution, change)),\n )\n const actions = rawActions.filter((a): a is CommitAction => a !== null)\n\n if (actions.length === 0) {\n // Nothing to apply. Callers don't generally build empty plans, but\n // if they do we short-circuit before touching the API.\n throw new Error('applyPlanToGitLab: plan contained no applicable actions')\n }\n\n const options: Record<string, unknown> = {\n authorName: input.author.name,\n authorEmail: input.author.email,\n }\n if (!branchExists) {\n options.startBranch = baseBranch\n }\n\n const response = await client.Commits.create(\n project.projectId,\n input.branch,\n input.message,\n actions,\n options,\n )\n\n // Gitbeaker's generic-heavy response type widens string fields to\n // `string | Camelize<…>` — at runtime they are always strings under\n // the default (non-camelize) response mode we use. Cast narrows the\n // shape we actually touch.\n const commit = response as {\n id: string\n message?: string\n author_name?: string\n author_email?: string\n created_at?: string\n }\n const commitTimestamp = toIsoTimestamp(commit.created_at) ?? new Date().toISOString()\n return {\n sha: commit.id,\n message: commit.message ?? input.message,\n author: {\n name: commit.author_name ?? input.author.name,\n email: commit.author_email ?? input.author.email,\n },\n timestamp: commitTimestamp,\n }\n}\n\nasync function resolveAction(\n client: GitLabClient,\n project: ProjectRef,\n ref: string,\n change: FileChange,\n): Promise<CommitAction | null> {\n const filePath = resolveRepoPath(project.contentRoot, change.path)\n const exists = await fileExistsAtRef(client, project, filePath, ref)\n\n if (change.content === null) {\n // Filter out deletes against non-existent files — GitLab returns\n // 400 otherwise. The plan author asked us to \"make this file not\n // exist\", which is already satisfied.\n if (!exists) return null\n return { action: 'delete', filePath }\n }\n\n return {\n action: exists ? 'update' : 'create',\n filePath,\n content: change.content,\n encoding: 'text',\n }\n}\n\nasync function branchHasRef(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n): Promise<boolean> {\n try {\n await client.Branches.show(project.projectId, branch)\n return true\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n}\n\nasync function fileExistsAtRef(\n client: GitLabClient,\n project: ProjectRef,\n filePath: string,\n ref: string,\n): Promise<boolean> {\n try {\n await client.RepositoryFiles.show(project.projectId, filePath, ref)\n return true\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n}\n\n\nfunction toIsoTimestamp(raw: unknown): string | null {\n if (typeof raw !== 'string') return null\n const date = new Date(raw)\n if (Number.isNaN(date.getTime())) return null\n return date.toISOString()\n}\n\n","import type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport type { GitLabClient } from './client.js'\nimport type { ProjectRef } from './types.js'\n\n/**\n * Branch / merge / diff helpers backed by the GitLab REST API.\n *\n * Pure functions so they can be composed into `GitLabProvider` or used\n * standalone. 404s collapse to empty-ish results where that matches\n * the `RepoProvider` contract (missing branch prefix → `[]`).\n *\n * Merge semantics: GitLab does not expose a direct branch-to-branch\n * merge endpoint. Every merge flows through a merge request, so\n * `mergeBranch` opens an MR and immediately accepts it. The resulting\n * `MergeResult` mirrors GitHub's `repos.merge` return shape — callers\n * see the same `{ merged, sha, pullRequestUrl }` envelope either way,\n * with the MR URL available for audit.\n */\n\nexport async function getDefaultBranch(\n client: GitLabClient,\n project: ProjectRef,\n): Promise<string> {\n const p = await client.Projects.show(project.projectId) as { default_branch?: string }\n return p.default_branch ?? 'main'\n}\n\nexport async function listBranches(\n client: GitLabClient,\n project: ProjectRef,\n prefix?: string,\n): Promise<Branch[]> {\n // Gitbeaker's `all` supports a `search` string (substring match). For\n // Contentrain's `cr/*` naming the substring case is equivalent to a\n // prefix because the slug never appears anywhere except at the start.\n // Server-side filter + client-side prefix enforcement keeps us\n // correct even if the substring coincidence breaks someday.\n const options: Record<string, unknown> = {\n perPage: 100,\n maxPages: 10,\n }\n if (prefix) options.search = prefix\n\n const rawBranches = await client.Branches.all(project.projectId, options)\n const branches = Array.isArray(rawBranches) ? rawBranches : []\n\n return branches\n .filter((b: { name: string }) => !prefix || b.name.startsWith(prefix))\n .map((b: { name: string, commit: { id: string }, protected?: boolean }) => ({\n name: b.name,\n sha: b.commit.id,\n protected: b.protected ?? false,\n }))\n}\n\nexport async function createBranch(\n client: GitLabClient,\n project: ProjectRef,\n name: string,\n fromRef: string,\n): Promise<void> {\n await client.Branches.create(project.projectId, name, fromRef)\n}\n\nexport async function deleteBranch(\n client: GitLabClient,\n project: ProjectRef,\n name: string,\n): Promise<void> {\n await client.Branches.remove(project.projectId, name)\n}\n\nexport async function getBranchDiff(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const response = await client.Repositories.compare(\n project.projectId,\n base,\n branch,\n { straight: false },\n )\n const diffs = Array.isArray(response.diffs) ? response.diffs : []\n return diffs.map((d: { new_path: string, old_path: string, new_file?: boolean, deleted_file?: boolean }) => ({\n path: d.new_file ? d.new_path : d.old_path,\n status: d.deleted_file ? 'removed' : d.new_file ? 'added' : 'modified',\n before: null,\n after: null,\n }))\n}\n\nexport async function mergeBranch(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n // 1. Open MR — GitLab rejects create when source === target or when\n // an MR is already open for this pair. Let the error propagate in\n // those cases; the caller retries or surfaces the message.\n const mr = await client.MergeRequests.create(\n project.projectId,\n branch,\n into,\n `[contentrain] merge ${branch} → ${into}`,\n { removeSourceBranch: false },\n )\n\n // 2. Accept the MR immediately. `shouldRemoveSourceBranch: false`\n // preserves the feature branch so audit/retry logic still has\n // access to it; Studio's cleanup flow deletes it separately.\n const accepted = await client.MergeRequests.accept(\n project.projectId,\n (mr as { iid: number }).iid,\n { shouldRemoveSourceBranch: false, squash: false },\n )\n\n const mergeSha = (accepted as { merge_commit_sha?: string | null, sha?: string | null }).merge_commit_sha\n ?? (accepted as { sha?: string | null }).sha\n ?? null\n const webUrl = (mr as { web_url?: string }).web_url ?? null\n\n return { merged: mergeSha !== null, sha: mergeSha, pullRequestUrl: webUrl }\n}\n\nexport async function isMerged(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n into: string,\n): Promise<boolean> {\n // compare(from=into, to=branch) — commits list is empty when branch\n // is fully contained in into (i.e. already merged).\n const response = await client.Repositories.compare(\n project.projectId,\n into,\n branch,\n { straight: false },\n )\n const commits = Array.isArray(response.commits) ? response.commits : []\n return commits.length === 0\n}\n","import type { RepoReader } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitLabClient } from './client.js'\nimport type { ProjectRef } from './types.js'\n\n/**\n * GitLabReader — `RepoReader` backed by the GitLab REST API.\n *\n * File reads go through `RepositoryFiles.showRaw`, which returns the\n * file content as UTF-8 text (or a `Blob` on browser/edge runtimes we\n * decode with `.text()`). Directory listings go through\n * `Repositories.allRepositoryTrees`. `fileExists` tries the file\n * endpoint first and falls back to a tree listing so directories\n * resolve to `true` as well — matching LocalReader / GitHubReader.\n *\n * `ref` is forwarded verbatim and may be a branch name, tag name or\n * commit SHA. Callers should always pass the explicit `contentrain`\n * tracking branch; GitLab's default resolution is the project's\n * default branch, which is usually wrong for Contentrain flows.\n */\nexport class GitLabReader implements RepoReader {\n constructor(\n private readonly client: GitLabClient,\n private readonly project: ProjectRef,\n ) {}\n\n async readFile(path: string, ref?: string): Promise<string> {\n const repoPath = resolveRepoPath(this.project.contentRoot, path)\n const resolvedRef = ref ?? await this.resolveDefaultRef()\n const raw = await this.client.RepositoryFiles.showRaw(\n this.project.projectId,\n repoPath,\n resolvedRef,\n )\n if (typeof raw === 'string') return raw\n // Browser / edge runtimes return a Blob; decode as UTF-8.\n return (raw as Blob).text()\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const repoPath = resolveRepoPath(this.project.contentRoot, path)\n const resolvedRef = ref ?? await this.resolveDefaultRef()\n try {\n const entries = await this.client.Repositories.allRepositoryTrees(\n this.project.projectId,\n {\n path: repoPath,\n ref: resolvedRef,\n perPage: 100,\n recursive: false,\n },\n )\n return Array.isArray(entries) ? entries.map(e => e.name) : []\n } catch (error) {\n if (isNotFoundError(error)) return []\n throw error\n }\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const repoPath = resolveRepoPath(this.project.contentRoot, path)\n const resolvedRef = ref ?? await this.resolveDefaultRef()\n\n // 1. Try as a file — cheap and most common case for Contentrain.\n try {\n await this.client.RepositoryFiles.show(\n this.project.projectId,\n repoPath,\n resolvedRef,\n )\n return true\n } catch (error) {\n if (!isNotFoundError(error)) throw error\n }\n\n // 2. Fall back to a tree listing — directories and empty dirs show\n // up here. Any non-404 result means the path resolves.\n try {\n const entries = await this.client.Repositories.allRepositoryTrees(\n this.project.projectId,\n { path: repoPath, ref: resolvedRef, perPage: 1 },\n )\n return Array.isArray(entries) && entries.length > 0\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n }\n\n private async resolveDefaultRef(): Promise<string> {\n const project = await this.client.Projects.show(this.project.projectId) as { default_branch?: string }\n return project.default_branch ?? 'main'\n }\n}\n\n","import type {\n ApplyPlanInput,\n Branch,\n Commit,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { applyPlanToGitLab } from './apply-plan.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { GITLAB_CAPABILITIES } from './capabilities.js'\nimport type { GitLabClient } from './client.js'\nimport { GitLabReader } from './reader.js'\nimport type { ProjectRef } from './types.js'\n\n/**\n * GitLabProvider — `RepoProvider` backed by the gitbeaker-driven GitLab\n * REST API.\n *\n * Transport-agnostic: the provider only talks to a `GitLabClient`\n * (a `@gitbeaker/rest` `Gitlab` instance). `createGitLabProvider` in\n * `factory.ts` wraps the dynamic import so consumers never touch\n * `@gitbeaker/rest` directly unless they want to.\n *\n * Capability gaps versus `GitHubProvider`: none — both providers\n * expose the same set. GitLab's merge flow routes through an MR under\n * the hood, but `mergeBranch` presents the same `MergeResult` shape.\n */\nexport class GitLabProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = GITLAB_CAPABILITIES\n private readonly reader: GitLabReader\n\n constructor(\n private readonly client: GitLabClient,\n public readonly project: ProjectRef,\n ) {\n this.reader = new GitLabReader(client, project)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n applyPlan(input: ApplyPlanInput): Promise<Commit> {\n return applyPlanToGitLab(this.client, this.project, input)\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.client, this.project, prefix)\n }\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? await getDefaultBranchOp(this.client, this.project)\n await createBranchOp(this.client, this.project, name, resolved)\n }\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.client, this.project, name)\n }\n async getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? await getDefaultBranchOp(this.client, this.project)\n return getBranchDiffOp(this.client, this.project, branch, resolved)\n }\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.client, this.project, branch, into)\n }\n async isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? await getDefaultBranchOp(this.client, this.project)\n return isMergedOp(this.client, this.project, branch, resolved)\n }\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.client, this.project)\n }\n}\n","import type { GitLabClient } from './client.js'\nimport { GitLabProvider } from './provider.js'\nimport type { GitLabAuth, ProjectRef } from './types.js'\n\n/**\n * Create a gitbeaker-backed `GitLabClient` from an auth configuration.\n *\n * The `@gitbeaker/rest` module is imported dynamically so it stays a\n * pure optional peer dependency — self-hosted MCP on stdio runs fine\n * without it. If the module is not installed, the import throws with\n * a helpful hint pointing the operator at the peer dependency.\n *\n * Supported auth types: `pat`, `oauth`, `job`. All three are thin\n * wrappers over gitbeaker's `token`, `oauthToken`, and `jobToken`\n * constructor options.\n */\nexport async function createGitLabClient(\n auth: GitLabAuth,\n host?: string,\n): Promise<GitLabClient> {\n let GitlabCtor: typeof import('@gitbeaker/rest').Gitlab\n try {\n ({ Gitlab: GitlabCtor } = await import('@gitbeaker/rest'))\n } catch (error) {\n throw new Error(\n '@gitbeaker/rest is required for the GitLabProvider but could not be loaded. '\n + 'Install it as a peer dependency: pnpm add @gitbeaker/rest.',\n { cause: error },\n )\n }\n\n const config: Record<string, unknown> = host ? { host } : {}\n switch (auth.type) {\n case 'pat':\n config.token = auth.token\n break\n case 'oauth':\n config.oauthToken = auth.oauthToken\n break\n case 'job':\n config.jobToken = auth.jobToken\n break\n default: {\n const { type } = auth as { type: string }\n throw new Error(`Unsupported GitLab auth type: \"${type}\"`)\n }\n }\n\n return new GitlabCtor(config) as GitLabClient\n}\n\n/**\n * Factory for the full provider — instantiates a gitbeaker client and\n * wraps it in a `GitLabProvider`. Consumers who already hold a\n * gitbeaker instance (HTTP server injecting shared clients, tests,\n * etc.) should instantiate `GitLabProvider` directly instead.\n */\nexport async function createGitLabProvider(\n opts: { auth: GitLabAuth, project: ProjectRef },\n): Promise<GitLabProvider> {\n const client = await createGitLabClient(opts.auth, opts.project.host)\n return new GitLabProvider(client, opts.project)\n}\n","import type { CommitAuthor } from '../../core/contracts/index.js'\n\n/**\n * A reference to a GitLab project.\n *\n * `projectId` accepts the numeric project ID or a URL-encoded path\n * (`namespace/project`). Gitbeaker URL-encodes string paths internally,\n * so either form is fine.\n *\n * `contentRoot` is the repo-relative directory prefix where Contentrain\n * content lives. For a flat content repo it stays `''`; for a monorepo\n * where Contentrain sits under `apps/web/.contentrain/` it holds that\n * prefix. All reader/writer paths are joined against it.\n *\n * `host` points at a self-hosted GitLab instance. Leave undefined to\n * use `https://gitlab.com` (gitbeaker's default).\n */\nexport interface ProjectRef {\n projectId: string | number\n contentRoot?: string\n host?: string\n}\n\n/**\n * Authentication options for the GitLab provider.\n *\n * - `pat` — personal access token with the `api` scope. Simplest for\n * self-hosted MCP or CI runners.\n * - `oauth` — OAuth2 token. Used when the runner is driven by a GitLab\n * OAuth flow.\n * - `job` — CI job token (`CI_JOB_TOKEN`). Scoped to the running\n * pipeline; useful for pipeline-driven content updates.\n */\nexport type GitLabAuth =\n | { type: 'pat', token: string }\n | { type: 'oauth', oauthToken: string }\n | { type: 'job', jobToken: string }\n\n/** Default author used when a call does not provide one. */\nexport const DEFAULT_GITLAB_AUTHOR: CommitAuthor = {\n name: 'Contentrain',\n email: 'mcp@contentrain.io',\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,MAAa,sBAA4C;CACvD,eAAe;CACf,YAAY;CACZ,aAAa;CACb,YAAY;CACZ,kBAAkB;CAClB,qBAAqB;CACrB,SAAS;CACV;;;;;;;;;;;;;;;;;;;;;;ACcD,eAAsB,kBACpB,QACA,SACA,OACiB;CACjB,MAAM,eAAe,MAAM,aAAa,QAAQ,SAAS,MAAM,OAAO;CAItE,MAAM,aAAa,MAAM,QAAQ;CAMjC,MAAM,yBAAyB,eAAe,MAAM,SAAS;CAI7D,MAAM,WAHa,MAAM,QAAQ,IAC/B,MAAM,QAAQ,KAAI,WAAU,cAAc,QAAQ,SAAS,wBAAwB,OAAO,CAAC,CAC5F,EAC0B,QAAQ,MAAyB,MAAM,KAAK;AAEvE,KAAI,QAAQ,WAAW,EAGrB,OAAM,IAAI,MAAM,0DAA0D;CAG5E,MAAM,UAAmC;EACvC,YAAY,MAAM,OAAO;EACzB,aAAa,MAAM,OAAO;EAC3B;AACD,KAAI,CAAC,aACH,SAAQ,cAAc;CAexB,MAAM,SAZW,MAAM,OAAO,QAAQ,OACpC,QAAQ,WACR,MAAM,QACN,MAAM,SACN,SACA,QACD;CAaD,MAAM,kBAAkB,eAAe,OAAO,WAAW,qBAAI,IAAI,MAAM,EAAC,aAAa;AACrF,QAAO;EACL,KAAK,OAAO;EACZ,SAAS,OAAO,WAAW,MAAM;EACjC,QAAQ;GACN,MAAM,OAAO,eAAe,MAAM,OAAO;GACzC,OAAO,OAAO,gBAAgB,MAAM,OAAO;GAC5C;EACD,WAAW;EACZ;;AAGH,eAAe,cACb,QACA,SACA,KACA,QAC8B;CAC9B,MAAM,WAAW,gBAAgB,QAAQ,aAAa,OAAO,KAAK;CAClE,MAAM,SAAS,MAAM,gBAAgB,QAAQ,SAAS,UAAU,IAAI;AAEpE,KAAI,OAAO,YAAY,MAAM;AAI3B,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO;GAAE,QAAQ;GAAU;GAAU;;AAGvC,QAAO;EACL,QAAQ,SAAS,WAAW;EAC5B;EACA,SAAS,OAAO;EAChB,UAAU;EACX;;AAGH,eAAe,aACb,QACA,SACA,QACkB;AAClB,KAAI;AACF,QAAM,OAAO,SAAS,KAAK,QAAQ,WAAW,OAAO;AACrD,SAAO;UACA,OAAO;AACd,MAAI,gBAAgB,MAAM,CAAE,QAAO;AACnC,QAAM;;;AAIV,eAAe,gBACb,QACA,SACA,UACA,KACkB;AAClB,KAAI;AACF,QAAM,OAAO,gBAAgB,KAAK,QAAQ,WAAW,UAAU,IAAI;AACnE,SAAO;UACA,OAAO;AACd,MAAI,gBAAgB,MAAM,CAAE,QAAO;AACnC,QAAM;;;AAKV,SAAS,eAAe,KAA6B;AACnD,KAAI,OAAO,QAAQ,SAAU,QAAO;CACpC,MAAM,OAAO,IAAI,KAAK,IAAI;AAC1B,KAAI,OAAO,MAAM,KAAK,SAAS,CAAC,CAAE,QAAO;AACzC,QAAO,KAAK,aAAa;;;;;;;;;;;;;;;;;;AC5I3B,eAAsB,iBACpB,QACA,SACiB;AAEjB,SADU,MAAM,OAAO,SAAS,KAAK,QAAQ,UAAU,EAC9C,kBAAkB;;AAG7B,eAAsB,aACpB,QACA,SACA,QACmB;CAMnB,MAAM,UAAmC;EACvC,SAAS;EACT,UAAU;EACX;AACD,KAAI,OAAQ,SAAQ,SAAS;CAE7B,MAAM,cAAc,MAAM,OAAO,SAAS,IAAI,QAAQ,WAAW,QAAQ;AAGzE,SAFiB,MAAM,QAAQ,YAAY,GAAG,cAAc,EAAE,EAG3D,QAAQ,MAAwB,CAAC,UAAU,EAAE,KAAK,WAAW,OAAO,CAAC,CACrE,KAAK,OAAsE;EAC1E,MAAM,EAAE;EACR,KAAK,EAAE,OAAO;EACd,WAAW,EAAE,aAAa;EAC3B,EAAE;;AAGP,eAAsB,aACpB,QACA,SACA,MACA,SACe;AACf,OAAM,OAAO,SAAS,OAAO,QAAQ,WAAW,MAAM,QAAQ;;AAGhE,eAAsB,aACpB,QACA,SACA,MACe;AACf,OAAM,OAAO,SAAS,OAAO,QAAQ,WAAW,KAAK;;AAGvD,eAAsB,cACpB,QACA,SACA,QACA,MACqB;CACrB,MAAM,WAAW,MAAM,OAAO,aAAa,QACzC,QAAQ,WACR,MACA,QACA,EAAE,UAAU,OAAO,CACpB;AAED,SADc,MAAM,QAAQ,SAAS,MAAM,GAAG,SAAS,QAAQ,EAAE,EACpD,KAAK,OAA2F;EAC3G,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE;EAClC,QAAQ,EAAE,eAAe,YAAY,EAAE,WAAW,UAAU;EAC5D,QAAQ;EACR,OAAO;EACR,EAAE;;AAGL,eAAsB,YACpB,QACA,SACA,QACA,MACsB;CAItB,MAAM,KAAK,MAAM,OAAO,cAAc,OACpC,QAAQ,WACR,QACA,MACA,uBAAuB,OAAO,KAAK,QACnC,EAAE,oBAAoB,OAAO,CAC9B;CAKD,MAAM,WAAW,MAAM,OAAO,cAAc,OAC1C,QAAQ,WACP,GAAuB,KACxB;EAAE,0BAA0B;EAAO,QAAQ;EAAO,CACnD;CAED,MAAM,WAAY,SAAuE,oBACnF,SAAqC,OACtC;CACL,MAAM,SAAU,GAA4B,WAAW;AAEvD,QAAO;EAAE,QAAQ,aAAa;EAAM,KAAK;EAAU,gBAAgB;EAAQ;;AAG7E,eAAsB,SACpB,QACA,SACA,QACA,MACkB;CAGlB,MAAM,WAAW,MAAM,OAAO,aAAa,QACzC,QAAQ,WACR,MACA,QACA,EAAE,UAAU,OAAO,CACpB;AAED,SADgB,MAAM,QAAQ,SAAS,QAAQ,GAAG,SAAS,UAAU,EAAE,EACxD,WAAW;;;;;;;;;;;;;;;;;;;AC1H5B,IAAa,eAAb,MAAgD;CAC9C,YACE,QACA,SACA;AAFiB,OAAA,SAAA;AACA,OAAA,UAAA;;CAGnB,MAAM,SAAS,MAAc,KAA+B;EAC1D,MAAM,WAAW,gBAAgB,KAAK,QAAQ,aAAa,KAAK;EAChE,MAAM,cAAc,OAAO,MAAM,KAAK,mBAAmB;EACzD,MAAM,MAAM,MAAM,KAAK,OAAO,gBAAgB,QAC5C,KAAK,QAAQ,WACb,UACA,YACD;AACD,MAAI,OAAO,QAAQ,SAAU,QAAO;AAEpC,SAAQ,IAAa,MAAM;;CAG7B,MAAM,cAAc,MAAc,KAAiC;EACjE,MAAM,WAAW,gBAAgB,KAAK,QAAQ,aAAa,KAAK;EAChE,MAAM,cAAc,OAAO,MAAM,KAAK,mBAAmB;AACzD,MAAI;GACF,MAAM,UAAU,MAAM,KAAK,OAAO,aAAa,mBAC7C,KAAK,QAAQ,WACb;IACE,MAAM;IACN,KAAK;IACL,SAAS;IACT,WAAW;IACZ,CACF;AACD,UAAO,MAAM,QAAQ,QAAQ,GAAG,QAAQ,KAAI,MAAK,EAAE,KAAK,GAAG,EAAE;WACtD,OAAO;AACd,OAAI,gBAAgB,MAAM,CAAE,QAAO,EAAE;AACrC,SAAM;;;CAIV,MAAM,WAAW,MAAc,KAAgC;EAC7D,MAAM,WAAW,gBAAgB,KAAK,QAAQ,aAAa,KAAK;EAChE,MAAM,cAAc,OAAO,MAAM,KAAK,mBAAmB;AAGzD,MAAI;AACF,SAAM,KAAK,OAAO,gBAAgB,KAChC,KAAK,QAAQ,WACb,UACA,YACD;AACD,UAAO;WACA,OAAO;AACd,OAAI,CAAC,gBAAgB,MAAM,CAAE,OAAM;;AAKrC,MAAI;GACF,MAAM,UAAU,MAAM,KAAK,OAAO,aAAa,mBAC7C,KAAK,QAAQ,WACb;IAAE,MAAM;IAAU,KAAK;IAAa,SAAS;IAAG,CACjD;AACD,UAAO,MAAM,QAAQ,QAAQ,IAAI,QAAQ,SAAS;WAC3C,OAAO;AACd,OAAI,gBAAgB,MAAM,CAAE,QAAO;AACnC,SAAM;;;CAIV,MAAc,oBAAqC;AAEjD,UADgB,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK,QAAQ,UAAU,EACxD,kBAAkB;;;;;;;;;;;;;;;;;;ACtDrC,IAAa,iBAAb,MAAoD;CAClD,eAA8C;CAC9C;CAEA,YACE,QACA,SACA;AAFiB,OAAA,SAAA;AACD,OAAA,UAAA;AAEhB,OAAK,SAAS,IAAI,aAAa,QAAQ,QAAQ;;CAGjD,SAAS,MAAc,KAA+B;AACpD,SAAO,KAAK,OAAO,SAAS,MAAM,IAAI;;CAExC,cAAc,MAAc,KAAiC;AAC3D,SAAO,KAAK,OAAO,cAAc,MAAM,IAAI;;CAE7C,WAAW,MAAc,KAAgC;AACvD,SAAO,KAAK,OAAO,WAAW,MAAM,IAAI;;CAG1C,UAAU,OAAwC;AAChD,SAAO,kBAAkB,KAAK,QAAQ,KAAK,SAAS,MAAM;;CAG5D,aAAa,QAAoC;AAC/C,SAAOA,aAAe,KAAK,QAAQ,KAAK,SAAS,OAAO;;CAE1D,MAAM,aAAa,MAAc,SAAiC;EAChE,MAAM,WAAW,WAAW,MAAMC,iBAAmB,KAAK,QAAQ,KAAK,QAAQ;AAC/E,QAAMC,aAAe,KAAK,QAAQ,KAAK,SAAS,MAAM,SAAS;;CAEjE,aAAa,MAA6B;AACxC,SAAOC,aAAe,KAAK,QAAQ,KAAK,SAAS,KAAK;;CAExD,MAAM,cAAc,QAAgB,MAAoC;EACtE,MAAM,WAAW,QAAQ,MAAMF,iBAAmB,KAAK,QAAQ,KAAK,QAAQ;AAC5E,SAAOG,cAAgB,KAAK,QAAQ,KAAK,SAAS,QAAQ,SAAS;;CAErE,YAAY,QAAgB,MAAoC;AAC9D,SAAOC,YAAc,KAAK,QAAQ,KAAK,SAAS,QAAQ,KAAK;;CAE/D,MAAM,SAAS,QAAgB,MAAiC;EAC9D,MAAM,WAAW,QAAQ,MAAMJ,iBAAmB,KAAK,QAAQ,KAAK,QAAQ;AAC5E,SAAOK,SAAW,KAAK,QAAQ,KAAK,SAAS,QAAQ,SAAS;;CAEhE,mBAAoC;AAClC,SAAOL,iBAAmB,KAAK,QAAQ,KAAK,QAAQ;;;;;;;;;;;;;;;;;ACpExD,eAAsB,mBACpB,MACA,MACuB;CACvB,IAAI;AACJ,KAAI;AACF,GAAC,CAAE,QAAQ,cAAe,MAAM,OAAO;UAChC,OAAO;AACd,QAAM,IAAI,MACR,0IAEA,EAAE,OAAO,OAAO,CACjB;;CAGH,MAAM,SAAkC,OAAO,EAAE,MAAM,GAAG,EAAE;AAC5D,SAAQ,KAAK,MAAb;EACE,KAAK;AACH,UAAO,QAAQ,KAAK;AACpB;EACF,KAAK;AACH,UAAO,aAAa,KAAK;AACzB;EACF,KAAK;AACH,UAAO,WAAW,KAAK;AACvB;EACF,SAAS;GACP,MAAM,EAAE,SAAS;AACjB,SAAM,IAAI,MAAM,kCAAkC,KAAK,GAAG;;;AAI9D,QAAO,IAAI,WAAW,OAAO;;;;;;;;AAS/B,eAAsB,qBACpB,MACyB;AAEzB,QAAO,IAAI,eADI,MAAM,mBAAmB,KAAK,MAAM,KAAK,QAAQ,KAAK,EACnC,KAAK,QAAQ;;;;;ACtBjD,MAAa,wBAAsC;CACjD,MAAM;CACN,OAAO;CACR"}
1
+ {"version":3,"file":"index.mjs","names":["listBranchesOp","getDefaultBranchOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp"],"sources":["../../../src/providers/gitlab/capabilities.ts","../../../src/providers/gitlab/apply-plan.ts","../../../src/providers/gitlab/branch-ops.ts","../../../src/providers/gitlab/reader.ts","../../../src/providers/gitlab/provider.ts","../../../src/providers/gitlab/factory.ts","../../../src/providers/gitlab/types.ts"],"sourcesContent":["import type { ProviderCapabilities } from '../../core/contracts/index.js'\n\n/**\n * Capability set for GitLabProvider.\n *\n * GitLab over REST has no working tree, so local worktree features,\n * source-file access and AST scans are unavailable. Push, merge and\n * branch-protection detection all work over the API. GitLab enforces\n * merges through merge requests, so `pullRequestFallback` is `true` —\n * `mergeBranch` opens an MR and auto-accepts it to match GitHub's\n * `repos.merge` semantics while still leaving an audit trail.\n */\nexport const GITLAB_CAPABILITIES: ProviderCapabilities = {\n localWorktree: false,\n sourceRead: false,\n sourceWrite: false,\n pushRemote: true,\n branchProtection: true,\n pullRequestFallback: true,\n astScan: false,\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitLabClient } from './client.js'\nimport type { ProjectRef } from './types.js'\n\ntype CommitActionType = 'create' | 'update' | 'delete'\n\ninterface CommitAction {\n action: CommitActionType\n filePath: string\n content?: string\n encoding?: 'text' | 'base64'\n}\n\n/**\n * Apply a plan to a GitLab repository as a single atomic commit via the\n * Commits API. High-level flow:\n *\n * 1. Check whether the target branch exists. If it does not, GitLab's\n * `startBranch` option forks a new branch from `input.base` (or the\n * project's default branch) as part of the same commit.\n * 2. For each `FileChange`, figure out the right GitLab action verb\n * (`create` vs `update` vs `delete`) by probing the path against the\n * resolved ref. GitLab validates this server-side and returns a 400\n * for mismatches — we avoid the round trip by getting it right up\n * front.\n * 3. POST `/repository/commits` once with the full action set. No\n * working tree, no multi-call tree-assembly dance — GitLab exposes\n * a richer primitive than the GitHub Git Data API for this case.\n *\n * The commit is durable as soon as the call returns; GitLab's ref\n * update happens inside the same request.\n */\nexport async function applyPlanToGitLab(\n client: GitLabClient,\n project: ProjectRef,\n input: ApplyPlanInput,\n): Promise<Commit> {\n const branchExists = await branchHasRef(client, project, input.branch)\n // Invariant: fork from the Contentrain content-tracking branch, not\n // the repo's default branch. See ApplyPlanInput.base docstring in\n // @contentrain/types.\n const baseBranch = input.base ?? CONTENTRAIN_BRANCH\n\n // Actions are computed against the HEAD of the feature branch when it\n // exists, otherwise against the fork point (baseBranch). GitLab\n // treats `create` / `update` / `delete` as a strict check against the\n // path's existence at that ref.\n const refForActionResolution = branchExists ? input.branch : baseBranch\n const rawActions = await Promise.all(\n input.changes.map(change => resolveAction(client, project, refForActionResolution, change)),\n )\n const actions = rawActions.filter((a): a is CommitAction => a !== null)\n\n if (actions.length === 0) {\n // Nothing to apply. Callers don't generally build empty plans, but\n // if they do we short-circuit before touching the API.\n throw new Error('applyPlanToGitLab: plan contained no applicable actions')\n }\n\n const options: Record<string, unknown> = {\n authorName: input.author.name,\n authorEmail: input.author.email,\n }\n if (!branchExists) {\n options.startBranch = baseBranch\n }\n\n const response = await client.Commits.create(\n project.projectId,\n input.branch,\n input.message,\n actions,\n options,\n )\n\n // Gitbeaker's generic-heavy response type widens string fields to\n // `string | Camelize<…>` — at runtime they are always strings under\n // the default (non-camelize) response mode we use. Cast narrows the\n // shape we actually touch.\n const commit = response as {\n id: string\n message?: string\n author_name?: string\n author_email?: string\n created_at?: string\n }\n const commitTimestamp = toIsoTimestamp(commit.created_at) ?? new Date().toISOString()\n return {\n sha: commit.id,\n message: commit.message ?? input.message,\n author: {\n name: commit.author_name ?? input.author.name,\n email: commit.author_email ?? input.author.email,\n },\n timestamp: commitTimestamp,\n }\n}\n\nasync function resolveAction(\n client: GitLabClient,\n project: ProjectRef,\n ref: string,\n change: FileChange,\n): Promise<CommitAction | null> {\n const filePath = resolveRepoPath(project.contentRoot, change.path)\n const exists = await fileExistsAtRef(client, project, filePath, ref)\n\n if (change.content === null) {\n // Filter out deletes against non-existent files — GitLab returns\n // 400 otherwise. The plan author asked us to \"make this file not\n // exist\", which is already satisfied.\n if (!exists) return null\n return { action: 'delete', filePath }\n }\n\n return {\n action: exists ? 'update' : 'create',\n filePath,\n content: change.content,\n encoding: 'text',\n }\n}\n\nasync function branchHasRef(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n): Promise<boolean> {\n try {\n await client.Branches.show(project.projectId, branch)\n return true\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n}\n\nasync function fileExistsAtRef(\n client: GitLabClient,\n project: ProjectRef,\n filePath: string,\n ref: string,\n): Promise<boolean> {\n try {\n await client.RepositoryFiles.show(project.projectId, filePath, ref)\n return true\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n}\n\n\nfunction toIsoTimestamp(raw: unknown): string | null {\n if (typeof raw !== 'string') return null\n const date = new Date(raw)\n if (Number.isNaN(date.getTime())) return null\n return date.toISOString()\n}\n\n","import type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport type { GitLabClient } from './client.js'\nimport type { ProjectRef } from './types.js'\n\n/**\n * Branch / merge / diff helpers backed by the GitLab REST API.\n *\n * Pure functions so they can be composed into `GitLabProvider` or used\n * standalone. 404s collapse to empty-ish results where that matches\n * the `RepoProvider` contract (missing branch prefix → `[]`).\n *\n * Merge semantics: GitLab does not expose a direct branch-to-branch\n * merge endpoint. Every merge flows through a merge request, so\n * `mergeBranch` opens an MR and immediately accepts it. The resulting\n * `MergeResult` mirrors GitHub's `repos.merge` return shape — callers\n * see the same `{ merged, sha, pullRequestUrl }` envelope either way,\n * with the MR URL available for audit.\n */\n\nexport async function getDefaultBranch(\n client: GitLabClient,\n project: ProjectRef,\n): Promise<string> {\n const p = await client.Projects.show(project.projectId) as { default_branch?: string }\n return p.default_branch ?? 'main'\n}\n\nexport async function listBranches(\n client: GitLabClient,\n project: ProjectRef,\n prefix?: string,\n): Promise<Branch[]> {\n // Gitbeaker's `all` supports a `search` string (substring match). For\n // Contentrain's `cr/*` naming the substring case is equivalent to a\n // prefix because the slug never appears anywhere except at the start.\n // Server-side filter + client-side prefix enforcement keeps us\n // correct even if the substring coincidence breaks someday.\n const options: Record<string, unknown> = {\n perPage: 100,\n maxPages: 10,\n }\n if (prefix) options.search = prefix\n\n const rawBranches = await client.Branches.all(project.projectId, options)\n const branches = Array.isArray(rawBranches) ? rawBranches : []\n\n return branches\n .filter((b: { name: string }) => !prefix || b.name.startsWith(prefix))\n .map((b: { name: string, commit: { id: string }, protected?: boolean }) => ({\n name: b.name,\n sha: b.commit.id,\n protected: b.protected ?? false,\n }))\n}\n\nexport async function createBranch(\n client: GitLabClient,\n project: ProjectRef,\n name: string,\n fromRef: string,\n): Promise<void> {\n await client.Branches.create(project.projectId, name, fromRef)\n}\n\nexport async function deleteBranch(\n client: GitLabClient,\n project: ProjectRef,\n name: string,\n): Promise<void> {\n await client.Branches.remove(project.projectId, name)\n}\n\nexport async function getBranchDiff(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const response = await client.Repositories.compare(\n project.projectId,\n base,\n branch,\n { straight: false },\n )\n const diffs = Array.isArray(response.diffs) ? response.diffs : []\n return diffs.map((d: { new_path: string, old_path: string, new_file?: boolean, deleted_file?: boolean }) => ({\n path: d.new_file ? d.new_path : d.old_path,\n status: d.deleted_file ? 'removed' : d.new_file ? 'added' : 'modified',\n before: null,\n after: null,\n }))\n}\n\nexport async function mergeBranch(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n // 1. Open MR — GitLab rejects create when source === target or when\n // an MR is already open for this pair. Let the error propagate in\n // those cases; the caller retries or surfaces the message.\n const mr = await client.MergeRequests.create(\n project.projectId,\n branch,\n into,\n `[contentrain] merge ${branch} → ${into}`,\n { removeSourceBranch: false },\n )\n\n // 2. Accept the MR immediately. `shouldRemoveSourceBranch: false`\n // preserves the feature branch so audit/retry logic still has\n // access to it; Studio's cleanup flow deletes it separately.\n const accepted = await client.MergeRequests.accept(\n project.projectId,\n (mr as { iid: number }).iid,\n { shouldRemoveSourceBranch: false, squash: false },\n )\n\n const mergeSha = (accepted as { merge_commit_sha?: string | null, sha?: string | null }).merge_commit_sha\n ?? (accepted as { sha?: string | null }).sha\n ?? null\n const webUrl = (mr as { web_url?: string }).web_url ?? null\n\n return { merged: mergeSha !== null, sha: mergeSha, pullRequestUrl: webUrl }\n}\n\nexport async function isMerged(\n client: GitLabClient,\n project: ProjectRef,\n branch: string,\n into: string,\n): Promise<boolean> {\n // compare(from=into, to=branch) — commits list is empty when branch\n // is fully contained in into (i.e. already merged).\n const response = await client.Repositories.compare(\n project.projectId,\n into,\n branch,\n { straight: false },\n )\n const commits = Array.isArray(response.commits) ? response.commits : []\n return commits.length === 0\n}\n","import type { RepoReader } from '../../core/contracts/index.js'\nimport { isNotFoundError, resolveRepoPath } from '../shared/index.js'\nimport type { GitLabClient } from './client.js'\nimport type { ProjectRef } from './types.js'\n\n/**\n * GitLabReader — `RepoReader` backed by the GitLab REST API.\n *\n * File reads go through `RepositoryFiles.showRaw`, which returns the\n * file content as UTF-8 text (or a `Blob` on browser/edge runtimes we\n * decode with `.text()`). Directory listings go through\n * `Repositories.allRepositoryTrees`. `fileExists` tries the file\n * endpoint first and falls back to a tree listing so directories\n * resolve to `true` as well — matching LocalReader / GitHubReader.\n *\n * `ref` is forwarded verbatim and may be a branch name, tag name or\n * commit SHA. Callers should always pass the explicit `contentrain`\n * tracking branch; GitLab's default resolution is the project's\n * default branch, which is usually wrong for Contentrain flows.\n */\nexport class GitLabReader implements RepoReader {\n constructor(\n private readonly client: GitLabClient,\n private readonly project: ProjectRef,\n ) {}\n\n async readFile(path: string, ref?: string): Promise<string> {\n const repoPath = resolveRepoPath(this.project.contentRoot, path)\n const resolvedRef = ref ?? await this.resolveDefaultRef()\n const raw = await this.client.RepositoryFiles.showRaw(\n this.project.projectId,\n repoPath,\n resolvedRef,\n )\n if (typeof raw === 'string') return raw\n // Browser / edge runtimes return a Blob; decode as UTF-8.\n return (raw as Blob).text()\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const repoPath = resolveRepoPath(this.project.contentRoot, path)\n const resolvedRef = ref ?? await this.resolveDefaultRef()\n try {\n const entries = await this.client.Repositories.allRepositoryTrees(\n this.project.projectId,\n {\n path: repoPath,\n ref: resolvedRef,\n perPage: 100,\n recursive: false,\n },\n )\n return Array.isArray(entries) ? entries.map(e => e.name) : []\n } catch (error) {\n if (isNotFoundError(error)) return []\n throw error\n }\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const repoPath = resolveRepoPath(this.project.contentRoot, path)\n const resolvedRef = ref ?? await this.resolveDefaultRef()\n\n // 1. Try as a file — cheap and most common case for Contentrain.\n try {\n await this.client.RepositoryFiles.show(\n this.project.projectId,\n repoPath,\n resolvedRef,\n )\n return true\n } catch (error) {\n if (!isNotFoundError(error)) throw error\n }\n\n // 2. Fall back to a tree listing — directories and empty dirs show\n // up here. Any non-404 result means the path resolves.\n try {\n const entries = await this.client.Repositories.allRepositoryTrees(\n this.project.projectId,\n { path: repoPath, ref: resolvedRef, perPage: 1 },\n )\n return Array.isArray(entries) && entries.length > 0\n } catch (error) {\n if (isNotFoundError(error)) return false\n throw error\n }\n }\n\n private async resolveDefaultRef(): Promise<string> {\n const project = await this.client.Projects.show(this.project.projectId) as { default_branch?: string }\n return project.default_branch ?? 'main'\n }\n}\n\n","import type {\n ApplyPlanInput,\n Branch,\n Commit,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { applyPlanToGitLab } from './apply-plan.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { GITLAB_CAPABILITIES } from './capabilities.js'\nimport type { GitLabClient } from './client.js'\nimport { GitLabReader } from './reader.js'\nimport type { ProjectRef } from './types.js'\n\n/**\n * GitLabProvider — `RepoProvider` backed by the gitbeaker-driven GitLab\n * REST API.\n *\n * Transport-agnostic: the provider only talks to a `GitLabClient`\n * (a `@gitbeaker/rest` `Gitlab` instance). `createGitLabProvider` in\n * `factory.ts` wraps the dynamic import so consumers never touch\n * `@gitbeaker/rest` directly unless they want to.\n *\n * Capability gaps versus `GitHubProvider`: none — both providers\n * expose the same set. GitLab's merge flow routes through an MR under\n * the hood, but `mergeBranch` presents the same `MergeResult` shape.\n */\nexport class GitLabProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = GITLAB_CAPABILITIES\n private readonly reader: GitLabReader\n\n constructor(\n private readonly client: GitLabClient,\n public readonly project: ProjectRef,\n ) {\n this.reader = new GitLabReader(client, project)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n applyPlan(input: ApplyPlanInput): Promise<Commit> {\n return applyPlanToGitLab(this.client, this.project, input)\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.client, this.project, prefix)\n }\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? await getDefaultBranchOp(this.client, this.project)\n await createBranchOp(this.client, this.project, name, resolved)\n }\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.client, this.project, name)\n }\n async getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? await getDefaultBranchOp(this.client, this.project)\n return getBranchDiffOp(this.client, this.project, branch, resolved)\n }\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.client, this.project, branch, into)\n }\n async isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? await getDefaultBranchOp(this.client, this.project)\n return isMergedOp(this.client, this.project, branch, resolved)\n }\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.client, this.project)\n }\n}\n","import type { GitLabClient } from './client.js'\nimport { GitLabProvider } from './provider.js'\nimport type { GitLabAuth, ProjectRef } from './types.js'\n\n/**\n * Create a gitbeaker-backed `GitLabClient` from an auth configuration.\n *\n * The `@gitbeaker/rest` module is imported dynamically so it stays a\n * pure optional peer dependency — self-hosted MCP on stdio runs fine\n * without it. If the module is not installed, the import throws with\n * a helpful hint pointing the operator at the peer dependency.\n *\n * Supported auth types: `pat`, `oauth`, `job`. All three are thin\n * wrappers over gitbeaker's `token`, `oauthToken`, and `jobToken`\n * constructor options.\n */\nexport async function createGitLabClient(\n auth: GitLabAuth,\n host?: string,\n): Promise<GitLabClient> {\n let GitlabCtor: typeof import('@gitbeaker/rest').Gitlab\n try {\n ({ Gitlab: GitlabCtor } = await import('@gitbeaker/rest'))\n } catch (error) {\n throw new Error(\n '@gitbeaker/rest is required for the GitLabProvider but could not be loaded. '\n + 'Install it as a peer dependency: pnpm add @gitbeaker/rest.',\n { cause: error },\n )\n }\n\n const config: Record<string, unknown> = host ? { host } : {}\n switch (auth.type) {\n case 'pat':\n config.token = auth.token\n break\n case 'oauth':\n config.oauthToken = auth.oauthToken\n break\n case 'job':\n config.jobToken = auth.jobToken\n break\n default: {\n const { type } = auth as { type: string }\n throw new Error(`Unsupported GitLab auth type: \"${type}\"`)\n }\n }\n\n return new GitlabCtor(config) as GitLabClient\n}\n\n/**\n * Factory for the full provider — instantiates a gitbeaker client and\n * wraps it in a `GitLabProvider`. Consumers who already hold a\n * gitbeaker instance (HTTP server injecting shared clients, tests,\n * etc.) should instantiate `GitLabProvider` directly instead.\n */\nexport async function createGitLabProvider(\n opts: { auth: GitLabAuth, project: ProjectRef },\n): Promise<GitLabProvider> {\n const client = await createGitLabClient(opts.auth, opts.project.host)\n return new GitLabProvider(client, opts.project)\n}\n","import type { CommitAuthor } from '../../core/contracts/index.js'\n\n/**\n * A reference to a GitLab project.\n *\n * `projectId` accepts the numeric project ID or a URL-encoded path\n * (`namespace/project`). Gitbeaker URL-encodes string paths internally,\n * so either form is fine.\n *\n * `contentRoot` is the repo-relative directory prefix where Contentrain\n * content lives. For a flat content repo it stays `''`; for a monorepo\n * where Contentrain sits under `apps/web/.contentrain/` it holds that\n * prefix. All reader/writer paths are joined against it.\n *\n * `host` points at a self-hosted GitLab instance. Leave undefined to\n * use `https://gitlab.com` (gitbeaker's default).\n */\nexport interface ProjectRef {\n projectId: string | number\n contentRoot?: string\n host?: string\n}\n\n/**\n * Authentication options for the GitLab provider.\n *\n * - `pat` — personal access token with the `api` scope. Simplest for\n * self-hosted MCP or CI runners.\n * - `oauth` — OAuth2 token. Used when the runner is driven by a GitLab\n * OAuth flow.\n * - `job` — CI job token (`CI_JOB_TOKEN`). Scoped to the running\n * pipeline; useful for pipeline-driven content updates.\n */\nexport type GitLabAuth =\n | { type: 'pat', token: string }\n | { type: 'oauth', oauthToken: string }\n | { type: 'job', jobToken: string }\n\n/** Default author used when a call does not provide one. */\nexport const DEFAULT_GITLAB_AUTHOR: CommitAuthor = {\n name: 'Contentrain',\n email: 'ai@contentrain.io',\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import "../../contracts-DfL0BfrD.mjs";
2
- import "../../ops-DjEWi5Wa.mjs";
2
+ import "../../ops-Dh2T3u53.mjs";
3
3
  import "../../serialization-B1CEzR4H.mjs";
4
4
  import "../../fs-DLbVB-Ek.mjs";
5
5
  import "../../config-Lw7vpK9n.mjs";
@@ -7,6 +7,6 @@ import { C as LocalReader } from "../../model-manager-BhLsUgaB.mjs";
7
7
  import "../../meta-manager-C900rEyG.mjs";
8
8
  import "../../context-DS40WOdw.mjs";
9
9
  import "../../id-CIO9s0x_.mjs";
10
- import "../../transaction-0D_edyh0.mjs";
11
- import { t as LocalProvider } from "../../local-BEl5L7p5.mjs";
10
+ import "../../transaction-LiTjan9Q.mjs";
11
+ import { t as LocalProvider } from "../../local-D56D9e5T.mjs";
12
12
  export { LocalProvider, LocalReader };
@@ -1,5 +1,5 @@
1
1
  import "../../contracts-DfL0BfrD.mjs";
2
- import "../../ops-DjEWi5Wa.mjs";
2
+ import "../../ops-Dh2T3u53.mjs";
3
3
  import "../../serialization-B1CEzR4H.mjs";
4
4
  import "../../fs-DLbVB-Ek.mjs";
5
5
  import "../../config-Lw7vpK9n.mjs";
@@ -7,18 +7,18 @@ import "../../model-manager-BhLsUgaB.mjs";
7
7
  import "../../meta-manager-C900rEyG.mjs";
8
8
  import "../../context-DS40WOdw.mjs";
9
9
  import "../../id-CIO9s0x_.mjs";
10
- import "../../transaction-0D_edyh0.mjs";
11
- import "../../local-BEl5L7p5.mjs";
10
+ import "../../transaction-LiTjan9Q.mjs";
11
+ import "../../local-D56D9e5T.mjs";
12
12
  import "../../detect-DL9mFmaS.mjs";
13
13
  import "../../branch-lifecycle-DjX3MXNG.mjs";
14
14
  import "../../annotations-BFJbfSKe.mjs";
15
- import { t as createServer } from "../../server-BpQvNZke.mjs";
15
+ import { t as createServer } from "../../server-CufhOunD.mjs";
16
16
  import "../../validator-Bo06QPVB.mjs";
17
17
  import "../../scan-config-jC6XmiqF.mjs";
18
18
  import "../../graph-builder-DXv2JEp-.mjs";
19
19
  import "../../scanner-DKMwc03k.mjs";
20
20
  import "../../tsx-parser-CQc6nr-o.mjs";
21
- import "../../apply-manager-CDss8ZIr.mjs";
21
+ import "../../apply-manager-BJ3nKEls.mjs";
22
22
  import "../../doctor-Dxu_SzZy.mjs";
23
23
  import { randomUUID } from "node:crypto";
24
24
  import http from "node:http";
@@ -1,12 +1,12 @@
1
- import { i as planContentDelete, n as planModelDelete, r as planContentSave, t as planModelSave } from "./ops-DjEWi5Wa.mjs";
1
+ import { i as planContentDelete, n as planModelDelete, r as planContentSave, t as planModelSave } from "./ops-Dh2T3u53.mjs";
2
2
  import { n as contentFilePath, t as contentDirPath } from "./paths-CYl5vCBD.mjs";
3
3
  import { a as readJson, n as ensureDir, r as pathExists, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
4
4
  import { n as readVocabulary, t as readConfig } from "./config-Lw7vpK9n.mjs";
5
5
  import { S as writeContent, _ as resolveMdFilePath, a as fieldDefZodSchema, c as validateModelDefinition, d as listContent, g as resolveLocaleStrategy, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, n as checkReferences, o as listModels, r as countEntries, s as readModel, u as deleteContent } from "./model-manager-BhLsUgaB.mjs";
6
6
  import { n as readMeta, r as writeMeta } from "./meta-manager-C900rEyG.mjs";
7
7
  import { n as readContext } from "./context-DS40WOdw.mjs";
8
- import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-0D_edyh0.mjs";
9
- import { n as isMerged, t as LocalProvider } from "./local-BEl5L7p5.mjs";
8
+ import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-LiTjan9Q.mjs";
9
+ import { n as isMerged, t as LocalProvider } from "./local-D56D9e5T.mjs";
10
10
  import { t as detectStack } from "./detect-DL9mFmaS.mjs";
11
11
  import { n as checkBranchHealth, r as cleanupMergedBranches } from "./branch-lifecycle-DjX3MXNG.mjs";
12
12
  import { t as TOOL_ANNOTATIONS } from "./annotations-BFJbfSKe.mjs";
@@ -15,7 +15,7 @@ import { t as validateProject } from "./validator-Bo06QPVB.mjs";
15
15
  import { t as OverlayReader } from "./overlay-reader-CUs9jgqk.mjs";
16
16
  import { t as buildGraph } from "./graph-builder-DXv2JEp-.mjs";
17
17
  import { n as scanSummary, t as scanCandidates } from "./scanner-DKMwc03k.mjs";
18
- import { n as applyExtract, r as applyReuse } from "./apply-manager-CDss8ZIr.mjs";
18
+ import { n as applyExtract, r as applyReuse } from "./apply-manager-BJ3nKEls.mjs";
19
19
  import { t as runDoctor } from "./doctor-Dxu_SzZy.mjs";
20
20
  import { join } from "node:path";
21
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -788,7 +788,7 @@ async function commitThroughProvider(provider, input) {
788
788
  message,
789
789
  author: {
790
790
  name: "Contentrain",
791
- email: "mcp@contentrain.io"
791
+ email: "ai@contentrain.io"
792
792
  },
793
793
  base: CONTENTRAIN_BRANCH
794
794
  })).sha,
@@ -1030,7 +1030,7 @@ const validateModel = validateModelDefinition;
1030
1030
  //#endregion
1031
1031
  //#region src/tools/content.ts
1032
1032
  function registerContentTools(server, provider, projectRoot) {
1033
- server.tool("contentrain_content_save", "Save content entries. Entry format varies by model kind: DICTIONARY — provide \"locale\" and \"data\" (flat key-value, all string values); \"id\" and \"slug\" are ignored; data keys are the identities. COLLECTION — provide \"locale\" and \"data\"; \"id\" is optional (auto-generated if omitted); \"slug\" is ignored. DOCUMENT — provide \"slug\" (required), \"locale\", and \"data\"; use the \"body\" key inside data for markdown content. SINGLETON — provide only \"locale\" and \"data\". Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.", {
1033
+ server.tool("contentrain_content_save", "Save content entries. Entry format varies by model kind: DICTIONARY — provide \"locale\" and \"data\" (flat key-value, all string values); \"id\" and \"slug\" are ignored; data keys are the identities. COLLECTION — provide \"locale\" and \"data\"; \"id\" is optional (auto-generated if omitted); \"slug\" is ignored. DOCUMENT — provide \"slug\" (required), \"locale\", and \"data\"; use the \"body\" key inside data for markdown content. SINGLETON — provide only \"locale\" and \"data\". MEDIA FIELDS (image/video/file): for a media-library asset, pass its storage path (\"media/...\") or URL; in cloud mode these are automatically normalized to absolute public delivery URLs on save (in markdown bodies too), so saved content renders in a browser anywhere with no SDK — in local mode the relative path is kept as-is. For external images (e.g. a CDN or Unsplash URL), pass the URL directly; it is saved untouched. Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.", {
1034
1034
  model: z.string().describe("Model ID"),
1035
1035
  entries: z.array(z.object({
1036
1036
  id: z.string().optional().describe("Entry ID (collection only, auto-generated if omitted)"),
@@ -1118,7 +1118,8 @@ function registerContentTools(server, provider, projectRoot) {
1118
1118
  model,
1119
1119
  entries: input.entries,
1120
1120
  config,
1121
- vocabulary
1121
+ vocabulary,
1122
+ mediaBaseUrl: provider.mediaBaseUrl
1122
1123
  });
1123
1124
  } catch (error) {
1124
1125
  return {
@@ -2333,7 +2334,7 @@ function registerDoctorTools(server, _provider, projectRoot) {
2333
2334
  }
2334
2335
  //#endregion
2335
2336
  //#region package.json
2336
- var version = "1.5.1";
2337
+ var version = "1.6.0";
2337
2338
  //#endregion
2338
2339
  //#region src/server.ts
2339
2340
  function createServer(input) {
@@ -2374,4 +2375,4 @@ function resolveServerContext(input) {
2374
2375
  //#endregion
2375
2376
  export { createServer as t };
2376
2377
 
2377
- //# sourceMappingURL=server-BpQvNZke.mjs.map
2378
+ //# sourceMappingURL=server-CufhOunD.mjs.map