strapi-plugin-mcp-chat 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-mcp-chat",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",
@@ -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
  };
@@ -6,6 +6,7 @@ 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';
9
10
  import { FRONTEND_BASE_PORT } from './runner';
10
11
  import { grantPublicRead, type PermissionsResult } from './permissions';
11
12
  import { type LinkContext } from './adapters';
@@ -161,6 +162,7 @@ export interface RunPendingResult {
161
162
  seed?: SeedResult;
162
163
  link?: LinkResult;
163
164
  permissions?: PermissionsResult;
165
+ wire?: WireResult;
164
166
  errors: string[];
165
167
  }
166
168
 
@@ -204,6 +206,17 @@ export async function runPendingProvision(
204
206
  } catch (e: any) {
205
207
  result.errors.push(`link: ${e?.message ?? e}`);
206
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
+ }
207
220
 
208
221
  // grava o resumo de conclusão para a UI anunciar "preview pronto".
209
222
  try {
@@ -0,0 +1,393 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Manifest } from './manifest';
4
+ import { apiUid } from './generate';
5
+
6
+ /**
7
+ * "Wire": liga o frontend à Strapi por FETCH AO VIVO (modelo live-fetch).
8
+ *
9
+ * Em vez do snapshot (integrate, que reescreve arquivos de dados), aqui:
10
+ * 1) Geramos uma camada de dados DETERMINÍSTICA e SEM dependências externas
11
+ * (só React): src/lib/strapi.ts + src/hooks/useStrapi.ts. REST puro, flat,
12
+ * sem populate/nesting — chamadas leves.
13
+ * 2) Religamos os componentes via IA, arquivo a arquivo, trocando o conteúdo
14
+ * hardcoded por leitura da Strapi COM fallback no texto original. Cada
15
+ * arquivo é salvo como .bak antes, e só é gravado se passar numa checagem de
16
+ * sanidade — se algo sair estranho, o arquivo é DEIXADO como estava. Assim o
17
+ * frontend nunca deixa de compilar por nossa causa.
18
+ *
19
+ * NUNCA toca na Strapi (só escreve no frontendDir, validado). No pior caso,
20
+ * algum componente fica sem religar (ainda hardcoded) — nada quebra.
21
+ */
22
+
23
+ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
24
+ const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
25
+
26
+ const SKIP_DIRS = new Set([
27
+ 'node_modules', '.git', 'dist', '.next', '.output', '.vinxi', '.tanstack',
28
+ 'build', 'coverage', '.turbo', '.cache', 'public',
29
+ ]);
30
+
31
+ export interface WireResult {
32
+ ok: boolean;
33
+ dataLayer: string[];
34
+ componentsWired: string[];
35
+ componentsSkipped: { rel: string; reason: string }[];
36
+ warnings: string[];
37
+ errors: string[];
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // util
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function ensureInside(base: string, target: string): boolean {
45
+ const b = path.resolve(base);
46
+ const t = path.resolve(target);
47
+ return t === b || t.startsWith(b + path.sep);
48
+ }
49
+
50
+ function isNext(manifest: Manifest): boolean {
51
+ return manifest.framework === 'next';
52
+ }
53
+
54
+ /** Detecta se o projeto usa o alias "@/..." (tsconfig/jsconfig paths). */
55
+ function usesAtAlias(frontendDir: string): boolean {
56
+ for (const f of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) {
57
+ try {
58
+ const c = fs.readFileSync(path.join(frontendDir, f), 'utf8');
59
+ if (/"@\/\*"\s*:/.test(c)) return true;
60
+ } catch { /* ignore */ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // 1) camada de dados (determinística, sem dependências além do React)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function strapiClientSrc(manifest: Manifest): string {
70
+ const envExpr = isNext(manifest)
71
+ ? "(typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_STRAPI_URL : undefined)"
72
+ : "(import.meta as any).env?.VITE_STRAPI_URL";
73
+ return `// Gerado pelo mcp-chat — camada de acesso à Strapi (REST, flat, sem nesting).
74
+ // Não edite à mão: é regenerado ao religar o frontend.
75
+ export const STRAPI_URL =
76
+ (${envExpr} || "http://localhost:1337").replace(/\\/$/, "");
77
+
78
+ export type PreviewMode = { isPreview: boolean; status: "draft" | "published" };
79
+
80
+ /** Lê o modo de preview da URL (?preview=1 / ?status=draft). */
81
+ export function getPreviewMode(): PreviewMode {
82
+ if (typeof window === "undefined") return { isPreview: false, status: "published" };
83
+ const p = new URLSearchParams(window.location.search);
84
+ const status = p.get("status");
85
+ const isPreview = p.get("preview") === "1" || p.has("preview") || status === "draft";
86
+ return { isPreview, status: status === "draft" || isPreview ? "draft" : "published" };
87
+ }
88
+
89
+ /** Busca um singleType e devolve só os atributos (objeto). null em erro. */
90
+ export async function fetchSection<T = Record<string, any>>(
91
+ name: string,
92
+ status: "draft" | "published" = "published"
93
+ ): Promise<T | null> {
94
+ const qs = status === "draft" ? "?status=draft" : "";
95
+ try {
96
+ const res = await fetch(\`\${STRAPI_URL}/api/\${name}\${qs}\`);
97
+ if (!res.ok) return null;
98
+ const json = await res.json();
99
+ return (json?.data ?? null) as T | null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Busca uma collection (lista) pelo nome plural; ordena por sortOrder se existir. */
106
+ export async function fetchCollection<T = Record<string, any>>(
107
+ pluralName: string,
108
+ status: "draft" | "published" = "published"
109
+ ): Promise<T[]> {
110
+ const qs = new URLSearchParams({ "sort": "sortOrder:asc", "pagination[pageSize]": "100" });
111
+ if (status === "draft") qs.set("status", "draft");
112
+ try {
113
+ const res = await fetch(\`\${STRAPI_URL}/api/\${pluralName}?\${qs.toString()}\`);
114
+ if (!res.ok) return [];
115
+ const json = await res.json();
116
+ return (Array.isArray(json?.data) ? json.data : []) as T[];
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+ `;
122
+ }
123
+
124
+ function hooksSrc(manifest: Manifest, libImport: string): string {
125
+ const clientDirective = isNext(manifest) ? `"use client";\n\n` : '';
126
+ return `${clientDirective}// Gerado pelo mcp-chat — hooks de leitura da Strapi (sem dependências além do React).
127
+ // Em modo preview faz polling curto + refetch via postMessage (reflete edições ao vivo).
128
+ import { useEffect, useState } from "react";
129
+ import { fetchSection, fetchCollection, getPreviewMode } from "${libImport}";
130
+
131
+ export function useSection<T = Record<string, any>>(name: string): Partial<T> {
132
+ const [data, setData] = useState<Partial<T>>({});
133
+ useEffect(() => {
134
+ let alive = true;
135
+ const { isPreview, status } = getPreviewMode();
136
+ const load = () => fetchSection<T>(name, status).then((d) => { if (alive && d) setData(d as Partial<T>); });
137
+ load();
138
+ if (!isPreview) return () => { alive = false; };
139
+ const id = window.setInterval(load, 2500);
140
+ const onMsg = () => load();
141
+ window.addEventListener("message", onMsg);
142
+ return () => { alive = false; window.clearInterval(id); window.removeEventListener("message", onMsg); };
143
+ }, [name]);
144
+ return data;
145
+ }
146
+
147
+ export function useCollection<T = Record<string, any>>(pluralName: string): T[] {
148
+ const [data, setData] = useState<T[]>([]);
149
+ useEffect(() => {
150
+ let alive = true;
151
+ const { isPreview, status } = getPreviewMode();
152
+ const load = () => fetchCollection<T>(pluralName, status).then((d) => { if (alive) setData(d); });
153
+ load();
154
+ if (!isPreview) return () => { alive = false; };
155
+ const id = window.setInterval(load, 2500);
156
+ const onMsg = () => load();
157
+ window.addEventListener("message", onMsg);
158
+ return () => { alive = false; window.clearInterval(id); window.removeEventListener("message", onMsg); };
159
+ }, [pluralName]);
160
+ return data;
161
+ }
162
+ `;
163
+ }
164
+
165
+ /** Escreve a camada de dados. Determinístico e seguro (arquivos novos). */
166
+ function writeDataLayer(frontendDir: string, manifest: Manifest, dryRun?: boolean): string[] {
167
+ const written: string[] = [];
168
+ const libDir = path.join(frontendDir, 'src', 'lib');
169
+ const hooksDir = path.join(frontendDir, 'src', 'hooks');
170
+ const libFile = path.join(libDir, 'strapi.ts');
171
+ const hooksFile = path.join(hooksDir, 'useStrapi.ts');
172
+ if (!ensureInside(frontendDir, libFile) || !ensureInside(frontendDir, hooksFile)) {
173
+ throw new Error('camada de dados fora do frontendDir');
174
+ }
175
+ const libImport = usesAtAlias(frontendDir) ? '@/lib/strapi' : '../lib/strapi';
176
+ if (!dryRun) {
177
+ fs.mkdirSync(libDir, { recursive: true });
178
+ fs.mkdirSync(hooksDir, { recursive: true });
179
+ fs.writeFileSync(libFile, strapiClientSrc(manifest), 'utf8');
180
+ fs.writeFileSync(hooksFile, hooksSrc(manifest, libImport), 'utf8');
181
+ }
182
+ written.push('src/lib/strapi.ts', 'src/hooks/useStrapi.ts');
183
+ return written;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // 2) religação dos componentes (IA, com .bak + checagem de sanidade)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ const CODE_EXT = new Set(['.tsx', '.jsx']);
191
+ const MAX_COMPONENT_CHARS = 16000;
192
+
193
+ function walkComponents(dir: string, base: string, out: string[]) {
194
+ let entries: fs.Dirent[];
195
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
196
+ for (const e of entries) {
197
+ if (e.name.startsWith('.')) continue;
198
+ const full = path.join(dir, e.name);
199
+ if (e.isDirectory()) {
200
+ if (SKIP_DIRS.has(e.name) || e.name === 'ui') continue; // pula primitivos shadcn/ui
201
+ walkComponents(full, base, out);
202
+ } else if (CODE_EXT.has(path.extname(e.name))) {
203
+ out.push(path.relative(base, full));
204
+ }
205
+ }
206
+ }
207
+
208
+ /** pluralName REAL da content-type (Strapi sabe pluralizar); fallback +s. */
209
+ function resolvePlural(strapi: any, singularName: string): string {
210
+ const real = strapi?.contentTypes?.[apiUid(singularName)]?.info?.pluralName;
211
+ return real || `${singularName}s`;
212
+ }
213
+
214
+ /** Resumo do modelo de conteúdo (tipos + campos) para orientar a IA. */
215
+ function contentModelSummary(strapi: any, manifest: Manifest): string {
216
+ const lines: string[] = [];
217
+ for (const ct of manifest.contentTypes) {
218
+ const fields = Object.keys(ct.attributes || {}).join(', ');
219
+ if (ct.kind === 'singleType') {
220
+ lines.push(`singleType "${ct.singularName}" (useSection("${ct.singularName}")) campos: ${fields}`);
221
+ } else {
222
+ const plural = resolvePlural(strapi, ct.singularName);
223
+ lines.push(`collection "${ct.singularName}" (useCollection("${plural}")) campos: ${fields}`);
224
+ }
225
+ }
226
+ return lines.join('\n');
227
+ }
228
+
229
+ function wirePrompt(rel: string, source: string, model: string, hooksImport: string, seedSnippet: string): string {
230
+ return `Você religa um componente React para ler o conteúdo da Strapi, SEM quebrar nada.
231
+
232
+ MODELO DE CONTEÚDO (use EXATAMENTE estes nomes de hook/campo):
233
+ ${model}
234
+
235
+ DADOS SEMEADOS (para casar o texto hardcoded com o campo certo):
236
+ ${seedSnippet}
237
+
238
+ REGRAS (siga à risca):
239
+ - Importe os hooks de "${hooksImport}" (ex.: import { useSection, useCollection } from "${hooksImport}";).
240
+ - Dentro do componente, chame os hooks necessários (ex.: const hero = useSection("hero-section-content");).
241
+ - Troque CADA texto hardcoded que casa com um campo por { obj.campo ?? "TEXTO ORIGINAL" } — SEMPRE mantenha o texto original como fallback no ?? .
242
+ - Para listas (arrays hardcoded de objetos), troque o array por useCollection(...) e itere sobre ele; mantenha ícones/imagens/classes/animacoes/layout EXATAMENTE como estão (não são conteúdo).
243
+ - NÃO altere imports de ícones/assets, JSX estrutural, classes Tailwind, hooks de animação, nada que não seja texto/dado.
244
+ - NÃO invente campos nem textos. Se um trecho não casa com nenhum campo, deixe como está.
245
+ - Mantenha o arquivo VÁLIDO e COMPLETO (TypeScript/TSX que compila). Responda com JSON: {"code":"<arquivo .tsx completo>"} e NADA além disso.
246
+
247
+ ARQUIVO: ${rel}
248
+ \`\`\`tsx
249
+ ${source}
250
+ \`\`\``;
251
+ }
252
+
253
+ /**
254
+ * Validação de sintaxe REAL via esbuild (presente em projetos Strapi/Vite).
255
+ * Retorna a mensagem de erro se NÃO parsear, '' se parsear, ou null se o esbuild
256
+ * não estiver disponível (aí caímos só na checagem heurística).
257
+ */
258
+ function syntaxError(code: string): string | null {
259
+ let esbuild: any;
260
+ try {
261
+ // resolve do node_modules do host (o bundle é --packages=external).
262
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
263
+ esbuild = require('esbuild');
264
+ } catch {
265
+ return null; // sem esbuild → não dá pra validar aqui
266
+ }
267
+ try {
268
+ esbuild.transformSync(code, { loader: 'tsx', jsx: 'automatic' });
269
+ return '';
270
+ } catch (e: any) {
271
+ return (e?.message || 'erro de sintaxe').split('\n')[0];
272
+ }
273
+ }
274
+
275
+ /** Checagem de sanidade leve (sem AST): evita gravar lixo. */
276
+ function looksSane(original: string, next: string, hooksImport: string): string | null {
277
+ if (!next || next.length < 40) return 'saída vazia/curta demais';
278
+ if (next.length < original.length * 0.5) return 'saída muito menor que o original (possível truncamento)';
279
+ if (!/export\s+default|export\s+function|export\s+const/.test(next)) return 'sem export';
280
+ if (!next.includes(hooksImport)) return 'não importou os hooks';
281
+ const balanced = (s: string, a: string, b: string) =>
282
+ (s.split(a).length - 1) === (s.split(b).length - 1);
283
+ if (!balanced(next, '{', '}')) return 'chaves desbalanceadas';
284
+ if (!balanced(next, '(', ')')) return 'parênteses desbalanceados';
285
+ if (!balanced(next, '[', ']')) return 'colchetes desbalanceados';
286
+ // não pode ter sobrado cerca de código markdown
287
+ if (/```/.test(next)) return 'markdown na saída';
288
+ return null;
289
+ }
290
+
291
+ async function callOpenAI(apiKey: string, prompt: string): Promise<string> {
292
+ const res = await fetch(OPENAI_URL, {
293
+ method: 'POST',
294
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({
296
+ model: MODEL,
297
+ temperature: 0,
298
+ response_format: { type: 'json_object' },
299
+ messages: [
300
+ { role: 'system', content: 'Você religa componentes React para a Strapi e responde só com JSON {"code": "..."} válido.' },
301
+ { role: 'user', content: prompt },
302
+ ],
303
+ }),
304
+ });
305
+ if (!res.ok) throw new Error(`OpenAI: ${await res.text()}`);
306
+ const data = await res.json();
307
+ const raw = JSON.parse(data.choices?.[0]?.message?.content ?? '{}');
308
+ return typeof raw.code === 'string' ? raw.code : '';
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // orquestração
313
+ // ---------------------------------------------------------------------------
314
+
315
+ export async function wireFrontend(
316
+ _strapi: any,
317
+ opts: { frontendDir: string; manifest: Manifest; dryRun?: boolean }
318
+ ): Promise<WireResult> {
319
+ const { frontendDir, manifest } = opts;
320
+ const result: WireResult = {
321
+ ok: false, dataLayer: [], componentsWired: [], componentsSkipped: [], warnings: [], errors: [],
322
+ };
323
+
324
+ if (!path.isAbsolute(frontendDir)) { result.errors.push('frontendDir deve ser absoluto'); return result; }
325
+
326
+ // 1) camada de dados (sempre — determinística e segura)
327
+ try {
328
+ result.dataLayer = writeDataLayer(frontendDir, manifest, opts.dryRun);
329
+ } catch (e: any) {
330
+ result.errors.push(`camada de dados: ${e?.message ?? e}`);
331
+ return result; // sem a camada, não adianta religar
332
+ }
333
+
334
+ // 2) religação dos componentes (best-effort, com .bak + sanidade)
335
+ const apiKey = process.env.OPENAI_API_KEY;
336
+ if (!apiKey) {
337
+ result.warnings.push('Sem OPENAI_API_KEY: camada de dados criada, mas os componentes NÃO foram religados (precisa da chave).');
338
+ result.ok = true;
339
+ return result;
340
+ }
341
+
342
+ const srcDir = path.join(frontendDir, 'src');
343
+ const all: string[] = [];
344
+ walkComponents(srcDir, frontendDir, all);
345
+ const components = all.filter((rel) => /(\/|^)(components|pages|app|routes)(\/|$)/.test(rel.replace(/\\/g, '/')));
346
+
347
+ const model = contentModelSummary(_strapi, manifest);
348
+ const hooksImport = usesAtAlias(frontendDir) ? '@/hooks/useStrapi' : '../hooks/useStrapi';
349
+ const seedSnippet = JSON.stringify(manifest.seed ?? []).slice(0, 6000);
350
+
351
+ for (const rel of components) {
352
+ const abs = path.join(frontendDir, rel);
353
+ if (!ensureInside(frontendDir, abs)) continue;
354
+ let source: string;
355
+ try { source = fs.readFileSync(abs, 'utf8'); } catch { continue; }
356
+ if (source.length > MAX_COMPONENT_CHARS) { result.componentsSkipped.push({ rel, reason: 'arquivo grande demais' }); continue; }
357
+ // já religado? pula.
358
+ if (source.includes('useStrapi')) { result.componentsSkipped.push({ rel, reason: 'já religado' }); continue; }
359
+
360
+ // valida: heurística + sintaxe real (esbuild). Se falhar, 1 retry pedindo correção.
361
+ const check = (code: string): string | null => {
362
+ const h = looksSane(source, code, hooksImport);
363
+ if (h) return h;
364
+ const s = syntaxError(code); // '' ok, null = sem esbuild, string = erro
365
+ return s ? `sintaxe: ${s}` : null;
366
+ };
367
+
368
+ try {
369
+ const prompt = wirePrompt(rel, source, model, hooksImport, seedSnippet);
370
+ let next = await callOpenAI(apiKey, prompt);
371
+ let bad = check(next);
372
+ if (bad) {
373
+ // retry único, devolvendo o erro pra IA corrigir.
374
+ const fix = `${prompt}\n\nA sua tentativa anterior foi REJEITADA por: ${bad}. Corrija e responda só com o JSON {"code":"..."} do arquivo completo e válido.`;
375
+ const next2 = await callOpenAI(apiKey, fix);
376
+ const bad2 = check(next2);
377
+ if (bad2) { result.componentsSkipped.push({ rel, reason: bad2 }); continue; }
378
+ next = next2;
379
+ }
380
+ if (!opts.dryRun) {
381
+ const bak = abs + '.bak';
382
+ if (!fs.existsSync(bak)) fs.writeFileSync(bak, source, 'utf8');
383
+ fs.writeFileSync(abs, next, 'utf8');
384
+ }
385
+ result.componentsWired.push(rel);
386
+ } catch (e: any) {
387
+ result.componentsSkipped.push({ rel, reason: `IA: ${e?.message ?? e}` });
388
+ }
389
+ }
390
+
391
+ result.ok = true;
392
+ return result;
393
+ }
@@ -61,6 +61,12 @@ export default {
61
61
  handler: 'frontend.integrate',
62
62
  config: { policies: [] },
63
63
  },
64
+ {
65
+ method: 'POST',
66
+ path: '/frontend/wire',
67
+ handler: 'frontend.wire',
68
+ config: { policies: [] },
69
+ },
64
70
  ],
65
71
  },
66
72
  };