@fire-spark/cli 0.13.2 → 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.
- package/package.json +3 -1
- package/src/commands/doctor/client/admin.flow.ts +553 -0
- package/src/commands/doctor/client/checkout.flow.ts +400 -0
- package/src/commands/doctor/client/content.flow.ts +450 -0
- package/src/commands/doctor/client/copy.ts +22 -2
- package/src/commands/doctor/client/flow.ts +182 -0
- package/src/commands/doctor/client/log.flow.ts +410 -0
- package/src/commands/doctor/client/men.flow.ts +412 -0
- package/src/commands/doctor/client/ops.flow.ts +312 -0
- package/src/commands/doctor/client/ord.flow.ts +445 -0
- package/src/commands/doctor/client/order.ts +1 -1
- package/src/commands/doctor/client/pay.flow.ts +772 -0
- package/src/commands/doctor/client/repo.scan.ts +142 -0
- package/src/commands/doctor/client/runner.ts +21 -1
- package/src/commands/doctor/client/sdk.flow.ts +630 -0
- package/src/commands/doctor/client/store.flow.ts +577 -0
- package/src/commands/doctor/live/http.ts +10 -18
- package/src/commands/doctor/live/live.session.ts +13 -6
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import type { Copy } from "../../../core/i18n";
|
|
2
|
+
import { dataOf } from "../live/http";
|
|
3
|
+
import { fromLive, isOutcome, isRecord, openLive, prepare } from "./flow";
|
|
4
|
+
import { queryOf, say, textOf, trpcCall, withSafeFix } from "./flow";
|
|
5
|
+
import { readSources } from "./repo.scan";
|
|
6
|
+
import type { CheckBinding, Outcome, RunCtx } from "./runner";
|
|
7
|
+
import type { YamlChannel } from "./yaml";
|
|
8
|
+
|
|
9
|
+
const line = (es: string, en: string, pt: string): Copy => {
|
|
10
|
+
return { "es-EC": es, "en-US": en, "pt-BR": pt };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const COPY = {
|
|
14
|
+
channel: line(
|
|
15
|
+
"{id} no aplica al canal {template}.",
|
|
16
|
+
"{id} does not apply to the {template} channel.",
|
|
17
|
+
"{id} não se aplica ao canal {template}."
|
|
18
|
+
),
|
|
19
|
+
list: line(
|
|
20
|
+
"El menú no es una lista.",
|
|
21
|
+
"The menu is not a list.",
|
|
22
|
+
"O cardápio não é uma lista."
|
|
23
|
+
),
|
|
24
|
+
named: line(
|
|
25
|
+
"{id} solo corre cuando lo nombras. No cambié la cuenta.",
|
|
26
|
+
"{id} runs only when you name it. The account was not changed.",
|
|
27
|
+
"{id} só roda quando você o nomeia. A conta não foi alterada."
|
|
28
|
+
),
|
|
29
|
+
need: line(
|
|
30
|
+
"{id} se omite: falta {field}.",
|
|
31
|
+
"{id} is skipped: {field} is missing.",
|
|
32
|
+
"{id} é omitido: falta {field}."
|
|
33
|
+
),
|
|
34
|
+
home: line(
|
|
35
|
+
"CMS-02 se omite: este canal no publicó el bloque de home.",
|
|
36
|
+
"CMS-02 is skipped: this channel did not publish the home block.",
|
|
37
|
+
"CMS-02 é omitido: este canal não publicou o bloco da home."
|
|
38
|
+
),
|
|
39
|
+
shape: line(
|
|
40
|
+
"El bloque de home no tiene la forma esperada.",
|
|
41
|
+
"The home block does not have the expected shape.",
|
|
42
|
+
"O bloco da home não tem a forma esperada."
|
|
43
|
+
),
|
|
44
|
+
offline: line(
|
|
45
|
+
"El repo del canal no maneja imágenes sin red.",
|
|
46
|
+
"The channel repo does not handle images while offline.",
|
|
47
|
+
"O repo do canal não trata imagens sem rede."
|
|
48
|
+
),
|
|
49
|
+
keypad: line(
|
|
50
|
+
"El repo del canal no tiene el keypad.",
|
|
51
|
+
"The channel repo does not include the keypad.",
|
|
52
|
+
"O repo do canal não tem o keypad."
|
|
53
|
+
),
|
|
54
|
+
block: line(
|
|
55
|
+
"CMS-01 se omite: no hay un block de CMS publicado para este canal.",
|
|
56
|
+
"CMS-01 is skipped: there is no published CMS block for this channel.",
|
|
57
|
+
"CMS-01 é omitido: não há um block de CMS publicado para este canal."
|
|
58
|
+
),
|
|
59
|
+
long: line(
|
|
60
|
+
"El block supera 25 ítems o un LIST supera 10 hijos.",
|
|
61
|
+
"The block has more than 25 items, or a LIST has more than 10 children.",
|
|
62
|
+
"O block passa de 25 itens ou um LIST passa de 10 filhos."
|
|
63
|
+
)
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const LIVE = [
|
|
67
|
+
"api_url",
|
|
68
|
+
"channel_id",
|
|
69
|
+
"FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
|
|
70
|
+
"FIRESPARK_DOCTOR_CUSTOMER_PASSWORD",
|
|
71
|
+
"FIRESPARK_DOCTOR_FIREBASE_API_KEY"
|
|
72
|
+
] as const;
|
|
73
|
+
|
|
74
|
+
const LIST_MAX = 25;
|
|
75
|
+
const CHILD_MAX = 10;
|
|
76
|
+
|
|
77
|
+
const applies = (ctx: RunCtx, id: string, allowed: ReadonlySet<string>) => {
|
|
78
|
+
const ready = prepare(ctx, ["channel_id"]);
|
|
79
|
+
if (isOutcome(ready)) return ready;
|
|
80
|
+
const template = textOf(ready.template);
|
|
81
|
+
if (allowed.has(template)) return null;
|
|
82
|
+
return say(ctx.locale, "skip", COPY.channel, { id, template });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const menusOf = async (ctx: RunCtx, channel: YamlChannel) => {
|
|
86
|
+
const live = openLive(ctx, channel);
|
|
87
|
+
const path = queryOf("/menus", { channel_id: textOf(channel.channel_id) });
|
|
88
|
+
const codes = [200];
|
|
89
|
+
const args = ["menus", "GET", path, codes] as const;
|
|
90
|
+
const step = await live.storefront<unknown>(...args);
|
|
91
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
92
|
+
if (!Array.isArray(step.value)) return say(ctx.locale, "fail", COPY.list);
|
|
93
|
+
return live;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const homeName = (template: string) => {
|
|
97
|
+
if (template === "app" || template === "kiosk") return "home-hero-video-app";
|
|
98
|
+
return "home-hero-banners-web";
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const blockOf = async (ctx: RunCtx, channel: YamlChannel, name: string) => {
|
|
102
|
+
const live = openLive(ctx, channel);
|
|
103
|
+
const path = `/cms/blocks/${encodeURIComponent(name)}`;
|
|
104
|
+
const codes = [200, 404];
|
|
105
|
+
const args = ["cms", "GET", path, codes] as const;
|
|
106
|
+
const step = await live.probe(...args);
|
|
107
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
108
|
+
if (step.value.status === 404) return null;
|
|
109
|
+
const data = dataOf<unknown>(step.value);
|
|
110
|
+
if (data === undefined) return say(ctx.locale, "fail", COPY.shape);
|
|
111
|
+
return data;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const passwordToken = async (ctx: RunCtx, channel: YamlChannel) => {
|
|
115
|
+
const key = textOf(ctx.env.FIRESPARK_DOCTOR_FIREBASE_API_KEY);
|
|
116
|
+
const auth = textOf(ctx.env.FIRESPARK_DOCTOR_FIREBASE_AUTH_URL);
|
|
117
|
+
const email = textOf(ctx.env.FIRESPARK_DOCTOR_CUSTOMER_EMAIL);
|
|
118
|
+
const password = textOf(ctx.env.FIRESPARK_DOCTOR_CUSTOMER_PASSWORD);
|
|
119
|
+
const base = (auth || "https://identitytoolkit.googleapis.com").replace(
|
|
120
|
+
/\/$/,
|
|
121
|
+
""
|
|
122
|
+
);
|
|
123
|
+
const url = `${base}/v1/accounts:signInWithPassword?key=${key}`;
|
|
124
|
+
const body = JSON.stringify({ email, password, returnSecureToken: true });
|
|
125
|
+
const headers = { "content-type": "application/json" };
|
|
126
|
+
const signal = AbortSignal.timeout(15000);
|
|
127
|
+
const signed = await fetch(url, { method: "POST", headers, body, signal });
|
|
128
|
+
const signedJson = (await signed.json()) as { idToken?: string };
|
|
129
|
+
if (!signed.ok || !signedJson.idToken) return "";
|
|
130
|
+
const api = textOf(channel.api_url).replace(/\/$/, "");
|
|
131
|
+
const grant = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
132
|
+
const subject = "urn:ietf:params:oauth:token-type:id_token";
|
|
133
|
+
const exchange = {
|
|
134
|
+
grant_type: grant,
|
|
135
|
+
subject_token: signedJson.idToken,
|
|
136
|
+
subject_token_type: subject
|
|
137
|
+
};
|
|
138
|
+
const exchangeUrl = `${api}/api/storefront/v1/oauth/exchange`;
|
|
139
|
+
const payload = JSON.stringify(exchange);
|
|
140
|
+
const init = { method: "POST", headers, body: payload, signal };
|
|
141
|
+
const traded = await fetch(exchangeUrl, init);
|
|
142
|
+
const bag = (await traded.json()) as {
|
|
143
|
+
access_token?: string;
|
|
144
|
+
data?: { access_token?: string };
|
|
145
|
+
};
|
|
146
|
+
return bag.access_token ?? bag.data?.access_token ?? "";
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const runLog11 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
150
|
+
const allowed = new Set(["kiosk", "web", "app", "call-center", "dashboard"]);
|
|
151
|
+
const skipped = applies(ctx, "LOG-11", allowed);
|
|
152
|
+
if (skipped) return skipped;
|
|
153
|
+
const ready = prepare(ctx, LIVE);
|
|
154
|
+
if (isOutcome(ready)) return ready;
|
|
155
|
+
let token = "";
|
|
156
|
+
try {
|
|
157
|
+
token = await passwordToken(ctx, ready);
|
|
158
|
+
} catch {
|
|
159
|
+
return say(ctx.locale, "fail", COPY.shape);
|
|
160
|
+
}
|
|
161
|
+
if (!token) return say(ctx.locale, "fail", COPY.shape);
|
|
162
|
+
const channelId = textOf(ready.channel_id);
|
|
163
|
+
const api = textOf(ready.api_url).replace(/\/$/, "");
|
|
164
|
+
const menus = `${api}/api/storefront/v1/menus?channel_id=${channelId}`;
|
|
165
|
+
const authorization = `Bearer ${token}`;
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch(menus, { headers: { authorization } });
|
|
168
|
+
if (!response.ok) return say(ctx.locale, "fail", COPY.shape);
|
|
169
|
+
} catch {
|
|
170
|
+
return say(ctx.locale, "fail", COPY.shape);
|
|
171
|
+
}
|
|
172
|
+
return { status: "pass" };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const runCms02 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
176
|
+
const allowed = new Set(["kiosk", "web", "app", "chat", "call-center"]);
|
|
177
|
+
const skipped = applies(ctx, "CMS-02", allowed);
|
|
178
|
+
if (skipped) return skipped;
|
|
179
|
+
const ready = prepare(ctx, LIVE);
|
|
180
|
+
if (isOutcome(ready)) return ready;
|
|
181
|
+
const name = homeName(textOf(ready.template));
|
|
182
|
+
const block = await blockOf(ctx, ready, name);
|
|
183
|
+
if (isOutcome(block)) return block;
|
|
184
|
+
if (!block) return say(ctx.locale, "skip", COPY.home);
|
|
185
|
+
if (
|
|
186
|
+
!isRecord(block) ||
|
|
187
|
+
!Array.isArray(block.items) ||
|
|
188
|
+
block.items.length === 0
|
|
189
|
+
) {
|
|
190
|
+
return say(ctx.locale, "fail", COPY.shape);
|
|
191
|
+
}
|
|
192
|
+
const items = block.items.filter(isRecord);
|
|
193
|
+
const image = items.some(item => {
|
|
194
|
+
if (typeof item.image_url === "string" && item.image_url) return true;
|
|
195
|
+
return item.type === "IMAGE" || isRecord(item.image);
|
|
196
|
+
});
|
|
197
|
+
const video = items.some(item => {
|
|
198
|
+
if (typeof item.video_url === "string" && item.video_url) return true;
|
|
199
|
+
return item.type === "VIDEO" || isRecord(item.video);
|
|
200
|
+
});
|
|
201
|
+
const template = textOf(ready.template);
|
|
202
|
+
if (template === "app" || template === "kiosk") {
|
|
203
|
+
if (!video || !image) return say(ctx.locale, "fail", COPY.shape);
|
|
204
|
+
return { status: "pass" };
|
|
205
|
+
}
|
|
206
|
+
if (!image) return say(ctx.locale, "fail", COPY.shape);
|
|
207
|
+
return { status: "pass" };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const runCms01 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
211
|
+
const skipped = applies(ctx, "CMS-01", new Set(["kiosk", "dashboard"]));
|
|
212
|
+
if (skipped) return skipped;
|
|
213
|
+
const ready = prepare(ctx, LIVE);
|
|
214
|
+
if (isOutcome(ready)) return ready;
|
|
215
|
+
const block = await blockOf(ctx, ready, homeName(textOf(ready.template)));
|
|
216
|
+
if (isOutcome(block)) return block;
|
|
217
|
+
if (!isRecord(block) || !Array.isArray(block.items)) {
|
|
218
|
+
return say(ctx.locale, "skip", COPY.block);
|
|
219
|
+
}
|
|
220
|
+
const lists = block.items.filter(isRecord).filter(row => {
|
|
221
|
+
return row.type === "LIST";
|
|
222
|
+
});
|
|
223
|
+
const broken = lists.some(row => !Array.isArray(row.items));
|
|
224
|
+
if (broken) return say(ctx.locale, "fail", COPY.shape);
|
|
225
|
+
const overItems = block.items.length > LIST_MAX;
|
|
226
|
+
const overChildren = lists.some(row => {
|
|
227
|
+
return Array.isArray(row.items) && row.items.length > CHILD_MAX;
|
|
228
|
+
});
|
|
229
|
+
if (overItems || overChildren) return say(ctx.locale, "fail", COPY.long);
|
|
230
|
+
return { status: "pass" };
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const onlyNamed = (ctx: RunCtx, id: string) => {
|
|
234
|
+
if (ctx.named.has(id)) return null;
|
|
235
|
+
return say(ctx.locale, "skip", COPY.named, { id });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const need = (ctx: RunCtx, id: string, field: string) => {
|
|
239
|
+
return say(ctx.locale, "skip", COPY.need, { id, field });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const deviceGap = (env: NodeJS.ProcessEnv) => {
|
|
243
|
+
if (!textOf(env.FIRESPARK_DOCTOR_DEVICE_UID)) {
|
|
244
|
+
return "FIRESPARK_DOCTOR_DEVICE_UID";
|
|
245
|
+
}
|
|
246
|
+
if (!textOf(env.FIRESPARK_DOCTOR_DEVICE_SECRET)) {
|
|
247
|
+
return "FIRESPARK_DOCTOR_DEVICE_SECRET";
|
|
248
|
+
}
|
|
249
|
+
return "";
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const runLog12 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
253
|
+
const allowed = new Set(["kiosk", "web", "app", "dashboard"]);
|
|
254
|
+
const skipped = applies(ctx, "LOG-12", allowed);
|
|
255
|
+
if (skipped) return skipped;
|
|
256
|
+
const named = onlyNamed(ctx, "LOG-12");
|
|
257
|
+
if (named) return named;
|
|
258
|
+
const ready = prepare(ctx, LIVE);
|
|
259
|
+
if (isOutcome(ready)) return ready;
|
|
260
|
+
const live = openLive(ctx, ready);
|
|
261
|
+
const codes = [200];
|
|
262
|
+
const args = ["delete-customer", "DELETE", "/customers/me", codes] as const;
|
|
263
|
+
const step = await live.storefront<unknown>(...args);
|
|
264
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
265
|
+
return { status: "pass" };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const badPassword = async (ctx: RunCtx) => {
|
|
269
|
+
const key = textOf(ctx.env.FIRESPARK_DOCTOR_FIREBASE_API_KEY);
|
|
270
|
+
const authUrl = textOf(ctx.env.FIRESPARK_DOCTOR_FIREBASE_AUTH_URL);
|
|
271
|
+
const base = authUrl || "https://identitytoolkit.googleapis.com";
|
|
272
|
+
const email = textOf(ctx.env.FIRESPARK_DOCTOR_CUSTOMER_EMAIL);
|
|
273
|
+
const url = `${base}/v1/accounts:signInWithPassword?key=${key}`;
|
|
274
|
+
const body = { email, password: "not-the-password", returnSecureToken: true };
|
|
275
|
+
const signal = AbortSignal.timeout(15000);
|
|
276
|
+
const headers = { "content-type": "application/json" };
|
|
277
|
+
const payload = JSON.stringify(body);
|
|
278
|
+
const init = { method: "POST", headers, body: payload, signal };
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetch(url, init);
|
|
281
|
+
return response.status;
|
|
282
|
+
} catch {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const runLog13 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
288
|
+
const skipped = applies(ctx, "LOG-13", new Set(["dashboard"]));
|
|
289
|
+
if (skipped) return skipped;
|
|
290
|
+
const ready = prepare(ctx, LIVE);
|
|
291
|
+
if (isOutcome(ready)) return ready;
|
|
292
|
+
const loaded = await menusOf(ctx, ready);
|
|
293
|
+
if (isOutcome(loaded)) return loaded;
|
|
294
|
+
const status = await badPassword(ctx);
|
|
295
|
+
if (status !== 400 && status !== 401 && status !== 403) {
|
|
296
|
+
return say(ctx.locale, "fail", COPY.shape);
|
|
297
|
+
}
|
|
298
|
+
return { status: "pass" };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const runLog14 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
302
|
+
const skipped = applies(ctx, "LOG-14", new Set(["dashboard"]));
|
|
303
|
+
if (skipped) return skipped;
|
|
304
|
+
const ready = prepare(ctx, LIVE);
|
|
305
|
+
if (isOutcome(ready)) return ready;
|
|
306
|
+
const live = openLive(ctx, ready);
|
|
307
|
+
const codes = [200];
|
|
308
|
+
const args = ["preferences", "GET", "/preferences", codes] as const;
|
|
309
|
+
const step = await live.storefront<unknown>(...args);
|
|
310
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
311
|
+
const security = isRecord(step.value) ? step.value.security : undefined;
|
|
312
|
+
const required = isRecord(security) && security.mfa_required === true;
|
|
313
|
+
if (!required)
|
|
314
|
+
return need(ctx, "LOG-14", "preferences.security.mfa_required");
|
|
315
|
+
const secret = textOf(ctx.env.FIRESPARK_DOCTOR_TOTP_SECRET);
|
|
316
|
+
if (!secret) return need(ctx, "LOG-14", "FIRESPARK_DOCTOR_TOTP_SECRET");
|
|
317
|
+
return need(ctx, "LOG-14", "supabase.auth.mfa.challenge");
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const heartbeat = async (ctx: RunCtx, channel: YamlChannel, path: string) => {
|
|
321
|
+
const live = openLive(ctx, channel);
|
|
322
|
+
const device_uid = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_UID);
|
|
323
|
+
const device_secret = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_SECRET);
|
|
324
|
+
const version = { app_version_code: 1, app_version_name: "doctor" };
|
|
325
|
+
const body = { device_uid, device_secret, ...version };
|
|
326
|
+
const codes = [200, 401, 403];
|
|
327
|
+
const args = ["device", "POST", path, codes, body] as const;
|
|
328
|
+
return live.probe(...args);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const runLog15 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
332
|
+
const skipped = applies(ctx, "LOG-15", new Set(["pos", "dashboard"]));
|
|
333
|
+
if (skipped) return skipped;
|
|
334
|
+
const ready = prepare(ctx, LIVE);
|
|
335
|
+
if (isOutcome(ready)) return ready;
|
|
336
|
+
const field = deviceGap(ctx.env);
|
|
337
|
+
if (field) return need(ctx, "LOG-15", field);
|
|
338
|
+
const step = await heartbeat(ctx, ready, "/pos/devices/me");
|
|
339
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
340
|
+
if (step.value.status !== 200) return say(ctx.locale, "fail", COPY.shape);
|
|
341
|
+
return { status: "pass" };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const runLog16 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
345
|
+
const skipped = applies(ctx, "LOG-16", new Set(["kiosk", "pos"]));
|
|
346
|
+
if (skipped) return skipped;
|
|
347
|
+
const ready = prepare(ctx, LIVE);
|
|
348
|
+
if (isOutcome(ready)) return ready;
|
|
349
|
+
const field = deviceGap(ctx.env);
|
|
350
|
+
if (field) return need(ctx, "LOG-16", field);
|
|
351
|
+
const pos = textOf(ready.template) === "pos";
|
|
352
|
+
const path = pos ? "/pos/devices/me" : "/kiosk/devices/me";
|
|
353
|
+
const step = await heartbeat(ctx, ready, path);
|
|
354
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
355
|
+
if (step.value.status !== 200) return say(ctx.locale, "fail", COPY.shape);
|
|
356
|
+
const live = openLive(ctx, ready);
|
|
357
|
+
const device_uid = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_UID);
|
|
358
|
+
const version = { app_version_code: 1, app_version_name: "doctor" };
|
|
359
|
+
const body = { device_uid, device_secret: "invalid", ...version };
|
|
360
|
+
const codes = [401, 403];
|
|
361
|
+
const args = ["device-bad", "POST", path, codes, body] as const;
|
|
362
|
+
const rejected = await live.probe(...args);
|
|
363
|
+
if (!rejected.ok) return fromLive(ctx.locale, rejected.result);
|
|
364
|
+
return { status: "pass" };
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const runImg01 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
368
|
+
const skipped = applies(ctx, "IMG-01", new Set(["dashboard"]));
|
|
369
|
+
if (skipped) return skipped;
|
|
370
|
+
const ready = prepare(ctx, LIVE);
|
|
371
|
+
if (isOutcome(ready)) return ready;
|
|
372
|
+
const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
|
|
373
|
+
if (!cookie) return need(ctx, "IMG-01", "FIRESPARK_DOCTOR_DASHBOARD_COOKIE");
|
|
374
|
+
const dash = textOf(ready.dashboard_url);
|
|
375
|
+
if (!dash) return need(ctx, "IMG-01", "dashboard_url");
|
|
376
|
+
const input = {
|
|
377
|
+
merchant_id: textOf(ready.merchant_id),
|
|
378
|
+
env: textOf(ready.env)
|
|
379
|
+
};
|
|
380
|
+
const step = await trpcCall(dash, cookie, "multimedia.selectByRange", input);
|
|
381
|
+
if (step.status < 200 || step.status >= 300) {
|
|
382
|
+
return say(ctx.locale, "fail", COPY.shape);
|
|
383
|
+
}
|
|
384
|
+
const limits = step.text.includes("5242880") || step.text.includes("5MB");
|
|
385
|
+
if (!limits && !step.text.includes("image")) {
|
|
386
|
+
return need(ctx, "IMG-01", "multimedia.image");
|
|
387
|
+
}
|
|
388
|
+
return { status: "pass" };
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const runImg02 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
392
|
+
const skipped = applies(ctx, "IMG-02", new Set(["kiosk", "pos"]));
|
|
393
|
+
if (skipped) return skipped;
|
|
394
|
+
const files = await readSources(ctx.cwd);
|
|
395
|
+
const found = files.some(file => {
|
|
396
|
+
if (file.body.includes("NetInfo")) return true;
|
|
397
|
+
return file.body.toLowerCase().includes("offline");
|
|
398
|
+
});
|
|
399
|
+
if (!found) return say(ctx.locale, "fail", COPY.offline);
|
|
400
|
+
return { status: "pass" };
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const runHw01 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
404
|
+
const skipped = applies(ctx, "HW-01", new Set(["kiosk", "dashboard"]));
|
|
405
|
+
if (skipped) return skipped;
|
|
406
|
+
const ready = prepare(ctx, LIVE);
|
|
407
|
+
if (isOutcome(ready)) return ready;
|
|
408
|
+
const live = openLive(ctx, ready);
|
|
409
|
+
const codes = [200];
|
|
410
|
+
const args = ["preferences", "GET", "/preferences", codes] as const;
|
|
411
|
+
const step = await live.storefront<unknown>(...args);
|
|
412
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
413
|
+
const pagers = isRecord(step.value) ? step.value.pagers : undefined;
|
|
414
|
+
if (!isRecord(pagers)) return need(ctx, "HW-01", "preferences.pagers");
|
|
415
|
+
const fields = [
|
|
416
|
+
"enabled",
|
|
417
|
+
"required",
|
|
418
|
+
"side",
|
|
419
|
+
"payment_ids",
|
|
420
|
+
"range_start",
|
|
421
|
+
"range"
|
|
422
|
+
];
|
|
423
|
+
const missing = fields.find(field => pagers[field] === undefined);
|
|
424
|
+
if (missing) return need(ctx, "HW-01", `preferences.pagers.${missing}`);
|
|
425
|
+
return { status: "pass" };
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const runHw03 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
429
|
+
const skipped = applies(ctx, "HW-03", new Set(["pos"]));
|
|
430
|
+
if (skipped) return skipped;
|
|
431
|
+
const files = await readSources(ctx.cwd);
|
|
432
|
+
const found = files.some(file => file.body.toLowerCase().includes("keypad"));
|
|
433
|
+
if (!found) return say(ctx.locale, "fail", COPY.keypad);
|
|
434
|
+
return { status: "pass" };
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
export const CONTENT_BINDINGS: Record<string, CheckBinding> = {
|
|
438
|
+
"LOG-11": withSafeFix(runLog11),
|
|
439
|
+
"LOG-12": withSafeFix(runLog12),
|
|
440
|
+
"LOG-13": withSafeFix(runLog13),
|
|
441
|
+
"LOG-14": withSafeFix(runLog14),
|
|
442
|
+
"LOG-15": withSafeFix(runLog15),
|
|
443
|
+
"LOG-16": withSafeFix(runLog16),
|
|
444
|
+
"CMS-01": withSafeFix(runCms01),
|
|
445
|
+
"CMS-02": withSafeFix(runCms02),
|
|
446
|
+
"IMG-01": withSafeFix(runImg01),
|
|
447
|
+
"IMG-02": withSafeFix(runImg02),
|
|
448
|
+
"HW-01": withSafeFix(runHw01),
|
|
449
|
+
"HW-03": withSafeFix(runHw03)
|
|
450
|
+
};
|
|
@@ -16,8 +16,8 @@ export const SUMMARY = text(
|
|
|
16
16
|
"Verifica o FireSpark já implantado de um merchant."
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const usage = "firespark doctor <run|list|show|init|docs> [ID ...] [options]";
|
|
20
|
+
export const USAGE = usage;
|
|
21
21
|
|
|
22
22
|
const detailsEs = [
|
|
23
23
|
"Comandos: run [ID ...], list, show <ID>, init [--config <archivo>], docs.",
|
|
@@ -218,6 +218,26 @@ export const COPY = {
|
|
|
218
218
|
"There is no automatic repair that does not invent a channel_id. No file was changed.",
|
|
219
219
|
"Não há um reparo automático que não invente um channel_id. Nenhum arquivo mudou."
|
|
220
220
|
),
|
|
221
|
+
flowMissing: text(
|
|
222
|
+
"El canal {channel} le falta {field}.",
|
|
223
|
+
"Channel {channel} is missing {field}.",
|
|
224
|
+
"O canal {channel} falta {field}."
|
|
225
|
+
),
|
|
226
|
+
flowNoChannel: text(
|
|
227
|
+
"Falta channel_id para elegir el canal.",
|
|
228
|
+
"channel_id is missing, so no channel was chosen.",
|
|
229
|
+
"Falta channel_id para escolher o canal."
|
|
230
|
+
),
|
|
231
|
+
noSafeChange: text(
|
|
232
|
+
"No hay un arreglo automático que no invente datos. No cambié nada.",
|
|
233
|
+
"There is no automatic repair that does not invent data. Nothing was changed.",
|
|
234
|
+
"Não há um reparo automático que não invente dados. Nada foi alterado."
|
|
235
|
+
),
|
|
236
|
+
namedOrder: text(
|
|
237
|
+
"{id} solo corre cuando lo nombras. No se creó ninguna orden.",
|
|
238
|
+
"{id} runs only when you name it. No order was created.",
|
|
239
|
+
"{id} só roda quando você o nomeia. Nenhum pedido foi criado."
|
|
240
|
+
),
|
|
221
241
|
strictSkipped: text(
|
|
222
242
|
"Modo estricto: hay checks omitidos.",
|
|
223
243
|
"Strict mode: some checks were skipped.",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Copy, Locale } from "../../../core/i18n";
|
|
2
|
+
import { translate } from "../../../core/i18n";
|
|
3
|
+
import type { LiveSession } from "../live/live.session";
|
|
4
|
+
import { createLiveSession } from "../live/live.session";
|
|
5
|
+
import type { CheckFail, CheckSkip } from "../live/result";
|
|
6
|
+
import { channelLabel, paymentOf } from "./config";
|
|
7
|
+
import { COPY } from "./copy";
|
|
8
|
+
import { liveConfigOf } from "./order";
|
|
9
|
+
import type { CheckBinding, Outcome, RunCtx } from "./runner";
|
|
10
|
+
import type { YamlChannel } from "./yaml";
|
|
11
|
+
|
|
12
|
+
export const textOf = (value: string | null | undefined) => {
|
|
13
|
+
return value?.trim() ?? "";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
17
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const isOutcome = (value: unknown): value is Outcome => {
|
|
21
|
+
if (!isRecord(value)) return false;
|
|
22
|
+
const status = value.status;
|
|
23
|
+
if (status !== "pass" && status !== "fail" && status !== "skip") return false;
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const say = (
|
|
28
|
+
locale: Locale,
|
|
29
|
+
status: Outcome["status"],
|
|
30
|
+
copy: Copy,
|
|
31
|
+
vars?: Record<string, string | number>
|
|
32
|
+
): Outcome => {
|
|
33
|
+
return { status, detail: translate(locale, copy, vars) };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const chooseChannel = (ctx: RunCtx): YamlChannel | Outcome => {
|
|
37
|
+
const { channels, channelId, locale } = ctx;
|
|
38
|
+
if (channelId) {
|
|
39
|
+
const found = channels.find(item => item.channel_id === channelId);
|
|
40
|
+
if (found) return found;
|
|
41
|
+
const detail = translate(locale, COPY.unknownChannel, { id: channelId });
|
|
42
|
+
return { status: "fail", detail };
|
|
43
|
+
}
|
|
44
|
+
if (channels.length !== 1) {
|
|
45
|
+
return say(locale, "fail", COPY.flowNoChannel);
|
|
46
|
+
}
|
|
47
|
+
const only = channels[0];
|
|
48
|
+
if (!only) return say(locale, "fail", COPY.flowNoChannel);
|
|
49
|
+
return only;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const fieldGap = (
|
|
53
|
+
channel: YamlChannel,
|
|
54
|
+
env: NodeJS.ProcessEnv,
|
|
55
|
+
fields: readonly string[]
|
|
56
|
+
) => {
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
if (field.startsWith("FIRESPARK_")) {
|
|
59
|
+
if (!textOf(env[field])) return field;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (field === "payment") {
|
|
63
|
+
const payment = channel.payment ? paymentOf(channel.payment) : null;
|
|
64
|
+
if (!payment) return "payment";
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const bag = channel as unknown as Record<string, unknown>;
|
|
68
|
+
const value = bag[field];
|
|
69
|
+
if (typeof value !== "string" || !value.trim()) return field;
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const prepare = (ctx: RunCtx, fields: readonly string[]) => {
|
|
75
|
+
const chosen = chooseChannel(ctx);
|
|
76
|
+
if (isOutcome(chosen)) return chosen;
|
|
77
|
+
const field = fieldGap(chosen, ctx.env, fields);
|
|
78
|
+
if (!field) return chosen;
|
|
79
|
+
const channel = channelLabel(chosen);
|
|
80
|
+
const vars = { channel, field };
|
|
81
|
+
return say(ctx.locale, "fail", COPY.flowMissing, vars);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const fromLive = (
|
|
85
|
+
locale: Locale,
|
|
86
|
+
result: CheckFail | CheckSkip
|
|
87
|
+
): Outcome => {
|
|
88
|
+
const detail = translate(locale, result.reason.copy, result.reason.vars);
|
|
89
|
+
if (result.status === "skip") return { status: "skip", detail };
|
|
90
|
+
return { status: "fail", detail: `${result.hop}: ${detail}` };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const openLive = (ctx: RunCtx, channel: YamlChannel): LiveSession => {
|
|
94
|
+
const config = liveConfigOf(channel, ctx.env);
|
|
95
|
+
return createLiveSession(config, { fetch }, ctx.redactor);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const rowsOf = (value: unknown) => {
|
|
99
|
+
if (!Array.isArray(value)) return [];
|
|
100
|
+
return value.filter(isRecord);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const queryOf = (path: string, pairs: Record<string, string>) => {
|
|
104
|
+
const params = new URLSearchParams();
|
|
105
|
+
for (const [key, value] of Object.entries(pairs)) {
|
|
106
|
+
if (value) params.set(key, value);
|
|
107
|
+
}
|
|
108
|
+
const query = params.toString();
|
|
109
|
+
if (!query) return path;
|
|
110
|
+
return `${path}?${query}`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const isLocalHost = (value: string) => {
|
|
114
|
+
try {
|
|
115
|
+
const host = new URL(value).hostname.toLowerCase();
|
|
116
|
+
if (host === "localhost" || host === "0.0.0.0") return true;
|
|
117
|
+
if (host === "127.0.0.1" || host === "::1") return true;
|
|
118
|
+
return host.endsWith(".local");
|
|
119
|
+
} catch {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const withQuery = (url: string, key: string, value: string) => {
|
|
125
|
+
const parsed = new URL(url);
|
|
126
|
+
parsed.searchParams.set(key, value);
|
|
127
|
+
return parsed.toString();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const readPage = async (url: string) => {
|
|
131
|
+
const signal = AbortSignal.timeout(15000);
|
|
132
|
+
const response = await fetch(url, { signal });
|
|
133
|
+
const body = await response.text();
|
|
134
|
+
return { status: response.status, body };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const namedOrderSkip = (ctx: RunCtx, id: string): Outcome | null => {
|
|
138
|
+
if (ctx.named.has(id)) return null;
|
|
139
|
+
return say(ctx.locale, "skip", COPY.namedOrder, { id });
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const trpcCall = async (
|
|
143
|
+
base: string,
|
|
144
|
+
cookie: string,
|
|
145
|
+
procedure: string,
|
|
146
|
+
input: unknown
|
|
147
|
+
) => {
|
|
148
|
+
const root = base.replace(/\/$/, "");
|
|
149
|
+
const url = `${root}/api/trpc/${procedure}`;
|
|
150
|
+
const headers = { "content-type": "application/json", cookie };
|
|
151
|
+
const signal = AbortSignal.timeout(15000);
|
|
152
|
+
const body = JSON.stringify({ json: input });
|
|
153
|
+
const init = { method: "POST", headers, body, signal };
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetch(url, init);
|
|
156
|
+
const text = await response.text();
|
|
157
|
+
return { status: response.status, text };
|
|
158
|
+
} catch {
|
|
159
|
+
return { status: 0, text: "" };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const withSafeFix = (
|
|
164
|
+
run: (ctx: RunCtx) => Promise<Outcome>
|
|
165
|
+
): CheckBinding => {
|
|
166
|
+
const fix = async (ctx: RunCtx): Promise<Outcome> => {
|
|
167
|
+
const result = await run(ctx);
|
|
168
|
+
if (result.status !== "fail") return result;
|
|
169
|
+
const note = translate(ctx.locale, COPY.noSafeChange);
|
|
170
|
+
const detail = result.detail ? `${note} ${result.detail}` : note;
|
|
171
|
+
const failed: Outcome = { status: "fail", detail };
|
|
172
|
+
return failed;
|
|
173
|
+
};
|
|
174
|
+
return { kind: "script", run, fix };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const ACTIVE = "ACTIVE";
|
|
178
|
+
|
|
179
|
+
export const isActive = (row: Record<string, unknown>) => {
|
|
180
|
+
if (!("status" in row)) return true;
|
|
181
|
+
return row.status === ACTIVE;
|
|
182
|
+
};
|