strapi-plugin-mcp-chat 0.3.1 → 0.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.
- package/README.md +31 -5
- package/admin/src/components/AdminOverlays.tsx +8 -18
- package/admin/src/components/ErrorBoundary.tsx +29 -0
- package/admin/src/components/FloatingChat.tsx +78 -3
- package/admin/src/index.tsx +39 -5
- package/dist/server/index.js +734 -397
- package/package.json +1 -1
- package/server/src/content-tools.ts +20 -6
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/controllers/frontend.ts +7 -2
- package/server/src/index.ts +14 -2
- package/server/src/mcp/define.ts +72 -0
- package/server/src/mcp/index.ts +19 -6
- package/server/src/mcp/tools/buscar-texto.ts +18 -24
- package/server/src/mcp/tools/criar-locale.ts +20 -26
- package/server/src/mcp/tools/editar-campo.ts +29 -35
- package/server/src/mcp/tools/habilitar-i18n.ts +23 -29
- package/server/src/mcp/tools/listar-locales.ts +17 -23
- package/server/src/mcp/tools/publicar.ts +21 -27
- package/server/src/mcp/tools/traduzir.ts +26 -32
- package/server/src/mcp/types.ts +12 -9
- package/server/src/mcp-client.ts +15 -3
- package/server/src/provision/infer.ts +92 -20
- package/server/src/provision/link.ts +232 -35
- package/server/src/provision/orchestrate.ts +4 -3
- package/server/src/provision/runner.ts +44 -1
- package/server/src/provision/write.ts +92 -0
- package/server/src/services/chat.ts +36 -8
|
@@ -1,36 +1,30 @@
|
|
|
1
1
|
import { z } from '@strapi/utils';
|
|
2
|
-
import
|
|
2
|
+
import { defineTool } from '../define';
|
|
3
3
|
import { createContentTools } from '../../content-tools';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const r = await createContentTools(strapi).traduzir(args);
|
|
30
|
-
return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
|
|
31
|
-
},
|
|
32
|
-
});
|
|
5
|
+
export default defineTool({
|
|
6
|
+
name: 'mcp_chat_traduzir',
|
|
7
|
+
title: 'Translate localized content',
|
|
8
|
+
description:
|
|
9
|
+
'Translate localized content into one or more languages. Creates missing locales, translates field by field (long text is split and reassembled, never overflows) and publishes (only on content-types with Draft & Publish). Without uid/documentId, translates ALL localized content-types. Handles many locales at once.',
|
|
10
|
+
resolveInputSchema: () =>
|
|
11
|
+
z.object({
|
|
12
|
+
target_locales: z.array(z.string()).min(1),
|
|
13
|
+
source_locale: z.string().optional(),
|
|
14
|
+
uid: z.string().optional(),
|
|
15
|
+
documentId: z.string().optional(),
|
|
16
|
+
publish: z.boolean().optional(),
|
|
17
|
+
}),
|
|
18
|
+
resolveOutputSchema: () =>
|
|
19
|
+
z.object({
|
|
20
|
+
ok: z.boolean().optional(),
|
|
21
|
+
source: z.string().optional(),
|
|
22
|
+
por_locale: z.array(z.any()).optional(),
|
|
23
|
+
erro: z.string().optional(),
|
|
24
|
+
}),
|
|
25
|
+
auth: { policies: [{ action: 'plugin::content-manager.explorer.update' }] },
|
|
26
|
+
createHandler: (strapi: any) => async ({ args }) => {
|
|
27
|
+
const r = await createContentTools(strapi).traduzir(args);
|
|
28
|
+
return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
|
|
33
29
|
},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export default tool;
|
|
30
|
+
});
|
package/server/src/mcp/types.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tipos
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Tipos do MCP do plugin. As tools agora são DEFINIÇÕES puras criadas com
|
|
3
|
+
* `defineTool` (ver ./define.ts) e registradas a partir de um array — alinhado
|
|
4
|
+
* à direção do PR #26603 (`ai.mcp.defineTool`). Re-exportamos os tipos de
|
|
5
|
+
* `./define` aqui por conveniência/compatibilidade.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
export type {
|
|
8
|
+
McpToolDef,
|
|
9
|
+
McpResourceDef,
|
|
10
|
+
McpPromptDef,
|
|
11
|
+
McpToolResult,
|
|
12
|
+
McpAuth,
|
|
13
|
+
RegisterTool,
|
|
14
|
+
} from './define';
|
package/server/src/mcp-client.ts
CHANGED
|
@@ -14,6 +14,18 @@ const baseHeaders = {
|
|
|
14
14
|
Accept: 'application/json, text/event-stream',
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
/** fetch com timeout (AbortController) — nenhum endpoint MCP pode pendurar uma
|
|
18
|
+
* request do Strapi indefinidamente. */
|
|
19
|
+
const fetchT = async (url: string, opts: any, timeoutMs = 8000): Promise<Response> => {
|
|
20
|
+
const ctrl = new AbortController();
|
|
21
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
return await fetch(url, { ...opts, signal: ctrl.signal });
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(t);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
17
29
|
const parseSse = (text: string): any => {
|
|
18
30
|
const dataLines = text
|
|
19
31
|
.split('\n')
|
|
@@ -50,7 +62,7 @@ export class McpClient {
|
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
async init(): Promise<void> {
|
|
53
|
-
const res = await
|
|
65
|
+
const res = await fetchT(this.url, {
|
|
54
66
|
method: 'POST',
|
|
55
67
|
headers: this.headers(),
|
|
56
68
|
body: JSON.stringify({
|
|
@@ -67,7 +79,7 @@ export class McpClient {
|
|
|
67
79
|
this.sessionId = res.headers.get('mcp-session-id') || undefined;
|
|
68
80
|
await res.text();
|
|
69
81
|
// Notifica que o handshake terminou (sem corpo de resposta relevante).
|
|
70
|
-
await
|
|
82
|
+
await fetchT(this.url, {
|
|
71
83
|
method: 'POST',
|
|
72
84
|
headers: this.headers(),
|
|
73
85
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
@@ -75,7 +87,7 @@ export class McpClient {
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
private async rpc(method: string, params: any, id: number): Promise<any> {
|
|
78
|
-
const res = await
|
|
90
|
+
const res = await fetchT(this.url, {
|
|
79
91
|
method: 'POST',
|
|
80
92
|
headers: this.headers(),
|
|
81
93
|
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
@@ -80,32 +80,53 @@ interface CollectResult {
|
|
|
80
80
|
tree: string[];
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/** Detecta um array de objetos declarado inline (ex.: `const services = [{...}]`),
|
|
84
|
+
* inclusive DENTRO de componentes — é onde os frontends Lovable/Figma guardam
|
|
85
|
+
* os dados (serviços, avaliações, etc.). */
|
|
86
|
+
function hasInlineDataArray(content: string): boolean {
|
|
87
|
+
return /(?:export\s+)?const\s+\w+\s*(?::[^=\n]+)?=\s*\[\s*\{/.test(content);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Coleta os arquivos de código mais promissores (com conteúdo) + árvore.
|
|
92
|
+
*
|
|
93
|
+
* Ciente de conteúdo: além da pontuação por caminho, dá um bônus forte a QUALQUER
|
|
94
|
+
* arquivo que contenha um array de objetos inline — assim componentes (.tsx/.jsx)
|
|
95
|
+
* com `const X = [{...}]` entram na análise (antes eram excluídos por serem
|
|
96
|
+
* componentes, e os dados embutidos neles nunca eram modelados).
|
|
97
|
+
*/
|
|
84
98
|
function collectFiles(frontendDir: string): CollectResult {
|
|
85
99
|
const all: string[] = [];
|
|
86
100
|
walk(frontendDir, frontendDir, all);
|
|
87
|
-
|
|
88
101
|
const tree = all.slice().sort();
|
|
89
|
-
// candidatos com conteúdo de array/objeto exportado, por pontuação
|
|
90
|
-
const ranked = all
|
|
91
|
-
.map((rel) => ({ rel, s: score(rel) }))
|
|
92
|
-
.filter((x) => x.s > 0)
|
|
93
|
-
.sort((a, b) => b.s - a.s);
|
|
94
102
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
for (const
|
|
98
|
-
|
|
103
|
+
// lê e pontua cada candidato (pontuação por caminho + bônus por dados inline).
|
|
104
|
+
const scored: { rel: string; content: string; s: number }[] = [];
|
|
105
|
+
for (const rel of all) {
|
|
106
|
+
let content: string;
|
|
99
107
|
try {
|
|
100
|
-
|
|
101
|
-
// só interessa se houver dados estruturados ou tipos
|
|
102
|
-
if (!/export\s+(const|default|type|interface)/.test(content)) continue;
|
|
103
|
-
if (content.length > MAX_FILE_CHARS) content = content.slice(0, MAX_FILE_CHARS) + '\n/* …truncado… */';
|
|
104
|
-
files.push({ rel, content });
|
|
105
|
-
total += content.length;
|
|
108
|
+
content = fs.readFileSync(path.join(frontendDir, rel), 'utf8');
|
|
106
109
|
} catch {
|
|
107
|
-
|
|
110
|
+
continue;
|
|
108
111
|
}
|
|
112
|
+
const dataArray = hasInlineDataArray(content);
|
|
113
|
+
const hasExport = /export\s+(const|default|type|interface)/.test(content);
|
|
114
|
+
if (!hasExport && !dataArray) continue; // sem dados nem exports → ignora
|
|
115
|
+
let s = score(rel);
|
|
116
|
+
if (dataArray) s += 8; // arrays de objetos inline valem muito (incl. componentes)
|
|
117
|
+
if (s <= 0) continue;
|
|
118
|
+
scored.push({ rel, content, s });
|
|
119
|
+
}
|
|
120
|
+
scored.sort((a, b) => b.s - a.s);
|
|
121
|
+
|
|
122
|
+
const files: { rel: string; content: string }[] = [];
|
|
123
|
+
let total = 0;
|
|
124
|
+
for (const it of scored) {
|
|
125
|
+
if (files.length >= MAX_FILES || total >= MAX_TOTAL_CHARS) break;
|
|
126
|
+
let content = it.content;
|
|
127
|
+
if (content.length > MAX_FILE_CHARS) content = content.slice(0, MAX_FILE_CHARS) + '\n/* …truncado… */';
|
|
128
|
+
files.push({ rel: it.rel, content });
|
|
129
|
+
total += content.length;
|
|
109
130
|
}
|
|
110
131
|
return { files, tree };
|
|
111
132
|
}
|
|
@@ -341,13 +362,15 @@ Gere um JSON "strapi.manifest.json" com ESTE formato:
|
|
|
341
362
|
|
|
342
363
|
REGRAS:
|
|
343
364
|
- Crie uma content-type para cada COLEÇÃO de dados (arrays de objetos). Use os MESMOS nomes de campo do código.
|
|
365
|
+
- IMPORTANTE: muitos frontends guardam os dados em arrays declarados INLINE dentro de componentes (.tsx/.jsx), ex.: \`const services = [{ name, price, desc }]\`, \`const reviews = [...]\`, \`const hours = [...]\`. TRATE esses arrays como coleções e modele cada um como um collectionType, mesmo que estejam dentro de um componente de UI.
|
|
366
|
+
- Ao modelar um array desses, inclua SOMENTE os campos que são dados/texto (ex.: name, price, desc, label, href, value). IGNORE props que são código/apresentação: componentes de ícone (ex.: \`icon: Scissors\`), elementos React, funções, classes CSS, imports de imagem.
|
|
344
367
|
- Dados de "configuração do site" (objeto único: nome, telefone, etc.) → singleType.
|
|
345
368
|
- Campos string longos/descrições → "text" ou "richtext". Listas de strings → "json".
|
|
346
369
|
- Use "date"/"datetime" SOMENTE para datas ISO completas (YYYY-MM-DD). Datas parciais como "2025-04" ou textos livres → use "string" (senão o seed falha).
|
|
347
370
|
- Imagens (imports de assets ou caminhos) → "media" (NÃO coloque o valor da imagem no seed; omita o campo no seed).
|
|
348
|
-
- Em "seed",
|
|
371
|
+
- Em "seed", copie o conteúdo REAL hardcoded no código, VERBATIM (exatamente como está, sem reescrever, traduzir ou inventar), omitindo campos de mídia e relações. Todo valor de seed TEM que existir literalmente no código fornecido.
|
|
349
372
|
- Foque APENAS em coleções/objetos de dados — NÃO precisa modelar textos soltos de UI (isso é tratado à parte).
|
|
350
|
-
- NÃO invente. singularName kebab-case, sem repetir. Relações só apontam para types definidos por você.
|
|
373
|
+
- NÃO invente NADA. Se não tiver certeza de um valor, omita-o. singularName kebab-case, sem repetir. Relações só apontam para types definidos por você.
|
|
351
374
|
- Se não houver coleções de dados, devolva contentTypes: [] e seed: [].
|
|
352
375
|
- Responda APENAS com o JSON, nada de markdown.
|
|
353
376
|
|
|
@@ -465,6 +488,55 @@ export async function inferManifest(
|
|
|
465
488
|
result.warnings.push('Sem OPENAI_API_KEY: modelando os TEXTOS (determinístico); coleções de dados não inferidas.');
|
|
466
489
|
}
|
|
467
490
|
|
|
491
|
+
// 3b) TRAVA ANTI-ALUCINAÇÃO (determinística): todo valor de seed gerado pela IA
|
|
492
|
+
// TEM que existir literalmente no código analisado. Construímos um "haystack"
|
|
493
|
+
// com o código-fonte real (não truncado) e descartamos entradas cujo conteúdo
|
|
494
|
+
// não aparece nele. Assim a IA não consegue inventar dados — só transcrever.
|
|
495
|
+
if (dataCts.length && dataSeed.length) {
|
|
496
|
+
const parts: string[] = [];
|
|
497
|
+
for (const f of collected.files) {
|
|
498
|
+
try {
|
|
499
|
+
parts.push(fs.readFileSync(path.join(frontendDir, f.rel), 'utf8'));
|
|
500
|
+
} catch {
|
|
501
|
+
parts.push(f.content);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const norm = (x: any) => String(x).toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
505
|
+
const hay = norm(parts.join('\n'));
|
|
506
|
+
const present = (v: any) => {
|
|
507
|
+
if (typeof v !== 'string') return false;
|
|
508
|
+
const n = norm(v);
|
|
509
|
+
return n.length >= 4 && hay.includes(n); // valores curtos não são distintivos
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const keep = new Set<string>();
|
|
513
|
+
const verifiedSeed: any[] = [];
|
|
514
|
+
let droppedEntries = 0;
|
|
515
|
+
for (const grp of dataSeed) {
|
|
516
|
+
const entries = (grp.entries ?? []).filter((e: any) => {
|
|
517
|
+
const ok = Object.values(e).some(present);
|
|
518
|
+
if (!ok) droppedEntries++;
|
|
519
|
+
return ok;
|
|
520
|
+
});
|
|
521
|
+
if (entries.length) {
|
|
522
|
+
verifiedSeed.push({ ...grp, entries });
|
|
523
|
+
keep.add(grp.singularName);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// coleções de dados sem NENHUMA entrada verificada são provável alucinação → fora.
|
|
527
|
+
const verifiedCts = dataCts.filter(
|
|
528
|
+
(ct: any) => ct.kind === 'singleType' || keep.has(ct.singularName)
|
|
529
|
+
);
|
|
530
|
+
const droppedCts = dataCts.length - verifiedCts.length;
|
|
531
|
+
if (droppedEntries || droppedCts) {
|
|
532
|
+
result.warnings.push(
|
|
533
|
+
`Anti-alucinação: descartei ${droppedEntries} entrada(s) e ${droppedCts} content-type(s) cujos valores não batiam com o código.`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
dataCts = verifiedCts;
|
|
537
|
+
dataSeed = verifiedSeed;
|
|
538
|
+
}
|
|
539
|
+
|
|
468
540
|
// 4) TEXTOS via extração DETERMINÍSTICA (garantido, sem IA, escala em qualquer tamanho)
|
|
469
541
|
const budget = 60 - dataCts.length - 1; // teto de content-types do manifest
|
|
470
542
|
const page = buildPageContentTypes(pageTexts, budget);
|
|
@@ -4,6 +4,7 @@ import type { Manifest } from './manifest';
|
|
|
4
4
|
import { adapterForManifest, type LinkContext } from './adapters';
|
|
5
5
|
import { generateTypes } from './types-gen';
|
|
6
6
|
import { apiUid } from './generate';
|
|
7
|
+
import { FRONTEND_BASE_PORT } from './runner';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Link: conecta o frontend já instalado à Strapi.
|
|
@@ -31,7 +32,11 @@ export interface LinkResult {
|
|
|
31
32
|
envPreserved: string[];
|
|
32
33
|
typesFile: string;
|
|
33
34
|
previewFile: string;
|
|
34
|
-
previewAction: 'created' | '
|
|
35
|
+
previewAction: 'created' | 'merged' | 'skipped';
|
|
36
|
+
/** vars adicionadas ao .env do BACKEND (CLIENT_URL/PREVIEW_SECRET). */
|
|
37
|
+
backendEnvAdded: string[];
|
|
38
|
+
/** ação no config/middlewares (CSP frame-src para o iframe do preview). */
|
|
39
|
+
cspAction: 'patched' | 'already' | 'manual' | 'skipped';
|
|
35
40
|
errors: string[];
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -79,44 +84,68 @@ function mergeEnv(
|
|
|
79
84
|
// config de Preview da Strapi
|
|
80
85
|
// ---------------------------------------------------------------------------
|
|
81
86
|
|
|
87
|
+
// marca que o config/admin.ts já tem o preview do mcp-chat mesclado (idempotência).
|
|
88
|
+
const PREVIEW_MARKER = 'mcp-chat:preview-merged';
|
|
89
|
+
const PREVIEW_MODULE = 'mcp-chat-preview';
|
|
90
|
+
|
|
82
91
|
/**
|
|
83
|
-
* Gera o
|
|
84
|
-
*
|
|
92
|
+
* Gera o MÓDULO de preview (config/mcp-chat-preview.ts): default-exporta o
|
|
93
|
+
* fragmento { preview: {...} } do config/admin, com o handler que mapeia uid ->
|
|
94
|
+
* rota do frontend. É 100% owned pelo gerador (sempre reescrito).
|
|
95
|
+
*
|
|
96
|
+
* Decisões importantes:
|
|
97
|
+
* - Rota DEFAULT "/" para qualquer type sem rota declarada — assim o botão
|
|
98
|
+
* Preview SEMPRE tem URL (singleTypes de uma landing page caem aqui).
|
|
99
|
+
* - URL por framework: SPA (Vite/TanStack) abre a página direta com query;
|
|
100
|
+
* Next usa a convenção /api/preview (draft mode).
|
|
85
101
|
*/
|
|
86
|
-
export function buildPreviewConfig(
|
|
102
|
+
export function buildPreviewConfig(
|
|
103
|
+
manifest: Manifest,
|
|
104
|
+
framework: string = manifest.framework
|
|
105
|
+
): string {
|
|
87
106
|
const routes: Record<string, string> = {};
|
|
88
107
|
for (const ct of manifest.contentTypes) {
|
|
89
|
-
|
|
108
|
+
// rota declarada quando há página de detalhe; senão a raiz da app.
|
|
109
|
+
routes[apiUid(ct.singularName)] = ct.preview?.route ?? '/';
|
|
90
110
|
}
|
|
91
111
|
const routesJson = JSON.stringify(routes, null, 2);
|
|
112
|
+
const isNext = framework === 'next';
|
|
113
|
+
|
|
114
|
+
// ramo de montagem da URL final, por framework.
|
|
115
|
+
const urlBranch = isNext
|
|
116
|
+
? ` // Next.js: rota de draft mode que seta o cookie e redireciona p/ \`path\`.
|
|
117
|
+
const qs = new URLSearchParams({ secret, status: status ?? 'draft', path: pathname });
|
|
118
|
+
return \`\${clientUrl}/api/preview?\${qs.toString()}\`;`
|
|
119
|
+
: ` // SPA (Vite/TanStack): abre a página direta; o front lê ?preview/status.
|
|
120
|
+
const qs = new URLSearchParams({ preview: '1', status: status ?? 'draft', secret });
|
|
121
|
+
return \`\${clientUrl}\${pathname}?\${qs.toString()}\`;`;
|
|
92
122
|
|
|
93
|
-
return `// Preview gerado pelo mcp-chat a partir do strapi.manifest.json.
|
|
123
|
+
return `// Preview gerado pelo mcp-chat a partir do strapi.manifest.json (framework: ${framework}).
|
|
94
124
|
// Mapa uid -> rota do frontend (placeholders :campo são preenchidos pelo doc).
|
|
125
|
+
// Este arquivo é mesclado em config/admin.ts — não precisa editá-lo à mão.
|
|
95
126
|
const PREVIEW_ROUTES: Record<string, string> = ${routesJson};
|
|
96
127
|
|
|
97
|
-
export default ({ env }) => ({
|
|
98
|
-
auth: {
|
|
99
|
-
secret: env('ADMIN_JWT_SECRET'),
|
|
100
|
-
},
|
|
101
|
-
apiToken: { salt: env('API_TOKEN_SALT') },
|
|
102
|
-
transfer: { token: { salt: env('TRANSFER_TOKEN_SALT') } },
|
|
128
|
+
export default ({ env }: { env: any }) => ({
|
|
103
129
|
preview: {
|
|
104
130
|
enabled: true,
|
|
105
131
|
config: {
|
|
106
132
|
allowedOrigins: [env('CLIENT_URL', 'http://localhost:3000')],
|
|
107
133
|
async handler(uid: string, { documentId, locale, status }: any) {
|
|
108
|
-
const route = PREVIEW_ROUTES[uid];
|
|
109
|
-
if (!route) return null;
|
|
110
|
-
const doc = await strapi.documents(uid as any).findOne({ documentId, locale });
|
|
111
|
-
if (!doc) return null;
|
|
112
|
-
// substitui :campo pelos valores do documento (ex.: :slug)
|
|
113
|
-
const pathname = route.replace(/:([a-zA-Z0-9_]+)/g, (_m, f) =>
|
|
114
|
-
encodeURIComponent(String((doc as any)[f] ?? ''))
|
|
115
|
-
);
|
|
134
|
+
const route = PREVIEW_ROUTES[uid] ?? '/';
|
|
116
135
|
const clientUrl = env('CLIENT_URL', 'http://localhost:3000');
|
|
117
136
|
const secret = env('PREVIEW_SECRET', '');
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
|
|
138
|
+
// só busca o doc se a rota tiver placeholders (ex.: :slug) a preencher.
|
|
139
|
+
let pathname = route;
|
|
140
|
+
if (pathname.includes(':')) {
|
|
141
|
+
const doc = await strapi.documents(uid as any).findOne({ documentId, locale });
|
|
142
|
+
if (!doc) return null;
|
|
143
|
+
pathname = pathname.replace(/:([a-zA-Z0-9_]+)/g, (_m, f) =>
|
|
144
|
+
encodeURIComponent(String((doc as any)[f] ?? ''))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
${urlBranch}
|
|
120
149
|
},
|
|
121
150
|
},
|
|
122
151
|
},
|
|
@@ -124,6 +153,112 @@ export default ({ env }) => ({
|
|
|
124
153
|
`;
|
|
125
154
|
}
|
|
126
155
|
|
|
156
|
+
/** admin.ts auto-contido (quando o projeto ainda não tem um). */
|
|
157
|
+
function buildStandaloneAdmin(): string {
|
|
158
|
+
return `// ${PREVIEW_MARKER} — admin.ts gerado pelo mcp-chat (preview incluído).
|
|
159
|
+
import previewConfig from './${PREVIEW_MODULE}';
|
|
160
|
+
|
|
161
|
+
export default ({ env }: { env: any }) => ({
|
|
162
|
+
auth: { secret: env('ADMIN_JWT_SECRET') },
|
|
163
|
+
apiToken: { salt: env('API_TOKEN_SALT') },
|
|
164
|
+
transfer: { token: { salt: env('TRANSFER_TOKEN_SALT') } },
|
|
165
|
+
secrets: { encryptionKey: env('ENCRYPTION_KEY') },
|
|
166
|
+
flags: {
|
|
167
|
+
nps: env.bool('FLAG_NPS', true),
|
|
168
|
+
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
|
169
|
+
},
|
|
170
|
+
...previewConfig({ env }),
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Wrapper que PRESERVA o admin existente (movido p/ admin.base.ts) e mescla o
|
|
177
|
+
* preview por cima. Como o admin é mesclado e não substituído, qualquer config
|
|
178
|
+
* do usuário (auth, flags, custom) continua valendo.
|
|
179
|
+
*/
|
|
180
|
+
function buildAdminWrapper(): string {
|
|
181
|
+
return `// ${PREVIEW_MARKER} — preview do mcp-chat mesclado sobre o admin original.
|
|
182
|
+
// Sua config original está preservada em ./admin.base — edite lá, não aqui.
|
|
183
|
+
import base from './admin.base';
|
|
184
|
+
import previewConfig from './${PREVIEW_MODULE}';
|
|
185
|
+
|
|
186
|
+
export default (ctx: any) => {
|
|
187
|
+
const b = typeof base === 'function' ? (base as any)(ctx) : (base ?? {});
|
|
188
|
+
return { ...b, ...previewConfig(ctx) };
|
|
189
|
+
};
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Lê a porta de dev do frontend (Vite) do config; null se não achar. */
|
|
194
|
+
export function detectFrontendPort(frontendDir: string): number | null {
|
|
195
|
+
for (const f of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
|
|
196
|
+
try {
|
|
197
|
+
const c = fs.readFileSync(path.join(frontendDir, f), 'utf8');
|
|
198
|
+
const m = c.match(/port\s*:\s*(\d{2,5})/);
|
|
199
|
+
if (m) return parseInt(m[1], 10);
|
|
200
|
+
} catch {
|
|
201
|
+
/* ignore */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// CSP do admin: liberar frame-src para o iframe do preview
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
const CSP_MARKER = 'mcp-chat:csp-frame';
|
|
212
|
+
|
|
213
|
+
/** Bloco strapi::security com frame-src liberado para o dev server do preview. */
|
|
214
|
+
function securityBlock(): string {
|
|
215
|
+
return ` // ${CSP_MARKER} — libera o frame-src p/ o admin embutir o preview do frontend
|
|
216
|
+
// (dev server local em qualquer porta). Sem isto a CSP padrão (default-src 'self')
|
|
217
|
+
// bloqueia o iframe e o preview fica em branco.
|
|
218
|
+
{
|
|
219
|
+
name: 'strapi::security',
|
|
220
|
+
config: {
|
|
221
|
+
contentSecurityPolicy: {
|
|
222
|
+
useDefaults: true,
|
|
223
|
+
directives: {
|
|
224
|
+
'connect-src': ["'self'", 'https:', 'http:'],
|
|
225
|
+
'frame-src': ["'self'", 'http://localhost:*', 'http://127.0.0.1:*'],
|
|
226
|
+
'img-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io'],
|
|
227
|
+
'media-src': ["'self'", 'data:', 'blob:'],
|
|
228
|
+
upgradeInsecureRequests: null,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Garante que o config/middlewares tenha frame-src liberado p/ o preview.
|
|
237
|
+
* Não-destrutivo e idempotente:
|
|
238
|
+
* - já tem o marcador → 'already';
|
|
239
|
+
* - tem o `'strapi::security'` string padrão → troca pelo bloco com frame-src → 'patched';
|
|
240
|
+
* - já é um objeto custom de security → 'manual' (não mexe; reporta p/ o usuário ajustar).
|
|
241
|
+
*/
|
|
242
|
+
function patchSecurityMiddleware(strapiAppDir: string, dryRun?: boolean): LinkResult['cspAction'] {
|
|
243
|
+
const configDir = path.join(strapiAppDir, 'config');
|
|
244
|
+
const file = ['middlewares.ts', 'middlewares.js'].find((f) =>
|
|
245
|
+
fs.existsSync(path.join(configDir, f))
|
|
246
|
+
);
|
|
247
|
+
if (!file) return 'skipped';
|
|
248
|
+
const p = path.join(configDir, file);
|
|
249
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
250
|
+
|
|
251
|
+
if (content.includes(CSP_MARKER) || content.includes("'frame-src'")) return 'already';
|
|
252
|
+
|
|
253
|
+
// troca a entrada string padrão pelo bloco objeto (cobre o starter do Strapi).
|
|
254
|
+
// O bloco já vem com indentação de 2 espaços, igual ao array do starter.
|
|
255
|
+
const m = content.match(/^[ \t]*['"]strapi::security['"]\s*,/m);
|
|
256
|
+
if (!m) return 'manual'; // já customizado de outra forma → não arrisca
|
|
257
|
+
const next = content.replace(m[0], securityBlock());
|
|
258
|
+
if (!dryRun) fs.writeFileSync(p, next, 'utf8');
|
|
259
|
+
return 'patched';
|
|
260
|
+
}
|
|
261
|
+
|
|
127
262
|
// ---------------------------------------------------------------------------
|
|
128
263
|
// orquestração do link
|
|
129
264
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +281,8 @@ export function linkFrontend(
|
|
|
146
281
|
typesFile: 'strapi-types.ts',
|
|
147
282
|
previewFile: 'config/admin.ts',
|
|
148
283
|
previewAction: 'skipped',
|
|
284
|
+
backendEnvAdded: [],
|
|
285
|
+
cspAction: 'skipped',
|
|
149
286
|
errors: [],
|
|
150
287
|
};
|
|
151
288
|
|
|
@@ -177,27 +314,87 @@ export function linkFrontend(
|
|
|
177
314
|
result.errors.push(`types: ${e?.message ?? e}`);
|
|
178
315
|
}
|
|
179
316
|
|
|
180
|
-
// 3) config de Preview (não
|
|
317
|
+
// 3) config de Preview — merge REAL no config/admin.ts (não sidecar morto).
|
|
181
318
|
try {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
319
|
+
const configDir = path.join(opts.strapiAppDir, 'config');
|
|
320
|
+
if (!ensureInside(opts.strapiAppDir, configDir)) throw new Error('config fora do strapiAppDir');
|
|
321
|
+
|
|
322
|
+
const adminPath = path.join(configDir, 'admin.ts');
|
|
323
|
+
const adminBasePath = path.join(configDir, 'admin.base.ts');
|
|
324
|
+
const modulePath = path.join(configDir, `${PREVIEW_MODULE}.ts`);
|
|
325
|
+
|
|
326
|
+
if (!opts.dryRun) {
|
|
327
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
328
|
+
// 3a) módulo de preview — sempre (re)escrito (owned pelo gerador).
|
|
329
|
+
fs.writeFileSync(modulePath, buildPreviewConfig(manifest, adapter.framework), 'utf8');
|
|
330
|
+
// limpa o sidecar morto de versões antigas, se existir.
|
|
331
|
+
try {
|
|
332
|
+
fs.unlinkSync(path.join(configDir, 'admin.mcp-chat-preview.ts'));
|
|
333
|
+
} catch {
|
|
334
|
+
/* não existia */
|
|
194
335
|
}
|
|
336
|
+
}
|
|
337
|
+
result.previewFile = `config/${PREVIEW_MODULE}.ts`;
|
|
338
|
+
|
|
339
|
+
if (!fs.existsSync(adminPath)) {
|
|
340
|
+
// 3b) sem admin.ts: cria um auto-contido já com o preview.
|
|
341
|
+
if (!opts.dryRun) fs.writeFileSync(adminPath, buildStandaloneAdmin(), 'utf8');
|
|
195
342
|
result.previewAction = 'created';
|
|
343
|
+
} else {
|
|
344
|
+
const adminContent = fs.readFileSync(adminPath, 'utf8');
|
|
345
|
+
if (!adminContent.includes(PREVIEW_MARKER)) {
|
|
346
|
+
// 3c) admin.ts do usuário: preserva em admin.base.ts e troca por wrapper.
|
|
347
|
+
if (!opts.dryRun) {
|
|
348
|
+
if (!fs.existsSync(adminBasePath)) {
|
|
349
|
+
fs.writeFileSync(adminBasePath, adminContent, 'utf8');
|
|
350
|
+
}
|
|
351
|
+
fs.writeFileSync(adminPath, buildAdminWrapper(), 'utf8');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// se já tinha o marcador, só o módulo (3a) foi atualizado — idempotente.
|
|
355
|
+
result.previewAction = 'merged';
|
|
196
356
|
}
|
|
197
357
|
} catch (e: any) {
|
|
198
358
|
result.errors.push(`preview: ${e?.message ?? e}`);
|
|
199
359
|
}
|
|
200
360
|
|
|
361
|
+
// 4) .env do BACKEND: CLIENT_URL (origem do iframe + allowedOrigins) e, se houver,
|
|
362
|
+
// PREVIEW_SECRET. Aditivo: nunca sobrescreve o que o usuário já definiu.
|
|
363
|
+
//
|
|
364
|
+
// CRÍTICO: o preview NATIVO do Strapi usa CLIENT_URL, e ele PRECISA bater com a
|
|
365
|
+
// porta onde o runner sobe o dev server. O runner ignora o vite.config e usa
|
|
366
|
+
// FRONTEND_BASE_PORT — então CLIENT_URL tem que apontar pra ESSA porta, não pra
|
|
367
|
+
// porta do vite.config (senão o preview nativo abre uma porta morta).
|
|
368
|
+
try {
|
|
369
|
+
const clientUrl =
|
|
370
|
+
opts.context.frontendUrl || `http://localhost:${FRONTEND_BASE_PORT}`;
|
|
371
|
+
const backendVars: Record<string, string> = { CLIENT_URL: clientUrl };
|
|
372
|
+
if (opts.context.previewSecret) backendVars.PREVIEW_SECRET = opts.context.previewSecret;
|
|
373
|
+
|
|
374
|
+
const backendEnvPath = path.join(opts.strapiAppDir, '.env');
|
|
375
|
+
if (ensureInside(opts.strapiAppDir, backendEnvPath)) {
|
|
376
|
+
const existing = fs.existsSync(backendEnvPath) ? fs.readFileSync(backendEnvPath, 'utf8') : '';
|
|
377
|
+
const { content, added } = mergeEnv(existing, backendVars);
|
|
378
|
+
result.backendEnvAdded = added;
|
|
379
|
+
if (!opts.dryRun && added.length) fs.writeFileSync(backendEnvPath, content, 'utf8');
|
|
380
|
+
}
|
|
381
|
+
} catch (e: any) {
|
|
382
|
+
result.errors.push(`backend env: ${e?.message ?? e}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 5) CSP do admin: libera frame-src p/ o iframe do preview (senão fica em branco).
|
|
386
|
+
try {
|
|
387
|
+
result.cspAction = patchSecurityMiddleware(opts.strapiAppDir, opts.dryRun);
|
|
388
|
+
if (result.cspAction === 'manual') {
|
|
389
|
+
result.errors.push(
|
|
390
|
+
'CSP: config/middlewares já tem strapi::security customizado — adicione manualmente ' +
|
|
391
|
+
"frame-src 'self' http://localhost:* http://127.0.0.1:* para o preview embutir o frontend."
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
} catch (e: any) {
|
|
395
|
+
result.errors.push(`csp: ${e?.message ?? e}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
201
398
|
result.ok = result.errors.length === 0;
|
|
202
399
|
return result;
|
|
203
400
|
}
|
|
@@ -6,8 +6,9 @@ import { writeApis, requestReload, type WriteResult } from './write';
|
|
|
6
6
|
export { requestReload };
|
|
7
7
|
import { seedContent, type SeedResult } from './seed';
|
|
8
8
|
import { linkFrontend, type LinkResult } from './link';
|
|
9
|
+
import { FRONTEND_BASE_PORT } from './runner';
|
|
9
10
|
import { grantPublicRead, type PermissionsResult } from './permissions';
|
|
10
|
-
import {
|
|
11
|
+
import { type LinkContext } from './adapters';
|
|
11
12
|
import { apiUid } from './generate';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -206,9 +207,9 @@ export async function runPendingProvision(
|
|
|
206
207
|
|
|
207
208
|
// grava o resumo de conclusão para a UI anunciar "preview pronto".
|
|
208
209
|
try {
|
|
209
|
-
|
|
210
|
+
// mesma porta do runner (onde o dev server realmente sobe), p/ o preview bater.
|
|
210
211
|
const previewUrl =
|
|
211
|
-
marker.context.frontendUrl || `http://localhost:${
|
|
212
|
+
marker.context.frontendUrl || `http://localhost:${FRONTEND_BASE_PORT}`;
|
|
212
213
|
const done: ProvisionDone = {
|
|
213
214
|
name: marker.manifest.name,
|
|
214
215
|
framework: marker.manifest.framework,
|