@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.
- package/bin/firespark.mjs +14 -0
- package/package.json +39 -3
- package/src/commands/doctor/client/admin.flow.ts +553 -0
- package/src/commands/doctor/client/catalog.json +1586 -0
- package/src/commands/doctor/client/catalog.ts +179 -0
- package/src/commands/doctor/client/checkout.flow.ts +400 -0
- package/src/commands/doctor/client/command.ts +246 -0
- package/src/commands/doctor/client/config.ts +346 -0
- package/src/commands/doctor/client/content.flow.ts +450 -0
- package/src/commands/doctor/client/copy.ts +248 -0
- package/src/commands/doctor/client/flow.ts +182 -0
- package/src/commands/doctor/client/log.flow.ts +410 -0
- package/src/commands/doctor/client/manifests.ts +161 -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/options.ts +201 -0
- package/src/commands/doctor/client/ord.flow.ts +445 -0
- package/src/commands/doctor/client/order.ts +261 -0
- 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 +182 -0
- package/src/commands/doctor/client/sdk.flow.ts +630 -0
- package/src/commands/doctor/client/store.flow.ts +577 -0
- package/src/commands/doctor/client/yaml.ts +179 -0
- package/src/commands/doctor/live/http.ts +81 -0
- package/src/commands/doctor/live/live.session.ts +250 -0
- package/src/commands/doctor/live/order.ts +95 -0
- package/src/commands/doctor/live/realtime.ts +161 -0
- package/src/commands/doctor/live/result.ts +76 -0
- package/src/commands/index.ts +8 -0
- package/src/core/command.types.ts +25 -0
- package/src/core/help.ts +85 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/options.ts +131 -0
- package/src/core/output.ts +47 -0
- package/src/core/redact.ts +71 -0
- package/src/index.ts +4 -0
- package/src/main.ts +113 -0
- package/README.md +0 -3
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { Copy } from "../../../core/i18n";
|
|
4
|
+
import { dataOf } from "../live/http";
|
|
5
|
+
import { buildOrderBody, pickProduct } from "../live/order";
|
|
6
|
+
import { channelLabel } from "./config";
|
|
7
|
+
import { COPY as BASE } from "./copy";
|
|
8
|
+
import { fromLive, isActive, isLocalHost, isOutcome } from "./flow";
|
|
9
|
+
import { namedOrderSkip, openLive, prepare, queryOf } from "./flow";
|
|
10
|
+
import { readPage, rowsOf, say, textOf, withQuery, withSafeFix } from "./flow";
|
|
11
|
+
import { listManifests, manifestProblem } from "./manifests";
|
|
12
|
+
import { parseLegacyManifest } from "./manifests";
|
|
13
|
+
import type { ManifestFile } from "./manifests";
|
|
14
|
+
import { liveConfigOf } from "./order";
|
|
15
|
+
import { changelogNotes, declaresDep, importsLib } from "./repo.scan";
|
|
16
|
+
import { readSources, semverCmp, workspaceIssues } from "./repo.scan";
|
|
17
|
+
import type { SourceFile } from "./repo.scan";
|
|
18
|
+
import type { Outcome, RunCtx } from "./runner";
|
|
19
|
+
import type { YamlChannel } from "./yaml";
|
|
20
|
+
|
|
21
|
+
const text = (es: string, en: string, pt: string): Copy => {
|
|
22
|
+
return { "es-EC": es, "en-US": en, "pt-BR": pt };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const COPY = {
|
|
26
|
+
noManifest: text(
|
|
27
|
+
"Este repo no tiene apps/*/src/channel-manifest.json.",
|
|
28
|
+
"This repo has no apps/*/src/channel-manifest.json.",
|
|
29
|
+
"Este repo não tem apps/*/src/channel-manifest.json."
|
|
30
|
+
),
|
|
31
|
+
npm: text(
|
|
32
|
+
"No pude leer la versión latest de npm (@fire-spark/storefront). No inventé una versión.",
|
|
33
|
+
"Could not read the latest npm version (@fire-spark/storefront). No version was invented.",
|
|
34
|
+
"Não consegui ler a versão latest do npm (@fire-spark/storefront). Não inventei uma versão."
|
|
35
|
+
),
|
|
36
|
+
sdkBehind: text(
|
|
37
|
+
"{file} tiene sdkVersion {actual} y npm latest es {expected}.",
|
|
38
|
+
"{file} has sdkVersion {actual} and npm latest is {expected}.",
|
|
39
|
+
"{file} tem sdkVersion {actual} e o npm latest é {expected}."
|
|
40
|
+
),
|
|
41
|
+
workspace: text(
|
|
42
|
+
"@fire-spark/* usa workspace:* (salvo @fire-spark/rn-ui): {detail}",
|
|
43
|
+
"@fire-spark/* uses workspace:* (except @fire-spark/rn-ui): {detail}",
|
|
44
|
+
"@fire-spark/* usa workspace:* (exceto @fire-spark/rn-ui): {detail}"
|
|
45
|
+
),
|
|
46
|
+
noChangelog: text(
|
|
47
|
+
"El canal {channel} le falta el changelog de la plantilla {template}.",
|
|
48
|
+
"Channel {channel} is missing the changelog for template {template}.",
|
|
49
|
+
"O canal {channel} falta o changelog da plantilla {template}."
|
|
50
|
+
),
|
|
51
|
+
templateBehind: text(
|
|
52
|
+
"{channel} tiene templateVersion {actual}. Pendiente {version}: {title}. {description}",
|
|
53
|
+
"{channel} has templateVersion {actual}. Pending {version}: {title}. {description}",
|
|
54
|
+
"{channel} tem templateVersion {actual}. Pendente {version}: {title}. {description}"
|
|
55
|
+
),
|
|
56
|
+
storeMissing: text(
|
|
57
|
+
"GET /stores no incluye la tienda {store} del canal {channel}.",
|
|
58
|
+
"GET /stores does not include store {store} for channel {channel}.",
|
|
59
|
+
"GET /stores não inclui a loja {store} do canal {channel}."
|
|
60
|
+
),
|
|
61
|
+
burned: text(
|
|
62
|
+
"{field} {value} está quemado en {file}.",
|
|
63
|
+
"{field} {value} is hardcoded in {file}.",
|
|
64
|
+
"{field} {value} está fixo em {file}."
|
|
65
|
+
),
|
|
66
|
+
page: text(
|
|
67
|
+
"La URL del canal {channel} respondió {status}.",
|
|
68
|
+
"The URL of channel {channel} answered {status}.",
|
|
69
|
+
"A URL do canal {channel} respondeu {status}."
|
|
70
|
+
),
|
|
71
|
+
fulfillment: text(
|
|
72
|
+
"GET /fulfillment no incluye {id} para el canal {channel}.",
|
|
73
|
+
"GET /fulfillment does not include {id} for channel {channel}.",
|
|
74
|
+
"GET /fulfillment não inclui {id} para o canal {channel}."
|
|
75
|
+
),
|
|
76
|
+
payments: text(
|
|
77
|
+
"GET /payments no trajo métodos para el canal {channel}.",
|
|
78
|
+
"GET /payments returned no methods for channel {channel}.",
|
|
79
|
+
"GET /payments não trouxe métodos para o canal {channel}."
|
|
80
|
+
),
|
|
81
|
+
secret: text(
|
|
82
|
+
"El JS público contiene {name}.",
|
|
83
|
+
"The public JS contains {name}.",
|
|
84
|
+
"O JS público contém {name}."
|
|
85
|
+
),
|
|
86
|
+
localApi: text(
|
|
87
|
+
"api_url del canal {channel} apunta a localhost.",
|
|
88
|
+
"api_url of channel {channel} points at localhost.",
|
|
89
|
+
"api_url do canal {channel} aponta para localhost."
|
|
90
|
+
),
|
|
91
|
+
channelOff: text(
|
|
92
|
+
"El canal {id} no está activo en GET /channels.",
|
|
93
|
+
"Channel {id} is not active on GET /channels.",
|
|
94
|
+
"O canal {id} não está ativo em GET /channels."
|
|
95
|
+
),
|
|
96
|
+
emptyMenu: text(
|
|
97
|
+
"Un channel_id inválido devolvió un menú vacío, no storefront no encontrado.",
|
|
98
|
+
"An invalid channel_id returned an empty menu, not storefront not found.",
|
|
99
|
+
"Um channel_id inválido devolveu um cardápio vazio, não storefront não encontrado."
|
|
100
|
+
),
|
|
101
|
+
leakedMenu: text(
|
|
102
|
+
"Un channel_id inválido devolvió un menú con productos.",
|
|
103
|
+
"An invalid channel_id returned a menu with products.",
|
|
104
|
+
"Um channel_id inválido devolveu um cardápio com produtos."
|
|
105
|
+
),
|
|
106
|
+
localBundle: text(
|
|
107
|
+
"El JS público de {channel} apunta a localhost ({file}).",
|
|
108
|
+
"The public JS of {channel} points at localhost ({file}).",
|
|
109
|
+
"O JS público de {channel} aponta para localhost ({file})."
|
|
110
|
+
),
|
|
111
|
+
demo: text(
|
|
112
|
+
"Aparece texto de muestra ({hit}) en {where}.",
|
|
113
|
+
"Sample copy ({hit}) appears in {where}.",
|
|
114
|
+
"Aparece texto de amostra ({hit}) em {where}."
|
|
115
|
+
),
|
|
116
|
+
badId: text(
|
|
117
|
+
"El id {id} no cumple ord-<store>-<hash> ni isGenId.",
|
|
118
|
+
"Id {id} is not ord-<store>-<hash> or isGenId.",
|
|
119
|
+
"O id {id} não cumpre ord-<store>-<hash> nem isGenId."
|
|
120
|
+
),
|
|
121
|
+
posId: text(
|
|
122
|
+
"En POS el id tiene que ser POS-<client_id> o isGenId. Llegó {id}.",
|
|
123
|
+
"On POS the id must be POS-<client_id> or isGenId. Got {id}.",
|
|
124
|
+
"No POS o id tem que ser POS-<client_id> ou isGenId. Chegou {id}."
|
|
125
|
+
),
|
|
126
|
+
noLib: text(
|
|
127
|
+
"{file} no declara {lib}.",
|
|
128
|
+
"{file} does not declare {lib}.",
|
|
129
|
+
"{file} não declara {lib}."
|
|
130
|
+
),
|
|
131
|
+
surface: text(
|
|
132
|
+
"La superficie {surface} de {channel} no importa {lib}.",
|
|
133
|
+
"The {surface} surface of {channel} does not import {lib}.",
|
|
134
|
+
"A superfície {surface} de {channel} não importa {lib}."
|
|
135
|
+
),
|
|
136
|
+
serverSkip: text(
|
|
137
|
+
"SDK-10 no aplica al canal server.",
|
|
138
|
+
"SDK-10 does not apply to the server channel.",
|
|
139
|
+
"SDK-10 não se aplica ao canal server."
|
|
140
|
+
)
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const NPM = "https://registry.npmjs.org/@fire-spark/storefront/latest";
|
|
144
|
+
const LIVE = [
|
|
145
|
+
"api_url",
|
|
146
|
+
"channel_id",
|
|
147
|
+
"FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
|
|
148
|
+
"FIRESPARK_DOCTOR_CUSTOMER_PASSWORD",
|
|
149
|
+
"FIRESPARK_DOCTOR_FIREBASE_API_KEY"
|
|
150
|
+
] as const;
|
|
151
|
+
const DEMO = ["Demo Kitchen", "Av. Isla Floreana E5-44", "storefront demo"];
|
|
152
|
+
const SECRETS = [
|
|
153
|
+
"FIRESPARK_CLIENT_SECRET",
|
|
154
|
+
"FIRESPARK_WEBHOOK_SECRET",
|
|
155
|
+
"FIRESPARK_INJECTION_SECRET",
|
|
156
|
+
"SUPABASE_SERVICE_ROLE",
|
|
157
|
+
"FIREBASE_PRIVATE_KEY"
|
|
158
|
+
];
|
|
159
|
+
const HASH = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
160
|
+
const ORDER_RE = /^ord-[A-Za-z0-9]+-[0-9a-z]{12}$/;
|
|
161
|
+
const POS_RE = /^POS-[A-Za-z0-9]+$/;
|
|
162
|
+
const GEN_RUN = /[0-9a-z]{8}/;
|
|
163
|
+
const PUBLIC_JS = /\.(js|mjs|cjs|html)$/;
|
|
164
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
165
|
+
|
|
166
|
+
interface Identity {
|
|
167
|
+
file: string;
|
|
168
|
+
appDir: string;
|
|
169
|
+
channelId: string | null;
|
|
170
|
+
name: string;
|
|
171
|
+
template: string;
|
|
172
|
+
templateVersion: string | null;
|
|
173
|
+
sdkVersion: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const missing = (ctx: RunCtx, channel: YamlChannel, field: string) => {
|
|
177
|
+
const vars = { channel: channelLabel(channel), field };
|
|
178
|
+
return say(ctx.locale, "fail", BASE.flowMissing, vars);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const identityOf = (file: ManifestFile): Identity | Outcome => {
|
|
182
|
+
if (file.kind === "json") {
|
|
183
|
+
const problem = manifestProblem(file.body);
|
|
184
|
+
if (problem) return { status: "fail", detail: `${file.file}: ${problem}` };
|
|
185
|
+
const json = JSON.parse(file.body) as Record<string, unknown>;
|
|
186
|
+
const id = json.channel_id;
|
|
187
|
+
const channelId = typeof id === "string" ? id : null;
|
|
188
|
+
const name = typeof json.name === "string" ? json.name : "";
|
|
189
|
+
const template = String(json.template);
|
|
190
|
+
const templateVersion = String(json.templateVersion);
|
|
191
|
+
const sdkVersion = String(json.sdkVersion);
|
|
192
|
+
const b1 = { file: file.file, appDir: file.appDir, channelId, name };
|
|
193
|
+
const b2 = { template, templateVersion, sdkVersion };
|
|
194
|
+
return { ...b1, ...b2 };
|
|
195
|
+
}
|
|
196
|
+
const parsed = parseLegacyManifest(file.body);
|
|
197
|
+
if (!parsed) {
|
|
198
|
+
const detail = `${file.file}: channel-manifest.ts is not a channel`;
|
|
199
|
+
return { status: "fail", detail };
|
|
200
|
+
}
|
|
201
|
+
const b1 = { file: file.file, appDir: file.appDir };
|
|
202
|
+
const b2 = { channelId: parsed.channelId, name: parsed.name };
|
|
203
|
+
const b3 = { template: parsed.template };
|
|
204
|
+
const b4 = { templateVersion: parsed.templateVersion };
|
|
205
|
+
const b5 = { sdkVersion: parsed.sdkVersion };
|
|
206
|
+
return { ...b1, ...b2, ...b3, ...b4, ...b5 };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const identities = async (cwd: string) => {
|
|
210
|
+
const files = await listManifests(cwd);
|
|
211
|
+
const rows: Identity[] = [];
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const row = identityOf(file);
|
|
214
|
+
if (isOutcome(row)) return row;
|
|
215
|
+
rows.push(row);
|
|
216
|
+
}
|
|
217
|
+
return rows;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const latestSdk = async (locale: RunCtx["locale"]) => {
|
|
221
|
+
try {
|
|
222
|
+
const signal = AbortSignal.timeout(15000);
|
|
223
|
+
const response = await fetch(NPM, { signal });
|
|
224
|
+
const json = (await response.json()) as { version?: unknown };
|
|
225
|
+
if (response.ok && typeof json.version === "string" && json.version) {
|
|
226
|
+
return json.version;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
return say(locale, "fail", COPY.npm);
|
|
230
|
+
}
|
|
231
|
+
return say(locale, "fail", COPY.npm);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const runSdk01 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
235
|
+
const rows = await identities(ctx.cwd);
|
|
236
|
+
if (isOutcome(rows)) return rows;
|
|
237
|
+
if (rows.length === 0) return say(ctx.locale, "fail", COPY.noManifest);
|
|
238
|
+
const latest = await latestSdk(ctx.locale);
|
|
239
|
+
if (typeof latest !== "string") return latest;
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
if (row.sdkVersion === latest) continue;
|
|
242
|
+
const file = row.file;
|
|
243
|
+
const actual = row.sdkVersion ?? "";
|
|
244
|
+
const expected = latest;
|
|
245
|
+
const vars = { file, actual, expected };
|
|
246
|
+
return say(ctx.locale, "fail", COPY.sdkBehind, vars);
|
|
247
|
+
}
|
|
248
|
+
const issues = workspaceIssues(await readSources(ctx.cwd));
|
|
249
|
+
const first = issues[0];
|
|
250
|
+
if (!first) return { status: "pass" };
|
|
251
|
+
return say(ctx.locale, "fail", COPY.workspace, { detail: first });
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const changelogFor = (files: SourceFile[], row: Identity) => {
|
|
255
|
+
const named = [
|
|
256
|
+
`templates/${row.template}/CHANGELOG.md`,
|
|
257
|
+
`${row.appDir}/CHANGELOG.md`,
|
|
258
|
+
"CHANGELOG.md"
|
|
259
|
+
];
|
|
260
|
+
for (const rel of named) {
|
|
261
|
+
const file = files.find(item => item.rel === rel);
|
|
262
|
+
if (file) return changelogNotes(file.body);
|
|
263
|
+
}
|
|
264
|
+
return [];
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const runSdk02 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
268
|
+
const rows = await identities(ctx.cwd);
|
|
269
|
+
if (isOutcome(rows)) return rows;
|
|
270
|
+
if (rows.length === 0) return say(ctx.locale, "fail", COPY.noManifest);
|
|
271
|
+
const files = await readSources(ctx.cwd);
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
const notes = changelogFor(files, row);
|
|
274
|
+
const latest = notes[0];
|
|
275
|
+
const channel = row.name || row.channelId || row.template;
|
|
276
|
+
if (!latest) {
|
|
277
|
+
const vars = { channel, template: row.template };
|
|
278
|
+
return say(ctx.locale, "fail", COPY.noChangelog, vars);
|
|
279
|
+
}
|
|
280
|
+
const actual = row.templateVersion ?? "";
|
|
281
|
+
if (semverCmp(actual, latest.version) >= 0) continue;
|
|
282
|
+
const version = latest.version;
|
|
283
|
+
const title = latest.title || version;
|
|
284
|
+
const description = latest.description || title;
|
|
285
|
+
const vars = { channel, actual, version, title, description };
|
|
286
|
+
return say(ctx.locale, "fail", COPY.templateBehind, vars);
|
|
287
|
+
}
|
|
288
|
+
return { status: "pass" };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const burned = (files: SourceFile[], field: string, value: string) => {
|
|
292
|
+
if (value.length < 4) return null;
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
if (file.rel.endsWith(".doctor.yml")) continue;
|
|
295
|
+
if (file.rel.endsWith("channel-manifest.json")) continue;
|
|
296
|
+
if (file.rel.endsWith("channel-manifest.ts")) continue;
|
|
297
|
+
const code = CODE.test(file.rel) || file.rel.endsWith(".env");
|
|
298
|
+
if (!code) continue;
|
|
299
|
+
const quoted = file.body.includes(`"${value}"`);
|
|
300
|
+
const single = file.body.includes(`'${value}'`);
|
|
301
|
+
if (!quoted && !single) continue;
|
|
302
|
+
return { field, value, file: file.rel };
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const pageOk = async (
|
|
308
|
+
ctx: RunCtx,
|
|
309
|
+
channel: YamlChannel,
|
|
310
|
+
key: string,
|
|
311
|
+
id: string
|
|
312
|
+
) => {
|
|
313
|
+
let page: { status: number; body: string };
|
|
314
|
+
try {
|
|
315
|
+
page = await readPage(withQuery(textOf(channel.url), key, id));
|
|
316
|
+
} catch (error) {
|
|
317
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
318
|
+
return { status: "fail" as const, detail };
|
|
319
|
+
}
|
|
320
|
+
if (page.status < 400) return null;
|
|
321
|
+
const vars = { channel: channelLabel(channel), status: page.status };
|
|
322
|
+
return say(ctx.locale, "fail", COPY.page, vars);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const runSdk03 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
326
|
+
const ready = prepare(ctx, [...LIVE, "store_id", "url"]);
|
|
327
|
+
if (isOutcome(ready)) return ready;
|
|
328
|
+
const live = openLive(ctx, ready);
|
|
329
|
+
const path = queryOf("/stores", { channel_id: textOf(ready.channel_id) });
|
|
330
|
+
const stores = await live.storefront<unknown>("stores", "GET", path, [200]);
|
|
331
|
+
if (!stores.ok) return fromLive(ctx.locale, stores.result);
|
|
332
|
+
const storeId = textOf(ready.store_id);
|
|
333
|
+
const rows = rowsOf(stores.value).filter(isActive);
|
|
334
|
+
if (!rows.some(row => row.id === storeId)) {
|
|
335
|
+
const vars = { store: storeId, channel: channelLabel(ready) };
|
|
336
|
+
return say(ctx.locale, "fail", COPY.storeMissing, vars);
|
|
337
|
+
}
|
|
338
|
+
const hit = burned(await readSources(ctx.cwd), "store_id", storeId);
|
|
339
|
+
if (hit) return say(ctx.locale, "fail", COPY.burned, hit);
|
|
340
|
+
const linked = await pageOk(ctx, ready, "store_id", storeId);
|
|
341
|
+
if (linked) return linked;
|
|
342
|
+
return { status: "pass" };
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const runSdk04 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
346
|
+
const ready = prepare(ctx, [...LIVE, "fulfillment_id", "url"]);
|
|
347
|
+
if (isOutcome(ready)) return ready;
|
|
348
|
+
const live = openLive(ctx, ready);
|
|
349
|
+
const query = { channel_id: textOf(ready.channel_id) };
|
|
350
|
+
const path = queryOf("/fulfillment", query);
|
|
351
|
+
const hop = "fulfillment";
|
|
352
|
+
const step = await live.storefront<unknown>(hop, "GET", path, [200]);
|
|
353
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
354
|
+
const id = textOf(ready.fulfillment_id);
|
|
355
|
+
const rows = rowsOf(step.value).filter(isActive);
|
|
356
|
+
if (!rows.some(row => row.id === id)) {
|
|
357
|
+
const vars = { id, channel: channelLabel(ready) };
|
|
358
|
+
return say(ctx.locale, "fail", COPY.fulfillment, vars);
|
|
359
|
+
}
|
|
360
|
+
const linked = await pageOk(ctx, ready, "fulfillment_id", id);
|
|
361
|
+
if (linked) return linked;
|
|
362
|
+
return { status: "pass" };
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const scopeQuery = (channel: YamlChannel) => {
|
|
366
|
+
return {
|
|
367
|
+
channel_id: textOf(channel.channel_id),
|
|
368
|
+
store_id: textOf(channel.store_id),
|
|
369
|
+
fulfillment_id: textOf(channel.fulfillment_id)
|
|
370
|
+
};
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const runSdk05 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
374
|
+
const ready = prepare(ctx, [...LIVE, "store_id", "fulfillment_id"]);
|
|
375
|
+
if (isOutcome(ready)) return ready;
|
|
376
|
+
const live = openLive(ctx, ready);
|
|
377
|
+
const path = queryOf("/payments", scopeQuery(ready));
|
|
378
|
+
const step = await live.storefront<unknown>("payments", "GET", path, [200]);
|
|
379
|
+
if (!step.ok) return fromLive(ctx.locale, step.result);
|
|
380
|
+
const rows = rowsOf(step.value).filter(isActive);
|
|
381
|
+
if (rows.length === 0) {
|
|
382
|
+
const channel = channelLabel(ready);
|
|
383
|
+
return say(ctx.locale, "fail", COPY.payments, { channel });
|
|
384
|
+
}
|
|
385
|
+
const files = await readSources(ctx.cwd);
|
|
386
|
+
for (const row of rows) {
|
|
387
|
+
const id = typeof row.id === "string" ? row.id : "";
|
|
388
|
+
const hit = burned(files, "payment_id", id);
|
|
389
|
+
if (hit) return say(ctx.locale, "fail", COPY.burned, hit);
|
|
390
|
+
}
|
|
391
|
+
return { status: "pass" };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const isGenId = (value: string) => {
|
|
395
|
+
if (value.length < 8 || value.length > 64) return false;
|
|
396
|
+
if (!/^[A-Za-z0-9-]+$/.test(value)) return false;
|
|
397
|
+
if (value.startsWith("-") || value.endsWith("-")) return false;
|
|
398
|
+
if (value.includes("--")) return false;
|
|
399
|
+
return GEN_RUN.test(value);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const hash12 = () => {
|
|
403
|
+
const bytes = randomBytes(12);
|
|
404
|
+
let out = "";
|
|
405
|
+
for (const byte of bytes) out += HASH[byte % HASH.length];
|
|
406
|
+
return out;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const newOrderId = (storeId: string) => {
|
|
410
|
+
const store = storeId.replace(/[^A-Za-z0-9]/g, "") || "nostore";
|
|
411
|
+
return `ord-${store}-${hash12()}`;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const ORDER_FIELDS = [
|
|
415
|
+
...LIVE,
|
|
416
|
+
"store_id",
|
|
417
|
+
"fulfillment_id",
|
|
418
|
+
"payment",
|
|
419
|
+
"merchant_id"
|
|
420
|
+
] as const;
|
|
421
|
+
|
|
422
|
+
const runSdk06 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
423
|
+
const skipped = namedOrderSkip(ctx, "SDK-06");
|
|
424
|
+
if (skipped) return skipped;
|
|
425
|
+
const ready = prepare(ctx, ORDER_FIELDS);
|
|
426
|
+
if (isOutcome(ready)) return ready;
|
|
427
|
+
if (ready.template === "pos") {
|
|
428
|
+
const id = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
|
|
429
|
+
if (!id) return missing(ctx, ready, "FIRESPARK_DOCTOR_ORDER_ID");
|
|
430
|
+
if (POS_RE.test(id) || isGenId(id)) return { status: "pass" };
|
|
431
|
+
return say(ctx.locale, "fail", COPY.posId, { id });
|
|
432
|
+
}
|
|
433
|
+
const live = openLive(ctx, ready);
|
|
434
|
+
const menus = await live.menus();
|
|
435
|
+
if (!menus.ok) return fromLive(ctx.locale, menus.result);
|
|
436
|
+
const picked = ctx.env.FIRESPARK_DOCTOR_PRODUCT_ID;
|
|
437
|
+
const product = pickProduct(menus.value, picked);
|
|
438
|
+
if (!product?.id) return missing(ctx, ready, "menus[].products");
|
|
439
|
+
const id = newOrderId(textOf(ready.store_id));
|
|
440
|
+
const menu = menus.value.find(item => item.products?.includes(product));
|
|
441
|
+
const config = liveConfigOf(ready, ctx.env);
|
|
442
|
+
const lineUid = randomUUID();
|
|
443
|
+
const menuId = menu?.id;
|
|
444
|
+
const b1 = { id, config, product, menuId, lineUid };
|
|
445
|
+
const body = buildOrderBody(b1);
|
|
446
|
+
const codes = [201, 202];
|
|
447
|
+
const args = ["order-create", "POST", "/orders", codes, body] as const;
|
|
448
|
+
const created = await live.storefront<{ id?: string }>(...args);
|
|
449
|
+
if (!created.ok) return fromLive(ctx.locale, created.result);
|
|
450
|
+
const actual = created.value.id ?? "";
|
|
451
|
+
const valid = actual === id && ORDER_RE.test(actual) && isGenId(actual);
|
|
452
|
+
if (valid) return { status: "pass" };
|
|
453
|
+
return say(ctx.locale, "fail", COPY.badId, { id: actual || id });
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const hitDemo = (body: string) => {
|
|
457
|
+
const lower = body.toLowerCase();
|
|
458
|
+
return DEMO.find(item => lower.includes(item.toLowerCase())) ?? "";
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const runSdk07 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
462
|
+
const ready = prepare(ctx, ["url"]);
|
|
463
|
+
if (isOutcome(ready)) return ready;
|
|
464
|
+
const files = await readSources(ctx.cwd);
|
|
465
|
+
for (const file of files) {
|
|
466
|
+
const code = CODE.test(file.rel) || file.rel.endsWith(".html");
|
|
467
|
+
if (!code) continue;
|
|
468
|
+
const hit = hitDemo(file.body);
|
|
469
|
+
if (!hit) continue;
|
|
470
|
+
return say(ctx.locale, "fail", COPY.demo, { hit, where: file.rel });
|
|
471
|
+
}
|
|
472
|
+
let page: { status: number; body: string };
|
|
473
|
+
try {
|
|
474
|
+
page = await readPage(textOf(ready.url));
|
|
475
|
+
} catch (error) {
|
|
476
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
477
|
+
return { status: "fail", detail };
|
|
478
|
+
}
|
|
479
|
+
if (page.status >= 400) {
|
|
480
|
+
const vars = { channel: channelLabel(ready), status: page.status };
|
|
481
|
+
return say(ctx.locale, "fail", COPY.page, vars);
|
|
482
|
+
}
|
|
483
|
+
const hit = hitDemo(page.body);
|
|
484
|
+
if (!hit) return { status: "pass" };
|
|
485
|
+
return say(ctx.locale, "fail", COPY.demo, { hit, where: "url" });
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const secretHit = (body: string) => {
|
|
489
|
+
return SECRETS.find(name => body.includes(name)) ?? "";
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const runSdk08 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
493
|
+
const ready = prepare(ctx, ["url"]);
|
|
494
|
+
if (isOutcome(ready)) return ready;
|
|
495
|
+
const files = await readSources(ctx.cwd);
|
|
496
|
+
for (const file of files) {
|
|
497
|
+
if (!PUBLIC_JS.test(file.rel)) continue;
|
|
498
|
+
const name = secretHit(file.body);
|
|
499
|
+
if (name) return say(ctx.locale, "fail", COPY.secret, { name });
|
|
500
|
+
}
|
|
501
|
+
let page: { status: number; body: string };
|
|
502
|
+
try {
|
|
503
|
+
page = await readPage(textOf(ready.url));
|
|
504
|
+
} catch (error) {
|
|
505
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
506
|
+
return { status: "fail", detail };
|
|
507
|
+
}
|
|
508
|
+
if (page.status >= 400) {
|
|
509
|
+
const vars = { channel: channelLabel(ready), status: page.status };
|
|
510
|
+
return say(ctx.locale, "fail", COPY.page, vars);
|
|
511
|
+
}
|
|
512
|
+
const name = secretHit(page.body);
|
|
513
|
+
if (name) return say(ctx.locale, "fail", COPY.secret, { name });
|
|
514
|
+
return { status: "pass" };
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const localJs = (
|
|
518
|
+
files: SourceFile[],
|
|
519
|
+
channel: string,
|
|
520
|
+
locale: RunCtx["locale"]
|
|
521
|
+
) => {
|
|
522
|
+
for (const file of files) {
|
|
523
|
+
if (!PUBLIC_JS.test(file.rel)) continue;
|
|
524
|
+
const local = file.body.includes("localhost");
|
|
525
|
+
const loop = file.body.includes("127.0.0.1");
|
|
526
|
+
if (!local && !loop) continue;
|
|
527
|
+
const vars = { channel, file: file.rel };
|
|
528
|
+
return say(locale, "fail", COPY.localBundle, vars);
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const runSdk09 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
534
|
+
const ready = prepare(ctx, [...LIVE, "url"]);
|
|
535
|
+
if (isOutcome(ready)) return ready;
|
|
536
|
+
const api = textOf(ready.api_url);
|
|
537
|
+
if (isLocalHost(api)) {
|
|
538
|
+
const channel = channelLabel(ready);
|
|
539
|
+
return say(ctx.locale, "fail", COPY.localApi, { channel });
|
|
540
|
+
}
|
|
541
|
+
const files = await readSources(ctx.cwd);
|
|
542
|
+
const channel = channelLabel(ready);
|
|
543
|
+
const local = localJs(files, channel, ctx.locale);
|
|
544
|
+
if (local) return local;
|
|
545
|
+
const live = openLive(ctx, ready);
|
|
546
|
+
const hop = "channels";
|
|
547
|
+
const listed = await live.storefront<unknown>(hop, "GET", "/channels", [200]);
|
|
548
|
+
if (!listed.ok) return fromLive(ctx.locale, listed.result);
|
|
549
|
+
const id = textOf(ready.channel_id);
|
|
550
|
+
const rows = rowsOf(listed.value);
|
|
551
|
+
const mine = rows.find(row => row.id === id || row.channel_id === id);
|
|
552
|
+
if (!mine || !isActive(mine)) {
|
|
553
|
+
return say(ctx.locale, "fail", COPY.channelOff, { id });
|
|
554
|
+
}
|
|
555
|
+
const query = queryOf("/menus", { channel_id: "doctor-missing-channel" });
|
|
556
|
+
const codes = [200, 400, 404];
|
|
557
|
+
const probe = await live.probe("invalid-channel", "GET", query, codes);
|
|
558
|
+
if (!probe.ok) return fromLive(ctx.locale, probe.result);
|
|
559
|
+
if (probe.value.status !== 200) return { status: "pass" };
|
|
560
|
+
const data = dataOf<unknown>(probe.value);
|
|
561
|
+
const menus = rowsOf(data);
|
|
562
|
+
const products = menus.flatMap(row => {
|
|
563
|
+
return Array.isArray(row.products) ? row.products : [];
|
|
564
|
+
});
|
|
565
|
+
if (products.length > 0) return say(ctx.locale, "fail", COPY.leakedMenu);
|
|
566
|
+
return say(ctx.locale, "fail", COPY.emptyMenu);
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const LIBS: Record<string, string> = {
|
|
570
|
+
app: "@fire-spark/rn-ui",
|
|
571
|
+
kiosk: "@fire-spark/rn-ui",
|
|
572
|
+
pos: "@fire-spark/rn-ui",
|
|
573
|
+
web: "@fire-spark/react-ui",
|
|
574
|
+
chat: "@fire-spark/react-ui",
|
|
575
|
+
"call-center": "@fire-spark/react-ui"
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const SURFACES = [
|
|
579
|
+
{ id: "menu", test: (rel: string) => rel.toLowerCase().includes("/menu/") },
|
|
580
|
+
{
|
|
581
|
+
id: "product",
|
|
582
|
+
test: (rel: string) => rel.toLowerCase().includes("product")
|
|
583
|
+
},
|
|
584
|
+
{ id: "cart", test: (rel: string) => rel.toLowerCase().includes("/cart/") },
|
|
585
|
+
{
|
|
586
|
+
id: "checkout",
|
|
587
|
+
test: (rel: string) => rel.toLowerCase().includes("/checkout/")
|
|
588
|
+
}
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
const runSdk10 = async (ctx: RunCtx): Promise<Outcome> => {
|
|
592
|
+
const rows = await identities(ctx.cwd);
|
|
593
|
+
if (isOutcome(rows)) return rows;
|
|
594
|
+
if (rows.length === 0) return say(ctx.locale, "fail", COPY.noManifest);
|
|
595
|
+
const ui = rows.filter(row => LIBS[row.template]);
|
|
596
|
+
if (ui.length === 0) return say(ctx.locale, "skip", COPY.serverSkip);
|
|
597
|
+
const files = await readSources(ctx.cwd);
|
|
598
|
+
for (const row of ui) {
|
|
599
|
+
const lib = LIBS[row.template] ?? "";
|
|
600
|
+
const prefix = `${row.appDir}/`;
|
|
601
|
+
if (!declaresDep(files, prefix, lib)) {
|
|
602
|
+
return say(ctx.locale, "fail", COPY.noLib, { file: prefix, lib });
|
|
603
|
+
}
|
|
604
|
+
const mine = files.filter(file => {
|
|
605
|
+
return file.rel.startsWith(prefix) && CODE.test(file.rel);
|
|
606
|
+
});
|
|
607
|
+
for (const surface of SURFACES) {
|
|
608
|
+
const group = mine.filter(file => surface.test(file.rel));
|
|
609
|
+
const imported = group.some(file => importsLib(file.body, lib));
|
|
610
|
+
if (group.length > 0 && imported) continue;
|
|
611
|
+
const channel = row.name || row.template;
|
|
612
|
+
const vars = { surface: surface.id, channel, lib };
|
|
613
|
+
return say(ctx.locale, "fail", COPY.surface, vars);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { status: "pass" };
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
export const SDK_BINDINGS = {
|
|
620
|
+
"SDK-01": withSafeFix(runSdk01),
|
|
621
|
+
"SDK-02": withSafeFix(runSdk02),
|
|
622
|
+
"SDK-03": withSafeFix(runSdk03),
|
|
623
|
+
"SDK-04": withSafeFix(runSdk04),
|
|
624
|
+
"SDK-05": withSafeFix(runSdk05),
|
|
625
|
+
"SDK-06": withSafeFix(runSdk06),
|
|
626
|
+
"SDK-07": withSafeFix(runSdk07),
|
|
627
|
+
"SDK-08": withSafeFix(runSdk08),
|
|
628
|
+
"SDK-09": withSafeFix(runSdk09),
|
|
629
|
+
"SDK-10": withSafeFix(runSdk10)
|
|
630
|
+
};
|