orquestra-mcp 1.20.0 → 1.23.1

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # orquestra-mcp
2
2
 
3
- Servidor MCP (Model Context Protocol) para Orquestra. Expone **90 tools**
3
+ Servidor MCP (Model Context Protocol) para Orquestra. Expone **99 tools**
4
4
  que llaman a la API de `orquestra-infra`: lectura de contexto/dashboard/
5
5
  workspace/infra/paridad/listados, escritura de tareas/ideas/changelog/
6
6
  notas/docs/módulos/flujo/categorías/infra/proyectos, y corridas de QA —
@@ -192,7 +192,7 @@ create/update sigue siendo criterio del agente y del humano.
192
192
 
193
193
  ## Estado
194
194
 
195
- 90 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
195
+ 99 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
196
196
  sync report, setup guide, playbooks de etapa y de plataforma, catálogo de
197
197
  skills, search) + `list_*` de cada colección + creación/edición de tareas
198
198
  (con checklist, tiempo, adjuntos, merge), ideas (con comentarios), notas,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.20.0",
3
+ "version": "1.23.1",
4
4
  "description": "Servidor MCP de Orquestra -- 89 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,15 @@
1
1
  // Puerto exacto de orquestra-web/src/lib/platformPlaybooks.js (PLATFORM_PLAYBOOKS) --
2
2
  // constantes estaticas, sin acceso a red ni a la base de datos. get_platform_playbook
3
3
  // lo lee directo desde aca (mismo criterio que stages.mjs/foundations.mjs).
