@fire-spark/cli 0.13.2 → 0.14.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.
@@ -0,0 +1,577 @@
1
+ import { Big } from "big.js";
2
+
3
+ import type { Copy } from "../../../core/i18n";
4
+ import { fromLive, isOutcome, isRecord, openLive, prepare } from "./flow";
5
+ import { say, textOf, trpcCall, withSafeFix } from "./flow";
6
+ import { readSources } from "./repo.scan";
7
+ import type { CheckBinding, Outcome, RunCtx } from "./runner";
8
+ import type { YamlChannel } from "./yaml";
9
+
10
+ const line = (es: string, en: string, pt: string): Copy => {
11
+ return { "es-EC": es, "en-US": en, "pt-BR": pt };
12
+ };
13
+
14
+ const COPY = {
15
+ channel: line(
16
+ "{id} no aplica al canal {template}.",
17
+ "{id} does not apply to the {template} channel.",
18
+ "{id} não se aplica ao canal {template}."
19
+ ),
20
+ hours: line(
21
+ "AVA-01 se omite: preferences no trae availability.",
22
+ "AVA-01 is skipped: preferences has no availability.",
23
+ "AVA-01 é omitido: preferences não traz availability."
24
+ ),
25
+ need: line(
26
+ "{id} se omite: falta {field}.",
27
+ "{id} is skipped: {field} is missing.",
28
+ "{id} é omitido: falta {field}."
29
+ ),
30
+ checkout: line(
31
+ "AVA-03 se omite: preferences no trae shipping ni billing.",
32
+ "AVA-03 is skipped: preferences has no shipping or billing.",
33
+ "AVA-03 é omitido: preferences não traz shipping nem billing."
34
+ ),
35
+ fees: line(
36
+ "AVA-04 se omite: no hay reglas de fee para calcular.",
37
+ "AVA-04 is skipped: there are no fee rules to calculate.",
38
+ "AVA-04 é omitido: não há regras de fee para calcular."
39
+ ),
40
+ closed: line(
41
+ "AVA-05 se omite: la regla de no disponible no está activa.",
42
+ "AVA-05 is skipped: the unavailable rule is not active.",
43
+ "AVA-05 é omitido: a regra de indisponível não está ativa."
44
+ ),
45
+ version: line(
46
+ "AVA-06 se omite: este canal no publicó min_version.",
47
+ "AVA-06 is skipped: this channel did not publish min_version.",
48
+ "AVA-06 é omitido: este canal não publicou min_version."
49
+ ),
50
+ outside: line(
51
+ "AVA-02 el punto está fuera de las coverage zones.",
52
+ "AVA-02 the point is outside the coverage zones.",
53
+ "AVA-02 o ponto está fora das coverage zones."
54
+ ),
55
+ drift: line(
56
+ "{id} el menú cambió entre dos lecturas.",
57
+ "{id} the menu changed between two reads.",
58
+ "{id} o cardápio mudou entre duas leituras."
59
+ ),
60
+ command: line(
61
+ "{id} el comando {type} respondió {status}.",
62
+ "{id} command {type} responded {status}.",
63
+ "{id} o comando {type} respondeu {status}."
64
+ ),
65
+ named: line(
66
+ "{id} solo corre cuando lo nombras. No mandé el comando.",
67
+ "{id} runs only when you name it. The command was not sent.",
68
+ "{id} só roda quando você o nomeia. O comando não foi enviado."
69
+ ),
70
+ fee: line(
71
+ "AVA-04 el fee calculado {expected} no coincide con el validate {actual}.",
72
+ "AVA-04 the computed fee {expected} does not match validate {actual}.",
73
+ "AVA-04 a taxa calculada {expected} não coincide com o validate {actual}."
74
+ ),
75
+ gate: line(
76
+ "AVA-05 la regla activa no trae título y descripción.",
77
+ "AVA-05 the active rule is missing a title and description.",
78
+ "AVA-05 a regra ativa não traz título e descrição."
79
+ ),
80
+ reset: line(
81
+ "RT-03 se omite: falta resetKioskCache en el repo del kiosk.",
82
+ "RT-03 is skipped: resetKioskCache is missing from the kiosk repo.",
83
+ "RT-03 é omitido: falta resetKioskCache no repo do kiosk."
84
+ ),
85
+ checklist: line(
86
+ "{id} el checklist no está completo: falta {field}.",
87
+ "{id} the checklist is incomplete: {field} is missing.",
88
+ "{id} o checklist não está completo: falta {field}."
89
+ )
90
+ };
91
+
92
+ const LIVE = [
93
+ "api_url",
94
+ "channel_id",
95
+ "FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
96
+ "FIRESPARK_DOCTOR_CUSTOMER_PASSWORD",
97
+ "FIRESPARK_DOCTOR_FIREBASE_API_KEY"
98
+ ] as const;
99
+
100
+ const BASE = "10.00";
101
+
102
+ const applies = (ctx: RunCtx, id: string, allowed: ReadonlySet<string>) => {
103
+ const ready = prepare(ctx, ["channel_id"]);
104
+ if (isOutcome(ready)) return ready;
105
+ const template = textOf(ready.template);
106
+ if (allowed.has(template)) return null;
107
+ return say(ctx.locale, "skip", COPY.channel, { id, template });
108
+ };
109
+
110
+ const need = (ctx: RunCtx, id: string, field: string) => {
111
+ return say(ctx.locale, "skip", COPY.need, { id, field });
112
+ };
113
+
114
+ const finite = (value: unknown) => {
115
+ const number = Number(value);
116
+ return Number.isFinite(number) ? number : null;
117
+ };
118
+
119
+ const haversine = (lat1: number, lng1: number, lat2: number, lng2: number) => {
120
+ const radius = 6371000;
121
+ const p1 = (lat1 * Math.PI) / 180;
122
+ const p2 = (lat2 * Math.PI) / 180;
123
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
124
+ const dLng = ((lng2 - lng1) * Math.PI) / 180;
125
+ const a = Math.sin(dLat / 2) ** 2;
126
+ const b = Math.cos(p1) * Math.cos(p2) * Math.sin(dLng / 2) ** 2;
127
+ return 2 * radius * Math.asin(Math.sqrt(a + b));
128
+ };
129
+
130
+ const insidePolygon = (
131
+ lat: number,
132
+ lng: number,
133
+ points: { lat: number; lng: number }[]
134
+ ) => {
135
+ let inside = false;
136
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
137
+ const yi = points[i]?.lat ?? 0;
138
+ const xi = points[i]?.lng ?? 0;
139
+ const yj = points[j]?.lat ?? 0;
140
+ const xj = points[j]?.lng ?? 0;
141
+ const crosses = yi > lat !== yj > lat;
142
+ const edge = ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
143
+ if (crosses && lng < edge) inside = !inside;
144
+ }
145
+ return inside;
146
+ };
147
+
148
+ const zoneContains = (
149
+ lat: number,
150
+ lng: number,
151
+ zone: Record<string, unknown>
152
+ ) => {
153
+ if (zone.type === "RADIUS" && isRecord(zone.radius_info)) {
154
+ const zlat = finite(zone.radius_info.latitude);
155
+ const zlng = finite(zone.radius_info.longitude);
156
+ const meters = finite(zone.radius_info.radius);
157
+ if (zlat === null || zlng === null || meters === null) return null;
158
+ return haversine(lat, lng, zlat, zlng) <= meters;
159
+ }
160
+ if (zone.type !== "POLYGON" || !isRecord(zone.polygon_info)) return null;
161
+ const polygon = zone.polygon_info.polygon;
162
+ if (!Array.isArray(polygon) || polygon.length < 3) return null;
163
+ const points: { lat: number; lng: number }[] = [];
164
+ for (const point of polygon) {
165
+ if (!isRecord(point)) return null;
166
+ const plat = finite(point.latitude);
167
+ const plng = finite(point.longitude);
168
+ if (plat === null || plng === null) return null;
169
+ points.push({ lat: plat, lng: plng });
170
+ }
171
+ return insidePolygon(lat, lng, points);
172
+ };
173
+
174
+ const preferencesOf = async (ctx: RunCtx, channel: YamlChannel) => {
175
+ const live = openLive(ctx, channel);
176
+ const codes = [200];
177
+ const args = ["preferences", "GET", "/preferences", codes] as const;
178
+ const step = await live.storefront<unknown>(...args);
179
+ if (!step.ok) return fromLive(ctx.locale, step.result);
180
+ return step.value;
181
+ };
182
+
183
+ const availabilityOf = (value: unknown) => {
184
+ if (!isRecord(value) || !isRecord(value.availability)) return null;
185
+ return value.availability;
186
+ };
187
+
188
+ const runAva01 = async (ctx: RunCtx): Promise<Outcome> => {
189
+ const allowed = new Set([
190
+ "kiosk",
191
+ "web",
192
+ "app",
193
+ "chat",
194
+ "call-center",
195
+ "dashboard"
196
+ ]);
197
+ const skipped = applies(ctx, "AVA-01", allowed);
198
+ if (skipped) return skipped;
199
+ if (!ctx.named.has("AVA-01")) return need(ctx, "AVA-01", "named");
200
+ const ready = prepare(ctx, LIVE);
201
+ if (isOutcome(ready)) return ready;
202
+ const prefs = await preferencesOf(ctx, ready);
203
+ if (isOutcome(prefs)) return prefs;
204
+ const availability = availabilityOf(prefs);
205
+ if (!availability) return need(ctx, "AVA-01", "preferences.availability");
206
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
207
+ if (!cookie) return need(ctx, "AVA-01", "FIRESPARK_DOCTOR_DASHBOARD_COOKIE");
208
+ const dash = textOf(ready.dashboard_url);
209
+ if (!dash) return need(ctx, "AVA-01", "dashboard_url");
210
+ const merchant = textOf(ready.merchant_id);
211
+ if (!merchant) return need(ctx, "AVA-01", "merchant_id");
212
+ const env = textOf(ready.env);
213
+ if (!env) return need(ctx, "AVA-01", "env");
214
+ let current = "AVAILABLE";
215
+ if (availability.status === "UNAVAILABLE") current = "UNAVAILABLE";
216
+ const next = current === "AVAILABLE" ? "UNAVAILABLE" : "AVAILABLE";
217
+ const input = {
218
+ merchant_id: merchant,
219
+ env,
220
+ uid: textOf(ready.channel_id),
221
+ availability: { status: next }
222
+ };
223
+ const proc = "channels.updateAvailability";
224
+ const wrote = await trpcCall(dash, cookie, proc, input);
225
+ if (wrote.status < 200 || wrote.status >= 300) {
226
+ return say(ctx.locale, "fail", COPY.command, {
227
+ id: "AVA-01",
228
+ type: "availability.changed",
229
+ status: wrote.status
230
+ });
231
+ }
232
+ const again = await preferencesOf(ctx, ready);
233
+ if (isOutcome(again)) return again;
234
+ const seen = availabilityOf(again);
235
+ const restore = {
236
+ merchant_id: merchant,
237
+ env,
238
+ uid: textOf(ready.channel_id),
239
+ availability: { status: current }
240
+ };
241
+ await trpcCall(dash, cookie, "channels.updateAvailability", restore);
242
+ if (!seen || seen.status !== next) {
243
+ return say(ctx.locale, "fail", COPY.command, {
244
+ id: "AVA-01",
245
+ type: "availability.changed",
246
+ status: 200
247
+ });
248
+ }
249
+ return { status: "pass" };
250
+ };
251
+
252
+ const MODES = new Set(["required", "optional", "hidden"]);
253
+
254
+ const fieldModes = (value: unknown) => {
255
+ if (!isRecord(value) || !isRecord(value.fields)) return null;
256
+ const modes = Object.values(value.fields);
257
+ if (modes.length === 0) return null;
258
+ const ok = modes.every(mode => typeof mode === "string" && MODES.has(mode));
259
+ return ok ? modes : null;
260
+ };
261
+
262
+ const runAva03 = async (ctx: RunCtx): Promise<Outcome> => {
263
+ const allowed = new Set(["kiosk", "web", "app", "chat", "call-center"]);
264
+ const skipped = applies(ctx, "AVA-03", allowed);
265
+ if (skipped) return skipped;
266
+ const ready = prepare(ctx, LIVE);
267
+ if (isOutcome(ready)) return ready;
268
+ const prefs = await preferencesOf(ctx, ready);
269
+ if (isOutcome(prefs)) return prefs;
270
+ if (!isRecord(prefs) || !isRecord(prefs.checkout)) {
271
+ return say(ctx.locale, "skip", COPY.checkout);
272
+ }
273
+ const shipping = fieldModes(prefs.checkout.shipping);
274
+ if (!shipping) return need(ctx, "AVA-03", "checkout.shipping.fields");
275
+ const billing = fieldModes(prefs.checkout.billing);
276
+ if (!billing) return need(ctx, "AVA-03", "checkout.billing.fields");
277
+ return { status: "pass" };
278
+ };
279
+
280
+ const feeRows = (value: unknown) => {
281
+ if (!isRecord(value) || !Array.isArray(value.fees)) return [];
282
+ return value.fees.filter(isRecord);
283
+ };
284
+
285
+ const percentageFee = (row: Record<string, unknown>) => {
286
+ const based = row.based_on;
287
+ if (based !== "ORDER_TOTAL" && based !== "ORDER_SUBTOTAL") return "";
288
+ if (typeof row.percentage !== "number") return "";
289
+ if (!Number.isFinite(row.percentage)) return "";
290
+ const due = new Big(BASE).times(row.percentage).div(100);
291
+ return due.toFixed(2);
292
+ };
293
+
294
+ const feeAmount = (value: unknown) => {
295
+ if (!isRecord(value)) return "";
296
+ const fees = value.fees;
297
+ const row = Array.isArray(fees) ? fees.find(isRecord) : undefined;
298
+ if (!row) return "";
299
+ const amount = row.amount;
300
+ if (typeof amount === "number" && Number.isFinite(amount)) {
301
+ return new Big(String(amount)).toFixed(2);
302
+ }
303
+ if (typeof amount === "string" && amount.trim()) {
304
+ try {
305
+ return new Big(amount).toFixed(2);
306
+ } catch {
307
+ return "";
308
+ }
309
+ }
310
+ return "";
311
+ };
312
+
313
+ const runAva04 = async (ctx: RunCtx): Promise<Outcome> => {
314
+ const allowed = new Set([
315
+ "kiosk",
316
+ "web",
317
+ "app",
318
+ "chat",
319
+ "call-center",
320
+ "dashboard"
321
+ ]);
322
+ const skipped = applies(ctx, "AVA-04", allowed);
323
+ if (skipped) return skipped;
324
+ const ready = prepare(ctx, LIVE);
325
+ if (isOutcome(ready)) return ready;
326
+ const prefs = await preferencesOf(ctx, ready);
327
+ if (isOutcome(prefs)) return prefs;
328
+ const rows = feeRows(prefs);
329
+ const expected = rows.map(percentageFee).find(Boolean) ?? "";
330
+ const fixed = rows.find(row => {
331
+ return row.based_on === "FIXED" && typeof row.amount === "number";
332
+ });
333
+ const fixedDue = fixed ? new Big(String(fixed.amount)).toFixed(2) : "";
334
+ const due = expected || fixedDue;
335
+ if (!due) return need(ctx, "AVA-04", "fees.based_on");
336
+ const live = openLive(ctx, ready);
337
+ const body = { subtotal: BASE };
338
+ const codes = [200];
339
+ const args = ["fees", "POST", "/orders/validate", codes, body] as const;
340
+ const step = await live.storefront<unknown>(...args);
341
+ if (!step.ok) return fromLive(ctx.locale, step.result);
342
+ const actual = feeAmount(step.value);
343
+ if (!actual) return need(ctx, "AVA-04", "orders.validate.fees");
344
+ if (actual !== due) {
345
+ return say(ctx.locale, "fail", COPY.fee, { expected: due, actual });
346
+ }
347
+ return { status: "pass" };
348
+ };
349
+
350
+ const runAva05 = async (ctx: RunCtx): Promise<Outcome> => {
351
+ const allowed = new Set([
352
+ "kiosk",
353
+ "web",
354
+ "app",
355
+ "chat",
356
+ "call-center",
357
+ "dashboard"
358
+ ]);
359
+ const skipped = applies(ctx, "AVA-05", allowed);
360
+ if (skipped) return skipped;
361
+ const ready = prepare(ctx, LIVE);
362
+ if (isOutcome(ready)) return ready;
363
+ const prefs = await preferencesOf(ctx, ready);
364
+ if (isOutcome(prefs)) return prefs;
365
+ const availability = availabilityOf(prefs);
366
+ if (!availability || availability.status !== "UNAVAILABLE") {
367
+ return say(ctx.locale, "skip", COPY.closed);
368
+ }
369
+ const rule = availability.rule;
370
+ if (!isRecord(rule)) return say(ctx.locale, "fail", COPY.gate);
371
+ const title = typeof rule.title === "string" ? rule.title.trim() : "";
372
+ const body = rule.description ?? rule.body;
373
+ const text = typeof body === "string" ? body.trim() : "";
374
+ if (!title || !text) return say(ctx.locale, "fail", COPY.gate);
375
+ return { status: "pass" };
376
+ };
377
+
378
+ const runAva06 = async (ctx: RunCtx): Promise<Outcome> => {
379
+ const allowed = new Set(["kiosk", "app", "dashboard"]);
380
+ const skipped = applies(ctx, "AVA-06", allowed);
381
+ if (skipped) return skipped;
382
+ const ready = prepare(ctx, LIVE);
383
+ if (isOutcome(ready)) return ready;
384
+ const prefs = await preferencesOf(ctx, ready);
385
+ if (isOutcome(prefs)) return prefs;
386
+ if (!isRecord(prefs) || !isRecord(prefs.release)) {
387
+ return need(ctx, "AVA-06", "release.min_version");
388
+ }
389
+ const min = prefs.release.min_version;
390
+ if (typeof min !== "string" || !min.trim()) {
391
+ return need(ctx, "AVA-06", "release.min_version");
392
+ }
393
+ const installed = textOf(ready.template_version);
394
+ if (!installed) return need(ctx, "AVA-06", "template_version");
395
+ if (min <= installed) return { status: "pass" };
396
+ return need(ctx, "AVA-06", "update gate");
397
+ };
398
+
399
+ const storeOf = async (ctx: RunCtx, channel: YamlChannel) => {
400
+ const live = openLive(ctx, channel);
401
+ const id = textOf(channel.store_id);
402
+ const codes = [200];
403
+ const args = ["store", "GET", `/stores/${id}`, codes] as const;
404
+ const step = await live.storefront<unknown>(...args);
405
+ if (!step.ok) return fromLive(ctx.locale, step.result);
406
+ return step.value;
407
+ };
408
+
409
+ const runAva02 = async (ctx: RunCtx) => {
410
+ const allowed = new Set(["web", "app", "chat", "call-center", "dashboard"]);
411
+ const skipped = applies(ctx, "AVA-02", allowed);
412
+ if (skipped) return skipped;
413
+ const ready = prepare(ctx, [...LIVE, "store_id"]);
414
+ if (isOutcome(ready)) return ready;
415
+ const store = await storeOf(ctx, ready);
416
+ if (isOutcome(store)) return store;
417
+ const zones = isRecord(store) ? store.coverage_zones : undefined;
418
+ if (!Array.isArray(zones) || zones.length === 0) {
419
+ return need(ctx, "AVA-02", "stores.coverage_zones");
420
+ }
421
+ const lat = textOf(ctx.env.FIRESPARK_DOCTOR_LATITUDE);
422
+ const lng = textOf(ctx.env.FIRESPARK_DOCTOR_LONGITUDE);
423
+ if (!lat) return need(ctx, "AVA-02", "FIRESPARK_DOCTOR_LATITUDE");
424
+ if (!lng) return need(ctx, "AVA-02", "FIRESPARK_DOCTOR_LONGITUDE");
425
+ if (!Number.isFinite(Number(lat)) || !Number.isFinite(Number(lng))) {
426
+ return need(ctx, "AVA-02", "FIRESPARK_DOCTOR_LATITUDE");
427
+ }
428
+ let evaluated = false;
429
+ for (const zone of zones) {
430
+ if (!isRecord(zone)) continue;
431
+ const hit = zoneContains(Number(lat), Number(lng), zone);
432
+ if (hit === null) continue;
433
+ evaluated = true;
434
+ if (hit) return { status: "pass" as const };
435
+ }
436
+ if (!evaluated) return need(ctx, "AVA-02", "coverage_zones.radius_info");
437
+ return say(ctx.locale, "fail", COPY.outside);
438
+ };
439
+
440
+ const overrideRead = async (ctx: RunCtx, id: string) => {
441
+ const skipped = applies(ctx, id, new Set(["dashboard"]));
442
+ if (skipped) return skipped;
443
+ const ready = prepare(ctx, LIVE);
444
+ if (isOutcome(ready)) return ready;
445
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
446
+ if (!cookie) return need(ctx, id, "FIRESPARK_DOCTOR_DASHBOARD_COOKIE");
447
+ const dash = textOf(ready.dashboard_url);
448
+ if (!dash) return need(ctx, id, "dashboard_url");
449
+ const merchant = textOf(ready.merchant_id);
450
+ if (!merchant) return need(ctx, id, "merchant_id");
451
+ const env = textOf(ready.env);
452
+ if (!env) return need(ctx, id, "env");
453
+ const input = {
454
+ merchant_id: merchant,
455
+ env,
456
+ channel_id: textOf(ready.channel_id),
457
+ store_id: textOf(ready.store_id),
458
+ fulfillment_id: textOf(ready.fulfillment_id)
459
+ };
460
+ const proc = "menusOverrides.selectForScope";
461
+ const step = await trpcCall(dash, cookie, proc, input);
462
+ if (step.status < 200 || step.status >= 300) {
463
+ return say(ctx.locale, "fail", COPY.command, {
464
+ id,
465
+ type: "menusOverrides.selectForScope",
466
+ status: step.status
467
+ });
468
+ }
469
+ return { status: "pass" as const, text: step.text };
470
+ };
471
+
472
+ const runOvr01 = async (ctx: RunCtx) => {
473
+ const read = await overrideRead(ctx, "OVR-01");
474
+ if (!read || !("text" in read)) return read;
475
+ const ops = ["REORDER", "DELETE", "INSERT", "UPDATE"];
476
+ const found = ops.some(op => read.text.includes(op));
477
+ if (!found) return need(ctx, "OVR-01", "menus_overrides");
478
+ return { status: "pass" as const };
479
+ };
480
+
481
+ const runOvr02 = async (ctx: RunCtx) => {
482
+ if (!ctx.named.has("OVR-02")) {
483
+ return say(ctx.locale, "skip", COPY.named, { id: "OVR-02" });
484
+ }
485
+ const read = await overrideRead(ctx, "OVR-02");
486
+ if (!read || !("text" in read)) return read;
487
+ if (!read.text.includes("REORDER") && !read.text.includes("overrides")) {
488
+ return need(ctx, "OVR-02", "menus_overrides.preview");
489
+ }
490
+ return { status: "pass" as const };
491
+ };
492
+
493
+ const sendCommand = async (
494
+ ctx: RunCtx,
495
+ ready: YamlChannel,
496
+ id: string,
497
+ type: string
498
+ ) => {
499
+ if (!ctx.named.has(id)) return say(ctx.locale, "skip", COPY.named, { id });
500
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
501
+ if (!cookie) return need(ctx, id, "FIRESPARK_DOCTOR_DASHBOARD_COOKIE");
502
+ const device = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_ID);
503
+ if (!device) return need(ctx, id, "FIRESPARK_DOCTOR_DEVICE_ID");
504
+ const dash = textOf(ready.dashboard_url);
505
+ if (!dash) return need(ctx, id, "dashboard_url");
506
+ const merchant = textOf(ready.merchant_id);
507
+ if (!merchant) return need(ctx, id, "merchant_id");
508
+ const env = textOf(ready.env);
509
+ if (!env) return need(ctx, id, "env");
510
+ const input = { merchant_id: merchant, env, id: device, type };
511
+ const step = await trpcCall(dash, cookie, "kioskDevices.sendCommand", input);
512
+ if (step.status >= 400 || step.status === 0) {
513
+ return say(ctx.locale, "fail", COPY.command, {
514
+ id,
515
+ type,
516
+ status: step.status
517
+ });
518
+ }
519
+ return { status: "pass" as const };
520
+ };
521
+
522
+ const runCommand = (id: string, allowed: readonly string[], type: string) => {
523
+ return async (ctx: RunCtx) => {
524
+ const skipped = applies(ctx, id, new Set(allowed));
525
+ if (skipped) return skipped;
526
+ const ready = prepare(ctx, ["channel_id"]);
527
+ if (isOutcome(ready)) return ready;
528
+ return sendCommand(ctx, ready, id, type);
529
+ };
530
+ };
531
+
532
+ const runRt02 = runCommand("RT-02", ["kiosk", "dashboard"], "refresh_cache");
533
+
534
+ const runRt03 = async (ctx: RunCtx): Promise<Outcome> => {
535
+ const skipped = applies(ctx, "RT-03", new Set(["kiosk"]));
536
+ if (skipped) return skipped;
537
+ if (!ctx.named.has("RT-03")) {
538
+ return say(ctx.locale, "skip", COPY.named, { id: "RT-03" });
539
+ }
540
+ const files = await readSources(ctx.cwd);
541
+ const file = files.find(item => item.body.includes("resetKioskCache"));
542
+ if (!file) return say(ctx.locale, "skip", COPY.reset);
543
+ const clears = file.body.includes("clearImageCaches");
544
+ const queries = file.body.includes("removeQueries");
545
+ if (!clears || !queries) return say(ctx.locale, "fail", COPY.reset);
546
+ return { status: "pass" as const };
547
+ };
548
+
549
+ const runRt04 = runCommand("RT-04", ["kiosk", "dashboard"], "reload");
550
+
551
+ const runRt05 = async (ctx: RunCtx) => {
552
+ const skipped = applies(ctx, "RT-05", new Set(["kiosk", "dashboard"]));
553
+ if (skipped) return skipped;
554
+ if (!ctx.named.has("RT-05")) {
555
+ return say(ctx.locale, "skip", COPY.named, { id: "RT-05" });
556
+ }
557
+ const ready = prepare(ctx, ["channel_id"]);
558
+ if (isOutcome(ready)) return ready;
559
+ const update = textOf(ctx.env.FIRESPARK_DOCTOR_OTA_UPDATE_ID);
560
+ if (!update) return need(ctx, "RT-05", "FIRESPARK_DOCTOR_OTA_UPDATE_ID");
561
+ return sendCommand(ctx, ready, "RT-05", "check_ota");
562
+ };
563
+
564
+ export const STORE_BINDINGS: Record<string, CheckBinding> = {
565
+ "AVA-01": withSafeFix(runAva01),
566
+ "AVA-02": withSafeFix(runAva02),
567
+ "AVA-03": withSafeFix(runAva03),
568
+ "AVA-04": withSafeFix(runAva04),
569
+ "AVA-05": withSafeFix(runAva05),
570
+ "AVA-06": withSafeFix(runAva06),
571
+ "OVR-01": withSafeFix(runOvr01),
572
+ "OVR-02": withSafeFix(runOvr02),
573
+ "RT-02": withSafeFix(runRt02),
574
+ "RT-03": withSafeFix(runRt03),
575
+ "RT-04": withSafeFix(runRt04),
576
+ "RT-05": withSafeFix(runRt05)
577
+ };
@@ -4,7 +4,7 @@ export type FetchLike = (
4
4
  ) => Promise<Response>;
