@fire-spark/cli 0.0.0-stage → 0.13.3

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 (39) hide show
  1. package/bin/firespark.mjs +14 -0
  2. package/package.json +39 -3
  3. package/src/commands/doctor/client/admin.flow.ts +553 -0
  4. package/src/commands/doctor/client/catalog.json +1586 -0
  5. package/src/commands/doctor/client/catalog.ts +179 -0
  6. package/src/commands/doctor/client/checkout.flow.ts +400 -0
  7. package/src/commands/doctor/client/command.ts +246 -0
  8. package/src/commands/doctor/client/config.ts +346 -0
  9. package/src/commands/doctor/client/content.flow.ts +450 -0
  10. package/src/commands/doctor/client/copy.ts +248 -0
  11. package/src/commands/doctor/client/flow.ts +182 -0
  12. package/src/commands/doctor/client/log.flow.ts +410 -0
  13. package/src/commands/doctor/client/manifests.ts +161 -0
  14. package/src/commands/doctor/client/men.flow.ts +412 -0
  15. package/src/commands/doctor/client/ops.flow.ts +312 -0
  16. package/src/commands/doctor/client/options.ts +201 -0
  17. package/src/commands/doctor/client/ord.flow.ts +445 -0
  18. package/src/commands/doctor/client/order.ts +261 -0
  19. package/src/commands/doctor/client/pay.flow.ts +772 -0
  20. package/src/commands/doctor/client/repo.scan.ts +142 -0
  21. package/src/commands/doctor/client/runner.ts +182 -0
  22. package/src/commands/doctor/client/sdk.flow.ts +630 -0
  23. package/src/commands/doctor/client/store.flow.ts +577 -0
  24. package/src/commands/doctor/client/yaml.ts +179 -0
  25. package/src/commands/doctor/live/http.ts +81 -0
  26. package/src/commands/doctor/live/live.session.ts +250 -0
  27. package/src/commands/doctor/live/order.ts +95 -0
  28. package/src/commands/doctor/live/realtime.ts +161 -0
  29. package/src/commands/doctor/live/result.ts +76 -0
  30. package/src/commands/index.ts +8 -0
  31. package/src/core/command.types.ts +25 -0
  32. package/src/core/help.ts +85 -0
  33. package/src/core/i18n.ts +49 -0
  34. package/src/core/options.ts +131 -0
  35. package/src/core/output.ts +47 -0
  36. package/src/core/redact.ts +71 -0
  37. package/src/index.ts +4 -0
  38. package/src/main.ts +113 -0
  39. package/README.md +0 -3