4
+ // Puerto de TRACKING_TOOLS de la web: herramientas de medición conectables a una
5
+ // distribución web. El ID vive en distributions.publishForm[key]. `pattern` valida
6
+ // el formato (se serializa como string en la web, acá es el RegExp directo).
7
+ export const TRACKING_TOOLS = [
8
+ { key: 'ga4_measurement_id', label: 'Google Analytics 4', idLabel: 'ID de medición', pattern: /^G-[A-Z0-9]{6,12}$/, example: 'G-ABC123DEF4', where: 'Google Analytics → Admin → Flujos de datos' },
9
+ { key: 'meta_pixel_id', label: 'Meta Pixel', idLabel: 'ID del Pixel', pattern: /^\d{8,20}$/, example: '1234567890123456', where: 'Meta Business → Events Manager' },
10
+ { key: 'gtm_container_id', label: 'Google Tag Manager', idLabel: 'ID del contenedor', pattern: /^GTM-[A-Z0-9]{4,10}$/, example: 'GTM-ABC1234', where: 'Tag Manager → Administrador → ID del contenedor' },
11
+ ]
12
+
4
13
  export const PLATFORM_PLAYBOOKS = {
5
14
 
6
15
  ios: {
@@ -288,6 +297,11 @@ export const PLATFORM_PLAYBOOKS = {
288
297
  title: 'Google Analytics 4 / Firebase Analytics',
289
298
  body: 'Mide visitas, origen y conversiones. Si el sitio ya usa Firebase, activa Google Analytics en el proyecto y usa el SDK web (measurementId); si no, crea una propiedad GA4 y pega la etiqueta. Define al menos un evento de conversión (registro, clic en CTA, descarga). Si el sitio sirve a usuarios de la UE/México, acuerda el aviso de cookies y consentimiento antes de activar.',
290
299
  },
300
+ {
301
+ key: 'meta_pixel',
302
+ title: 'Meta Pixel (si harás anuncios)',
303
+ body: 'Solo si vas a pautar en Facebook/Instagram: crea el Pixel en Meta Business → Events Manager y pega su ID numérico abajo en "Conexiones de medición". Sirve para medir conversiones de los anuncios y armar audiencias de remarketing. Requiere consentimiento de cookies en la UE/México y debe mencionarse en la política de privacidad.',
304
+ },
291
305
  {
292
306
  key: 'bing_webmaster',
293
307
  title: 'Bing Webmaster Tools (opcional)',
@@ -339,7 +353,12 @@ export const PLATFORM_PLAYBOOKS = {
339
353
  {
340
354
  title: 'Instalar analítica (Google Analytics 4 / Firebase Analytics) y definir una conversión',
341
355
  description: 'Activar GA4 (o Google Analytics en el proyecto Firebase y usar el SDK web con measurementId), confirmar en Tiempo real que registra visitas propias y marcar al menos un evento como conversión (registro, CTA, descarga).',
342
- type: 'tech', priority: 'high', tags: ['seo', 'web', 'analytics'], offsetDays: 2,
356
+ type: 'tech', priority: 'high', tags: ['seo', 'web', 'analytics'], offsetDays: 2, checkKey: 'analytics',
357
+ },
358
+ {
359
+ title: 'Instalar Meta Pixel y verificar el evento PageView',
360
+ description: 'Solo si habrá anuncios en Facebook/Instagram: crear el Pixel en Events Manager, instalarlo en el sitio y confirmar PageView con la extensión Meta Pixel Helper o la pestaña Probar eventos.',
361
+ type: 'tech', priority: 'low', tags: ['seo', 'web', 'analytics', 'meta'], offsetDays: 4, checkKey: 'meta_pixel',
343
362
  },
344
363
  {
345
364
  title: 'Verificar que sitemap.xml y robots.txt respondan 200 y no bloqueen el sitio',
package/src/server.mjs CHANGED
@@ -6,7 +6,7 @@ import { z } from 'zod'
6
6
  import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
7
7
  import { STAGES, PLAYBOOKS } from './stages.mjs'
8
8
  import { FOUNDATIONS } from './foundations.mjs'
9
- import { PLATFORM_PLAYBOOKS } from './platformPlaybooks.mjs'
9
+ import { PLATFORM_PLAYBOOKS, TRACKING_TOOLS } from './platformPlaybooks.mjs'
10
10
  import { SKILLS_CATALOG } from './skillsCatalog.mjs'
11
11
  import { parseSource, previewSkill, installSkill } from './skillInstall.mjs'
12
12
 
@@ -89,6 +89,8 @@ Al aplicar un playbook con apply_stage_playbook -- sobre todo el de etapa "post"
89
89
 
90
90
  Al aplicar apply_platform_playbook (Platform Playbook de publicación iOS/Android) con publishForm: solo completa las claves para las que tengas base real del proyecto (código, get_project_context, servicios/entornos ya configurados) -- mismo criterio que el botón "Completar con IA" de la web: category/content_rights/data_collection/content_rating suelen inferirse bien para una app B2B sin ads/tracking, pero pricing, availability, package/bundle ID, cuentas demo para el reviewer o el nombre exacto de la app NUNCA se inventan -- dejalas fuera de publishForm para que el usuario las complete a mano. Si la tool devuelve publishFormConflict, la distribución ya tenía datos guardados en esas claves y no se escribió nada -- mostrale al usuario el valor actual vs. el propuesto de cada una, y repetí el llamado con confirmReplace:true solo si confirma explícito el reemplazo (no lo asumas ni lo repitas sin esa confirmación).
91
91
 
92
+ Analítica y anuncios en un sitio web: antes de instalar o tocar Google Analytics 4, Meta Pixel o Tag Manager en el código, llama a get_tracking_ids -- si la distribución web ya tiene el ID guardado (ga4_measurement_id, meta_pixel_id, gtm_container_id), úsalo tal cual; nunca inventes un ID ni se lo vuelvas a pedir al usuario. Si falta y el usuario te lo da (o lo lees literal en el código/consola real), guárdalo con set_tracking_ids para que el resto de agentes y la guía web lo vean. Si ya hay uno distinto guardado, no lo reemplaces sin confirmación explícita (confirmReplace).
93
+
92
94
  Durante el trabajo normal de código, sin que el usuario te lo pida explícitamente y SIN ESPERAR a que lo mencione (si tienes esta tool disponible en la sesión, se usa por default en todo trabajo real, no solo cuando alguien dice "usa Orquestra"):
93
95
  - Si te pide una funcionalidad o arreglo real (no una pregunta rápida ni exploración de código sin cambios), revisa con list_tasks/search si ya existe una tarea relacionada; si no, créala con create_task ANTES de empezar a trabajar, en el estatus inicial del proyecto (ver list_statuses). Si no hay un proyecto default configurado para esta carpeta, no te rindas ahí -- usa get_workspace_summary para encontrar el proyecto correcto y pásalo explícito como projectId en cada llamada.
94
96
  - Mientras trabajas, mueve la tarea por los estatus reales según avanzas (no la dejes en el inicial hasta el final). Al terminar, muévela a su estatus final con update_task y registra una entrada de changelog con create_changelog_entry -- así el usuario nunca tiene que documentar esto a mano. Si sabes en qué repo real estás trabajando (podés correr "git remote get-url origin" o ya lo sabes por la carpeta), pásalo SIEMPRE como "repo" ("owner/name") -- encontrado real (17 ago 2026): ni el hook local ni el GitHub Action ni la reconciliación de orquestra-infra lo estaban mandando pese a que el campo existe hace rato, así que ninguna entrada automática sabía de qué repo/plataforma venía. Si además la distribución de ese repo ya está identificada (ver list_distributions), pásala en "distributions" -- sin esto el changelog no puede filtrar/agrupar por plataforma.
@@ -613,6 +615,126 @@ export function createServer() {
613
615
  }
614
616
  )
615
617
 
618
+ // Revisión de Contenido (ORQ-184, 2 oct 2026). Aprobar NO tiene tool propia:
619
+ // es update_content con status "approved" (el server estampa quién/cuándo).
620
+ // GitHub (ORQ-200, 3 oct 2026): las rutas aceptan token de servicio del proyecto.
621
+ // Crear/borrar integraciones NO se expone a propósito: maneja el PAT y exige
622
+ // sesión de miembro (web).
623
+ registerRead(
624
+ 'list_github_integrations',
625
+ '/v1/github-integrations',
626
+ 'Lista los repos de GitHub conectados a un proyecto (repo, rama, distribución vinculada, último error/chequeo). Úsala para saber qué repos alimentan el changelog antes de pedir actividad o un sync.',
627
+ {},
628
+ (args, projectId) => ({ projectId })
629
+ )
630
+
631
+ registerRead(
632
+ 'get_github_activity',
633
+ '/v1/github-activity',
634
+ 'Actividad reciente de GitHub por repo conectado del proyecto (commits, PRs). Sirve para saber qué se hizo realmente sin leer el repo a mano.',
635
+ { days: z.number().int().min(1).max(90).optional().describe('Ventana en días hacia atrás (default del servidor)') },
636
+ (args, projectId) => ({ projectId, days: args.days })
637
+ )
638
+
639
+ server.registerTool(
640
+ 'sync_task_from_github',
641
+ {
642
+ description: 'Actualiza UNA tarea desde GitHub: busca commits/PRs que la mencionan (ORQ-###) y refleja su estado real. Si el proyecto tiene varios repos conectados, pasa integrationId (list_github_integrations).',
643
+ inputSchema: {
644
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
645
+ taskId: z.string().describe('ID de la tarea'),
646
+ integrationId: z.string().optional().describe('Integración (repo) concreta; opcional si solo hay una'),
647
+ },
648
+ },
649
+ async ({ projectId, taskId, integrationId }) => {
650
+ try {
651
+ return textResult(await apiPost(config, `/v1/tasks/${taskId}/sync-github`, { projectId: resolveProjectId(projectId), ...(integrationId ? { integrationId } : {}) }))
652
+ } catch (err) {
653
+ return errorResult(err)
654
+ }
655
+ }
656
+ )
657
+
658
+ server.registerTool(
659
+ 'run_closure_scan',
660
+ {
661
+ description: 'Escanea la actividad reciente de GitHub y PROPONE cerrar tareas cuyo trabajo ya aparece hecho (sugerencias closure_proposal, nunca cierra nada sola). Revisa las propuestas con list_suggestions y acepta con accept_suggestion.',
662
+ inputSchema: {
663
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
664
+ days: z.number().int().min(1).max(90).optional().describe('Ventana en días hacia atrás (default del servidor)'),
665
+ },
666
+ },
667
+ async ({ projectId, days }) => {
668
+ try {
669
+ return textResult(await apiPost(config, '/v1/closure-scan', { projectId: resolveProjectId(projectId), ...(days ? { days } : {}) }))
670
+ } catch (err) {
671
+ return errorResult(err)
672
+ }
673
+ }
674
+ )
675
+
676
+ server.registerTool(
677
+ 'request_content_changes',
678
+ {
679
+ description: 'Pide cambios en una pieza de contenido que está en revisión: guarda tu comentario en el historial (reviewComments), la devuelve a borrador y avisa (push + correo) a quien la creó. No existe un estado "rechazado". En una pieza multi-canal (channels[]) channelId es obligatorio. Para aprobar usa update_content con status "approved".',
680
+ inputSchema: {
681
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
682
+ contentId: z.string().describe('ID de la pieza (list_content)'),
683
+ text: z.string().describe('Qué hay que cambiar -- queda guardado como comentario de revisión'),
684
+ channelId: z.string().optional().describe('Solo si la pieza es multi-canal: el canal (variante) al que se refieren los cambios'),
685
+ },
686
+ },
687
+ async ({ projectId, contentId, text, channelId }) => {
688
+ try {
689
+ const data = await apiPost(config, `/v1/content/${contentId}/request-changes`, { projectId: resolveProjectId(projectId), text, ...(channelId ? { channelId } : {}) })
690
+ return textResult(data)
691
+ } catch (err) {
692
+ return errorResult(err)
693
+ }
694
+ }
695
+ )
696
+
697
+ server.registerTool(
698
+ 'ai_review_content',
699
+ {
700
+ description: 'Pre-revisión visual con IA de la imagen de una pieza contra el Brand Kit (colores, texto legible, voz de marca): deja un semáforo green/yellow/red con observaciones en aiPreReview. Es informativa, nunca aprueba ni rechaza sola. Cuesta 1 crédito de IA. Se corre sola antes del aviso al aprobador.',
701
+ inputSchema: {
702
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
703
+ contentId: z.string().describe('ID de la pieza (list_content)'),
704
+ channelId: z.string().optional().describe('Solo si la pieza es multi-canal'),
705
+ },
706
+ },
707
+ async ({ projectId, contentId, channelId }) => {
708
+ try {
709
+ return textResult(await apiPost(config, `/v1/content/${contentId}/ai-review`, { projectId: resolveProjectId(projectId), ...(channelId ? { channelId } : {}) }))
710
+ } catch (err) {
711
+ return errorResult(err)
712
+ }
713
+ }
714
+ )
715
+
716
+ server.registerTool(
717
+ 'regenerate_content_image',
718
+ {
719
+ description: 'Regenera la imagen de una pieza con IA usando su imagePrompt original MÁS tus comentarios (y los anteriores). Corre en segundo plano (la respuesta es inmediata, "regenerating"; el resultado aparece en la pieza en ~1 minuto: la imagen nueva queda en mediaFiles, la anterior en imageHistory, y la pieza vuelve a "review" avisando al aprobador). Gasta 5 créditos de IA del workspace (se reembolsan si falla) y hay un tope de 5 regeneraciones por pieza/canal. mode "reference" (default) edita la imagen actual para mantener la composición; "scratch" genera desde cero.',
720
+ inputSchema: {
721
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
722
+ contentId: z.string().describe('ID de la pieza (list_content)'),
723
+ text: z.string().describe('Comentarios para la nueva imagen (ej. "más luz, sin texto")'),
724
+ mode: z.enum(['reference', 'scratch']).optional().describe('reference (default) = editar la imagen actual; scratch = desde cero'),
725
+ channelId: z.string().optional().describe('Solo si la pieza es multi-canal: el canal (variante) cuya imagen se regenera'),
726
+ },
727
+ },
728
+ async ({ projectId, contentId, text, mode, channelId }) => {
729
+ try {
730
+ const data = await apiPost(config, `/v1/content/${contentId}/regenerate-image`, { projectId: resolveProjectId(projectId), text, ...(mode ? { mode } : {}), ...(channelId ? { channelId } : {}) })
731
+ return textResult(data)
732
+ } catch (err) {
733
+ return errorResult(err)
734
+ }
735
+ }
736
+ )
737
+
616
738
  // Recordatorio con hora de una tarea (2 oct 2026, a pedido de José) --
617
739
  // "recuérdame en 5 horas revisar X con Ana" / "mañana revisa Y para poder
618
740
  // cerrarla". Azúcar sobre PATCH /v1/tasks { remindAt (epoch ms), remindNote }:
@@ -1030,7 +1152,7 @@ export function createServer() {
1030
1152
  effort: z.enum(IDEA_IMPACT_EFFORT).optional(),
1031
1153
  risk: z.enum(IDEA_IMPACT_EFFORT).optional().describe('Riesgo de esta idea (alto/bajo) -- agregado 12 ago 2026, alimenta la recomendación del detalle de idea junto con impact/effort.'),
1032
1154
  voteCount: z.number().optional().describe('Default: 1 + requestedBy.length'),
1033
- linkedTaskIds: z.array(z.string()).optional().describe('Tareas YA EXISTENTES relacionadas con esta idea (no creadas a partir de ella) -- ver list_tasks. Distinto de taskId (la tarea "principal"/de conversión, que solo se setea vía convert_idea_to_task): acá pueden ser varias. Reemplaza el array completo si ya tenía algo, no hace merge.'),
1155
+ linkedTaskIds: z.array(z.string()).optional().describe('Tareas YA EXISTENTES relacionadas con esta idea (no creadas a partir de ella) -- ver list_tasks. Distinto de taskId (la tarea "principal"/de conversión, que solo se setea vía convert_idea_to_task): acá pueden ser varias. Ligar al menos una deja la idea en status "hecha" sin crear tarea (no pisa un status explícito ni resucita una descartada). Reemplaza el array completo si ya tenía algo, no hace merge.'),
1034
1156
  }
1035
1157
  )
1036
1158
 
@@ -1051,7 +1173,7 @@ export function createServer() {
1051
1173
  effort: z.enum(IDEA_IMPACT_EFFORT).optional(),
1052
1174
  risk: z.enum(IDEA_IMPACT_EFFORT).optional(),
1053
1175
  voteCount: z.number().optional(),
1054
- linkedTaskIds: z.array(z.string()).optional().describe('Tareas YA EXISTENTES relacionadas con esta idea (no creadas a partir de ella) -- ver list_tasks. Distinto de taskId (la tarea "principal"/de conversión, que solo se setea vía convert_idea_to_task): acá pueden ser varias. Reemplaza el array completo, no hace merge -- si querés agregar una sin perder las que ya tenía, primero mirá la idea con search/get_project_context.'),
1176
+ linkedTaskIds: z.array(z.string()).optional().describe('Tareas YA EXISTENTES relacionadas con esta idea (no creadas a partir de ella) -- ver list_tasks. Distinto de taskId (la tarea "principal"/de conversión, que solo se setea vía convert_idea_to_task): acá pueden ser varias. Ligar al menos una deja la idea en status "hecha" sin crear tarea (no pisa un status explícito ni resucita una descartada). Reemplaza el array completo, no hace merge -- si querés agregar una sin perder las que ya tenía, primero mirá la idea con search/get_project_context.'),
1055
1177
  })
1056
1178
 
1057
1179
  // Convertir idea → tarea (21 ago 2026, gap encontrado: no había forma de
@@ -1990,5 +2112,96 @@ export function createServer() {
1990
2112
  }
1991
2113
  )
1992
2114
 
2115
+ // ═══ IDs de medición (GA4 / Meta Pixel / GTM) de una distribución web ═══
2116
+ // Viven en distributions.publishForm (mismo mapa que la Guía de publicación,
2117
+ // ver TRACKING_TOOLS en platformPlaybooks.mjs). PATCH reemplaza el mapa
2118
+ // completo, así que set_tracking_ids lee y mezcla antes de escribir.
2119
+ server.registerTool(
2120
+ 'get_tracking_ids',
2121
+ {
2122
+ description: 'Lee los IDs de medición conectados a las distribuciones del proyecto: Google Analytics 4 (ID de medición G-…), Meta Pixel (ID numérico) y Google Tag Manager (GTM-…). Úsalo ANTES de instalar o editar analítica/anuncios en el código de un sitio: si hay un ID guardado, ese es el que debes usar -- nunca inventes ni pidas de nuevo uno que ya está. Devuelve por distribución los IDs presentes y cuáles faltan.',
2123
+ inputSchema: {
2124
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
2125
+ distributionId: z.string().optional().describe('Solo esta distribución (ver list_distributions). Omitido = todas las de tipo web.'),
2126
+ },
2127
+ },
2128
+ async ({ projectId, distributionId }) => {
2129
+ try {
2130
+ const pid = resolveProjectId(projectId)
2131
+ const { items } = await apiGet(config, '/v1/distributions', { projectId: pid })
2132
+ const dists = items.filter(d => distributionId ? d.id === distributionId : d.type === 'web')
2133
+ if (distributionId && dists.length === 0) {
2134
+ throw new Error(`No se encontró la distribución "${distributionId}" en este proyecto -- revisá list_distributions.`)
2135
+ }
2136
+ return textResult({
2137
+ distributions: dists.map(d => {
2138
+ const form = d.publishForm || {}
2139
+ return {
2140
+ distributionId: d.id,
2141
+ name: d.name || d.label,
2142
+ type: d.type,
2143
+ domain: d.domain || null,
2144
+ ids: Object.fromEntries(TRACKING_TOOLS.map(t => [t.key, form[t.key] || null])),
2145
+ missing: TRACKING_TOOLS.filter(t => !form[t.key]).map(t => t.key),
2146
+ }
2147
+ }),
2148
+ tools: TRACKING_TOOLS.map(t => ({ key: t.key, label: t.label, idLabel: t.idLabel, example: t.example, where: t.where })),
2149
+ })
2150
+ } catch (err) {
2151
+ return errorResult(err)
2152
+ }
2153
+ }
2154
+ )
2155
+
2156
+ server.registerTool(
2157
+ 'set_tracking_ids',
2158
+ {
2159
+ description: 'Guarda (o quita) los IDs de medición de una distribución web: ga4_measurement_id (G-…), meta_pixel_id (solo dígitos) y gtm_container_id (GTM-…). Mezcla con lo ya guardado -- solo toca las claves que mandas; "" quita una. Valida el formato. SOLO guarda un ID que el usuario te dio o que leíste literal en el código/consola real del sitio -- nunca lo inventes. Si ya hay un valor distinto guardado y no mandas confirmReplace:true, no escribe y devuelve el conflicto: muéstraselo al usuario y repite con confirmReplace:true solo si confirma.',
2160
+ inputSchema: {
2161
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
2162
+ distributionId: z.string().describe('Distribución web (ver list_distributions)'),
2163
+ ga4_measurement_id: z.string().optional().describe('ID de medición de GA4, ej. G-ABC123DEF4. "" lo quita.'),
2164
+ meta_pixel_id: z.string().optional().describe('ID numérico del Meta Pixel. "" lo quita.'),
2165
+ gtm_container_id: z.string().optional().describe('ID del contenedor de Tag Manager, ej. GTM-ABC1234. "" lo quita.'),
2166
+ confirmReplace: z.boolean().optional().describe('Default false. true solo si el usuario ya confirmó reemplazar un valor existente que te devolvió el conflicto.'),
2167
+ },
2168
+ },
2169
+ async ({ projectId, distributionId, confirmReplace, ...ids }) => {
2170
+ try {
2171
+ const pid = resolveProjectId(projectId)
2172
+ const { items } = await apiGet(config, '/v1/distributions', { projectId: pid })
2173
+ const dist = items.find(d => d.id === distributionId)
2174
+ if (!dist) throw new Error(`No se encontró la distribución "${distributionId}" en este proyecto -- revisá list_distributions.`)
2175
+
2176
+ const existing = dist.publishForm || {}
2177
+ const patch = {}
2178
+ const invalid = []
2179
+ for (const t of TRACKING_TOOLS) {
2180
+ if (ids[t.key] === undefined) continue
2181
+ const raw = String(ids[t.key]).trim()
2182
+ const v = t.key === 'meta_pixel_id' ? raw : raw.toUpperCase()
2183
+ if (v && !t.pattern.test(v)) invalid.push({ key: t.key, value: raw, expected: t.example })
2184
+ else patch[t.key] = v
2185
+ }
2186
+ if (invalid.length) throw new Error(`Formato inválido: ${invalid.map(i => `${i.key} "${i.value}" (ej. ${i.expected})`).join(', ')}`)
2187
+ if (Object.keys(patch).length === 0) throw new Error('No mandaste ningún ID para guardar.')
2188
+
2189
+ const conflicts = Object.entries(patch)
2190
+ .filter(([k, v]) => existing[k] && existing[k] !== v)
2191
+ .map(([key, proposed]) => ({ key, current: existing[key], proposed: proposed || '(quitar)' }))
2192
+ if (conflicts.length > 0 && !confirmReplace) {
2193
+ return textResult({ updated: false, conflicts, message: 'Ya hay valores guardados distintos. No se escribió nada -- muéstraselos al usuario y repite con confirmReplace:true solo si confirma el reemplazo.' })
2194
+ }
2195
+
2196
+ const merged = { ...existing }
2197
+ for (const [k, v] of Object.entries(patch)) { if (v) merged[k] = v; else delete merged[k] }
2198
+ await apiPatch(config, '/v1/distributions', { projectId: pid, id: distributionId, publishForm: merged })
2199
+ return textResult({ updated: true, distributionId, written: patch })
2200
+ } catch (err) {
2201
+ return errorResult(err)
2202
+ }
2203
+ }
2204
+ )
2205
+
1993
2206
  return server
1994
2207
  }