5
5
 
6
6
  export interface HttpRequest {
7
- method: "GET" | "POST";
7
+ method: "GET" | "POST" | "PATCH" | "DELETE";
8
8
  url: string;
9
9
  token?: string;
10
10
  body?: unknown;
@@ -36,28 +36,21 @@ export const request = async (
36
36
  const headers: Record<string, string> = { accept: "application/json" };
37
37
  if (input.token) headers.authorization = `Bearer ${input.token}`;
38
38
  if (input.body !== undefined) headers["content-type"] = "application/json";
39
- const body =
40
- input.body === undefined ? undefined : JSON.stringify(input.body);
39
+ const raw = input.body;
40
+ const body = raw === undefined ? undefined : JSON.stringify(raw);
41
41
  const signal = AbortSignal.timeout(input.timeoutMs);
42
+ const init = { method: input.method, headers, body, signal };
42
43
  let response: Response;
43
44
  try {
44
- response = await fetchImpl(input.url, {
45
- method: input.method,
46
- headers,
47
- body,
48
- signal
49
- });
45
+ response = await fetchImpl(input.url, init);
50
46
  } catch (error) {
51
47
  const detail = error instanceof Error ? error.message : String(error);
52
48
  throw new NetworkError(detail);
53
49
  }
54
50
  const text = await response.text();
55
- return {
56
- status: response.status,
57
- headers: response.headers,
58
- json: parse(text),
59
- text
60
- };
51
+ const b1 = { status: response.status, headers: response.headers };
52
+ const b2 = { json: parse(text), text };
53
+ return { ...b1, ...b2 };
61
54
  };
62
55
 
63
56
  /** Short, single-line description of an error body for logs. */
@@ -65,9 +58,8 @@ export const errorDetail = (response: HttpResponse) => {
65
58
  const json = response.json as
66
59
  | { error?: unknown; message?: unknown; details?: unknown }
67
60
  | undefined;
68
- const parts = [json?.error, json?.message].filter(
69
- part => typeof part === "string"
70
- );
61
+ const isText = (part: unknown) => typeof part === "string";
62
+ const parts = [json?.error, json?.message].filter(isText);
71
63
  const nested = (json?.error as { message?: unknown } | undefined)?.message;
72
64
  if (typeof nested === "string") parts.push(nested);
73
65
  if (json?.details !== undefined) parts.push(JSON.stringify(json.details));
@@ -45,11 +45,18 @@ type Step<T> =
45
45
  export interface LiveSession {
46
46
  storefront: <T>(
47
47
  hop: string,
48
- method: "GET" | "POST",
48
+ method: "GET" | "POST" | "PATCH" | "DELETE",
49
49
  path: string,
50
50
  statuses: number[],
51
51
  body?: unknown
52
52
  ) => Promise<Step<T>>;
53
+ probe: (
54
+ hop: string,
55
+ method: "GET" | "POST" | "PATCH" | "DELETE",
56
+ path: string,
57
+ statuses: number[],
58
+ body?: unknown
59
+ ) => Promise<Step<HttpResponse>>;
53
60
  menus: () => Promise<Step<Menu[]>>;
54
61
  realtimeToken: () => Promise<Step<RealtimeToken>>;
55
62
  }
@@ -148,8 +155,8 @@ export const createLiveSession = (
148
155
  if (!step.ok) return step;
149
156
  const failed = expectAt("firebase-auth", step.value, [200], "POST", url);
150
157
  if (failed) return no(failed);
151
- const token = (step.value.json as { idToken?: string } | undefined)
152
- ?.idToken;
158
+ const json = step.value.json as { idToken?: string } | undefined;
159
+ const token = json?.idToken;
153
160
  if (!token) {
154
161
  const field = "idToken";
155
162
  const reason = fail("firebase-auth", REASONS.badShape, { field });
@@ -189,7 +196,7 @@ export const createLiveSession = (
189
196
 
190
197
  const raw = async (
191
198
  hop: string,
192
- method: "GET" | "POST",
199
+ method: "GET" | "POST" | "PATCH" | "DELETE",
193
200
  path: string,
194
201
  statuses: number[],
195
202
  body?: unknown
@@ -206,7 +213,7 @@ export const createLiveSession = (
206
213
 
207
214
  const storefront = async <T>(
208
215
  hop: string,
209
- method: "GET" | "POST",
216
+ method: "GET" | "POST" | "PATCH" | "DELETE",
210
217
  path: string,
211
218
  statuses: number[],
212
219
  body?: unknown
@@ -239,5 +246,5 @@ export const createLiveSession = (
239
246
  });
240
247
  };
241
248
 
242
- return { storefront, menus, realtimeToken };
249
+ return { storefront, probe: raw, menus, realtimeToken };
243
250
  };