strapi-plugin-mcp-chat 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -0
  3. package/admin/src/components/AdminOverlays.tsx +190 -0
  4. package/admin/src/components/FloatingChat.tsx +370 -0
  5. package/admin/src/components/PreviewPanel.tsx +188 -0
  6. package/admin/src/index.tsx +49 -0
  7. package/admin/src/pages/App.tsx +14 -0
  8. package/admin/src/pages/HomePage.tsx +333 -0
  9. package/admin/src/pages/ProvisionPage.tsx +391 -0
  10. package/admin/src/pluginId.ts +1 -0
  11. package/dist/server/index.js +3511 -0
  12. package/package.json +77 -0
  13. package/server/src/content-tools.ts +520 -0
  14. package/server/src/controllers/audio.ts +45 -0
  15. package/server/src/controllers/chat.ts +22 -0
  16. package/server/src/controllers/frontend.ts +310 -0
  17. package/server/src/index.ts +43 -0
  18. package/server/src/mcp/index.ts +24 -0
  19. package/server/src/mcp/tools/buscar-texto.ts +28 -0
  20. package/server/src/mcp/tools/criar-locale.ts +30 -0
  21. package/server/src/mcp/tools/editar-campo.ts +39 -0
  22. package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
  23. package/server/src/mcp/tools/index.ts +17 -0
  24. package/server/src/mcp/tools/listar-locales.ts +27 -0
  25. package/server/src/mcp/tools/publicar.ts +31 -0
  26. package/server/src/mcp/tools/traduzir.ts +36 -0
  27. package/server/src/mcp/types.ts +11 -0
  28. package/server/src/mcp-client.ts +96 -0
  29. package/server/src/provision/adapters.ts +91 -0
  30. package/server/src/provision/enable-i18n.ts +129 -0
  31. package/server/src/provision/generate.ts +216 -0
  32. package/server/src/provision/infer.ts +495 -0
  33. package/server/src/provision/integrate.ts +963 -0
  34. package/server/src/provision/link.ts +203 -0
  35. package/server/src/provision/manifest.ts +281 -0
  36. package/server/src/provision/orchestrate.ts +236 -0
  37. package/server/src/provision/permissions.ts +58 -0
  38. package/server/src/provision/runner.ts +176 -0
  39. package/server/src/provision/seed.ts +115 -0
  40. package/server/src/provision/translate.ts +153 -0
  41. package/server/src/provision/types-gen.ts +117 -0
  42. package/server/src/provision/write.ts +136 -0
  43. package/server/src/register.ts +17 -0
  44. package/server/src/routes/index.ts +66 -0
  45. package/server/src/services/audio.ts +53 -0
  46. package/server/src/services/chat.ts +263 -0
