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.
@@ -32,6 +32,44 @@ export interface RunInfo {
32
32
  let info: RunInfo = { state: 'idle', dir: null, url: null, pm: null, error: null, log: [] };
33
33
  let child: ChildProcess | null = null;
34
34
  let pollTimer: ReturnType<typeof setInterval> | null = null;
35
+ let appRootForPid: string | null = null;
36
+
37
+ // ── pidfile: rastreia o PID do dev server p/ matá-lo no shutdown e limpar
38
+ // órfãos no boot (ex.: após um crash/SIGKILL onde o destroy não rodou). ──
39
+ function pidFilePath(appRoot: string): string {
40
+ return path.join(appRoot, '.mcp-chat', 'frontend.pid');
41
+ }
42
+ function writePid(pid: number): void {
43
+ try {
44
+ if (!appRootForPid) return;
45
+ const pf = pidFilePath(appRootForPid);
46
+ fs.mkdirSync(path.dirname(pf), { recursive: true });
47
+ fs.writeFileSync(pf, String(pid), 'utf8');
48
+ } catch { /* best-effort */ }
49
+ }
50
+ function clearPid(): void {
51
+ try {
52
+ if (appRootForPid) fs.unlinkSync(pidFilePath(appRootForPid));
53
+ } catch { /* não existia */ }
54
+ }
55
+ function isAlive(pid: number): boolean {
56
+ try { process.kill(pid, 0); return true; } catch { return false; }
57
+ }
58
+ /**
59
+ * Mata um dev server órfão deixado por uma execução anterior (crash/SIGKILL não
60
+ * roda o destroy()). Chamado no bootstrap do plugin — garante "limpar ao
61
+ * desligar" mesmo quando o shutdown não foi limpo, e evita o EADDRINUSE.
62
+ */
63
+ export function cleanupStaleFrontend(appRoot: string): void {
64
+ appRootForPid = appRoot;
65
+ try {
66
+ const pf = pidFilePath(appRoot);
67
+ if (!fs.existsSync(pf)) return;
68
+ const pid = parseInt(fs.readFileSync(pf, 'utf8').trim(), 10);
69
+ if (pid && isAlive(pid)) { try { process.kill(pid, 'SIGTERM'); } catch { /* já morto */ } }
70
+ fs.unlinkSync(pf);
71
+ } catch { /* best-effort */ }
72
+ }
35
73
 
36
74
  function detectPM(dir: string): string {
37
75
  if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
@@ -55,7 +93,7 @@ function detectFramework(dir: string): 'vite' | 'next' | 'other' {
55
93
  * - 3000 (Next default) e 1337 (Strapi).
56
94
  * Evita colisão com o admin do Strapi (que responde 426 a requests não-WS).
57
95
  */
58
- const FRONTEND_BASE_PORT = 4321;
96
+ export const FRONTEND_BASE_PORT = 4321;
59
97
 
60
98
  /** Acha uma porta TCP livre a partir de `start`, testando em 0.0.0.0 (pega
61
99
  * ocupações em `*:porta` de qualquer interface IPv4). */
@@ -101,11 +139,14 @@ export function stopFrontend(): void {
101
139
  try { child.kill('SIGTERM'); } catch { /* ignore */ }
102
140
  child = null;
103
141
  }
142
+ clearPid(); // limpa ao desligar — não deixa órfão
104
143
  if (info.state !== 'error') info.state = 'idle';
105
144
  }
106
145
 
107
146
  export async function startFrontend(_strapi: any, opts: { dir: string; url: string }): Promise<RunInfo> {
108
147
  const { dir } = opts;
148
+ // raiz da app p/ o pidfile (limpeza de órfãos no boot / shutdown).
149
+ if (_strapi?.dirs?.app?.root) appRootForPid = _strapi.dirs.app.root;
109
150
 
110
151
  // idempotente: já rodando/subindo para o mesmo dir
111
152
  if (child && info.dir === dir && ['installing', 'starting', 'running'].includes(info.state)) {
@@ -138,12 +179,14 @@ export async function startFrontend(_strapi: any, opts: { dir: string; url: stri
138
179
  const startDev = () => {
139
180
  info.state = 'starting';
140
181
  child = spawnIn(pm, devArgs);
182
+ if (child.pid) writePid(child.pid); // rastreia p/ limpar no shutdown/boot
141
183
  child.stdout?.on('data', (d) => pushLog(d));
142
184
  child.stderr?.on('data', (d) => pushLog(d));
143
185
  child.on('exit', (code) => {
144
186
  // processo morreu → frontend DOWN. Zera o child e libera o estado p/ que
145
187
  // apertar Preview de novo reinicie (em vez de ficar preso em "running").
146
188
  child = null;
189
+ clearPid();
147
190
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
148
191
  if (info.state === 'running') info.state = 'idle';
149
192
  else { info.state = 'error'; info.error = `dev encerrou (código ${code}). Veja o log.`; }
@@ -49,6 +49,58 @@ function isDev(): boolean {
49
49
  return process.env.NODE_ENV === 'development';
50
50
  }
51
51
 
52
+ // Tipos de atributo que a Strapi 5 aceita num schema.json. Um type fora desta
53
+ // lista faz a Strapi recusar o boot — então validamos ANTES de escrever.
54
+ const KNOWN_ATTR_TYPES = new Set([
55
+ 'string', 'text', 'richtext', 'blocks', 'email', 'password', 'uid', 'enumeration',
56
+ 'json', 'integer', 'biginteger', 'decimal', 'float', 'date', 'time', 'datetime',
57
+ 'timestamp', 'boolean', 'media', 'relation', 'component', 'dynamiczone',
58
+ ]);
59
+
60
+ /**
61
+ * Valida o conteúdo de um schema.json gerado contra o formato que a Strapi 5
62
+ * exige. Retorna a lista de erros (vazia = ok). É a trava que garante que uma
63
+ * provisão NUNCA escreva um schema que impeça o Strapi de bootar.
64
+ */
65
+ function validateApi(api: GeneratedApi): string[] {
66
+ const errs: string[] = [];
67
+ const rel = Object.keys(api.files).find((r) => r.endsWith('schema.json'));
68
+ if (!rel) {
69
+ errs.push(`${api.singularName}: schema.json ausente nos arquivos gerados`);
70
+ return errs;
71
+ }
72
+ let schema: any;
73
+ try {
74
+ schema = JSON.parse(api.files[rel]);
75
+ } catch (e: any) {
76
+ errs.push(`${api.singularName}: schema.json não é JSON válido (${e?.message ?? e})`);
77
+ return errs;
78
+ }
79
+ if (schema?.kind !== 'collectionType' && schema?.kind !== 'singleType') {
80
+ errs.push(`${api.singularName}: "kind" inválido (${schema?.kind})`);
81
+ }
82
+ if (!schema?.info?.singularName || !schema?.info?.pluralName) {
83
+ errs.push(`${api.singularName}: info.singularName/pluralName obrigatórios`);
84
+ }
85
+ const attrs = schema?.attributes;
86
+ if (!attrs || typeof attrs !== 'object') {
87
+ errs.push(`${api.singularName}: "attributes" ausente ou inválido`);
88
+ } else {
89
+ for (const [name, a] of Object.entries(attrs) as any[]) {
90
+ if (!a || typeof a !== 'object' || !a.type) {
91
+ errs.push(`${api.singularName}.${name}: atributo sem "type"`);
92
+ } else if (!KNOWN_ATTR_TYPES.has(a.type)) {
93
+ errs.push(`${api.singularName}.${name}: type desconhecido "${a.type}"`);
94
+ } else if (a.type === 'relation' && !a.target) {
95
+ errs.push(`${api.singularName}.${name}: relation sem "target"`);
96
+ } else if (a.type === 'component' && !a.component) {
97
+ errs.push(`${api.singularName}.${name}: component sem "component"`);
98
+ }
99
+ }
100
+ }
101
+ return errs;
102
+ }
103
+
52
104
  /**
53
105
  * Escreve as content-types geradas em src/api. Aditivo e idempotente: o que já
54
106
  * existe é preservado e reportado em `skipped`.
@@ -80,6 +132,46 @@ export function writeApis(
80
132
  return result;
81
133
  }
82
134
 
135
+ // ── Validação ALL-OR-NOTHING (antes de tocar no disco) ──────────────────────
136
+ // Só serão escritas as apis que ainda não existem (trava aditiva). Validamos
137
+ // TODAS elas; se UMA for inválida, não escrevemos NENHUMA — assim uma provisão
138
+ // jamais pode deixar o Strapi com um schema quebrado e sem bootar.
139
+ const toWrite = apis.filter((api) => !fs.existsSync(path.join(opts.apiRoot, api.singularName)));
140
+ const knownSingulars = new Set<string>([
141
+ ...apis.map((a) => a.singularName),
142
+ ...(fs.existsSync(opts.apiRoot)
143
+ ? fs.readdirSync(opts.apiRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
144
+ : []),
145
+ ]);
146
+ const validationErrors: string[] = [];
147
+ for (const api of toWrite) {
148
+ validationErrors.push(...validateApi(api));
149
+ // relações: o target (api::<s>.<s>) precisa existir (gerado agora ou já no disco)
150
+ const rel = Object.keys(api.files).find((r) => r.endsWith('schema.json'));
151
+ if (rel) {
152
+ try {
153
+ const attrs = JSON.parse(api.files[rel])?.attributes || {};
154
+ for (const [name, a] of Object.entries(attrs) as any[]) {
155
+ if (a?.type === 'relation' && typeof a.target === 'string') {
156
+ const tgt = a.target.split('::')[1]?.split('.')[0];
157
+ if (tgt && !knownSingulars.has(tgt)) {
158
+ validationErrors.push(`${api.singularName}.${name}: relation aponta para "${a.target}" inexistente`);
159
+ }
160
+ }
161
+ }
162
+ } catch {
163
+ /* já reportado por validateApi */
164
+ }
165
+ }
166
+ }
167
+ if (validationErrors.length) {
168
+ result.errors.push(
169
+ 'Schema gerado inválido — nada foi escrito (provisão abortada com segurança):',
170
+ ...validationErrors
171
+ );
172
+ return result;
173
+ }
174
+
83
175
  for (const api of apis) {
84
176
  const apiDir = path.join(opts.apiRoot, api.singularName);
85
177
 
@@ -25,6 +25,12 @@ type ChatInput = {
25
25
  lang?: Lang;
26
26
  /** URL da página aberta no preview — contexto do "isso aqui". */
27
27
  previewUrl?: string | null;
28
+ /**
29
+ * Status do preview atual: 'draft' (toggle de rascunho ligado) ou 'published'
30
+ * (live). A busca de texto segue ESTE modo — acha rascunho quando em draft, e
31
+ * o conteúdo no ar quando em live. Default 'draft'.
32
+ */
33
+ previewStatus?: 'draft' | 'published';
28
34
  /**
29
35
  * Política de publicação. `false` (default) = modo RASCUNHO: o agente edita o
30
36
  * draft e NÃO publica, a menos que o usuário peça explicitamente. `true` =
@@ -103,7 +109,7 @@ Be concise and actionable. ALWAYS answer in English.`,
103
109
  };
104
110
 
105
111
  export default ({ strapi }: { strapi: any }) => ({
106
- async chat({ messages, image, lang = 'pt', previewUrl, autoPublish = false }: ChatInput) {
112
+ async chat({ messages, image, lang = 'pt', previewUrl, previewStatus = 'draft', autoPublish = false }: ChatInput) {
107
113
  const apiKey = process.env.OPENAI_API_KEY;
108
114
  if (!apiKey) {
109
115
  throw new Error(
@@ -116,7 +122,7 @@ export default ({ strapi }: { strapi: any }) => ({
116
122
  const { buscarTexto, editarCampo, publicar, listarLocales, criarLocale, traduzir } =
117
123
  createContentTools(strapi);
118
124
  const LOCAL_TOOLS: Record<string, (args: any) => Promise<any>> = {
119
- buscar_texto: (a) => buscarTexto(a?.termo),
125
+ buscar_texto: (a) => buscarTexto(a?.termo, previewStatus),
120
126
  editar_campo: (a) => editarCampo(a),
121
127
  publicar: (a) => publicar(a),
122
128
  listar_locales: () => listarLocales(),
@@ -132,7 +138,11 @@ export default ({ strapi }: { strapi: any }) => ({
132
138
  if (process.env.PLAYWRIGHT_MCP_URL) {
133
139
  try {
134
140
  const client = new McpClient(process.env.PLAYWRIGHT_MCP_URL, 'playwright');
135
- await client.init();
141
+ // init com timeout: um Playwright MCP fora do ar não pode travar o chat.
142
+ await Promise.race([
143
+ client.init(),
144
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
145
+ ]);
136
146
  const list = await client.listTools();
137
147
  for (const t of list) {
138
148
  if (mcpByTool[t.name]) continue;
@@ -207,11 +217,23 @@ export default ({ strapi }: { strapi: any }) => ({
207
217
  });
208
218
 
209
219
  const callOpenAI = async (body: any) => {
210
- const res = await fetch(OPENAI_URL, {
211
- method: 'POST',
212
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
213
- body: JSON.stringify(body),
214
- });
220
+ // Timeout: uma chamada lenta da OpenAI não pode prender a request do admin.
221
+ const ctrl = new AbortController();
222
+ const timer = setTimeout(() => ctrl.abort(), 60000);
223
+ let res: Response;
224
+ try {
225
+ res = await fetch(OPENAI_URL, {
226
+ method: 'POST',
227
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
228
+ body: JSON.stringify(body),
229
+ signal: ctrl.signal,
230
+ });
231
+ } catch (e: any) {
232
+ if (e?.name === 'AbortError') throw new Error('OpenAI chat: tempo limite excedido (60s).');
233
+ throw e;
234
+ } finally {
235
+ clearTimeout(timer);
236
+ }
215
237
  if (!res.ok) throw new Error(`OpenAI chat: ${await res.text()}`);
216
238
  return res.json() as Promise<any>;
217
239
  };
@@ -254,6 +276,12 @@ export default ({ strapi }: { strapi: any }) => ({
254
276
  } catch (e: any) {
255
277
  content = `Erro ao chamar a tool ${name}: ${e?.message || e}`;
256
278
  }
279
+ // Cap defensivo: um resultado gigante não pode estourar o contexto do
280
+ // modelo nem inflar memória/latência.
281
+ const MAX_TOOL_CHARS = 12000;
282
+ if (content.length > MAX_TOOL_CHARS) {
283
+ content = content.slice(0, MAX_TOOL_CHARS) + `\n…[resultado truncado: ${content.length} chars]`;
284
+ }
257
285
  convo.push({ role: 'tool', tool_call_id: call.id, content });
258
286
  }
259
287
  continue;