@fire-spark/cli 0.0.0-stage → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ import { access, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, resolve } from "node:path";
3
+
4
+ import type { Locale } from "../../../core/i18n";
5
+ import { translate } from "../../../core/i18n";
6
+ import { UsageError, readString } from "../../../core/options";
7
+ import type { OptionValues } from "../../../core/options";
8
+ import { COPY } from "./copy";
9
+ import { identityFromLegacy, identityOf, listManifests } from "./manifests";
10
+ import { emptyChannel, parseDoctorYaml, writeDoctorYaml } from "./yaml";
11
+ import type { DoctorFile, YamlChannel } from "./yaml";
12
+
13
+ export interface TargetFlags {
14
+ url?: string;
15
+ dashboardUrl?: string;
16
+ apiUrl?: string;
17
+ storeId?: string;
18
+ fulfillmentId?: string;
19
+ payment?: string;
20
+ receiver?: string;
21
+ evidence?: string;
22
+ envName?: string;
23
+ merchantId?: string;
24
+ channelId?: string;
25
+ deployed: boolean;
26
+ }
27
+
28
+ const URL_FLAGS = ["url", "dashboard-url", "api-url", "receiver"] as const;
29
+
30
+ export const REQUIRED_FIELDS = [
31
+ "url",
32
+ "dashboard_url",
33
+ "api_url",
34
+ "store_id",
35
+ "fulfillment_id",
36
+ "payment",
37
+ "receiver",
38
+ "evidence"
39
+ ] as const;
40
+
41
+ export type RequiredField = (typeof REQUIRED_FIELDS)[number];
42
+
43
+ export const CREDENTIAL_ENV = [
44
+ "FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
45
+ "FIRESPARK_DOCTOR_CUSTOMER_PASSWORD"
46
+ ] as const;
47
+
48
+ const httpUrl = (value: string) => /^https?:\/\//i.test(value);
49
+
50
+ export const paymentOf = (value: string) => {
51
+ const text = value.trim().toLowerCase();
52
+ if (text === "cash" || text === "efectivo") return "cash";
53
+ if (text === "sandbox") return "sandbox";
54
+ return null;
55
+ };
56
+
57
+ export const readTargets = (values: OptionValues, locale: Locale) => {
58
+ const raw = (name: string) => readString(values, name);
59
+ for (const flag of URL_FLAGS) {
60
+ const value = raw(flag);
61
+ if (value && !httpUrl(value)) {
62
+ throw new UsageError(translate(locale, COPY.badUrl, { flag }));
63
+ }
64
+ }
65
+ const paymentText = raw("payment");
66
+ const payment = paymentText ? paymentOf(paymentText) : undefined;
67
+ if (paymentText && !payment) {
68
+ throw new UsageError(translate(locale, COPY.badPayment));
69
+ }
70
+ const flags: TargetFlags = {
71
+ url: raw("url"),
72
+ dashboardUrl: raw("dashboard-url"),
73
+ apiUrl: raw("api-url"),
74
+ storeId: raw("store-id"),
75
+ fulfillmentId: raw("fulfillment-id"),
76
+ payment: payment ?? undefined,
77
+ receiver: raw("receiver"),
78
+ evidence: raw("evidence"),
79
+ envName: raw("env"),
80
+ merchantId: raw("merchant-id"),
81
+ channelId: raw("channel-id"),
82
+ deployed: values.deployed === true
83
+ };
84
+ return flags;
85
+ };
86
+
87
+ const hasTarget = (flags: TargetFlags) => {
88
+ const { deployed, channelId, ...rest } = flags;
89
+ void channelId;
90
+ if (deployed) return true;
91
+ return Object.values(rest).some(value => value !== undefined);
92
+ };
93
+
94
+ const fill = (channel: YamlChannel, flags: TargetFlags): YamlChannel => {
95
+ const next: YamlChannel = { ...channel };
96
+ if (flags.url) next.url = flags.url;
97
+ if (flags.dashboardUrl) next.dashboard_url = flags.dashboardUrl;
98
+ if (flags.apiUrl) next.api_url = flags.apiUrl;
99
+ if (flags.storeId) next.store_id = flags.storeId;
100
+ if (flags.fulfillmentId) next.fulfillment_id = flags.fulfillmentId;
101
+ if (flags.payment) next.payment = flags.payment;
102
+ if (flags.receiver) next.receiver = flags.receiver;
103
+ if (flags.evidence) next.evidence = flags.evidence;
104
+ if (flags.envName) next.env = flags.envName;
105
+ if (flags.merchantId) next.merchant_id = flags.merchantId;
106
+ if (flags.deployed) next.deployed = true;
107
+ if (flags.channelId && channel.channel_id === flags.channelId) {
108
+ next.channel_id = flags.channelId;
109
+ }
110
+ return next;
111
+ };
112
+
113
+ export const applyTargets = (
114
+ channels: YamlChannel[],
115
+ flags: TargetFlags,
116
+ locale: Locale
117
+ ) => {
118
+ if (!hasTarget(flags)) return channels;
119
+ if (flags.channelId) {
120
+ const index = channels.findIndex(c => c.channel_id === flags.channelId);
121
+ if (index < 0) {
122
+ const id = flags.channelId;
123
+ throw new UsageError(translate(locale, COPY.unknownChannel, { id }));
124
+ }
125
+ return channels.map((channel, i) => {
126
+ return i === index ? fill(channel, flags) : channel;
127
+ });
128
+ }
129
+ if (channels.length !== 1) {
130
+ throw new UsageError(translate(locale, COPY.ambiguous));
131
+ }
132
+ const only = channels[0];
133
+ if (!only) return channels;
134
+ return [fill(only, flags)];
135
+ };
136
+
137
+ export const channelLabel = (channel: YamlChannel) => {
138
+ if (channel.name.trim()) return channel.name.trim();
139
+ if (channel.channel_id) return channel.channel_id;
140
+ return channel.template || "channel";
141
+ };
142
+
143
+ const missingField = (channel: YamlChannel): RequiredField | null => {
144
+ for (const field of REQUIRED_FIELDS) {
145
+ if (field === "payment") continue;
146
+ const value = channel[field];
147
+ if (typeof value !== "string" || value.trim() === "") return field;
148
+ }
149
+ const payment = channel.payment ? paymentOf(channel.payment) : null;
150
+ if (!payment) return "payment";
151
+ return null;
152
+ };
153
+
154
+ export interface PreflightGap {
155
+ channel: string;
156
+ field: string;
157
+ }
158
+
159
+ export const preflightGap = (
160
+ channels: YamlChannel[],
161
+ env: NodeJS.ProcessEnv
162
+ ): PreflightGap | null => {
163
+ for (const channel of channels) {
164
+ if (!channel.deployed) continue;
165
+ const field = missingField(channel);
166
+ if (field) return { channel: channelLabel(channel), field };
167
+ for (const name of CREDENTIAL_ENV) {
168
+ const value = env[name];
169
+ if (!value || !value.trim()) {
170
+ return { channel: channelLabel(channel), field: name };
171
+ }
172
+ }
173
+ }
174
+ return null;
175
+ };
176
+
177
+ export const fileExists = async (path: string) => {
178
+ try {
179
+ await access(path);
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ };
185
+
186
+ export const resolveConfigPath = async (
187
+ cwd: string,
188
+ flag: string | undefined,
189
+ locale: Locale
190
+ ) => {
191
+ if (flag) return isAbsolute(flag) ? flag : resolve(cwd, flag);
192
+ let names: string[] = [];
193
+ try {
194
+ names = await readdir(cwd);
195
+ } catch {
196
+ names = [];
197
+ }
198
+ const found = names.filter(name => name.endsWith(".doctor.yml")).sort();
199
+ if (found.length > 1) {
200
+ const files = found.join(", ");
201
+ throw new UsageError(translate(locale, COPY.manyConfigs, { files }));
202
+ }
203
+ const only = found[0];
204
+ if (only) return resolve(cwd, only);
205
+ return resolve(cwd, ".firespark.doctor.yml");
206
+ };
207
+
208
+ export const readConfig = async (path: string, locale: Locale) => {
209
+ const body = await readFile(path, "utf8");
210
+ return parseDoctorYaml(body, path, locale);
211
+ };
212
+
213
+ export const writeConfig = async (path: string, file: DoctorFile) => {
214
+ await writeFile(path, writeDoctorYaml(file), "utf8");
215
+ };
216
+
217
+ const YES = new Set(["si", "sí", "yes", "y", "s", "sim"]);
218
+ const NO = new Set(["no", "n", "nao", "não"]);
219
+
220
+ const foldAnswer = (value: string) => value.trim().toLowerCase();
221
+
222
+ export interface Asker {
223
+ ask: (question: string) => Promise<string>;
224
+ interactive: boolean;
225
+ }
226
+
227
+ const askYes = async (channel: YamlChannel, locale: Locale, asker: Asker) => {
228
+ const labelText = channelLabel(channel);
229
+ const question = translate(locale, COPY.deployedAsk, { channel: labelText });
230
+ for (let attempt = 0; attempt < 3; attempt += 1) {
231
+ const answer = foldAnswer(await asker.ask(question));
232
+ if (YES.has(answer)) return true;
233
+ if (NO.has(answer)) return false;
234
+ }
235
+ const vars = { channel: labelText };
236
+ throw new UsageError(translate(locale, COPY.answerYesNo, vars));
237
+ };
238
+
239
+ const ASK: Record<RequiredField, (typeof COPY)[keyof typeof COPY]> = {
240
+ url: COPY.askUrl,
241
+ dashboard_url: COPY.askDashboard,
242
+ api_url: COPY.askApi,
243
+ store_id: COPY.askStore,
244
+ fulfillment_id: COPY.askFulfillment,
245
+ payment: COPY.askPayment,
246
+ receiver: COPY.askReceiver,
247
+ evidence: COPY.askEvidence
248
+ };
249
+
250
+ const askField = async (
251
+ channel: YamlChannel,
252
+ field: RequiredField,
253
+ locale: Locale,
254
+ asker: Asker
255
+ ) => {
256
+ const labelText = channelLabel(channel);
257
+ const question = translate(locale, ASK[field], { channel: labelText });
258
+ const answer = (await asker.ask(question)).trim();
259
+ if (!answer) return null;
260
+ if (field === "payment") {
261
+ const payment = paymentOf(answer);
262
+ if (!payment)
263
+ throw new UsageError(translate(locale, COPY.badAnswerPayment));
264
+ return payment;
265
+ }
266
+ if (field === "url" || field === "dashboard_url" || field === "api_url") {
267
+ if (!httpUrl(answer)) {
268
+ throw new UsageError(translate(locale, COPY.badUrl, { flag: field }));
269
+ }
270
+ }
271
+ if (field === "receiver" && !httpUrl(answer)) {
272
+ throw new UsageError(translate(locale, COPY.badUrl, { flag: "receiver" }));
273
+ }
274
+ return answer;
275
+ };
276
+
277
+ const promptChannel = async (
278
+ channel: YamlChannel,
279
+ flags: TargetFlags,
280
+ locale: Locale,
281
+ asker: Asker,
282
+ askDeployed: boolean
283
+ ) => {
284
+ const next: YamlChannel = { ...channel };
285
+ const marked = flags.deployed && flags.channelId === channel.channel_id;
286
+ const sole = flags.deployed && !flags.channelId;
287
+ if (askDeployed && !next.deployed && asker.interactive && !marked && !sole) {
288
+ next.deployed = await askYes(next, locale, asker);
289
+ }
290
+ if (!next.deployed || !asker.interactive) return next;
291
+ for (const field of REQUIRED_FIELDS) {
292
+ const value = next[field];
293
+ if (typeof value === "string" && value.trim()) continue;
294
+ const answer = await askField(next, field, locale, asker);
295
+ if (answer) next[field] = answer;
296
+ }
297
+ return next;
298
+ };
299
+
300
+ export const promptAll = async (
301
+ channels: YamlChannel[],
302
+ flags: TargetFlags,
303
+ locale: Locale,
304
+ asker: Asker,
305
+ askDeployed: boolean
306
+ ) => {
307
+ const next: YamlChannel[] = [];
308
+ for (const channel of channels) {
309
+ const args = [channel, flags, locale, asker, askDeployed] as const;
310
+ const filled = await promptChannel(...args);
311
+ next.push(filled);
312
+ }
313
+ return next;
314
+ };
315
+
316
+ export const identitiesFromRepo = async (root: string) => {
317
+ const files = await listManifests(root);
318
+ const channels: YamlChannel[] = [];
319
+ for (const file of files) {
320
+ const read = file.kind === "json" ? identityOf : identityFromLegacy;
321
+ const channel = read(file.body);
322
+ if (channel) channels.push(channel);
323
+ }
324
+ return channels;
325
+ };
326
+
327
+ export const createConfig = async (
328
+ root: string,
329
+ flags: TargetFlags,
330
+ locale: Locale,
331
+ asker: Asker
332
+ ) => {
333
+ const seeded = await identitiesFromRepo(root);
334
+ let seed = seeded;
335
+ const empty = seeded.length === 0;
336
+ const targeted = hasTarget(flags) || flags.channelId;
337
+ const needsBlank = empty && targeted;
338
+ if (needsBlank) {
339
+ const blank = emptyChannel();
340
+ if (flags.channelId) blank.channel_id = flags.channelId;
341
+ seed = [blank];
342
+ }
343
+ const withFlags = applyTargets(seed, flags, locale);
344
+ const channels = await promptAll(withFlags, flags, locale, asker, true);
345
+ return { channels } satisfies DoctorFile;
346
+ };
@@ -0,0 +1,228 @@
1
+ import type { Copy } from "../../../core/i18n";
2
+
3
+ const text = (es: string, en: string, pt: string): Copy => {
4
+ return { "es-EC": es, "en-US": en, "pt-BR": pt };
5
+ };
6
+
7
+ export const DOCS_PAGE: Copy = {
8
+ "es-EC": "https://firespark.cloud/docs/es/guides/doctor",
9
+ "en-US": "https://firespark.cloud/docs/guides/doctor",
10
+ "pt-BR": "https://firespark.cloud/docs/guides/doctor"
11
+ };
12
+
13
+ export const SUMMARY = text(
14
+ "Revisa el FireSpark ya desplegado de un merchant.",
15
+ "Checks a merchant's already deployed FireSpark.",
16
+ "Verifica o FireSpark já implantado de um merchant."
17
+ );
18
+
19
+ export const USAGE =
20
+ "firespark doctor <run|list|show|init|docs> [ID ...] [options]";
21
+
22
+ const detailsEs = [
23
+ "Comandos: run [ID ...], list, show <ID>, init [--config <archivo>], docs.",
24
+ "run ejecuta los checks contra el FireSpark ya desplegado. Sin --fix solo informa.",
25
+ "Si falta el YAML, se crea aquí con init y se pregunta solo lo que falte.",
26
+ "Un canal que nunca se desplegó puede quedar solo con su identidad.",
27
+ "ORD-01 crea una orden de prueba solo si lo nombras y el pago es cash o sandbox. Antes de place se une al canal realtime y espera a que el checkout se libere.",
28
+ `La documentación está aquí: ${DOCS_PAGE["es-EC"]}`
29
+ ].join("\n");
30
+
31
+ const detailsEn = [
32
+ "Commands: run [ID ...], list, show <ID>, init [--config <file>], docs.",
33
+ "run checks an already deployed FireSpark. Without --fix it only reports.",
34
+ "If the YAML is missing, init creates it here and asks only for what is missing.",
35
+ "A channel that was never deployed can keep identity only.",
36
+ "ORD-01 places a test order only when you name it and payment is cash or sandbox. It joins the realtime channel before place and waits until checkout is released.",
37
+ `Documentation: ${DOCS_PAGE["en-US"]}`
38
+ ].join("\n");
39
+
40
+ const detailsPt = [
41
+ "Comandos: run [ID ...], list, show <ID>, init [--config <arquivo>], docs.",
42
+ "run verifica o FireSpark já implantado. Sem --fix, só informa.",
43
+ "Se o YAML não existe, o init cria o arquivo aqui e pergunta só o que falta.",
44
+ "Um canal que nunca foi implantado pode ficar só com a identidade.",
45
+ "ORD-01 cria um pedido de teste só se você o nomear e o pagamento for cash ou sandbox. Ele entra no canal realtime antes do place e espera o checkout ser liberado.",
46
+ `A documentação está aqui: ${DOCS_PAGE["pt-BR"]}`
47
+ ].join("\n");
48
+
49
+ export const DETAILS: Copy = {
50
+ "es-EC": detailsEs,
51
+ "en-US": detailsEn,
52
+ "pt-BR": detailsPt
53
+ };
54
+
55
+ export const COPY = {
56
+ unknownCommand: text(
57
+ "Comando desconocido: {name}. Usa run, list, show, init o docs.",
58
+ "Unknown command: {name}. Use run, list, show, init, or docs.",
59
+ "Comando desconhecido: {name}. Use run, list, show, init ou docs."
60
+ ),
61
+ unknownCheck: text(
62
+ "Check desconocido: {id}",
63
+ "Unknown check: {id}",
64
+ "Check desconhecido: {id}"
65
+ ),
66
+ none: text(
67
+ "Ningún check coincide con esos filtros.",
68
+ "No check matches those filters.",
69
+ "Nenhum check corresponde a esses filtros."
70
+ ),
71
+ showOne: text(
72
+ "show necesita un solo ID.",
73
+ "show needs exactly one ID.",
74
+ "show precisa de um único ID."
75
+ ),
76
+ manyConfigs: text(
77
+ "Hay varios YAML ({files}). Pasa --config con el archivo que quieres.",
78
+ "Several YAML files ({files}). Pass --config with the one you want.",
79
+ "Há vários YAML ({files}). Passe --config com o arquivo que você quer."
80
+ ),
81
+ ambiguous: text(
82
+ "Hay varios canales. Pasa --channel-id para aplicar esas flags.",
83
+ "There are several channels. Pass --channel-id to apply those flags.",
84
+ "Há vários canais. Passe --channel-id para aplicar essas flags."
85
+ ),
86
+ unknownChannel: text(
87
+ "No existe un canal con channel_id {id} en el YAML.",
88
+ "No channel with channel_id {id} exists in the YAML.",
89
+ "Não existe um canal com channel_id {id} no YAML."
90
+ ),
91
+ badPayment: text(
92
+ "--payment solo acepta cash o sandbox.",
93
+ "--payment only accepts cash or sandbox.",
94
+ "--payment só aceita cash ou sandbox."
95
+ ),
96
+ badFormat: text(
97
+ "--format solo acepta text o json.",
98
+ "--format only accepts text or json.",
99
+ "--format só aceita text ou json."
100
+ ),
101
+ badUrl: text(
102
+ "--{flag} tiene que ser una URL http o https.",
103
+ "--{flag} must be an http or https URL.",
104
+ "--{flag} tem que ser uma URL http ou https."
105
+ ),
106
+ badYaml: text(
107
+ "No pude leer {path}: {detail}",
108
+ "Could not read {path}: {detail}",
109
+ "Não consegui ler {path}: {detail}"
110
+ ),
111
+ preflight: text(
112
+ "El canal {channel} está desplegado y le falta {field}. No se ejecutó ningún check.",
113
+ "Channel {channel} is deployed and is missing {field}. No checks ran.",
114
+ "O canal {channel} está implantado e falta {field}. Nenhum check rodou."
115
+ ),
116
+ wrote: text(
117
+ "Configuración lista: {path}",
118
+ "Configuration ready: {path}",
119
+ "Configuração pronta: {path}"
120
+ ),
121
+ deployedAsk: text(
122
+ "¿El canal {channel} está desplegado? (sí/no) ",
123
+ "Is channel {channel} deployed? (yes/no) ",
124
+ "O canal {channel} está implantado? (sim/não) "
125
+ ),
126
+ answerYesNo: text(
127
+ "Responde sí o no para el canal {channel}. No adivino si está desplegado.",
128
+ "Answer yes or no for channel {channel}. I will not guess if it is deployed.",
129
+ "Responda sim ou não para o canal {channel}. Não adivinho se está implantado."
130
+ ),
131
+ askUrl: text(
132
+ "URL pública de {channel}: ",
133
+ "Public URL of {channel}: ",
134
+ "URL pública de {channel}: "
135
+ ),
136
+ askDashboard: text(
137
+ "URL del dashboard de {channel}: ",
138
+ "Dashboard URL of {channel}: ",
139
+ "URL do dashboard de {channel}: "
140
+ ),
141
+ askApi: text(
142
+ "URL de la API de {channel}: ",
143
+ "API URL of {channel}: ",
144
+ "URL da API de {channel}: "
145
+ ),
146
+ askStore: text(
147
+ "store-id de {channel}: ",
148
+ "store-id of {channel}: ",
149
+ "store-id de {channel}: "
150
+ ),
151
+ askFulfillment: text(
152
+ "fulfillment-id de {channel}: ",
153
+ "fulfillment-id of {channel}: ",
154
+ "fulfillment-id de {channel}: "
155
+ ),
156
+ askPayment: text(
157
+ "Pago de {channel} (cash o sandbox): ",
158
+ "Payment of {channel} (cash or sandbox): ",
159
+ "Pagamento de {channel} (cash ou sandbox): "
160
+ ),
161
+ askReceiver: text(
162
+ "URL del receptor de webhooks de {channel}: ",
163
+ "Webhook receiver URL of {channel}: ",
164
+ "URL do receptor de webhooks de {channel}: "
165
+ ),
166
+ askEvidence: text(
167
+ "Ruta de evidencia de {channel}: ",
168
+ "Evidence path of {channel}: ",
169
+ "Caminho de evidência de {channel}: "
170
+ ),
171
+ badAnswerPayment: text(
172
+ "El pago tiene que ser cash o sandbox.",
173
+ "Payment has to be cash or sandbox.",
174
+ "O pagamento tem que ser cash ou sandbox."
175
+ ),
176
+ docsLine: text(
177
+ "La documentación está aquí: {url}",
178
+ "Documentation: {url}",
179
+ "A documentação está aqui: {url}"
180
+ ),
181
+ orderMissing: text(
182
+ "El canal {channel} le falta {field}. No se creó ninguna orden.",
183
+ "Channel {channel} is missing {field}. No order was created.",
184
+ "O canal {channel} falta {field}. Nenhum pedido foi criado."
185
+ ),
186
+ orderNoChannel: text(
187
+ "Falta channel_id para elegir el canal. No se creó ninguna orden.",
188
+ "channel_id is missing, so no channel was chosen. No order was created.",
189
+ "Falta channel_id para escolher o canal. Nenhum pedido foi criado."
190
+ ),
191
+ orderSkipped: text(
192
+ "ORD-01 solo corre cuando lo nombras. No se creó ninguna orden.",
193
+ "ORD-01 runs only when you name it. No order was created.",
194
+ "ORD-01 só roda quando você o nomeia. Nenhum pedido foi criado."
195
+ ),
196
+ orderNoFix: text(
197
+ "No hay un arreglo automático para ORD-01. No se creó ninguna orden.",
198
+ "There is no automatic repair for ORD-01. No order was created.",
199
+ "Não há um reparo automático para ORD-01. Nenhum pedido foi criado."
200
+ ),
201
+ unbound: text(
202
+ "Este check todavía no tiene flujo en esta versión.",
203
+ "This check has no flow in this version yet.",
204
+ "Este check ainda não tem fluxo nesta versão."
205
+ ),
206
+ noManifests: text(
207
+ "Este repo no tiene apps/*/src/channel-manifest.json.",
208
+ "This repo has no apps/*/src/channel-manifest.json.",
209
+ "Este repo não tem apps/*/src/channel-manifest.json."
210
+ ),
211
+ duplicateId: text(
212
+ "channel_id {id} está en {left} y en {right}.",
213
+ "channel_id {id} is in {left} and in {right}.",
214
+ "channel_id {id} está em {left} e em {right}."
215
+ ),
216
+ noSafeFix: text(
217
+ "No hay un arreglo automático que no invente un channel_id. No cambié ningún archivo.",
218
+ "There is no automatic repair that does not invent a channel_id. No file was changed.",
219
+ "Não há um reparo automático que não invente um channel_id. Nenhum arquivo mudou."
220
+ ),
221
+ strictSkipped: text(
222
+ "Modo estricto: hay checks omitidos.",
223
+ "Strict mode: some checks were skipped.",
224
+ "Modo estrito: há checks omitidos."
225
+ ),
226
+ yes: text("sí", "yes", "sim"),
227
+ no: text("no", "no", "não")
228
+ } as const;