@@ -0,0 +1,236 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { validateManifest, type Manifest } from './manifest';
4
+ import { generateAll } from './generate';
5
+ import { writeApis, requestReload, type WriteResult } from './write';
6
+ export { requestReload };
7
+ import { seedContent, type SeedResult } from './seed';
8
+ import { linkFrontend, type LinkResult } from './link';
9
+ import { grantPublicRead, type PermissionsResult } from './permissions';
10
+ import { adapterForManifest, type LinkContext } from './adapters';
11
+ import { apiUid } from './generate';
12
+
13
+ /**
14
+ * Orquestrador da provisão.
15
+ *
16
+ * O ponto delicado: depois de gravar os schema.json, a Strapi precisa
17
+ * REINICIAR para reconhecer os novos content-types — e só DEPOIS dá pra semear
18
+ * e ligar o preview (que dependem dos types existindo). Como o reload derruba o
19
+ * contexto atual, dividimos em dois momentos:
20
+ *
21
+ * 1. stageProvision() — valida, grava src/api, escreve um marcador "pendente"
22
+ * que sobrevive ao restart e dispara o reload.
23
+ * 2. runPendingProvision() — roda no bootstrap após o restart: lê o marcador,
24
+ * semeia + linka, apaga o marcador. Idempotente (sem marcador = no-op).
25
+ */
26
+
27
+ const MARKER_DIR = '.mcp-chat';
28
+ const MARKER_FILE = 'pending-provision.json';
29
+ const DONE_FILE = 'last-provision.json';
30
+
31
+ interface PendingMarker {
32
+ manifest: Manifest;
33
+ frontendDir: string;
34
+ strapiAppDir: string;
35
+ context: LinkContext;
36
+ }
37
+
38
+ /** Resumo da última provisão concluída — lido pela UI para anunciar "preview pronto". */
39
+ export interface ProvisionDone {
40
+ name: string;
41
+ framework: string;
42
+ frontendDir: string;
43
+ contentTypes: string[];
44
+ previewUrl: string;
45
+ seedCreated: { uid: string; count: number }[];
46
+ linkErrors: string[];
47
+ finishedAt: string;
48
+ }
49
+
50
+ function markerPath(strapiAppDir: string): string {
51
+ return path.join(strapiAppDir, MARKER_DIR, MARKER_FILE);
52
+ }
53
+
54
+ function donePath(strapiAppDir: string): string {
55
+ return path.join(strapiAppDir, MARKER_DIR, DONE_FILE);
56
+ }
57
+
58
+ export interface ProvisionStatus {
59
+ /** há uma provisão agendada aguardando o pós-restart. */
60
+ pending: boolean;
61
+ /** resumo da última provisão concluída (null se nunca houve). */
62
+ done: ProvisionDone | null;
63
+ }
64
+
65
+ /** Lido pelo endpoint de status: a UI faz polling disto após o upload. */
66
+ export function getProvisionStatus(strapiAppDir: string): ProvisionStatus {
67
+ const pending = fs.existsSync(markerPath(strapiAppDir));
68
+ let done: ProvisionDone | null = null;
69
+ try {
70
+ const dp = donePath(strapiAppDir);
71
+ if (fs.existsSync(dp)) done = JSON.parse(fs.readFileSync(dp, 'utf8'));
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ return { pending, done };
76
+ }
77
+
78
+ export interface StageInput {
79
+ rawManifest: unknown;
80
+ /** src/api absoluto. */
81
+ apiRoot: string;
82
+ /** pasta do frontend já instalado. */
83
+ frontendDir: string;
84
+ /** raiz da app Strapi. */
85
+ strapiAppDir: string;
86
+ context: LinkContext;
87
+ dryRun?: boolean;
88
+ }
89
+
90
+ export interface StageResult {
91
+ ok: boolean;
92
+ validation: { ok: boolean; errors?: string[] };
93
+ write?: WriteResult;
94
+ staged: boolean;
95
+ willReload: boolean;
96
+ errors: string[];
97
+ }
98
+
99
+ /** Etapa 1: valida + grava content-types + agenda o pós-restart. */
100
+ export function stageProvision(strapi: any, input: StageInput): StageResult {
101
+ const result: StageResult = {
102
+ ok: false,
103
+ validation: { ok: false },
104
+ staged: false,
105
+ willReload: false,
106
+ errors: [],
107
+ };
108
+
109
+ const v = validateManifest(input.rawManifest);
110
+ if (!v.ok) {
111
+ result.validation = { ok: false, errors: v.errors };
112
+ result.errors.push('manifest inválido');
113
+ return result;
114
+ }
115
+ result.validation = { ok: true };
116
+
117
+ const apis = generateAll(v.data);
118
+ const write = writeApis(apis, { apiRoot: input.apiRoot, dryRun: input.dryRun });
119
+ result.write = write;
120
+ if (!write.ok) {
121
+ result.errors.push(...write.errors);
122
+ return result;
123
+ }
124
+
125
+ // grava o marcador para o pós-restart (seed + link)
126
+ const marker: PendingMarker = {
127
+ manifest: v.data,
128
+ frontendDir: input.frontendDir,
129
+ strapiAppDir: input.strapiAppDir,
130
+ context: input.context,
131
+ };
132
+ if (!input.dryRun) {
133
+ try {
134
+ const mp = markerPath(input.strapiAppDir);
135
+ fs.mkdirSync(path.dirname(mp), { recursive: true });
136
+ fs.writeFileSync(mp, JSON.stringify(marker, null, 2), 'utf8');
137
+ // limpa o resumo da provisão anterior: a UI faz polling por um NOVO.
138
+ try {
139
+ fs.unlinkSync(donePath(input.strapiAppDir));
140
+ } catch {
141
+ /* não existia, tudo bem */
142
+ }
143
+ result.staged = true;
144
+ } catch (e: any) {
145
+ result.errors.push(`marcador: ${e?.message ?? e}`);
146
+ return result;
147
+ }
148
+ }
149
+
150
+ result.ok = true;
151
+ // só recarrega se houve content-type nova escrita (senão nada mudou).
152
+ // O reload em si é disparado pelo controller DEPOIS de responder ao HTTP,
153
+ // senão o restart mata a resposta em voo (a UI não saberia que deu certo).
154
+ result.willReload = !input.dryRun && write.written.length > 0;
155
+ return result;
156
+ }
157
+
158
+ export interface RunPendingResult {
159
+ ran: boolean;
160
+ seed?: SeedResult;
161
+ link?: LinkResult;
162
+ permissions?: PermissionsResult;
163
+ errors: string[];
164
+ }
165
+
166
+ /** Etapa 2: roda no bootstrap após o restart. Idempotente. */
167
+ export async function runPendingProvision(
168
+ strapi: any,
169
+ strapiAppDir: string
170
+ ): Promise<RunPendingResult> {
171
+ const result: RunPendingResult = { ran: false, errors: [] };
172
+ const mp = markerPath(strapiAppDir);
173
+ if (!fs.existsSync(mp)) return result; // nada pendente
174
+
175
+ let marker: PendingMarker;
176
+ try {
177
+ marker = JSON.parse(fs.readFileSync(mp, 'utf8'));
178
+ } catch (e: any) {
179
+ result.errors.push(`marcador ilegível: ${e?.message ?? e}`);
180
+ return result;
181
+ }
182
+
183
+ result.ran = true;
184
+ try {
185
+ result.seed = await seedContent(strapi, marker.manifest);
186
+ } catch (e: any) {
187
+ result.errors.push(`seed: ${e?.message ?? e}`);
188
+ }
189
+ try {
190
+ result.permissions = await grantPublicRead(strapi, marker.manifest);
191
+ if (result.permissions.errors.length) {
192
+ result.errors.push(...result.permissions.errors.map((e) => `perm: ${e}`));
193
+ }
194
+ } catch (e: any) {
195
+ result.errors.push(`permissões: ${e?.message ?? e}`);
196
+ }
197
+ try {
198
+ result.link = linkFrontend(marker.manifest, {
199
+ frontendDir: marker.frontendDir,
200
+ strapiAppDir: marker.strapiAppDir,
201
+ context: marker.context,
202
+ });
203
+ } catch (e: any) {
204
+ result.errors.push(`link: ${e?.message ?? e}`);
205
+ }
206
+
207
+ // grava o resumo de conclusão para a UI anunciar "preview pronto".
208
+ try {
209
+ const adapter = adapterForManifest(marker.manifest);
210
+ const previewUrl =
211
+ marker.context.frontendUrl || `http://localhost:${adapter.defaultPort}`;
212
+ const done: ProvisionDone = {
213
+ name: marker.manifest.name,
214
+ framework: marker.manifest.framework,
215
+ frontendDir: marker.frontendDir,
216
+ contentTypes: marker.manifest.contentTypes.map((c) => apiUid(c.singularName)),
217
+ previewUrl,
218
+ seedCreated: result.seed?.created ?? [],
219
+ linkErrors: result.link?.errors ?? [],
220
+ finishedAt: new Date().toISOString(),
221
+ };
222
+ const dp = donePath(strapiAppDir);
223
+ fs.mkdirSync(path.dirname(dp), { recursive: true });
224
+ fs.writeFileSync(dp, JSON.stringify(done, null, 2), 'utf8');
225
+ } catch (e: any) {
226
+ result.errors.push(`resumo: ${e?.message ?? e}`);
227
+ }
228
+
229
+ // remove o marcador para não repetir (idempotência entre restarts)
230
+ try {
231
+ fs.unlinkSync(mp);
232
+ } catch {
233
+ /* ignore */
234
+ }
235
+ return result;
236
+ }
@@ -0,0 +1,58 @@
1
+ import type { Manifest } from './manifest';
2
+ import { apiUid } from './generate';
3
+
4
+ /**
5
+ * Concede leitura pública (find / findOne) ao papel "public" para as content-types
6
+ * recém-criadas — sem isso o frontend recebe 403 e o preview fica vazio. Faz parte
7
+ * do "ligar o preview". É idempotente (não duplica permissões já existentes) e
8
+ * só toca nas content-types do manifest (nunca mexe em outras).
9
+ *
10
+ * Single types expõem só `find`; collection types expõem `find` e `findOne`.
11
+ */
12
+ export interface PermissionsResult {
13
+ granted: string[];
14
+ errors: string[];
15
+ }
16
+
17
+ export async function grantPublicRead(
18
+ strapi: any,
19
+ manifest: Manifest
20
+ ): Promise<PermissionsResult> {
21
+ const result: PermissionsResult = { granted: [], errors: [] };
22
+
23
+ let publicRole: any;
24
+ try {
25
+ publicRole = await strapi
26
+ .query('plugin::users-permissions.role')
27
+ .findOne({ where: { type: 'public' } });
28
+ } catch (e: any) {
29
+ result.errors.push(`papel public: ${e?.message ?? e}`);
30
+ return result;
31
+ }
32
+ if (!publicRole) {
33
+ result.errors.push('papel "public" não encontrado (users-permissions ativo?)');
34
+ return result;
35
+ }
36
+
37
+ for (const ct of manifest.contentTypes) {
38
+ const uid = apiUid(ct.singularName);
39
+ const actions = ct.kind === 'singleType' ? ['find'] : ['find', 'findOne'];
40
+ for (const action of actions) {
41
+ const actionId = `${uid}.${action}`;
42
+ try {
43
+ const existing = await strapi
44
+ .query('plugin::users-permissions.permission')
45
+ .findOne({ where: { action: actionId, role: publicRole.id } });
46
+ if (!existing) {
47
+ await strapi
48
+ .query('plugin::users-permissions.permission')
49
+ .create({ data: { action: actionId, role: publicRole.id } });
50
+ result.granted.push(actionId);
51
+ }
52
+ } catch (e: any) {
53
+ result.errors.push(`${actionId}: ${e?.message ?? e}`);
54
+ }
55
+ }
56
+ }
57
+ return result;
58
+ }
@@ -0,0 +1,176 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import net from 'node:net';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * Roda o dev server do frontend provisionado, a pedido da UI (quando o usuário
8
+ * liga o preview). Detecta o gerenciador de pacotes, instala se preciso e sobe
9
+ * `dev`, reportando o estado para a UI mostrar o "carregando" até subir.
10
+ *
11
+ * ROBUSTEZ DE PORTA/HOST (evita o 426 "Upgrade Required" e iframe em branco):
12
+ * - Escolhe uma porta LIVRE em 127.0.0.1 (pula qualquer outro app que já ocupe
13
+ * a porta padrão do framework — ex.: outro Vite na 5173).
14
+ * - Sobe o dev server fixado em 127.0.0.1 (IPv4 explícito) com a flag de porta
15
+ * do framework, e o preview usa EXATAMENTE essa URL — sem depender da
16
+ * resolução ambígua de "localhost" (IPv4 vs IPv6).
17
+ *
18
+ * Só roda em dev; o controller valida o caminho. O filho morre com a Strapi.
19
+ */
20
+
21
+ export type RunState = 'idle' | 'installing' | 'starting' | 'running' | 'error';
22
+
23
+ export interface RunInfo {
24
+ state: RunState;
25
+ dir: string | null;
26
+ url: string | null;
27
+ pm: string | null;
28
+ error: string | null;
29
+ log: string[];
30
+ }
31
+
32
+ let info: RunInfo = { state: 'idle', dir: null, url: null, pm: null, error: null, log: [] };
33
+ let child: ChildProcess | null = null;
34
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
35
+
36
+ function detectPM(dir: string): string {
37
+ if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
38
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
39
+ if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
40
+ return 'npm';
41
+ }
42
+
43
+ const has = (dir: string, ...names: string[]) => names.some((n) => fs.existsSync(path.join(dir, n)));
44
+
45
+ /** Detecta o framework pelo arquivo de config, p/ passar a flag de porta certa. */
46
+ function detectFramework(dir: string): 'vite' | 'next' | 'other' {
47
+ if (has(dir, 'next.config.js', 'next.config.ts', 'next.config.mjs')) return 'next';
48
+ if (has(dir, 'vite.config.js', 'vite.config.ts', 'vite.config.mjs')) return 'vite';
49
+ return 'other';
50
+ }
51
+
52
+ /**
53
+ * Porta-base do dev server do FRONTEND no preview. Propositalmente longe de:
54
+ * - 5173 (Vite do PAINEL ADMIN do próprio Strapi 5 em `strapi develop`),
55
+ * - 3000 (Next default) e 1337 (Strapi).
56
+ * Evita colisão com o admin do Strapi (que responde 426 a requests não-WS).
57
+ */
58
+ const FRONTEND_BASE_PORT = 4321;
59
+
60
+ /** Acha uma porta TCP livre a partir de `start`, testando em 0.0.0.0 (pega
61
+ * ocupações em `*:porta` de qualquer interface IPv4). */
62
+ function findFreePort(start: number): Promise<number> {
63
+ return new Promise((resolve) => {
64
+ const tryPort = (p: number) => {
65
+ if (p > start + 200) return resolve(start); // desistência improvável
66
+ const srv = net.createServer();
67
+ srv.once('error', () => tryPort(p + 1));
68
+ srv.once('listening', () => srv.close(() => resolve(p)));
69
+ srv.listen(p, '0.0.0.0');
70
+ };
71
+ tryPort(start);
72
+ });
73
+ }
74
+
75
+ function pushLog(s: string) {
76
+ for (const line of String(s).split('\n')) {
77
+ const t = line.trim();
78
+ if (t) info.log.push(t);
79
+ }
80
+ if (info.log.length > 60) info.log = info.log.slice(-60);
81
+ }
82
+
83
+ async function urlUp(url: string): Promise<boolean> {
84
+ try {
85
+ const res = await fetch(url, { method: 'GET' });
86
+ // 2xx/3xx = app de verdade. 426 (Upgrade Required) e 5xx = servidor errado
87
+ // ou não pronto → NÃO considerar no ar.
88
+ return res.status >= 200 && res.status < 400;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function getRunStatus(): RunInfo {
95
+ return { ...info, log: info.log.slice(-15) };
96
+ }
97
+
98
+ export function stopFrontend(): void {
99
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
100
+ if (child) {
101
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
102
+ child = null;
103
+ }
104
+ if (info.state !== 'error') info.state = 'idle';
105
+ }
106
+
107
+ export async function startFrontend(_strapi: any, opts: { dir: string; url: string }): Promise<RunInfo> {
108
+ const { dir } = opts;
109
+
110
+ // idempotente: já rodando/subindo para o mesmo dir
111
+ if (child && info.dir === dir && ['installing', 'starting', 'running'].includes(info.state)) {
112
+ return getRunStatus();
113
+ }
114
+ stopFrontend(); // troca de projeto / reinício limpo
115
+
116
+ const pm = detectPM(dir);
117
+ const framework = detectFramework(dir);
118
+ // porta livre numa faixa dedicada (longe de 5173/3000/1337) — evita colidir
119
+ // com o Vite do admin do Strapi e outros servidores.
120
+ const port = await findFreePort(FRONTEND_BASE_PORT);
121
+ const url = `http://127.0.0.1:${port}`;
122
+
123
+ info = { state: 'installing', dir, url, pm, error: null, log: [] };
124
+
125
+ const spawnIn = (cmd: string, args: string[]) =>
126
+ spawn(cmd, args, { cwd: dir, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] });
127
+
128
+ // flags p/ fixar host+porta por framework
129
+ const fwArgs =
130
+ framework === 'next'
131
+ ? ['-H', '127.0.0.1', '-p', String(port)]
132
+ : framework === 'vite'
133
+ ? ['--host', '127.0.0.1', '--port', String(port), '--strictPort']
134
+ : ['--port', String(port)];
135
+ // yarn repassa args direto; os demais precisam do separador "--"
136
+ const devArgs = pm === 'yarn' ? ['dev', ...fwArgs] : ['run', 'dev', '--', ...fwArgs];
137
+
138
+ const startDev = () => {
139
+ info.state = 'starting';
140
+ child = spawnIn(pm, devArgs);
141
+ child.stdout?.on('data', (d) => pushLog(d));
142
+ child.stderr?.on('data', (d) => pushLog(d));
143
+ child.on('exit', (code) => {
144
+ // processo morreu → frontend DOWN. Zera o child e libera o estado p/ que
145
+ // apertar Preview de novo reinicie (em vez de ficar preso em "running").
146
+ child = null;
147
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
148
+ if (info.state === 'running') info.state = 'idle';
149
+ else { info.state = 'error'; info.error = `dev encerrou (código ${code}). Veja o log.`; }
150
+ });
151
+ // confirma que subiu fazendo polling no próprio URL (127.0.0.1:porta)
152
+ pollTimer = setInterval(async () => {
153
+ if (await urlUp(url)) {
154
+ info.state = 'running';
155
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
156
+ }
157
+ }, 1500);
158
+ };
159
+
160
+ const needInstall = !fs.existsSync(path.join(dir, 'node_modules'));
161
+ if (needInstall) {
162
+ pushLog(`Instalando dependências com ${pm}…`);
163
+ const installArgs = pm === 'npm' ? ['install', '--no-audit', '--no-fund'] : ['install'];
164
+ const inst = spawnIn(pm, installArgs);
165
+ inst.stdout?.on('data', (d) => pushLog(d));
166
+ inst.stderr?.on('data', (d) => pushLog(d));
167
+ inst.on('exit', (code) => {
168
+ if (code === 0) startDev();
169
+ else { info.state = 'error'; info.error = `instalação falhou (código ${code}). Veja o log.`; }
170
+ });
171
+ } else {
172
+ startDev();
173
+ }
174
+
175
+ return getRunStatus();
176
+ }
@@ -0,0 +1,115 @@
1
+ import type { Manifest } from './manifest';
2
+ import { apiUid } from './generate';
3
+
4
+ /**
5
+ * Seed: popula o conteúdo declarado em `manifest.seed` usando o Document Service
6
+ * da Strapi 5 (a API correta no v5 — nada de entityService legado).
7
+ *
8
+ * Roda DEPOIS do restart, quando os content-types já existem. Princípios:
9
+ * - Idempotente: só semeia se ainda estiver VAZIO. Re-rodar não duplica.
10
+ * - Aditivo: nunca apaga nem altera conteúdo existente.
11
+ * - Publica por padrão (status 'published') para o frontend já enxergar o
12
+ * conteúdo — mas só se o type tiver draftAndPublish; senão cria direto.
13
+ * - Cobre collectionType (vários) e singleType (a única entrada).
14
+ */
15
+
16
+ export interface SeedResult {
17
+ ok: boolean;
18
+ created: { uid: string; count: number }[];
19
+ skipped: { uid: string; reason: string }[];
20
+ errors: string[];
21
+ }
22
+
23
+ export async function seedContent(
24
+ strapi: any,
25
+ manifest: Manifest
26
+ ): Promise<SeedResult> {
27
+ const result: SeedResult = {
28
+ ok: false,
29
+ created: [],
30
+ skipped: [],
31
+ errors: [],
32
+ };
33
+
34
+ if (!manifest.seed?.length) {
35
+ result.ok = true;
36
+ return result;
37
+ }
38
+
39
+ // índice singularName -> definição da content-type (para saber kind/D&P)
40
+ const byName = new Map(
41
+ manifest.contentTypes.map((ct) => [ct.singularName, ct])
42
+ );
43
+
44
+ for (const group of manifest.seed) {
45
+ const uid = apiUid(group.singularName);
46
+ const def = byName.get(group.singularName);
47
+
48
+ if (!def) {
49
+ result.skipped.push({
50
+ uid,
51
+ reason: 'singularName não consta em contentTypes',
52
+ });
53
+ continue;
54
+ }
55
+
56
+ if (!strapi.contentTypes?.[uid]) {
57
+ result.skipped.push({
58
+ uid,
59
+ reason: 'content-type ainda não registrada (faltou restart?)',
60
+ });
61
+ continue;
62
+ }
63
+
64
+ // cria uma entrada e publica (publish() é o passo confiável na 5.47.1;
65
+ // create({status:'published'}) não publica de fato nesta versão).
66
+ const createOne = async (data: any) => {
67
+ const doc = await strapi.documents(uid).create({ data });
68
+ if (def.draftAndPublish && doc?.documentId) {
69
+ await strapi.documents(uid).publish({ documentId: doc.documentId });
70
+ }
71
+ };
72
+
73
+ try {
74
+ if (def.kind === 'singleType') {
75
+ // single type: só a primeira entrada; pula se já existir conteúdo.
76
+ const current = await strapi.documents(uid).findFirst();
77
+ if (current) {
78
+ result.skipped.push({ uid, reason: 'single type já tem conteúdo' });
79
+ continue;
80
+ }
81
+ if (group.entries[0]) {
82
+ await createOne(group.entries[0]);
83
+ result.created.push({ uid, count: 1 });
84
+ }
85
+ continue;
86
+ }
87
+
88
+ // collection: idempotente — só semeia se vazia
89
+ const existing = await strapi.documents(uid).findMany({ limit: 1 });
90
+ if (Array.isArray(existing) && existing.length > 0) {
91
+ result.skipped.push({ uid, reason: 'coleção já tem conteúdo' });
92
+ continue;
93
+ }
94
+ // resiliente: uma entrada ruim não derruba as outras
95
+ let count = 0;
96
+ let failed = 0;
97
+ for (const data of group.entries) {
98
+ try {
99
+ await createOne(data);
100
+ count++;
101
+ } catch (e: any) {
102
+ failed++;
103
+ if (failed === 1) result.errors.push(`seed ${uid} (entrada): ${e?.message ?? e}`);
104
+ }
105
+ }
106
+ result.created.push({ uid, count });
107
+ if (failed) result.skipped.push({ uid, reason: `${failed} entrada(s) falharam` });
108
+ } catch (e: any) {
109
+ result.errors.push(`seed ${uid}: ${e?.message ?? e}`);
110
+ }
111
+ }
112
+
113
+ result.ok = result.errors.length === 0;
114
+ return result;
115
+ }