@fire-spark/cli 0.15.0 → 0.15.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
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import type { Copy } from "../../../core/i18n";
|
|
6
|
+
import { say, textOf } from "./flow";
|
|
7
|
+
import type { CheckBinding, Outcome, RunCtx } from "./runner";
|
|
8
|
+
|
|
9
|
+
const text = (es: string, en: string, pt: string): Copy => {
|
|
10
|
+
return { "es-EC": es, "en-US": en, "pt-BR": pt };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const vars = "AGENT_SIGNING_JWKS y FIRESPARK_AGENT_LINK_SECRET.";
|
|
14
|
+
const varsEn = "AGENT_SIGNING_JWKS and FIRESPARK_AGENT_LINK_SECRET.";
|
|
15
|
+
const varsPt = "AGENT_SIGNING_JWKS e FIRESPARK_AGENT_LINK_SECRET.";
|
|
16
|
+
const skipEs = "AGT-09 se salta: falta {field}.";
|
|
17
|
+
const skipEn = "AGT-09 is skipped: {field} is missing.";
|
|
18
|
+
const skipPt = "AGT-09 é pulado: falta {field}.";
|
|
19
|
+
const builderEs = "AGT-09 se salta: este checkout no trae el Channel Builder.";
|
|
20
|
+
const builderEn = "AGT-09 is skipped: this checkout has no Channel Builder.";
|
|
21
|
+
const builderPt = "AGT-09 é pulado: este checkout não traz o Channel Builder.";
|
|
22
|
+
const failEs = "AGT-09 falló en {problem}.";
|
|
23
|
+
const failEn = "AGT-09 failed at {problem}.";
|
|
24
|
+
const failPt = "AGT-09 falhou em {problem}.";
|
|
25
|
+
const createdEs = `Se creó el canal Agent {id}. Documenta ${vars}`;
|
|
26
|
+
const createdEn = `Created Agent channel {id}. Documents ${varsEn}`;
|
|
27
|
+
const createdPt = `O canal Agent {id} foi criado. Documenta ${varsPt}`;
|
|
28
|
+
const linkedEs = `Se enlazó el canal Agent {id}. Documenta ${vars}`;
|
|
29
|
+
const linkedEn = `Linked Agent channel {id}. Documents ${varsEn}`;
|
|
30
|
+
const linkedPt = `O canal Agent {id} foi vinculado. Documenta ${varsPt}`;
|
|
31
|
+
|
|
32
|
+
const COPY = {
|
|
33
|
+
skip: text(skipEs, skipEn, skipPt),
|
|
34
|
+
noBuilder: text(builderEs, builderEn, builderPt),
|
|
35
|
+
fail: text(failEs, failEn, failPt),
|
|
36
|
+
created: text(createdEs, createdEn, createdPt),
|
|
37
|
+
linked: text(linkedEs, linkedEn, linkedPt)
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface DoctorResult {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
action: "created" | "linked" | null;
|
|
43
|
+
channelId: string;
|
|
44
|
+
problem: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DoctorModule {
|
|
48
|
+
runAgentDoctor: (input: {
|
|
49
|
+
root: string;
|
|
50
|
+
apiUrl: string;
|
|
51
|
+
clientId: string;
|
|
52
|
+
clientSecret: string;
|
|
53
|
+
merchantId: string;
|
|
54
|
+
}) => Promise<DoctorResult>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const exists = async (path: string) => {
|
|
58
|
+
try {
|
|
59
|
+
await access(path);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const monorepoRoot = async () => {
|
|
67
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
69
|
+
const marker = "packages/channel-builder/src/agent.doctor.ts";
|
|
70
|
+
if (await exists(join(dir, marker))) return dir;
|
|
71
|
+
const parent = dirname(dir);
|
|
72
|
+
if (parent === dir) return null;
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
interface MissingCredential {
|
|
79
|
+
kind: "missing";
|
|
80
|
+
field: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ReadyCredential {
|
|
84
|
+
kind: "ready";
|
|
85
|
+
clientId: string;
|
|
86
|
+
clientSecret: string;
|
|
87
|
+
apiUrl: string;
|
|
88
|
+
merchantId: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const gapOf = (ctx: RunCtx): MissingCredential | ReadyCredential => {
|
|
92
|
+
const clientId = textOf(ctx.env.FIRESPARK_CLIENT_ID);
|
|
93
|
+
const clientSecret = textOf(ctx.env.FIRESPARK_CLIENT_SECRET);
|
|
94
|
+
const fromEnv = textOf(ctx.env.FIRESPARK_API_URL);
|
|
95
|
+
const fromDoctor = textOf(ctx.env.FIRESPARK_DOCTOR_API_URL);
|
|
96
|
+
const merchantEnv = textOf(ctx.env.FIRESPARK_DOCTOR_MERCHANT_ID);
|
|
97
|
+
const channel = ctx.channels.find(item => {
|
|
98
|
+
return textOf(item.api_url) && textOf(item.merchant_id);
|
|
99
|
+
});
|
|
100
|
+
const apiUrl = fromEnv || fromDoctor || textOf(channel?.api_url);
|
|
101
|
+
const merchantId = merchantEnv || textOf(channel?.merchant_id);
|
|
102
|
+
if (!clientId) return { kind: "missing", field: "FIRESPARK_CLIENT_ID" };
|
|
103
|
+
if (!clientSecret) {
|
|
104
|
+
return { kind: "missing", field: "FIRESPARK_CLIENT_SECRET" };
|
|
105
|
+
}
|
|
106
|
+
if (!apiUrl) return { kind: "missing", field: "FIRESPARK_API_URL" };
|
|
107
|
+
const merchantField = "FIRESPARK_DOCTOR_MERCHANT_ID";
|
|
108
|
+
if (!merchantId) return { kind: "missing", field: merchantField };
|
|
109
|
+
const ready = { kind: "ready" as const, clientId, clientSecret, apiUrl };
|
|
110
|
+
return { ...ready, merchantId };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const loadDoctor = async (root: string) => {
|
|
114
|
+
const file = join(root, "packages/channel-builder/src/agent.doctor.ts");
|
|
115
|
+
try {
|
|
116
|
+
return (await import(pathToFileURL(file).href)) as DoctorModule;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const runAgent = async (ctx: RunCtx): Promise<Outcome> => {
|
|
123
|
+
const gap = gapOf(ctx);
|
|
124
|
+
if (gap.kind === "missing") {
|
|
125
|
+
return say(ctx.locale, "skip", COPY.skip, { field: gap.field });
|
|
126
|
+
}
|
|
127
|
+
const root = await monorepoRoot();
|
|
128
|
+
if (!root) return say(ctx.locale, "skip", COPY.noBuilder);
|
|
129
|
+
const doctor = await loadDoctor(root);
|
|
130
|
+
if (!doctor) return say(ctx.locale, "skip", COPY.noBuilder);
|
|
131
|
+
const b1 = { root, apiUrl: gap.apiUrl, clientId: gap.clientId };
|
|
132
|
+
const b2 = { clientSecret: gap.clientSecret, merchantId: gap.merchantId };
|
|
133
|
+
const result = await doctor.runAgentDoctor({ ...b1, ...b2 });
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
return say(ctx.locale, "fail", COPY.fail, { problem: result.problem });
|
|
136
|
+
}
|
|
137
|
+
const copy = result.action === "linked" ? COPY.linked : COPY.created;
|
|
138
|
+
return say(ctx.locale, "pass", copy, { id: result.channelId });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const WIDGETS = [
|
|
142
|
+
["ui://firespark/menu", "get_menu"],
|
|
143
|
+
["ui://firespark/product", "get_product"],
|
|
144
|
+
["ui://firespark/cart", "build_cart"]
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
const META = {
|
|
148
|
+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
149
|
+
"io.modelcontextprotocol/clientCapabilities": {}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const uiSkipEs = "{id} se salta: falta {field}.";
|
|
153
|
+
const uiSkipEn = "{id} is skipped: {field} is missing.";
|
|
154
|
+
const uiSkipPt = "{id} é pulado: falta {field}.";
|
|
155
|
+
const uiChannelEs = "{id} se salta: el canal no es Agent.";
|
|
156
|
+
const uiChannelEn = "{id} is skipped: the channel is not Agent.";
|
|
157
|
+
const uiChannelPt = "{id} é pulado: o canal não é Agent.";
|
|
158
|
+
const uiFailEs = "{id} falló: {problem}.";
|
|
159
|
+
const uiFailEn = "{id} failed: {problem}.";
|
|
160
|
+
const uiFailPt = "{id} falhou: {problem}.";
|
|
161
|
+
const uiPassEs = "{id} leyó las vistas del comercio.";
|
|
162
|
+
const uiPassEn = "{id} read the merchant views.";
|
|
163
|
+
const uiPassPt = "{id} leu as vistas do comércio.";
|
|
164
|
+
|
|
165
|
+
const UI_COPY = {
|
|
166
|
+
skip: text(uiSkipEs, uiSkipEn, uiSkipPt),
|
|
167
|
+
channel: text(uiChannelEs, uiChannelEn, uiChannelPt),
|
|
168
|
+
fail: text(uiFailEs, uiFailEn, uiFailPt),
|
|
169
|
+
pass: text(uiPassEs, uiPassEn, uiPassPt)
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
173
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const rpcText = async (response: Response) => {
|
|
177
|
+
const text = await response.text();
|
|
178
|
+
const trimmed = text.trim();
|
|
179
|
+
if (trimmed.startsWith("{")) return trimmed;
|
|
180
|
+
const line = trimmed.split("\n").find(item => item.startsWith("data:"));
|
|
181
|
+
return line ? line.slice(5).trim() : trimmed;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const rpc = async (
|
|
185
|
+
url: string,
|
|
186
|
+
token: string,
|
|
187
|
+
method: string,
|
|
188
|
+
params: Record<string, unknown>,
|
|
189
|
+
name?: string
|
|
190
|
+
) => {
|
|
191
|
+
const headers = new Headers();
|
|
192
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
193
|
+
headers.set("content-type", "application/json");
|
|
194
|
+
headers.set("accept", "application/json, text/event-stream");
|
|
195
|
+
headers.set("mcp-protocol-version", "2026-07-28");
|
|
196
|
+
headers.set("mcp-method", method);
|
|
197
|
+
if (name) headers.set("mcp-name", name);
|
|
198
|
+
const b1 = { jsonrpc: "2.0", id: 1, method };
|
|
199
|
+
const b2 = { params: { ...params, _meta: META } };
|
|
200
|
+
const body = JSON.stringify({ ...b1, ...b2 });
|
|
201
|
+
const response = await fetch(url, { method: "POST", headers, body });
|
|
202
|
+
const text = await rpcText(response);
|
|
203
|
+
const json = JSON.parse(text) as Record<string, unknown>;
|
|
204
|
+
return { status: response.status, json };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const endpointOf = (ctx: RunCtx) => {
|
|
208
|
+
const chosen = ctx.channels.find(item => item.template === "agent");
|
|
209
|
+
const channel = chosen ?? ctx.channels[0];
|
|
210
|
+
if (!channel || channel.template !== "agent") {
|
|
211
|
+
return { kind: "channel" as const };
|
|
212
|
+
}
|
|
213
|
+
const token = textOf(ctx.env.FIRESPARK_DOCTOR_AGENT_TOKEN);
|
|
214
|
+
if (!token)
|
|
215
|
+
return { kind: "missing" as const, field: "FIRESPARK_DOCTOR_AGENT_TOKEN" };
|
|
216
|
+
const fromEnv = textOf(ctx.env.FIRESPARK_DOCTOR_AGENT_MCP_URL);
|
|
217
|
+
const url = textOf(channel.url).replace(/\/+$/, "");
|
|
218
|
+
const merchant = textOf(channel.merchant_id);
|
|
219
|
+
let endpoint = fromEnv;
|
|
220
|
+
if (!endpoint && url.endsWith("/mcp")) endpoint = url;
|
|
221
|
+
if (!endpoint && url && merchant) {
|
|
222
|
+
endpoint = `${url}/m/${encodeURIComponent(merchant)}/mcp`;
|
|
223
|
+
}
|
|
224
|
+
if (!endpoint) return { kind: "missing" as const, field: "url" };
|
|
225
|
+
return { kind: "ready" as const, endpoint, token, channel };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const toolMeta = (tool: Record<string, unknown>) => {
|
|
229
|
+
const meta = isRecord(tool._meta) ? tool._meta : {};
|
|
230
|
+
const ui = isRecord(meta.ui) ? meta.ui : {};
|
|
231
|
+
return ui;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const runWidgets = async (ctx: RunCtx): Promise<Outcome> => {
|
|
235
|
+
const ready = endpointOf(ctx);
|
|
236
|
+
if (ready.kind === "channel") {
|
|
237
|
+
return say(ctx.locale, "skip", UI_COPY.channel, { id: "AGT-10" });
|
|
238
|
+
}
|
|
239
|
+
if (ready.kind === "missing") {
|
|
240
|
+
const skipped = { id: "AGT-10", field: ready.field };
|
|
241
|
+
return say(ctx.locale, "skip", UI_COPY.skip, skipped);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const listed = await rpc(ready.endpoint, ready.token, "tools/list", {});
|
|
245
|
+
const result = isRecord(listed.json.result) ? listed.json.result : {};
|
|
246
|
+
const tools = Array.isArray(result.tools) ? result.tools : [];
|
|
247
|
+
const byName = new Map<string, Record<string, unknown>>();
|
|
248
|
+
for (const tool of tools) {
|
|
249
|
+
if (isRecord(tool) && typeof tool.name === "string") {
|
|
250
|
+
byName.set(tool.name, tool);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const [uri, name] of WIDGETS) {
|
|
254
|
+
const tool = byName.get(name);
|
|
255
|
+
const ui = tool ? toolMeta(tool) : {};
|
|
256
|
+
if (ui.resourceUri !== uri) {
|
|
257
|
+
const failed = { id: "AGT-10", problem: name };
|
|
258
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const hidden = byName.get("get_menu_category");
|
|
262
|
+
const visibility = hidden ? toolMeta(hidden).visibility : [];
|
|
263
|
+
const appOnly = Array.isArray(visibility) && visibility.join() === "app";
|
|
264
|
+
if (!appOnly) {
|
|
265
|
+
const failed = { id: "AGT-10", problem: "get_menu_category" };
|
|
266
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
267
|
+
}
|
|
268
|
+
for (const [uri] of WIDGETS) {
|
|
269
|
+
const p1 = [ready.endpoint, ready.token, "resources/read"] as const;
|
|
270
|
+
const read = await rpc(...p1, { uri }, uri);
|
|
271
|
+
const body = isRecord(read.json.result) ? read.json.result : {};
|
|
272
|
+
const contents = Array.isArray(body.contents) ? body.contents : [];
|
|
273
|
+
const first = isRecord(contents[0]) ? contents[0] : {};
|
|
274
|
+
const mime = typeof first.mimeType === "string" ? first.mimeType : "";
|
|
275
|
+
const html = typeof first.text === "string" ? first.text : "";
|
|
276
|
+
if (!mime.includes("profile=mcp-app") || !html.includes('id="root"')) {
|
|
277
|
+
const failed = { id: "AGT-10", problem: uri };
|
|
278
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return say(ctx.locale, "pass", UI_COPY.pass, { id: "AGT-10" });
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const problem = error instanceof Error ? error.message : "mcp";
|
|
284
|
+
return say(ctx.locale, "fail", UI_COPY.fail, { id: "AGT-10", problem });
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const pageOf = (json: Record<string, unknown>) => {
|
|
289
|
+
const result = isRecord(json.result) ? json.result : {};
|
|
290
|
+
const raw = result.structuredContent;
|
|
291
|
+
const structured = isRecord(raw) ? raw : {};
|
|
292
|
+
const view = isRecord(structured.view) ? structured.view : structured;
|
|
293
|
+
const products = Array.isArray(view.products) ? view.products : [];
|
|
294
|
+
const page = isRecord(view.page) ? view.page : {};
|
|
295
|
+
const total = typeof page.total === "number" ? page.total : products.length;
|
|
296
|
+
const cursor = typeof view.next_cursor === "string" ? view.next_cursor : "";
|
|
297
|
+
const handle = typeof view.handle === "string" ? view.handle : "";
|
|
298
|
+
const category = typeof view.category_id === "string" ? view.category_id : "";
|
|
299
|
+
return { products, total, cursor, handle, category };
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const runPages = async (ctx: RunCtx): Promise<Outcome> => {
|
|
303
|
+
const ready = endpointOf(ctx);
|
|
304
|
+
if (ready.kind === "channel") {
|
|
305
|
+
return say(ctx.locale, "skip", UI_COPY.channel, { id: "AGT-11" });
|
|
306
|
+
}
|
|
307
|
+
if (ready.kind === "missing") {
|
|
308
|
+
const skipped = { id: "AGT-11", field: ready.field };
|
|
309
|
+
return say(ctx.locale, "skip", UI_COPY.skip, skipped);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const storeId = textOf(ready.channel.store_id);
|
|
313
|
+
const args: Record<string, unknown> = {};
|
|
314
|
+
if (storeId) args.store_id = storeId;
|
|
315
|
+
const call = { name: "get_menu", arguments: args };
|
|
316
|
+
const p1 = [ready.endpoint, ready.token, "tools/call"] as const;
|
|
317
|
+
const menu = await rpc(...p1, call, "get_menu");
|
|
318
|
+
const first = pageOf(menu.json);
|
|
319
|
+
if (first.products.length > 24 || first.products.length === 0) {
|
|
320
|
+
const failed = { id: "AGT-11", problem: "get_menu" };
|
|
321
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
322
|
+
}
|
|
323
|
+
if (first.total > first.products.length && !first.cursor) {
|
|
324
|
+
const failed = { id: "AGT-11", problem: "cursor" };
|
|
325
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
326
|
+
}
|
|
327
|
+
if (first.cursor && first.handle && first.category) {
|
|
328
|
+
const a1 = { handle: first.handle, category_id: first.category };
|
|
329
|
+
const a2 = { cursor: first.cursor, limit: 24 };
|
|
330
|
+
const call2 = { name: "get_menu_category", arguments: { ...a1, ...a2 } };
|
|
331
|
+
const p2 = [ready.endpoint, ready.token, "tools/call"] as const;
|
|
332
|
+
const more = await rpc(...p2, call2, "get_menu_category");
|
|
333
|
+
const next = pageOf(more.json);
|
|
334
|
+
if (next.products.length === 0) {
|
|
335
|
+
const failed = { id: "AGT-11", problem: "get_menu_category" };
|
|
336
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const sample = first.products.find(isRecord);
|
|
340
|
+
const query = sample && typeof sample.name === "string" ? sample.name : "";
|
|
341
|
+
const call3 = { name: "search_menu", arguments: { ...args, query } };
|
|
342
|
+
const p3 = [ready.endpoint, ready.token, "tools/call"] as const;
|
|
343
|
+
const search = await rpc(...p3, call3, "search_menu");
|
|
344
|
+
const found = pageOf(search.json);
|
|
345
|
+
const hit = found.products.some(item => {
|
|
346
|
+
return isRecord(item) && item.id === sample?.id;
|
|
347
|
+
});
|
|
348
|
+
if (!query || !hit) {
|
|
349
|
+
const failed = { id: "AGT-11", problem: "search_menu" };
|
|
350
|
+
return say(ctx.locale, "fail", UI_COPY.fail, failed);
|
|
351
|
+
}
|
|
352
|
+
return say(ctx.locale, "pass", UI_COPY.pass, { id: "AGT-11" });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const problem = error instanceof Error ? error.message : "mcp";
|
|
355
|
+
return say(ctx.locale, "fail", UI_COPY.fail, { id: "AGT-11", problem });
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
interface AuthCheck {
|
|
360
|
+
id: string;
|
|
361
|
+
status: "pass" | "fail" | "skip";
|
|
362
|
+
detail: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
interface AuthModule {
|
|
366
|
+
runAgentAuthChecks: () => Promise<AuthCheck[]>;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let authChecks: Promise<AuthCheck[]> | null = null;
|
|
370
|
+
|
|
371
|
+
const loadAuth = async (): Promise<AuthModule | null> => {
|
|
372
|
+
const root = await monorepoRoot();
|
|
373
|
+
if (!root) return null;
|
|
374
|
+
const file = join(root, "sdk/ai/src/auth/doctor.ts");
|
|
375
|
+
try {
|
|
376
|
+
return (await import(pathToFileURL(file).href)) as AuthModule;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const authSkipEs = "{id} se salta: falta {field}.";
|
|
383
|
+
const authSkipEn = "{id} is skipped: {field} is missing.";
|
|
384
|
+
const authSkipPt = "{id} é pulado: falta {field}.";
|
|
385
|
+
const missEsA = "{id} se salta: este checkout no trae";
|
|
386
|
+
const missEsB = " el servidor de autorización.";
|
|
387
|
+
const authMissEs = `${missEsA}${missEsB}`;
|
|
388
|
+
const missEn = "{id} is skipped: this checkout has no authorization server.";
|
|
389
|
+
const missPtA = "{id} é pulado: este checkout não traz";
|
|
390
|
+
const missPtB = " o servidor de autorização.";
|
|
391
|
+
const authMissPt = `${missPtA}${missPtB}`;
|
|
392
|
+
const authFailEs = "{id} falló en {detail}.";
|
|
393
|
+
const authFailEn = "{id} failed at {detail}.";
|
|
394
|
+
const authFailPt = "{id} falhou em {detail}.";
|
|
395
|
+
const authPassEs = "{id} pasó.";
|
|
396
|
+
const authPassEn = "{id} passed.";
|
|
397
|
+
const authPassPt = "{id} passou.";
|
|
398
|
+
|
|
399
|
+
const authCopy = {
|
|
400
|
+
skip: text(authSkipEs, authSkipEn, authSkipPt),
|
|
401
|
+
missing: text(authMissEs, missEn, authMissPt),
|
|
402
|
+
fail: text(authFailEs, authFailEn, authFailPt),
|
|
403
|
+
pass: text(authPassEs, authPassEn, authPassPt)
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const runAuth = (id: string) => {
|
|
407
|
+
return async (ctx: RunCtx): Promise<Outcome> => {
|
|
408
|
+
const auth = await loadAuth();
|
|
409
|
+
if (!auth) return say(ctx.locale, "skip", authCopy.missing, { id });
|
|
410
|
+
authChecks ??= auth.runAgentAuthChecks();
|
|
411
|
+
const found = (await authChecks).find(item => item.id === id);
|
|
412
|
+
if (!found) {
|
|
413
|
+
const miss = { id, detail: id };
|
|
414
|
+
return say(ctx.locale, "fail", authCopy.fail, miss);
|
|
415
|
+
}
|
|
416
|
+
if (found.status === "skip") {
|
|
417
|
+
const skipped = { id, field: found.detail };
|
|
418
|
+
return say(ctx.locale, "skip", authCopy.skip, skipped);
|
|
419
|
+
}
|
|
420
|
+
if (found.status === "fail") {
|
|
421
|
+
const failed = { id, detail: found.detail };
|
|
422
|
+
return say(ctx.locale, "fail", authCopy.fail, failed);
|
|
423
|
+
}
|
|
424
|
+
return say(ctx.locale, "pass", authCopy.pass, { id });
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
export const AGENT_BINDINGS: Record<string, CheckBinding> = {
|
|
429
|
+
"AGT-09": { kind: "script", run: runAgent },
|
|
430
|
+
"AGT-10": { kind: "script", run: runWidgets },
|
|
431
|
+
"AGT-11": { kind: "script", run: runPages },
|
|
432
|
+
"AGT-12": { kind: "script", run: runAuth("AGT-12") },
|
|
433
|
+
"AGT-13": { kind: "script", run: runAuth("AGT-13") },
|
|
434
|
+
"AGT-14": { kind: "script", run: runAuth("AGT-14") },
|
|
435
|
+
"AGT-15": { kind: "script", run: runAuth("AGT-15") },
|
|
436
|
+
"AGT-16": { kind: "script", run: runAuth("AGT-16") },
|
|
437
|
+
"AGT-17": { kind: "script", run: runAuth("AGT-17") },
|
|
438
|
+
"AGT-18": { kind: "script", run: runAuth("AGT-18") }
|
|
439
|
+
};
|
|
@@ -1582,5 +1582,135 @@
|
|
|
1582
1582
|
"notes": "Los datos operativos viven en el YAML de config (por ejemplo .bembos.doctor.yml) o en las flags equivalentes, no en el channel-manifest. El manifest cubre la identidad del canal. Un canal se trata como desplegado solo si está marcado así en esa config; tener manifest no implica que ya se desplegó. Si el YAML no existe al arrancar, la CLI auto-invoca init y no dispara este error. Fixable es No para que --fix no invente URLs ni IDs.",
|
|
1583
1583
|
"verification": "deterministic",
|
|
1584
1584
|
"howTo": "Script local: invocar la CLI con un canal marcado como desplegado y sin URL pública (y por separado sin store-id, fulfillment-id o credenciales) y afirmar que el código de salida no es 0, que no se corrió ningún check y que el error nombra el campo y el canal. Repetir con un canal nunca desplegado, solo con identidad, y afirmar que no aparece este error por falta de URLs u otros datos operativos."
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
"id": "AGT-09",
|
|
1588
|
+
"description": "Channel Builder genera el template AGENT.",
|
|
1589
|
+
"acceptance": "Al invocar firespark doctor run AGT-09, el check genera el template AGENT con el Channel Builder en un directorio temporal. El resultado incluye la ruta MCP, la página de pago, el checkout, las pantallas de órdenes y de detalle de orden, el servidor de autorización montado en /authorize, y documenta AGENT_SIGNING_JWKS (JWK set privado) y FIRESPARK_AGENT_LINK_SECRET (firma los links de pago). El merchantId, el issuer y el jwksUrl salen del canal, no de un arreglo en env. Si el comercio ya tiene un canal AGENT, lo enlaza; si no, lo crea por la API de integrations. El check se salta solo cuando falta una credencial real: FIRESPARK_CLIENT_ID, FIRESPARK_CLIENT_SECRET, la URL de la API o el id del comercio. No se salta por precaución.",
|
|
1590
|
+
"channels": ["agent"],
|
|
1591
|
+
"platforms": ["web"],
|
|
1592
|
+
"fixable": false,
|
|
1593
|
+
"required": true,
|
|
1594
|
+
"area": "sdk",
|
|
1595
|
+
"notes": "El template es un extracto del web template (auth, checkout, órdenes y detalle) más las rutas MCP y de pago del server template, y el paquete @fire-spark/ai. No copia el resto del sitio.",
|
|
1596
|
+
"verification": "deterministic",
|
|
1597
|
+
"howTo": "Script local: con FIRESPARK_CLIENT_ID, FIRESPARK_CLIENT_SECRET, la URL de la API y el id del comercio, correr firespark doctor run AGT-09. Afirmar que el canal AGENT se crea o se enlaza y que el directorio temporal trae la ruta MCP, la página de pago, checkout, órdenes, detalle, los widgets, /authorize y AGENT_SIGNING_JWKS más FIRESPARK_AGENT_LINK_SECRET en .env.example, README y docs. Sin una de esas credenciales, el check se salta y nombra el dato que falta."
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
"id": "AGT-10",
|
|
1601
|
+
"description": "Las vistas MCP del canal Agent se sirven como HTML mcp-app y las tools declaran _meta.ui.",
|
|
1602
|
+
"acceptance": "resources/read de ui://firespark/menu, ui://firespark/product y ui://firespark/cart responde mimeType text/html;profile=mcp-app con el HTML de la vista. get_menu, get_product, build_cart, validate_cart y search_menu traen _meta.ui.resourceUri. get_menu_category queda visible solo para la app.",
|
|
1603
|
+
"channels": ["agent"],
|
|
1604
|
+
"platforms": ["web"],
|
|
1605
|
+
"fixable": false,
|
|
1606
|
+
"required": false,
|
|
1607
|
+
"area": "menu",
|
|
1608
|
+
"notes": "Necesita FIRESPARK_DOCTOR_AGENT_TOKEN y la URL del MCP (url del canal o FIRESPARK_DOCTOR_AGENT_MCP_URL). Se salta si el canal no es agent o falta el token.",
|
|
1609
|
+
"verification": "deterministic",
|
|
1610
|
+
"howTo": "Registra el preview como servidor MCP y corre firespark doctor run AGT-10 con el token del cliente. Afirmar el mime mcp-app y _meta.ui."
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
"id": "AGT-11",
|
|
1614
|
+
"description": "get_menu pagina el menú real del comercio y search_menu encuentra un producto de esa página.",
|
|
1615
|
+
"acceptance": "get_menu devuelve como máximo 24 productos. Si hay más, next_cursor y get_menu_category traen la página siguiente. search_menu con el nombre de un producto de la primera página lo devuelve.",
|
|
1616
|
+
"channels": ["agent"],
|
|
1617
|
+
"platforms": ["web"],
|
|
1618
|
+
"fixable": false,
|
|
1619
|
+
"required": false,
|
|
1620
|
+
"area": "menu",
|
|
1621
|
+
"notes": "Usa el mismo token y la misma URL que AGT-10, contra el menú del comercio. No manda el menú completo.",
|
|
1622
|
+
"verification": "deterministic",
|
|
1623
|
+
"howTo": "Con el MCP del preview y FIRESPARK_DOCTOR_AGENT_TOKEN, correr firespark doctor run AGT-11. Afirmar la página de 24, el cursor y la búsqueda."
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
"id": "AGT-12",
|
|
1627
|
+
"description": "El servidor de autorización publica metadata OAuth.",
|
|
1628
|
+
"acceptance": "Al invocar firespark doctor run AGT-12, el check levanta el servidor de autorización real y pide /.well-known/oauth-authorization-server. La metadata trae issuer, JWKS, /authorize, /token, /register, /revoke, PKCE solo S256, iss en la respuesta y CIMD. Si FIRESPARK_DOCTOR_AGENT_ISSUER está definido, también pide esa metadata en vivo y falla si el issuer no coincide. Se salta solo si este checkout no trae el servidor.",
|
|
1629
|
+
"channels": ["agent"],
|
|
1630
|
+
"platforms": ["web"],
|
|
1631
|
+
"fixable": false,
|
|
1632
|
+
"required": true,
|
|
1633
|
+
"area": "sdk",
|
|
1634
|
+
"notes": "El mismo oidc-provider que monta el template. No hay un token fijo.",
|
|
1635
|
+
"verification": "deterministic",
|
|
1636
|
+
"howTo": "Correr firespark doctor run AGT-12. Afirmar pass. Con FIRESPARK_DOCTOR_AGENT_ISSUER, afirmar que un issuer distinto falla."
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
"id": "AGT-13",
|
|
1640
|
+
"description": "El authorization code exige PKCE S256.",
|
|
1641
|
+
"acceptance": "Al invocar firespark doctor run AGT-13, el check registra un cliente, rechaza /authorize sin code_challenge y completa el canje con S256. Se salta solo si este checkout no trae el servidor.",
|
|
1642
|
+
"channels": ["agent"],
|
|
1643
|
+
"platforms": ["web"],
|
|
1644
|
+
"fixable": false,
|
|
1645
|
+
"required": true,
|
|
1646
|
+
"area": "sdk",
|
|
1647
|
+
"notes": "plain no está en code_challenge_methods_supported.",
|
|
1648
|
+
"verification": "deterministic",
|
|
1649
|
+
"howTo": "Correr firespark doctor run AGT-13 y afirmar pass."
|
|
1650
|
+
},
|
|
1651
|
+
{
|
|
1652
|
+
"id": "AGT-14",
|
|
1653
|
+
"description": "DCR respeta la política de dominios y CIMD bloquea direcciones privadas.",
|
|
1654
|
+
"acceptance": "Al invocar firespark doctor run AGT-14, el check rechaza el registro de un redirect en un dominio bloqueado y rechaza un client_id CIMD en 127.0.0.1. Si FIRESPARK_DOCTOR_CIMD_URL apunta a un documento público, también acepta ese cliente. Se salta solo si este checkout no trae el servidor.",
|
|
1655
|
+
"channels": ["agent"],
|
|
1656
|
+
"platforms": ["web"],
|
|
1657
|
+
"fixable": false,
|
|
1658
|
+
"required": true,
|
|
1659
|
+
"area": "sdk",
|
|
1660
|
+
"notes": "El fetch de CIMD es https, sin redirects a redes privadas, con límite de tamaño y tiempo.",
|
|
1661
|
+
"verification": "deterministic",
|
|
1662
|
+
"howTo": "Correr firespark doctor run AGT-14. Sin FIRESPARK_DOCTOR_CIMD_URL igual corre el rechazo. Con esa URL, el documento público tiene que pasar."
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
"id": "AGT-15",
|
|
1666
|
+
"description": "El access token trae aud del MCP, iss del comercio y customer_id igual a sub.",
|
|
1667
|
+
"acceptance": "Al invocar firespark doctor run AGT-15, el check completa login, consentimiento y canje. El JWT tiene aud igual a la URL MCP, iss igual al issuer, y sub y customer_id iguales. Se salta solo si este checkout no trae el servidor.",
|
|
1668
|
+
"channels": ["agent"],
|
|
1669
|
+
"platforms": ["web"],
|
|
1670
|
+
"fixable": false,
|
|
1671
|
+
"required": true,
|
|
1672
|
+
"area": "sdk",
|
|
1673
|
+
"notes": "El exchange de Spark usa sub como customer id. Por eso los dos claims coinciden.",
|
|
1674
|
+
"verification": "deterministic",
|
|
1675
|
+
"howTo": "Correr firespark doctor run AGT-15 y afirmar pass."
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
"id": "AGT-16",
|
|
1679
|
+
"description": "Reusar un refresh token revoca la familia.",
|
|
1680
|
+
"acceptance": "Al invocar firespark doctor run AGT-16, el check rota el refresh token y luego rechaza el token viejo y el rotado. Se salta solo si este checkout no trae el servidor.",
|
|
1681
|
+
"channels": ["agent"],
|
|
1682
|
+
"platforms": ["web"],
|
|
1683
|
+
"fixable": false,
|
|
1684
|
+
"required": true,
|
|
1685
|
+
"area": "sdk",
|
|
1686
|
+
"notes": "La revocación también está en /revoke.",
|
|
1687
|
+
"verification": "deterministic",
|
|
1688
|
+
"howTo": "Correr firespark doctor run AGT-16 y afirmar pass."
|
|
1689
|
+
},
|
|
1690
|
+
{
|
|
1691
|
+
"id": "AGT-17",
|
|
1692
|
+
"description": "La pantalla de consentimiento está en español de Ecuador.",
|
|
1693
|
+
"acceptance": "Al invocar firespark doctor run AGT-17, el check abre el consentimiento real y afirma que nombra los scopes en palabras llanas (ver el menú, crear pedidos) y que usa aquí. Se salta solo si este checkout no trae el servidor.",
|
|
1694
|
+
"channels": ["agent"],
|
|
1695
|
+
"platforms": ["web"],
|
|
1696
|
+
"fixable": false,
|
|
1697
|
+
"required": true,
|
|
1698
|
+
"area": "sdk",
|
|
1699
|
+
"notes": "Crear un pedido sigue pidiendo confirmar el total en la conversación.",
|
|
1700
|
+
"verification": "deterministic",
|
|
1701
|
+
"howTo": "Correr firespark doctor run AGT-17 y afirmar pass."
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
"id": "AGT-18",
|
|
1705
|
+
"description": "El adaptador de Firebase verifica el ID token y toma customer_id.",
|
|
1706
|
+
"acceptance": "Al invocar firespark doctor run AGT-18, el check verifica un ID token real de Firebase contra el JWKS de securetoken y exige customer_id o, si no viene, el sub. Se salta solo cuando falta FIRESPARK_DOCTOR_FIREBASE_PROJECT_ID o FIRESPARK_DOCTOR_FIREBASE_ID_TOKEN. No se salta por precaución si los dos están.",
|
|
1707
|
+
"channels": ["agent"],
|
|
1708
|
+
"platforms": ["web"],
|
|
1709
|
+
"fixable": false,
|
|
1710
|
+
"required": true,
|
|
1711
|
+
"area": "sdk",
|
|
1712
|
+
"notes": "El dominio del servidor de autorización tiene que estar en los dominios autorizados de Firebase.",
|
|
1713
|
+
"verification": "deterministic",
|
|
1714
|
+
"howTo": "Sin las dos variables, el check se salta y nombra la que falta. Con un ID token real y el project id, afirmar pass."
|
|
1585
1715
|
}
|
|
1586
1716
|
]
|
|
@@ -89,7 +89,7 @@ for (const [slug, copy] of Object.entries(AREA_LABELS)) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const channelA = ["kiosk", "pos", "web", "app", "chat"] as const;
|
|
92
|
-
const channelB = ["call-center", "server", "dashboard"] as const;
|
|
92
|
+
const channelB = ["call-center", "server", "dashboard", "agent"] as const;
|
|
93
93
|
const CHANNELS = new Set([...channelA, ...channelB]);
|
|
94
94
|
|
|
95
95
|
const PLATFORMS = new Set(["web", "android", "ios", "dashboard"]);
|
|
@@ -22,9 +22,9 @@ export const CLIENT_OPTIONS: OptionSpec[] = [
|
|
|
22
22
|
name: "channel",
|
|
23
23
|
kind: "string",
|
|
24
24
|
description: text(
|
|
25
|
-
"Filtra por canal: kiosk, pos, web, app, chat, call-center, server, dashboard.",
|
|
26
|
-
"Filter by channel: kiosk, pos, web, app, chat, call-center, server, dashboard.",
|
|
27
|
-
"Filtra por canal: kiosk, pos, web, app, chat, call-center, server, dashboard."
|
|
25
|
+
"Filtra por canal: kiosk, pos, web, app, agent, chat, call-center, server, dashboard.",
|
|
26
|
+
"Filter by channel: kiosk, pos, web, app, agent, chat, call-center, server, dashboard.",
|
|
27
|
+
"Filtra por canal: kiosk, pos, web, app, agent, chat, call-center, server, dashboard."
|
|
28
28
|
)
|
|
29
29
|
}),
|
|
30
30
|
option({
|
|
@@ -3,6 +3,7 @@ import { translate } from "../../../core/i18n";
|
|
|
3
3
|
import type { Output } from "../../../core/output";
|
|
4
4
|
import type { Redactor } from "../../../core/redact";
|
|
5
5
|
import { ADMIN_BINDINGS } from "./admin.flow";
|
|
6
|
+
import { AGENT_BINDINGS } from "./agent.flow";
|
|
6
7
|
import type { CatalogCheck } from "./catalog";
|
|
7
8
|
import { CHECKOUT_BINDINGS } from "./checkout.flow";
|
|
8
9
|
import { CONTENT_BINDINGS } from "./content.flow";
|
|
@@ -113,7 +114,8 @@ export const BINDINGS: Record<string, CheckBinding> = {
|
|
|
113
114
|
...CONTENT_BINDINGS,
|
|
114
115
|
...STORE_BINDINGS,
|
|
115
116
|
...OPS_BINDINGS,
|
|
116
|
-
...ADMIN_BINDINGS
|
|
117
|
+
...ADMIN_BINDINGS,
|
|
118
|
+
...AGENT_BINDINGS
|
|
117
119
|
};
|
|
118
120
|
|
|
119
121
|
export const runOne = async (
|