strapi-plugin-mcp-chat 0.5.0 → 0.7.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/admin/src/components/AdminOverlays.tsx +8 -18
- package/admin/src/components/FloatingChat.tsx +70 -3
- package/dist/server/index.js +943 -335
- package/package.json +1 -1
- package/server/src/content-tools.ts +4 -2
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/controllers/frontend.ts +35 -0
- package/server/src/index.ts +8 -1
- package/server/src/provision/infer.ts +92 -20
- package/server/src/provision/link.ts +232 -35
- package/server/src/provision/orchestrate.ts +17 -3
- package/server/src/provision/runner.ts +44 -1
- package/server/src/provision/wire.ts +393 -0
- package/server/src/routes/index.ts +6 -0
- package/server/src/services/chat.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strapi-plugin-mcp-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "AI chat inside the Strapi 5 admin that reads and edits your content (incl. components & dynamic zones) via MCP, with voice and a side-by-side live preview.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"strapi",
|
|
@@ -108,9 +108,11 @@ export function createContentTools(strapi: any) {
|
|
|
108
108
|
};
|
|
109
109
|
|
|
110
110
|
const MAX_MATCHES = 100;
|
|
111
|
-
const buscarTexto = async (termo: string) => {
|
|
111
|
+
const buscarTexto = async (termo: string, status: 'draft' | 'published' = 'draft') => {
|
|
112
112
|
const needle = String(termo || '').toLowerCase().trim();
|
|
113
113
|
if (!needle) return { erro: 'termo vazio' };
|
|
114
|
+
// segue o modo do preview: 'draft' busca rascunhos, 'published' busca o que está no ar.
|
|
115
|
+
const st: 'draft' | 'published' = status === 'published' ? 'published' : 'draft';
|
|
114
116
|
const matches: any[] = [];
|
|
115
117
|
for (const ct of apiContentTypes() as any[]) {
|
|
116
118
|
if (matches.length >= MAX_MATCHES) break; // teto: não varre além do necessário
|
|
@@ -120,7 +122,7 @@ export function createContentTools(strapi: any) {
|
|
|
120
122
|
try {
|
|
121
123
|
const res = await strapi
|
|
122
124
|
.documents(ct.uid)
|
|
123
|
-
.findMany({ status:
|
|
125
|
+
.findMany({ status: st, populate, limit: 200 });
|
|
124
126
|
entries = Array.isArray(res) ? res : res ? [res] : [];
|
|
125
127
|
} catch {
|
|
126
128
|
continue;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
export default ({ strapi }: { strapi: any }) => ({
|
|
6
6
|
async message(ctx: any) {
|
|
7
|
-
const { messages, image, lang, previewUrl, autoPublish } = ctx.request.body || {};
|
|
7
|
+
const { messages, image, lang, previewUrl, previewStatus, autoPublish } = ctx.request.body || {};
|
|
8
8
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
9
9
|
return ctx.badRequest('Campo "messages" (array) é obrigatório.');
|
|
10
10
|
}
|
|
@@ -12,7 +12,7 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
12
12
|
const result = await strapi
|
|
13
13
|
.plugin('mcp-chat')
|
|
14
14
|
.service('chat')
|
|
15
|
-
.chat({ messages, image, lang, previewUrl, autoPublish });
|
|
15
|
+
.chat({ messages, image, lang, previewUrl, previewStatus, autoPublish });
|
|
16
16
|
ctx.body = result;
|
|
17
17
|
} catch (e: any) {
|
|
18
18
|
strapi.log.error(`[mcp-chat] ${e?.message || e}`);
|
|
@@ -5,6 +5,7 @@ import { stageProvision, getProvisionStatus } from '../provision/orchestrate';
|
|
|
5
5
|
import { inferManifest } from '../provision/infer';
|
|
6
6
|
import { startFrontend, getRunStatus } from '../provision/runner';
|
|
7
7
|
import { integrateFrontend } from '../provision/integrate';
|
|
8
|
+
import { wireFrontend } from '../provision/wire';
|
|
8
9
|
import { validateManifest } from '../provision/manifest';
|
|
9
10
|
import type { LinkContext } from '../provision/adapters';
|
|
10
11
|
|
|
@@ -312,4 +313,38 @@ export default {
|
|
|
312
313
|
|
|
313
314
|
ctx.body = await integrateFrontend(strapi, { frontendDir, manifest: v.data });
|
|
314
315
|
},
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Religa o frontend à Strapi por FETCH AO VIVO: gera a camada de dados (REST,
|
|
319
|
+
* flat) e religa os componentes (com .bak + fallback + sanidade). Só escreve no
|
|
320
|
+
* frontend, nunca na Strapi. Usa o último provisionado por padrão.
|
|
321
|
+
*/
|
|
322
|
+
async wire(ctx: any) {
|
|
323
|
+
const strapi = ctx.strapi ?? (global as any).strapi;
|
|
324
|
+
if (!devOnly(ctx)) return;
|
|
325
|
+
|
|
326
|
+
const body = ctx.request.body || {};
|
|
327
|
+
let frontendDir: string = body.frontendDir;
|
|
328
|
+
if (!frontendDir) {
|
|
329
|
+
const st = getProvisionStatus(strapi.dirs.app.root);
|
|
330
|
+
frontendDir = st.done?.frontendDir || '';
|
|
331
|
+
}
|
|
332
|
+
if (!frontendDir) return ctx.badRequest('Nenhum frontend provisionado.');
|
|
333
|
+
|
|
334
|
+
const parent = path.resolve(strapi.dirs.app.root, '..');
|
|
335
|
+
if (!ensureInside(parent, frontendDir) || !fs.existsSync(frontendDir)) {
|
|
336
|
+
return ctx.badRequest('Pasta do frontend inválida.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let manifest: any;
|
|
340
|
+
try {
|
|
341
|
+
manifest = JSON.parse(fs.readFileSync(path.join(frontendDir, MANIFEST_NAME), 'utf8'));
|
|
342
|
+
} catch {
|
|
343
|
+
return ctx.badRequest('Manifest do projeto não encontrado (rode a provisão primeiro).');
|
|
344
|
+
}
|
|
345
|
+
const v = validateManifest(manifest);
|
|
346
|
+
if (!v.ok) return ctx.badRequest({ message: 'Manifest inválido', errors: v.errors });
|
|
347
|
+
|
|
348
|
+
ctx.body = await wireFrontend(strapi, { frontendDir, manifest: v.data });
|
|
349
|
+
},
|
|
315
350
|
};
|
package/server/src/index.ts
CHANGED
|
@@ -10,11 +10,18 @@ import audioService from './services/audio';
|
|
|
10
10
|
import routes from './routes';
|
|
11
11
|
import register from './register';
|
|
12
12
|
import { runPendingProvision } from './provision/orchestrate';
|
|
13
|
-
import { stopFrontend } from './provision/runner';
|
|
13
|
+
import { stopFrontend, cleanupStaleFrontend } from './provision/runner';
|
|
14
14
|
|
|
15
15
|
export default {
|
|
16
16
|
register,
|
|
17
17
|
async bootstrap({ strapi }: { strapi: any }) {
|
|
18
|
+
// Limpa qualquer dev server do front que tenha sobrado de uma execução
|
|
19
|
+
// anterior (crash/SIGKILL não roda o destroy). Garante "limpar ao desligar"
|
|
20
|
+
// mesmo em shutdown sujo e evita o EADDRINUSE no próximo Preview.
|
|
21
|
+
try {
|
|
22
|
+
cleanupStaleFrontend(strapi.dirs.app.root);
|
|
23
|
+
} catch { /* best-effort */ }
|
|
24
|
+
|
|
18
25
|
// Após um restart causado por um upload de frontend, conclui a provisão:
|
|
19
26
|
// semeia o conteúdo e liga o preview. Idempotente (no-op se não há pendência).
|
|
20
27
|
try {
|
|
@@ -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,10 @@ 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 { wireFrontend, type WireResult } from './wire';
|
|
10
|
+
import { FRONTEND_BASE_PORT } from './runner';
|
|
9
11
|
import { grantPublicRead, type PermissionsResult } from './permissions';
|
|
10
|
-
import {
|
|
12
|
+
import { type LinkContext } from './adapters';
|
|
11
13
|
import { apiUid } from './generate';
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -160,6 +162,7 @@ export interface RunPendingResult {
|
|
|
160
162
|
seed?: SeedResult;
|
|
161
163
|
link?: LinkResult;
|
|
162
164
|
permissions?: PermissionsResult;
|
|
165
|
+
wire?: WireResult;
|
|
163
166
|
errors: string[];
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -203,12 +206,23 @@ export async function runPendingProvision(
|
|
|
203
206
|
} catch (e: any) {
|
|
204
207
|
result.errors.push(`link: ${e?.message ?? e}`);
|
|
205
208
|
}
|
|
209
|
+
// religação live-fetch: gera a camada de dados + religa os componentes (best-effort;
|
|
210
|
+
// só escreve no frontend, com .bak + validação de sintaxe — nunca quebra a Strapi).
|
|
211
|
+
try {
|
|
212
|
+
result.wire = await wireFrontend(strapi, {
|
|
213
|
+
frontendDir: marker.frontendDir,
|
|
214
|
+
manifest: marker.manifest,
|
|
215
|
+
});
|
|
216
|
+
if (result.wire.errors.length) result.errors.push(...result.wire.errors.map((e) => `wire: ${e}`));
|
|
217
|
+
} catch (e: any) {
|
|
218
|
+
result.errors.push(`wire: ${e?.message ?? e}`);
|
|
219
|
+
}
|
|
206
220
|
|
|
207
221
|
// grava o resumo de conclusão para a UI anunciar "preview pronto".
|
|
208
222
|
try {
|
|
209
|
-
|
|
223
|
+
// mesma porta do runner (onde o dev server realmente sobe), p/ o preview bater.
|
|
210
224
|
const previewUrl =
|
|
211
|
-
marker.context.frontendUrl || `http://localhost:${
|
|
225
|
+
marker.context.frontendUrl || `http://localhost:${FRONTEND_BASE_PORT}`;
|
|
212
226
|
const done: ProvisionDone = {
|
|
213
227
|
name: marker.manifest.name,
|
|
214
228
|
framework: marker.manifest.framework,
|