@@ -0,0 +1,445 @@
1
+ import { createHmac } from "node:crypto";
2
+
3
+ import type { Copy } from "../../../core/i18n";
4
+ import type { LiveSession } from "../live/live.session";
5
+ import { channelLabel } from "./config";
6
+ import { COPY as SHARED } from "./copy";
7
+ import { fromLive, isOutcome, isRecord, namedOrderSkip } from "./flow";
8
+ import { openLive, prepare, queryOf, rowsOf, say, textOf } from "./flow";
9
+ import { trpcCall, withSafeFix } from "./flow";
10
+ import type { CheckBinding, Outcome, RunCtx } from "./runner";
11
+ import type { YamlChannel } from "./yaml";
12
+
13
+ const line = (es: string, en: string, pt: string): Copy => {
14
+ return { "es-EC": es, "en-US": en, "pt-BR": pt };
15
+ };
16
+
17
+ const COPY = {
18
+ overlay: line(
19
+ "El overlay no distingue released, failed y el timeout de 60 s.",
20
+ "The overlay does not distinguish released, failed, and the 60s timeout.",
21
+ "O overlay não distingue released, failed e o timeout de 60 s."
22
+ ),
23
+ token: line(
24
+ "El token de realtime no trae {field}.",
25
+ "The realtime token is missing {field}.",
26
+ "O token de realtime não traz {field}."
27
+ ),
28
+ list: line(
29
+ "El listado de órdenes no es una lista.",
30
+ "The order list is not a list.",
31
+ "A lista de pedidos não é uma lista."
32
+ ),
33
+ drifted: line(
34
+ "El carrito cambió entre dos lecturas de la misma sesión.",
35
+ "The cart changed between two reads of the same session.",
36
+ "O carrinho mudou entre duas leituras da mesma sessão."
37
+ ),
38
+ notDashboard: line(
39
+ "{id} no aplica al canal {template}. El editor está en el dashboard.",
40
+ "{id} does not apply to the {template} channel. The editor is on the dashboard.",
41
+ "{id} não se aplica ao canal {template}. O editor está no dashboard."
42
+ ),
43
+ notServer: line(
44
+ "ORD-11 no aplica al canal {template}. Solo el canal server firma webhooks.",
45
+ "ORD-11 does not apply to the {template} channel. Only the server channel signs webhooks.",
46
+ "ORD-11 não se aplica ao canal {template}. Só o canal server assina webhooks."
47
+ ),
48
+ unsigned: line(
49
+ "Una request sin firma respondió {status}. Esperaba 401.",
50
+ "A request without a signature returned {status}. Expected 401.",
51
+ "Uma request sem assinatura respondeu {status}. Esperava 401."
52
+ ),
53
+ signed: line(
54
+ "Con firma válida, el receptor respondió {status}. Esperaba {expected}.",
55
+ "With a valid signature, the receiver returned {status}. Expected {expected}.",
56
+ "Com assinatura válida, o receptor respondeu {status}. Esperava {expected}."
57
+ ),
58
+ orderId: line(
59
+ "Falta FIRESPARK_DOCTOR_ORDER_ID. No inventé una orden.",
60
+ "FIRESPARK_DOCTOR_ORDER_ID is missing. No order was invented.",
61
+ "Falta FIRESPARK_DOCTOR_ORDER_ID. Nenhum pedido foi inventado."
62
+ ),
63
+ need: line(
64
+ "ORD-06 se omite: falta {field}.",
65
+ "ORD-06 is skipped: {field} is missing.",
66
+ "ORD-06 é omitido: falta {field}."
67
+ ),
68
+ gap: line(
69
+ "{id} se omite: falta {field}.",
70
+ "{id} is skipped: {field} is missing.",
71
+ "{id} é omitido: falta {field}."
72
+ ),
73
+ http: line(
74
+ "{id} respondió {status}.",
75
+ "{id} responded {status}.",
76
+ "{id} respondeu {status}."
77
+ )
78
+ };
79
+
80
+ const LIVE = [
81
+ "api_url",
82
+ "channel_id",
83
+ "FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
84
+ "FIRESPARK_DOCTOR_CUSTOMER_PASSWORD",
85
+ "FIRESPARK_DOCTOR_FIREBASE_API_KEY"
86
+ ] as const;
87
+
88
+ const sign = (secret: string, body: string) => {
89
+ const hex = createHmac("sha256", secret).update(body).digest("hex");
90
+ return `sha256=${hex}`;
91
+ };
92
+
93
+ type Opened = { channel: YamlChannel; live: LiveSession };
94
+
95
+ const open = async (
96
+ ctx: RunCtx,
97
+ fields: readonly string[]
98
+ ): Promise<Opened | Outcome> => {
99
+ const ready = prepare(ctx, fields);
100
+ if (isOutcome(ready)) return ready;
101
+ return { channel: ready, live: openLive(ctx, ready) };
102
+ };
103
+
104
+ const orderPath = (channel: YamlChannel) => {
105
+ const channelId = textOf(channel.channel_id);
106
+ const storeId = textOf(channel.store_id);
107
+ const query = { channel_id: channelId, store_id: storeId };
108
+ return queryOf("/orders", query);
109
+ };
110
+
111
+ const orderList = async (opened: Opened, ctx: RunCtx) => {
112
+ const path = orderPath(opened.channel);
113
+ const codes = [200];
114
+ const listArgs = ["orders", "GET", path, codes] as const;
115
+ const step = await opened.live.storefront<unknown>(...listArgs);
116
+ if (!step.ok) return fromLive(ctx.locale, step.result);
117
+ if (!Array.isArray(step.value)) return say(ctx.locale, "fail", COPY.list);
118
+ return rowsOf(step.value);
119
+ };
120
+
121
+ const gap = (ctx: RunCtx, id: string, field: string) => {
122
+ return say(ctx.locale, "skip", COPY.gap, { id, field });
123
+ };
124
+
125
+ const orderIdOf = (ctx: RunCtx, rows: Record<string, unknown>[]) => {
126
+ const named = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
127
+ if (named) return named;
128
+ const row = rows.find(item => typeof item.id === "string");
129
+ return row && typeof row.id === "string" ? row.id : "";
130
+ };
131
+
132
+ const runOrd02 = async (ctx: RunCtx): Promise<Outcome> => {
133
+ const opened = await open(ctx, LIVE);
134
+ if (isOutcome(opened)) return opened;
135
+ const rows = await orderList(opened, ctx);
136
+ if (!Array.isArray(rows)) return rows;
137
+ const id = orderIdOf(ctx, rows);
138
+ if (!id) return gap(ctx, "ORD-02", "FIRESPARK_DOCTOR_ORDER_ID");
139
+ const path = `/orders/${encodeURIComponent(id)}/fulfillment`;
140
+ const codes = [200];
141
+ const args = ["fulfillment", "GET", path, codes] as const;
142
+ const step = await opened.live.storefront<unknown>(...args);
143
+ if (!step.ok) return fromLive(ctx.locale, step.result);
144
+ if (!isRecord(step.value)) return say(ctx.locale, "fail", COPY.overlay);
145
+ const state = step.value.checkout_state;
146
+ const known =
147
+ state === "pending" || state === "released" || state === "failed";
148
+ if (!known) return gap(ctx, "ORD-02", "orders.fulfillment.checkout_state");
149
+ return { status: "pass" };
150
+ };
151
+
152
+ const runOrd03 = async (ctx: RunCtx): Promise<Outcome> => {
153
+ const fields = [...LIVE, "store_id"] as const;
154
+ const opened = await open(ctx, fields);
155
+ if (isOutcome(opened)) return opened;
156
+ const rows = await orderList(opened, ctx);
157
+ if (!Array.isArray(rows)) return rows;
158
+ const id = orderIdOf(ctx, rows);
159
+ if (!id) return gap(ctx, "ORD-03", "FIRESPARK_DOCTOR_ORDER_ID");
160
+ const path = `/orders/${encodeURIComponent(id)}/fulfillment`;
161
+ const codes = [200];
162
+ const args = ["tracking", "GET", path, codes] as const;
163
+ const step = await opened.live.storefront<unknown>(...args);
164
+ if (!step.ok) return fromLive(ctx.locale, step.result);
165
+ if (!isRecord(step.value)) return say(ctx.locale, "fail", COPY.list);
166
+ if (typeof step.value.status !== "string") {
167
+ return gap(ctx, "ORD-03", "orders.fulfillment.status");
168
+ }
169
+ const states = step.value.current_states ?? step.value.entered_node_ids;
170
+ if (!Array.isArray(states)) {
171
+ return gap(ctx, "ORD-03", "orders.fulfillment.current_states");
172
+ }
173
+ return { status: "pass" };
174
+ };
175
+
176
+ const runOrd04 = async (ctx: RunCtx): Promise<Outcome> => {
177
+ const ready = prepare(ctx, ["channel_id"]);
178
+ if (isOutcome(ready)) return ready;
179
+ if (ready.template !== "dashboard") {
180
+ const template = textOf(ready.template);
181
+ return say(ctx.locale, "skip", COPY.notDashboard, {
182
+ id: "ORD-04",
183
+ template
184
+ });
185
+ }
186
+ const opened = await open(ctx, LIVE);
187
+ if (isOutcome(opened)) return opened;
188
+ const codes = [200];
189
+ const args = ["workflow", "GET", "/fulfillment", codes] as const;
190
+ const step = await opened.live.storefront<unknown>(...args);
191
+ if (!step.ok) return fromLive(ctx.locale, step.result);
192
+ const rows = rowsOf(step.value);
193
+ const graph =
194
+ rows.find(isRecord) ?? (isRecord(step.value) ? step.value : null);
195
+ if (!graph) return gap(ctx, "ORD-04", "fulfillment.checkout_release");
196
+ if (!("checkout_release" in graph)) {
197
+ return gap(ctx, "ORD-04", "fulfillment.checkout_release");
198
+ }
199
+ const states = graph.states ?? graph.nodes;
200
+ if (!Array.isArray(states) || states.length === 0) {
201
+ return gap(ctx, "ORD-04", "fulfillment.states");
202
+ }
203
+ return { status: "pass" };
204
+ };
205
+
206
+ const runOrd05 = async (ctx: RunCtx): Promise<Outcome> => {
207
+ const skipped = namedOrderSkip(ctx, "ORD-05");
208
+ if (skipped) return skipped;
209
+ const ready = prepare(ctx, ["receiver"]);
210
+ if (isOutcome(ready)) return ready;
211
+ const id = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
212
+ if (!id) return say(ctx.locale, "fail", COPY.orderId);
213
+ const base = textOf(ready.receiver).replace(/\/$/, "");
214
+ const url = `${base}/api/injection`;
215
+ const data = { order_id: id, status: "open" };
216
+ const head = { id: "doctor-injection", type: "order.changed" };
217
+ const body = JSON.stringify({ ...head, data });
218
+ let status = 0;
219
+ try {
220
+ status = await postJson(url, body);
221
+ } catch (error) {
222
+ const detail = error instanceof Error ? error.message : String(error);
223
+ return { status: "fail", detail };
224
+ }
225
+ if (status < 200 || status >= 300) {
226
+ return say(ctx.locale, "fail", COPY.signed, { status, expected: 200 });
227
+ }
228
+ return { status: "pass" };
229
+ };
230
+
231
+ const runOrd06 = async (ctx: RunCtx): Promise<Outcome> => {
232
+ const skipped = namedOrderSkip(ctx, "ORD-06");
233
+ if (skipped) return skipped;
234
+ const id = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
235
+ if (!id) return say(ctx.locale, "fail", COPY.orderId);
236
+ const uid = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_UID);
237
+ if (!uid)
238
+ return say(ctx.locale, "skip", COPY.need, {
239
+ field: "FIRESPARK_DOCTOR_DEVICE_UID"
240
+ });
241
+ const secret = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_SECRET);
242
+ if (!secret) {
243
+ return say(ctx.locale, "skip", COPY.need, {
244
+ field: "FIRESPARK_DOCTOR_DEVICE_SECRET"
245
+ });
246
+ }
247
+ const session = textOf(ctx.env.FIRESPARK_DOCTOR_SESSION_UID);
248
+ if (!session) {
249
+ return say(ctx.locale, "skip", COPY.need, {
250
+ field: "FIRESPARK_DOCTOR_SESSION_UID"
251
+ });
252
+ }
253
+ const fields = [...LIVE, "store_id"] as const;
254
+ const opened = await open(ctx, fields);
255
+ if (isOutcome(opened)) return opened;
256
+ const operator = textOf(ctx.env.FIRESPARK_DOCTOR_OPERATOR_ID);
257
+ const version = Number(ctx.env.FIRESPARK_DOCTOR_SESSION_VERSION ?? "0");
258
+ const body = {
259
+ device_uid: uid,
260
+ device_secret: secret,
261
+ operator_id: operator,
262
+ session_uid: session,
263
+ expected_version: version,
264
+ order_id: id
265
+ };
266
+ const codes = [200];
267
+ const path = "/pos/kiosk-orders/settle";
268
+ const args = ["settle", "POST", path, codes, body] as const;
269
+ const step = await opened.live.probe(...args);
270
+ if (!step.ok) return fromLive(ctx.locale, step.result);
271
+ return { status: "pass" };
272
+ };
273
+
274
+ const openOrders = async (ctx: RunCtx) => {
275
+ const fields = [...LIVE, "store_id"] as const;
276
+ const opened = await open(ctx, fields);
277
+ if (isOutcome(opened)) return opened;
278
+ const channelId = textOf(opened.channel.channel_id);
279
+ const storeId = textOf(opened.channel.store_id);
280
+ const query = { channel_id: channelId, store_id: storeId, status: "OPEN" };
281
+ const path = queryOf("/orders", query);
282
+ const codes = [200];
283
+ const args = ["open-orders", "GET", path, codes] as const;
284
+ const step = await opened.live.storefront<unknown>(...args);
285
+ if (!step.ok) return fromLive(ctx.locale, step.result);
286
+ if (!Array.isArray(step.value)) return say(ctx.locale, "fail", COPY.list);
287
+ return { opened, rows: rowsOf(step.value) };
288
+ };
289
+
290
+ const runOrd07 = async (ctx: RunCtx): Promise<Outcome> => {
291
+ const first = await openOrders(ctx);
292
+ if (isOutcome(first)) return first;
293
+ const id = first.rows.find(row => typeof row.id === "string")?.id;
294
+ if (typeof id !== "string" || !id) {
295
+ return gap(ctx, "ORD-07", "orders.status=OPEN");
296
+ }
297
+ const second = await openOrders(ctx);
298
+ if (isOutcome(second)) return second;
299
+ const again = second.rows.some(row => row.id === id);
300
+ if (!again) return say(ctx.locale, "fail", COPY.drifted);
301
+ return { status: "pass" };
302
+ };
303
+
304
+ const runOrd08 = async (ctx: RunCtx): Promise<Outcome> => {
305
+ const fields = [...LIVE, "store_id"] as const;
306
+ const opened = await open(ctx, fields);
307
+ if (isOutcome(opened)) return opened;
308
+ const rows = await orderList(opened, ctx);
309
+ if (!Array.isArray(rows)) return rows;
310
+ const id = orderIdOf(ctx, rows);
311
+ if (!id) return gap(ctx, "ORD-08", "orders.id");
312
+ const codes = [200];
313
+ const args = [
314
+ "order",
315
+ "GET",
316
+ `/orders/${encodeURIComponent(id)}`,
317
+ codes
318
+ ] as const;
319
+ const step = await opened.live.storefront<unknown>(...args);
320
+ if (!step.ok) return fromLive(ctx.locale, step.result);
321
+ if (!isRecord(step.value)) return say(ctx.locale, "fail", COPY.list);
322
+ const lines = step.value.lines ?? step.value.items;
323
+ if (!Array.isArray(lines)) return gap(ctx, "ORD-08", "orders.lines");
324
+ return { status: "pass" };
325
+ };
326
+
327
+ const runOrd09 = async (ctx: RunCtx): Promise<Outcome> => {
328
+ const skipped = namedOrderSkip(ctx, "ORD-09");
329
+ if (skipped) return skipped;
330
+ const id = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
331
+ if (!id) return say(ctx.locale, "fail", COPY.orderId);
332
+ const fields = [...LIVE, "store_id"] as const;
333
+ const opened = await open(ctx, fields);
334
+ if (isOutcome(opened)) return opened;
335
+ const path = `/orders/${encodeURIComponent(id)}/reorder`;
336
+ const body = { strategy: "replace" };
337
+ const codes = [200, 201];
338
+ const args = ["reorder", "POST", path, codes, body] as const;
339
+ const step = await opened.live.storefront<unknown>(...args);
340
+ if (!step.ok) return fromLive(ctx.locale, step.result);
341
+ return { status: "pass" };
342
+ };
343
+
344
+ const runOrd10 = async (ctx: RunCtx): Promise<Outcome> => {
345
+ const skipped = namedOrderSkip(ctx, "ORD-10");
346
+ if (skipped) return skipped;
347
+ const ready = prepare(ctx, ["channel_id"]);
348
+ if (isOutcome(ready)) return ready;
349
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
350
+ if (!cookie) return gap(ctx, "ORD-10", "FIRESPARK_DOCTOR_DASHBOARD_COOKIE");
351
+ const id = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
352
+ if (!id) return gap(ctx, "ORD-10", "FIRESPARK_DOCTOR_ORDER_ID");
353
+ const dash = textOf(ready.dashboard_url);
354
+ if (!dash) return gap(ctx, "ORD-10", "dashboard_url");
355
+ const merchant = textOf(ready.merchant_id);
356
+ if (!merchant) return gap(ctx, "ORD-10", "merchant_id");
357
+ const env = textOf(ready.env);
358
+ if (!env) return gap(ctx, "ORD-10", "env");
359
+ const input = {
360
+ merchant_id: merchant,
361
+ env,
362
+ id,
363
+ fulfillment_status: "PREPARING"
364
+ };
365
+ const step = await trpcCall(dash, cookie, "orders.advanceFulfillment", input);
366
+ if (step.status < 200 || step.status >= 300) {
367
+ return say(ctx.locale, "fail", COPY.http, {
368
+ id: "ORD-10",
369
+ status: step.status
370
+ });
371
+ }
372
+ return { status: "pass" };
373
+ };
374
+
375
+ const postJson = async (url: string, body: string, signature?: string) => {
376
+ const headers: Record<string, string> = {
377
+ "content-type": "application/json"
378
+ };
379
+ if (signature) headers["x-firespark-signature"] = signature;
380
+ const signal = AbortSignal.timeout(15000);
381
+ const init = { method: "POST", headers, body, signal };
382
+ const response = await fetch(url, init);
383
+ return response.status;
384
+ };
385
+
386
+ const runOrd11 = async (ctx: RunCtx): Promise<Outcome> => {
387
+ const ready = prepare(ctx, ["channel_id", "receiver"]);
388
+ if (isOutcome(ready)) return ready;
389
+ const template = textOf(ready.template);
390
+ if (template !== "server") {
391
+ return say(ctx.locale, "skip", COPY.notServer, { template });
392
+ }
393
+ const base = textOf(ready.receiver).replace(/\/$/, "");
394
+ const url = `${base}/api/injection`;
395
+ let unsigned = 0;
396
+ try {
397
+ unsigned = await postJson(url, "{}");
398
+ } catch (error) {
399
+ const detail = error instanceof Error ? error.message : String(error);
400
+ return { status: "fail", detail };
401
+ }
402
+ if (unsigned !== 401) {
403
+ return say(ctx.locale, "fail", COPY.unsigned, { status: unsigned });
404
+ }
405
+ const secret = textOf(ctx.env.FIRESPARK_DOCTOR_WEBHOOK_SECRET);
406
+ if (!secret) {
407
+ const channel = channelLabel(ready);
408
+ const field = "FIRESPARK_DOCTOR_WEBHOOK_SECRET";
409
+ const vars = { channel, field };
410
+ return say(ctx.locale, "fail", SHARED.flowMissing, vars);
411
+ }
412
+ const bad = "{";
413
+ const odd = JSON.stringify({ hello: 1 });
414
+ let invalid = 0;
415
+ let envelope = 0;
416
+ try {
417
+ invalid = await postJson(url, bad, sign(secret, bad));
418
+ envelope = await postJson(url, odd, sign(secret, odd));
419
+ } catch (error) {
420
+ const detail = error instanceof Error ? error.message : String(error);
421
+ return { status: "fail", detail };
422
+ }
423
+ if (invalid !== 400) {
424
+ const badStatus = { status: invalid, expected: 400 };
425
+ return say(ctx.locale, "fail", COPY.signed, badStatus);
426
+ }
427
+ if (envelope !== 422) {
428
+ const oddStatus = { status: envelope, expected: 422 };
429
+ return say(ctx.locale, "fail", COPY.signed, oddStatus);
430
+ }
431
+ return { status: "pass" };
432
+ };
433
+
434
+ export const ORD_BINDINGS: Record<string, CheckBinding> = {
435
+ "ORD-02": withSafeFix(runOrd02),
436
+ "ORD-03": withSafeFix(runOrd03),
437
+ "ORD-04": withSafeFix(runOrd04),
438
+ "ORD-05": withSafeFix(runOrd05),
439
+ "ORD-06": withSafeFix(runOrd06),
440
+ "ORD-07": withSafeFix(runOrd07),
441
+ "ORD-08": withSafeFix(runOrd08),
442
+ "ORD-09": withSafeFix(runOrd09),
443
+ "ORD-10": withSafeFix(runOrd10),
444
+ "ORD-11": withSafeFix(runOrd11)
445
+ };