@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,261 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { Locale } from "../../../core/i18n";
|
|
4
|
+
import { translate } from "../../../core/i18n";
|
|
5
|
+
import type { Redactor } from "../../../core/redact";
|
|
6
|
+
import type { FetchLike } from "../live/http";
|
|
7
|
+
import type { LiveConfig } from "../live/live.session";
|
|
8
|
+
import { createLiveSession } from "../live/live.session";
|
|
9
|
+
import type { RealtimeToken } from "../live/live.session";
|
|
10
|
+
import { buildOrderBody, doctorOrderId, pickProduct } from "../live/order";
|
|
11
|
+
import type { OrderChanged, WebSocketFactory } from "../live/realtime";
|
|
12
|
+
import { subscribe } from "../live/realtime";
|
|
13
|
+
import type { Subscription } from "../live/realtime";
|
|
14
|
+
import { REASONS, fail } from "../live/result";
|
|
15
|
+
import type { CheckFail, CheckSkip } from "../live/result";
|
|
16
|
+
import { channelLabel, paymentOf } from "./config";
|
|
17
|
+
import { COPY } from "./copy";
|
|
18
|
+
import type { YamlChannel } from "./yaml";
|
|
19
|
+
|
|
20
|
+
/** Sheet check: place an order on the channel. Not a CLI flag. */
|
|
21
|
+
export const ORDER_CHECK_ID = "ORD-01";
|
|
22
|
+
|
|
23
|
+
const PLACE_STATUSES = ["ACCEPTED", "IN_PROGRESS", "EXISTS"];
|
|
24
|
+
const AUTH_URL = "https://identitytoolkit.googleapis.com";
|
|
25
|
+
const HTTP_TIMEOUT_MS = 15000;
|
|
26
|
+
const RELEASE_TIMEOUT_MS = 120000;
|
|
27
|
+
|
|
28
|
+
const nativeSocket = (): WebSocketFactory | undefined => {
|
|
29
|
+
const Socket = globalThis.WebSocket;
|
|
30
|
+
if (!Socket) return undefined;
|
|
31
|
+
return url => new Socket(url) as unknown as ReturnType<WebSocketFactory>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface OrderInput {
|
|
35
|
+
locale: Locale;
|
|
36
|
+
env: NodeJS.ProcessEnv;
|
|
37
|
+
channels: YamlChannel[];
|
|
38
|
+
channelId?: string;
|
|
39
|
+
redactor: Redactor;
|
|
40
|
+
fetch?: FetchLike;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const textOf = (value: string | null | undefined) => {
|
|
44
|
+
return value?.trim() ?? "";
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** First missing field the test order needs. Empty string means ready. */
|
|
48
|
+
export const orderFieldGap = (channel: YamlChannel, env: NodeJS.ProcessEnv) => {
|
|
49
|
+
const payment = channel.payment ? paymentOf(channel.payment) : null;
|
|
50
|
+
if (!payment) return "payment";
|
|
51
|
+
if (!textOf(channel.merchant_id)) return "merchant_id";
|
|
52
|
+
if (!textOf(channel.api_url)) return "api_url";
|
|
53
|
+
if (!textOf(channel.store_id)) return "store_id";
|
|
54
|
+
if (!textOf(channel.channel_id)) return "channel_id";
|
|
55
|
+
if (!textOf(channel.fulfillment_id)) return "fulfillment_id";
|
|
56
|
+
const email = textOf(env.FIRESPARK_DOCTOR_CUSTOMER_EMAIL);
|
|
57
|
+
if (!email) return "FIRESPARK_DOCTOR_CUSTOMER_EMAIL";
|
|
58
|
+
const password = textOf(env.FIRESPARK_DOCTOR_CUSTOMER_PASSWORD);
|
|
59
|
+
if (!password) return "FIRESPARK_DOCTOR_CUSTOMER_PASSWORD";
|
|
60
|
+
const key = textOf(env.FIRESPARK_DOCTOR_FIREBASE_API_KEY);
|
|
61
|
+
if (!key) return "FIRESPARK_DOCTOR_FIREBASE_API_KEY";
|
|
62
|
+
return "";
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const channelOf = (channels: YamlChannel[], channelId: string | undefined) => {
|
|
66
|
+
if (channelId) {
|
|
67
|
+
return channels.find(channel => channel.channel_id === channelId) ?? null;
|
|
68
|
+
}
|
|
69
|
+
if (channels.length !== 1) return null;
|
|
70
|
+
return channels[0] ?? null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const missing = (locale: Locale, channel: string, field: string) => {
|
|
74
|
+
const vars = { channel, field };
|
|
75
|
+
const detail = translate(locale, COPY.orderMissing, vars);
|
|
76
|
+
return { status: "fail" as const, detail };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const detailOf = (locale: Locale, result: CheckFail | CheckSkip) => {
|
|
80
|
+
const message = translate(locale, result.reason.copy, result.reason.vars);
|
|
81
|
+
if (result.status === "fail") return `${result.hop}: ${message}`;
|
|
82
|
+
return message;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const msOf = (value: string | undefined, fallback: number) => {
|
|
86
|
+
const parsed = Number(value);
|
|
87
|
+
if (!value || !Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
88
|
+
return parsed;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const liveConfigOf = (channel: YamlChannel, env: NodeJS.ProcessEnv) => {
|
|
92
|
+
const apiUrl = textOf(channel.api_url);
|
|
93
|
+
const firebaseApiKey = textOf(env.FIRESPARK_DOCTOR_FIREBASE_API_KEY);
|
|
94
|
+
const firebaseAuthUrl = textOf(env.FIRESPARK_DOCTOR_FIREBASE_AUTH_URL);
|
|
95
|
+
const customerEmail = textOf(env.FIRESPARK_DOCTOR_CUSTOMER_EMAIL);
|
|
96
|
+
const customerPassword = textOf(env.FIRESPARK_DOCTOR_CUSTOMER_PASSWORD);
|
|
97
|
+
const storeId = textOf(channel.store_id);
|
|
98
|
+
const channelId = textOf(channel.channel_id);
|
|
99
|
+
const fulfillmentId = textOf(channel.fulfillment_id);
|
|
100
|
+
const rawCurrency = textOf(env.FIRESPARK_DOCTOR_CURRENCY) || "USD";
|
|
101
|
+
const currency = rawCurrency.toUpperCase();
|
|
102
|
+
const httpName = env.FIRESPARK_DOCTOR_HTTP_TIMEOUT_MS;
|
|
103
|
+
const httpTimeoutMs = msOf(httpName, HTTP_TIMEOUT_MS);
|
|
104
|
+
const b1 = { apiUrl, firebaseApiKey };
|
|
105
|
+
const b2 = { firebaseAuthUrl: firebaseAuthUrl || AUTH_URL };
|
|
106
|
+
const b3 = { customerEmail, customerPassword, storeId, channelId };
|
|
107
|
+
const b4 = { fulfillmentId, currency, httpTimeoutMs };
|
|
108
|
+
const config: LiveConfig = { ...b1, ...b2, ...b3, ...b4 };
|
|
109
|
+
return config;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const placeBodyOf = () => {
|
|
113
|
+
const payment = { type: "CASH" };
|
|
114
|
+
return { payment };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const tokenGap = (token: RealtimeToken) => {
|
|
118
|
+
const fields = ["url", "key", "customer_topic", "access_token"] as const;
|
|
119
|
+
return fields.find(field => !token[field]);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const settled = (id: string, event: OrderChanged) => {
|
|
123
|
+
if (event.order_id !== id) return false;
|
|
124
|
+
if (event.checkout_state === "released") return true;
|
|
125
|
+
return event.checkout_state === "failed";
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const joinFail = (locale: Locale, error: unknown) => {
|
|
129
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
130
|
+
const vars = { target: "realtime", detail };
|
|
131
|
+
const reason = fail("realtime-join", REASONS.network, vars);
|
|
132
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const releaseOf = (
|
|
136
|
+
locale: Locale,
|
|
137
|
+
event: OrderChanged | undefined,
|
|
138
|
+
seconds: number
|
|
139
|
+
) => {
|
|
140
|
+
if (event?.checkout_state === "released") {
|
|
141
|
+
return { status: "pass" as const };
|
|
142
|
+
}
|
|
143
|
+
if (event?.checkout_state === "failed") {
|
|
144
|
+
const detail = event.error?.message?.trim() || "failed";
|
|
145
|
+
const reason = fail("checkout-release", REASONS.checkoutFailed, { detail });
|
|
146
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
147
|
+
}
|
|
148
|
+
const last = event?.checkout_state || "—";
|
|
149
|
+
const vars = { seconds, last };
|
|
150
|
+
const reason = fail("checkout-release", REASONS.timeout, vars);
|
|
151
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Cash and sandbox both place a cash tender. Anything else never places. */
|
|
155
|
+
export const runOrder = async (input: OrderInput) => {
|
|
156
|
+
const { locale, env, channels, channelId } = input;
|
|
157
|
+
const chosen = channelOf(channels, channelId);
|
|
158
|
+
if (!chosen) {
|
|
159
|
+
const detail = translate(locale, COPY.orderNoChannel);
|
|
160
|
+
return { status: "fail" as const, detail };
|
|
161
|
+
}
|
|
162
|
+
const field = orderFieldGap(chosen, env);
|
|
163
|
+
if (field) return missing(locale, channelLabel(chosen), field);
|
|
164
|
+
const config = liveConfigOf(chosen, env);
|
|
165
|
+
const fetchImpl = input.fetch ?? fetch;
|
|
166
|
+
const deps = { fetch: fetchImpl };
|
|
167
|
+
const live = createLiveSession(config, deps, input.redactor);
|
|
168
|
+
const menus = await live.menus();
|
|
169
|
+
if (!menus.ok) {
|
|
170
|
+
const detail = detailOf(locale, menus.result);
|
|
171
|
+
return { status: "fail" as const, detail };
|
|
172
|
+
}
|
|
173
|
+
const product = pickProduct(menus.value, env.FIRESPARK_DOCTOR_PRODUCT_ID);
|
|
174
|
+
if (!product?.id) {
|
|
175
|
+
const vars = { what: "menus[].products" };
|
|
176
|
+
const reason = fail("menu-fetch", REASONS.emptyList, vars);
|
|
177
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
178
|
+
}
|
|
179
|
+
const id = doctorOrderId();
|
|
180
|
+
const menu = menus.value.find(item => item.products?.includes(product));
|
|
181
|
+
const lineUid = randomUUID();
|
|
182
|
+
const menuId = menu?.id;
|
|
183
|
+
const bodyArgs = { id, config, product, menuId, lineUid };
|
|
184
|
+
const built = buildOrderBody(bodyArgs);
|
|
185
|
+
const codes = [201, 202];
|
|
186
|
+
const createArgs = ["order-create", "POST", "/orders", codes, built] as const;
|
|
187
|
+
const created = await live.storefront<{ id?: string }>(...createArgs);
|
|
188
|
+
if (!created.ok) {
|
|
189
|
+
const detail = detailOf(locale, created.result);
|
|
190
|
+
return { status: "fail" as const, detail };
|
|
191
|
+
}
|
|
192
|
+
if (created.value.id !== id) {
|
|
193
|
+
const vars = { expected: id, actual: String(created.value.id) };
|
|
194
|
+
const reason = fail("order-create", REASONS.mismatch, vars);
|
|
195
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
196
|
+
}
|
|
197
|
+
const token = await live.realtimeToken();
|
|
198
|
+
if (!token.ok) {
|
|
199
|
+
const detail = detailOf(locale, token.result);
|
|
200
|
+
return { status: "fail" as const, detail };
|
|
201
|
+
}
|
|
202
|
+
const createSocket = nativeSocket();
|
|
203
|
+
if (!createSocket) {
|
|
204
|
+
const field = "WebSocket";
|
|
205
|
+
const reason = fail("realtime-join", REASONS.badShape, { field });
|
|
206
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
207
|
+
}
|
|
208
|
+
const absent = tokenGap(token.value);
|
|
209
|
+
if (absent) {
|
|
210
|
+
const reason = fail("realtime-token", REASONS.badShape, { field: absent });
|
|
211
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
212
|
+
}
|
|
213
|
+
const url = token.value.url ?? "";
|
|
214
|
+
const key = token.value.key ?? "";
|
|
215
|
+
const topic = token.value.customer_topic ?? "";
|
|
216
|
+
const accessToken = token.value.access_token ?? "";
|
|
217
|
+
const joinTimeoutMs = config.httpTimeoutMs;
|
|
218
|
+
const b1 = { url, key, topic, accessToken };
|
|
219
|
+
const b2 = { joinTimeoutMs, createSocket };
|
|
220
|
+
const subscription = subscribe({ ...b1, ...b2 });
|
|
221
|
+
const joined = await openChannel(locale, subscription);
|
|
222
|
+
if (joined.status === "fail") return joined;
|
|
223
|
+
const placePath = `/orders/${encodeURIComponent(id)}/place`;
|
|
224
|
+
const pay = placeBodyOf();
|
|
225
|
+
const placeArgs = ["order-place", "POST", placePath, codes, pay] as const;
|
|
226
|
+
const placed = await live.storefront<{ status?: string }>(...placeArgs);
|
|
227
|
+
if (!placed.ok) {
|
|
228
|
+
subscription.close();
|
|
229
|
+
const detail = detailOf(locale, placed.result);
|
|
230
|
+
return { status: "fail" as const, detail };
|
|
231
|
+
}
|
|
232
|
+
const status = placed.value.status ?? "";
|
|
233
|
+
if (!PLACE_STATUSES.includes(status)) {
|
|
234
|
+
subscription.close();
|
|
235
|
+
const vars = { expected: PLACE_STATUSES.join(" | "), actual: status };
|
|
236
|
+
const reason = fail("order-place", REASONS.mismatch, vars);
|
|
237
|
+
return { status: "fail" as const, detail: detailOf(locale, reason) };
|
|
238
|
+
}
|
|
239
|
+
const timeoutMs = msOf(env.FIRESPARK_DOCTOR_TIMEOUT_MS, RELEASE_TIMEOUT_MS);
|
|
240
|
+
const match = (item: OrderChanged) => settled(id, item);
|
|
241
|
+
const event = await subscription.waitFor(match, timeoutMs);
|
|
242
|
+
subscription.close();
|
|
243
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
244
|
+
return releaseOf(locale, event, seconds);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const openChannel = async (locale: Locale, subscription: Subscription) => {
|
|
248
|
+
try {
|
|
249
|
+
await subscription.joined;
|
|
250
|
+
return { status: "pass" as const };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
subscription.close();
|
|
253
|
+
return joinFail(locale, error);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** `--fix` must not invent ids or place an order. */
|
|
258
|
+
export const fixOrder = (locale: Locale) => {
|
|
259
|
+
const detail = translate(locale, COPY.orderNoFix);
|
|
260
|
+
return { status: "fail" as const, detail };
|
|
261
|
+
};
|