@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.
- package/bin/firespark.mjs +14 -0
- package/package.json +37 -3
- package/src/commands/doctor/client/catalog.json +1586 -0
- package/src/commands/doctor/client/catalog.ts +179 -0
- package/src/commands/doctor/client/command.ts +246 -0
- package/src/commands/doctor/client/config.ts +346 -0
- package/src/commands/doctor/client/copy.ts +228 -0
- package/src/commands/doctor/client/manifests.ts +161 -0
- package/src/commands/doctor/client/options.ts +201 -0
- package/src/commands/doctor/client/order.ts +261 -0
- package/src/commands/doctor/client/runner.ts +162 -0
- package/src/commands/doctor/client/yaml.ts +179 -0
- package/src/commands/doctor/live/http.ts +89 -0
- package/src/commands/doctor/live/live.session.ts +243 -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,95 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { LiveConfig, Menu, MenuProduct } from "./live.session";
|
|
4
|
+
|
|
5
|
+
export const doctorOrderId = () => {
|
|
6
|
+
return `ord-doctor-${randomBytes(8).toString("hex")}`;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const money = (price: number) => {
|
|
10
|
+
const unit = {
|
|
11
|
+
unit_subtotal: price,
|
|
12
|
+
unit_discount: 0,
|
|
13
|
+
unit_tax_total: 0,
|
|
14
|
+
unit_total: price
|
|
15
|
+
};
|
|
16
|
+
const line = {
|
|
17
|
+
subtotal: price,
|
|
18
|
+
discount_total: 0,
|
|
19
|
+
tax_total: 0,
|
|
20
|
+
total: price
|
|
21
|
+
};
|
|
22
|
+
const groupUnit = {
|
|
23
|
+
unit_group_subtotal: price,
|
|
24
|
+
unit_group_discount: 0,
|
|
25
|
+
unit_group_tax_total: 0,
|
|
26
|
+
unit_group_total: price
|
|
27
|
+
};
|
|
28
|
+
const group = {
|
|
29
|
+
group_subtotal: price,
|
|
30
|
+
group_discount: 0,
|
|
31
|
+
group_tax_total: 0,
|
|
32
|
+
group_total: price
|
|
33
|
+
};
|
|
34
|
+
return { ...unit, ...line, ...groupUnit, ...group };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One unit of one product, no tax, no discount. Place sends cash. */
|
|
38
|
+
export const buildOrderBody = (input: {
|
|
39
|
+
id: string;
|
|
40
|
+
config: LiveConfig;
|
|
41
|
+
product: MenuProduct;
|
|
42
|
+
menuId?: string;
|
|
43
|
+
lineUid: string;
|
|
44
|
+
}) => {
|
|
45
|
+
const { config, product } = input;
|
|
46
|
+
const price = product.pricing?.price ?? 0;
|
|
47
|
+
const head = {
|
|
48
|
+
type: "PRODUCT",
|
|
49
|
+
uid: input.lineUid,
|
|
50
|
+
id: product.id,
|
|
51
|
+
parent_uid: null,
|
|
52
|
+
parent_id: null
|
|
53
|
+
};
|
|
54
|
+
const meta = {
|
|
55
|
+
name: product.name ?? product.id,
|
|
56
|
+
menu_id: input.menuId,
|
|
57
|
+
metadata: { source: "firespark-doctor" }
|
|
58
|
+
};
|
|
59
|
+
const rates = { quantity: 1, tax_rate: null, discount_rate: 0 };
|
|
60
|
+
const line = { ...head, ...meta, ...rates, ...money(price) };
|
|
61
|
+
const names = {
|
|
62
|
+
display_name: "Fire Spark Doctor",
|
|
63
|
+
first_name: "Fire Spark",
|
|
64
|
+
last_name: "Doctor"
|
|
65
|
+
};
|
|
66
|
+
const customer_info = { ...names, email: config.customerEmail };
|
|
67
|
+
const phone = { phone: "+593999999999" };
|
|
68
|
+
const customer = { customer_info: { ...customer_info, ...phone } };
|
|
69
|
+
const totals = {
|
|
70
|
+
currency: config.currency,
|
|
71
|
+
subtotal: price,
|
|
72
|
+
discount_rate: 0,
|
|
73
|
+
discount_total: 0,
|
|
74
|
+
tax_rate: 0,
|
|
75
|
+
tax_total: 0,
|
|
76
|
+
total: price
|
|
77
|
+
};
|
|
78
|
+
const scope = {
|
|
79
|
+
store_id: config.storeId,
|
|
80
|
+
channel_id: config.channelId,
|
|
81
|
+
fulfillment_id: config.fulfillmentId
|
|
82
|
+
};
|
|
83
|
+
const body = { id: input.id, ...scope, is_anonymous: false, customer };
|
|
84
|
+
return { ...body, lines: [line], totals };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The configured product, otherwise the lowest id that has a price. */
|
|
88
|
+
export const pickProduct = (menus: Menu[], productId?: string) => {
|
|
89
|
+
const products = menus.flatMap(menu => menu.products ?? []);
|
|
90
|
+
if (productId) return products.find(product => product.id === productId);
|
|
91
|
+
const priced = products.filter(product => {
|
|
92
|
+
return typeof product.pricing?.price === "number" && product.id;
|
|
93
|
+
});
|
|
94
|
+
return priced.toSorted((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))[0];
|
|
95
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export interface WebSocketLike {
|
|
2
|
+
send: (data: string) => void;
|
|
3
|
+
close: () => void;
|
|
4
|
+
addEventListener: (
|
|
5
|
+
type: string,
|
|
6
|
+
listener: (event: { data?: unknown }) => void
|
|
7
|
+
) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type WebSocketFactory = (url: string) => WebSocketLike;
|
|
11
|
+
|
|
12
|
+
export const ORDER_CHANGED_EVENT = "order.changed";
|
|
13
|
+
const HEARTBEAT_MS = 25_000;
|
|
14
|
+
|
|
15
|
+
export interface OrderChanged {
|
|
16
|
+
order_id?: string;
|
|
17
|
+
checkout_state?: string;
|
|
18
|
+
error?: { message?: string } | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SubscribeInput {
|
|
22
|
+
url: string;
|
|
23
|
+
key: string;
|
|
24
|
+
topic: string;
|
|
25
|
+
accessToken: string;
|
|
26
|
+
joinTimeoutMs: number;
|
|
27
|
+
createSocket: WebSocketFactory;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Subscription {
|
|
31
|
+
joined: Promise<void>;
|
|
32
|
+
events: OrderChanged[];
|
|
33
|
+
waitFor: (
|
|
34
|
+
match: (event: OrderChanged) => boolean,
|
|
35
|
+
timeoutMs: number
|
|
36
|
+
) => Promise<OrderChanged | undefined>;
|
|
37
|
+
close: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const socketUrl = (url: string, key: string) => {
|
|
41
|
+
const base = url.replace(/\/+$/, "").replace(/^http/i, "ws");
|
|
42
|
+
const apikey = encodeURIComponent(key);
|
|
43
|
+
return `${base}/realtime/v1/websocket?apikey=${apikey}&vsn=1.0.0`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface PhoenixMessage {
|
|
47
|
+
topic?: string;
|
|
48
|
+
event?: string;
|
|
49
|
+
ref?: string | null;
|
|
50
|
+
payload?: {
|
|
51
|
+
status?: string;
|
|
52
|
+
event?: string;
|
|
53
|
+
payload?: unknown;
|
|
54
|
+
response?: { reason?: string };
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parseMessage = (data: unknown): PhoenixMessage | undefined => {
|
|
59
|
+
if (typeof data !== "string") return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(data) as PhoenixMessage;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const joinPayload = (accessToken: string) => {
|
|
68
|
+
const broadcast = { ack: false, self: false };
|
|
69
|
+
const presence = { key: "" };
|
|
70
|
+
const config = { broadcast, presence, postgres_changes: [], private: true };
|
|
71
|
+
return { config, access_token: accessToken };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** One private broadcast channel. Join before place, then wait. */
|
|
75
|
+
export const subscribe = (input: SubscribeInput): Subscription => {
|
|
76
|
+
const channelTopic = `realtime:${input.topic}`;
|
|
77
|
+
const events: OrderChanged[] = [];
|
|
78
|
+
const waiters = new Set<(event: OrderChanged) => void>();
|
|
79
|
+
const socket = input.createSocket(socketUrl(input.url, input.key));
|
|
80
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
81
|
+
let ref = 0;
|
|
82
|
+
const send = (topic: string, event: string, payload: unknown) => {
|
|
83
|
+
ref += 1;
|
|
84
|
+
const id = String(ref);
|
|
85
|
+
const frame = { topic, event, payload, ref: id, join_ref: "1" };
|
|
86
|
+
socket.send(JSON.stringify(frame));
|
|
87
|
+
return id;
|
|
88
|
+
};
|
|
89
|
+
const push = (changed: OrderChanged) => {
|
|
90
|
+
events.push(changed);
|
|
91
|
+
for (const waiter of waiters) waiter(changed);
|
|
92
|
+
};
|
|
93
|
+
let joinRef = "";
|
|
94
|
+
const joined = new Promise<void>((resolveJoin, rejectJoin) => {
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
rejectJoin(new Error("join timeout"));
|
|
97
|
+
}, input.joinTimeoutMs);
|
|
98
|
+
timer.unref?.();
|
|
99
|
+
socket.addEventListener("open", () => {
|
|
100
|
+
const payload = joinPayload(input.accessToken);
|
|
101
|
+
joinRef = send(channelTopic, "phx_join", payload);
|
|
102
|
+
heartbeat = setInterval(() => {
|
|
103
|
+
send("phoenix", "heartbeat", {});
|
|
104
|
+
}, HEARTBEAT_MS);
|
|
105
|
+
heartbeat.unref?.();
|
|
106
|
+
});
|
|
107
|
+
socket.addEventListener("error", () => {
|
|
108
|
+
rejectJoin(new Error("websocket error"));
|
|
109
|
+
});
|
|
110
|
+
socket.addEventListener("close", () => {
|
|
111
|
+
rejectJoin(new Error("websocket closed"));
|
|
112
|
+
});
|
|
113
|
+
socket.addEventListener("message", event => {
|
|
114
|
+
const message = parseMessage(event.data);
|
|
115
|
+
if (!message || message.topic !== channelTopic) return;
|
|
116
|
+
const reply = message.event === "phx_reply" && message.ref === joinRef;
|
|
117
|
+
if (reply) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
if (message.payload?.status === "ok") resolveJoin();
|
|
120
|
+
else {
|
|
121
|
+
const reason = message.payload?.response?.reason ?? "join rejected";
|
|
122
|
+
rejectJoin(new Error(reason));
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (message.event === "phx_error") {
|
|
127
|
+
rejectJoin(new Error("channel error"));
|
|
128
|
+
}
|
|
129
|
+
if (message.event !== "broadcast") return;
|
|
130
|
+
if (message.payload?.event !== ORDER_CHANGED_EVENT) return;
|
|
131
|
+
push(message.payload.payload as OrderChanged);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
joined.catch(() => undefined);
|
|
135
|
+
const waitFor: Subscription["waitFor"] = (match, timeoutMs) => {
|
|
136
|
+
const seen = events.find(match);
|
|
137
|
+
if (seen) return Promise.resolve(seen);
|
|
138
|
+
return new Promise(resolveWait => {
|
|
139
|
+
const done = (value: OrderChanged | undefined) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
waiters.delete(listener);
|
|
142
|
+
resolveWait(value);
|
|
143
|
+
};
|
|
144
|
+
const listener = (event: OrderChanged) => {
|
|
145
|
+
if (match(event)) done(event);
|
|
146
|
+
};
|
|
147
|
+
const timer = setTimeout(() => done(undefined), timeoutMs);
|
|
148
|
+
waiters.add(listener);
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const close = () => {
|
|
152
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
153
|
+
waiters.clear();
|
|
154
|
+
try {
|
|
155
|
+
socket.close();
|
|
156
|
+
} catch {
|
|
157
|
+
// already closed
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
return { joined, events, waitFor, close };
|
|
161
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Copy, Vars } from "../../../core/i18n";
|
|
2
|
+
|
|
3
|
+
export interface Text {
|
|
4
|
+
copy: Copy;
|
|
5
|
+
vars?: Vars;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CheckFail {
|
|
9
|
+
status: "fail";
|
|
10
|
+
hop: string;
|
|
11
|
+
reason: Text;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CheckSkip {
|
|
15
|
+
status: "skip";
|
|
16
|
+
reason: Text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const text = (copy: Copy, vars?: Vars): Text => {
|
|
20
|
+
return { copy, vars };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const fail = (hop: string, copy: Copy, vars?: Vars): CheckFail => {
|
|
24
|
+
return { status: "fail", hop, reason: text(copy, vars) };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const skip = (copy: Copy, vars?: Vars): CheckSkip => {
|
|
28
|
+
return { status: "skip", reason: text(copy, vars) };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Reasons the live order path can still surface. */
|
|
32
|
+
export const REASONS = {
|
|
33
|
+
mismatch: {
|
|
34
|
+
"es-EC": "Se esperaba {expected} y llegó {actual}.",
|
|
35
|
+
"en-US": "Expected {expected}, got {actual}.",
|
|
36
|
+
"pt-BR": "Esperava {expected} e chegou {actual}."
|
|
37
|
+
},
|
|
38
|
+
httpStatus: {
|
|
39
|
+
"es-EC": "{method} {path} respondió {status}: {detail}",
|
|
40
|
+
"en-US": "{method} {path} answered {status}: {detail}",
|
|
41
|
+
"pt-BR": "{method} {path} respondeu {status}: {detail}"
|
|
42
|
+
},
|
|
43
|
+
network: {
|
|
44
|
+
"es-EC": "No se pudo llegar a {target}: {detail}",
|
|
45
|
+
"en-US": "Could not reach {target}: {detail}",
|
|
46
|
+
"pt-BR": "Não foi possível chegar a {target}: {detail}"
|
|
47
|
+
},
|
|
48
|
+
badShape: {
|
|
49
|
+
"es-EC": "La respuesta no trae {field}.",
|
|
50
|
+
"en-US": "The response has no {field}.",
|
|
51
|
+
"pt-BR": "A resposta não traz {field}."
|
|
52
|
+
},
|
|
53
|
+
liveMissing: {
|
|
54
|
+
"es-EC": "falta configurar {names}",
|
|
55
|
+
"en-US": "missing configuration: {names}",
|
|
56
|
+
"pt-BR": "falta configurar {names}"
|
|
57
|
+
},
|
|
58
|
+
emptyList: {
|
|
59
|
+
"es-EC": "{what} llegó vacío.",
|
|
60
|
+
"en-US": "{what} came back empty.",
|
|
61
|
+
"pt-BR": "{what} veio vazio."
|
|
62
|
+
},
|
|
63
|
+
checkoutFailed: {
|
|
64
|
+
"es-EC": "El checkout falló: {detail}.",
|
|
65
|
+
"en-US": "Checkout failed: {detail}.",
|
|
66
|
+
"pt-BR": "O checkout falhou: {detail}."
|
|
67
|
+
},
|
|
68
|
+
timeout: {
|
|
69
|
+
"es-EC":
|
|
70
|
+
"Pasaron {seconds} s y el checkout no se liberó. Último estado: {last}.",
|
|
71
|
+
"en-US":
|
|
72
|
+
"{seconds} s passed and checkout was not released. Last state: {last}.",
|
|
73
|
+
"pt-BR":
|
|
74
|
+
"Passaram {seconds} s e o checkout não foi liberado. Último estado: {last}."
|
|
75
|
+
}
|
|
76
|
+
} satisfies Record<string, Copy>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from "../core/command.types";
|
|
2
|
+
import { clientDoctorCommand } from "./doctor/client/command";
|
|
3
|
+
|
|
4
|
+
export const COMMANDS: Command[] = [clientDoctorCommand];
|
|
5
|
+
|
|
6
|
+
export const findCommand = (name: string) => {
|
|
7
|
+
return COMMANDS.find(command => command.name === name);
|
|
8
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Copy, Locale } from "./i18n";
|
|
2
|
+
import type { OptionSpec, OptionValues } from "./options";
|
|
3
|
+
import type { Output } from "./output";
|
|
4
|
+
import type { Redactor } from "./redact";
|
|
5
|
+
|
|
6
|
+
export interface CommandContext {
|
|
7
|
+
locale: Locale;
|
|
8
|
+
options: OptionValues;
|
|
9
|
+
positionals: string[];
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
cwd: string;
|
|
12
|
+
output: Output;
|
|
13
|
+
redactor: Redactor;
|
|
14
|
+
isTty?: boolean;
|
|
15
|
+
ask?: (question: string) => Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Command {
|
|
19
|
+
name: string;
|
|
20
|
+
summary: Copy;
|
|
21
|
+
usage: string;
|
|
22
|
+
options: OptionSpec[];
|
|
23
|
+
details?: Copy;
|
|
24
|
+
run: (context: CommandContext) => Promise<number>;
|
|
25
|
+
}
|
package/src/core/help.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Command } from "./command.types";
|
|
2
|
+
import { translate } from "./i18n";
|
|
3
|
+
import type { Copy, Locale } from "./i18n";
|
|
4
|
+
import { GLOBAL_OPTIONS } from "./options";
|
|
5
|
+
import type { OptionSpec } from "./options";
|
|
6
|
+
|
|
7
|
+
const HEADINGS = {
|
|
8
|
+
usage: { "es-EC": "Uso", "en-US": "Usage", "pt-BR": "Uso" },
|
|
9
|
+
commands: { "es-EC": "Comandos", "en-US": "Commands", "pt-BR": "Comandos" },
|
|
10
|
+
options: { "es-EC": "Opciones", "en-US": "Options", "pt-BR": "Opções" },
|
|
11
|
+
env: { "es-EC": "variable", "en-US": "env", "pt-BR": "variável" },
|
|
12
|
+
secret: { "es-EC": "secreto", "en-US": "secret", "pt-BR": "secreto" },
|
|
13
|
+
default: { "es-EC": "por defecto", "en-US": "default", "pt-BR": "padrão" },
|
|
14
|
+
more: {
|
|
15
|
+
"es-EC": "Ejecuta `firespark <comando> --help` para ver sus opciones.",
|
|
16
|
+
"en-US": "Run `firespark <command> --help` to see its options.",
|
|
17
|
+
"pt-BR": "Execute `firespark <comando> --help` para ver as opções."
|
|
18
|
+
}
|
|
19
|
+
} satisfies Record<string, Copy>;
|
|
20
|
+
|
|
21
|
+
const flagLabel = (spec: OptionSpec) => {
|
|
22
|
+
const value = spec.kind === "boolean" ? "" : " <value>";
|
|
23
|
+
const short = spec.short ? `-${spec.short}, ` : "";
|
|
24
|
+
return `${short}--${spec.name}${value}`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const optionNotes = (spec: OptionSpec, locale: Locale) => {
|
|
28
|
+
const notes: string[] = [];
|
|
29
|
+
if (spec.env) notes.push(`${translate(locale, HEADINGS.env)}: ${spec.env}`);
|
|
30
|
+
if (spec.secret) notes.push(translate(locale, HEADINGS.secret));
|
|
31
|
+
if (spec.defaultValue !== undefined) {
|
|
32
|
+
const label = translate(locale, HEADINGS.default);
|
|
33
|
+
notes.push(`${label}: ${spec.defaultValue}`);
|
|
34
|
+
}
|
|
35
|
+
return notes.length > 0 ? ` (${notes.join("; ")})` : "";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const renderOptions = (specs: OptionSpec[], locale: Locale) => {
|
|
39
|
+
const width = Math.max(...specs.map(s => flagLabel(s).length));
|
|
40
|
+
return specs.map(spec => {
|
|
41
|
+
const label = flagLabel(spec).padEnd(width);
|
|
42
|
+
const text = translate(locale, spec.description);
|
|
43
|
+
return ` ${label} ${text}${optionNotes(spec, locale)}`;
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const renderRootHelp = (commands: Command[], locale: Locale) => {
|
|
48
|
+
const width = Math.max(...commands.map(c => c.name.length));
|
|
49
|
+
const rows = commands.map(command => {
|
|
50
|
+
const summary = translate(locale, command.summary);
|
|
51
|
+
return ` ${command.name.padEnd(width)} ${summary}`;
|
|
52
|
+
});
|
|
53
|
+
const usage = `${translate(locale, HEADINGS.usage)}: firespark <command> [options]`;
|
|
54
|
+
const commandsTitle = `${translate(locale, HEADINGS.commands)}:`;
|
|
55
|
+
const optionsTitle = `${translate(locale, HEADINGS.options)}:`;
|
|
56
|
+
const globals = renderOptions(GLOBAL_OPTIONS, locale);
|
|
57
|
+
const more = translate(locale, HEADINGS.more);
|
|
58
|
+
return [
|
|
59
|
+
usage,
|
|
60
|
+
"",
|
|
61
|
+
commandsTitle,
|
|
62
|
+
...rows,
|
|
63
|
+
"",
|
|
64
|
+
optionsTitle,
|
|
65
|
+
...globals,
|
|
66
|
+
"",
|
|
67
|
+
more
|
|
68
|
+
].join("\n");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const renderCommandHelp = (command: Command, locale: Locale) => {
|
|
72
|
+
const usage = `${translate(locale, HEADINGS.usage)}: ${command.usage}`;
|
|
73
|
+
const summary = translate(locale, command.summary);
|
|
74
|
+
const details = command.details
|
|
75
|
+
? ["", translate(locale, command.details)]
|
|
76
|
+
: [];
|
|
77
|
+
const optionsTitle = `${translate(locale, HEADINGS.options)}:`;
|
|
78
|
+
const options = renderOptions(
|
|
79
|
+
[...command.options, ...GLOBAL_OPTIONS],
|
|
80
|
+
locale
|
|
81
|
+
);
|
|
82
|
+
return [usage, "", summary, ...details, "", optionsTitle, ...options].join(
|
|
83
|
+
"\n"
|
|
84
|
+
);
|
|
85
|
+
};
|
package/src/core/i18n.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const LOCALES = ["es-EC", "en-US", "pt-BR"] as const;
|
|
2
|
+
|
|
3
|
+
export type Locale = (typeof LOCALES)[number];
|
|
4
|
+
|
|
5
|
+
export type Copy = Record<Locale, string>;
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_LOCALE: Locale = "es-EC";
|
|
8
|
+
|
|
9
|
+
const byLanguage: Record<string, Locale> = { es: "es-EC", en: "en-US" };
|
|
10
|
+
const byLanguagePt: Record<string, Locale> = { pt: "pt-BR" };
|
|
11
|
+
const LANGUAGE_FALLBACK = { ...byLanguage, ...byLanguagePt };
|
|
12
|
+
|
|
13
|
+
export const isLocale = (value: string): value is Locale => {
|
|
14
|
+
return (LOCALES as readonly string[]).includes(value);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const matchLocale = (value: string | undefined) => {
|
|
18
|
+
if (!value) return undefined;
|
|
19
|
+
const tag = value.split(".")[0]!.replace("_", "-");
|
|
20
|
+
const exact = LOCALES.find(l => l.toLowerCase() === tag.toLowerCase());
|
|
21
|
+
if (exact) return exact;
|
|
22
|
+
const language = tag.split("-")[0]!.toLowerCase();
|
|
23
|
+
return LANGUAGE_FALLBACK[language];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const resolveLocale = (
|
|
27
|
+
flag: string | undefined,
|
|
28
|
+
env: NodeJS.ProcessEnv
|
|
29
|
+
) => {
|
|
30
|
+
const candidates = [flag, env.FIRESPARK_LOCALE, env.LC_ALL, env.LANG];
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
const locale = matchLocale(candidate);
|
|
33
|
+
if (locale) return locale;
|
|
34
|
+
}
|
|
35
|
+
return DEFAULT_LOCALE;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Vars = Record<string, string | number>;
|
|
39
|
+
|
|
40
|
+
export const format = (template: string, vars: Vars = {}) => {
|
|
41
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
|
|
42
|
+
const value = vars[key];
|
|
43
|
+
return value === undefined ? match : String(value);
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const translate = (locale: Locale, copy: Copy, vars?: Vars) => {
|
|
48
|
+
return format(copy[locale], vars);
|
|
49
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
import type { Copy } from "./i18n";
|
|
4
|
+
|
|
5
|
+
export type OptionKind = "string" | "boolean" | "number";
|
|
6
|
+
|
|
7
|
+
export interface OptionSpec {
|
|
8
|
+
name: string;
|
|
9
|
+
kind: OptionKind;
|
|
10
|
+
description: Copy;
|
|
11
|
+
env?: string;
|
|
12
|
+
secret?: boolean;
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
short?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type OptionValues = Record<string, string | boolean | undefined>;
|
|
18
|
+
|
|
19
|
+
export const GLOBAL_OPTIONS: OptionSpec[] = [
|
|
20
|
+
{
|
|
21
|
+
name: "help",
|
|
22
|
+
kind: "boolean",
|
|
23
|
+
short: "h",
|
|
24
|
+
description: {
|
|
25
|
+
"es-EC": "Muestra esta ayuda.",
|
|
26
|
+
"en-US": "Show this help.",
|
|
27
|
+
"pt-BR": "Mostra esta ajuda."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "locale",
|
|
32
|
+
kind: "string",
|
|
33
|
+
env: "FIRESPARK_LOCALE",
|
|
34
|
+
description: {
|
|
35
|
+
"es-EC":
|
|
36
|
+
"Idioma de la salida: cualquier etiqueta BCP 47 (es, es-MX, en, pt-BR…); se muestra en es-EC, en-US o pt-BR según su idioma.",
|
|
37
|
+
"en-US":
|
|
38
|
+
"Output language: any BCP 47 tag (es, es-MX, en, pt-BR…); shown in es-EC, en-US or pt-BR by its language.",
|
|
39
|
+
"pt-BR":
|
|
40
|
+
"Idioma da saída: qualquer tag BCP 47 (es, es-MX, en, pt-BR…); exibida em es-EC, en-US ou pt-BR conforme o idioma."
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "no-color",
|
|
45
|
+
kind: "boolean",
|
|
46
|
+
env: "NO_COLOR",
|
|
47
|
+
description: {
|
|
48
|
+
"es-EC": "Desactiva los colores.",
|
|
49
|
+
"en-US": "Disable colors.",
|
|
50
|
+
"pt-BR": "Desativa as cores."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export class UsageError extends Error {}
|
|
56
|
+
|
|
57
|
+
const toParseOption = (spec: OptionSpec) => {
|
|
58
|
+
const type = spec.kind === "boolean" ? "boolean" : "string";
|
|
59
|
+
if (spec.short) return { type, short: spec.short } as const;
|
|
60
|
+
return { type } as const;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const parseOptions = (argv: string[], specs: OptionSpec[]) => {
|
|
64
|
+
const options = Object.fromEntries(
|
|
65
|
+
specs.map(s => [s.name, toParseOption(s)])
|
|
66
|
+
);
|
|
67
|
+
try {
|
|
68
|
+
const config = {
|
|
69
|
+
args: argv,
|
|
70
|
+
options,
|
|
71
|
+
strict: true,
|
|
72
|
+
allowPositionals: true
|
|
73
|
+
};
|
|
74
|
+
const { values, positionals } = parseArgs(config);
|
|
75
|
+
return { values: values as OptionValues, positionals };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
throw new UsageError(message);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const envValue = (spec: OptionSpec, env: NodeJS.ProcessEnv) => {
|
|
83
|
+
if (!spec.env) return undefined;
|
|
84
|
+
const value = env[spec.env];
|
|
85
|
+
if (value === undefined || value === "") return undefined;
|
|
86
|
+
return value;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const fromEnv = (spec: OptionSpec, raw: string) => {
|
|
90
|
+
if (spec.kind !== "boolean") return raw;
|
|
91
|
+
return !["0", "false", "no"].includes(raw.toLowerCase());
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Flags win over env vars, env vars win over defaults.
|
|
95
|
+
export const resolveOptions = (
|
|
96
|
+
specs: OptionSpec[],
|
|
97
|
+
values: OptionValues,
|
|
98
|
+
env: NodeJS.ProcessEnv
|
|
99
|
+
) => {
|
|
100
|
+
const resolved: OptionValues = {};
|
|
101
|
+
for (const spec of specs) {
|
|
102
|
+
const flag = values[spec.name];
|
|
103
|
+
const raw = envValue(spec, env);
|
|
104
|
+
if (flag !== undefined) resolved[spec.name] = flag;
|
|
105
|
+
else if (raw !== undefined) resolved[spec.name] = fromEnv(spec, raw);
|
|
106
|
+
else resolved[spec.name] = spec.defaultValue;
|
|
107
|
+
}
|
|
108
|
+
return resolved;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const secretValues = (specs: OptionSpec[], resolved: OptionValues) => {
|
|
112
|
+
const secrets: string[] = [];
|
|
113
|
+
for (const spec of specs) {
|
|
114
|
+
const value = resolved[spec.name];
|
|
115
|
+
if (spec.secret && typeof value === "string") secrets.push(value);
|
|
116
|
+
}
|
|
117
|
+
return secrets;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const readString = (values: OptionValues, name: string) => {
|
|
121
|
+
const value = values[name];
|
|
122
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const readNumber = (values: OptionValues, name: string) => {
|
|
126
|
+
const raw = readString(values, name);
|
|
127
|
+
if (raw === undefined) return undefined;
|
|
128
|
+
const value = Number(raw);
|
|
129
|
+
if (!Number.isFinite(value)) throw new UsageError(`--${name}: ${raw}`);
|
|
130
|
+
return value;
|
|
131
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Redactor } from "./redact";
|
|
2
|
+
|
|
3
|
+
export interface Writer {
|
|
4
|
+
write: (text: string) => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Output {
|
|
8
|
+
line: (text?: string) => void;
|
|
9
|
+
error: (text: string) => void;
|
|
10
|
+
color: Colors;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Colors {
|
|
14
|
+
green: (text: string) => string;
|
|
15
|
+
red: (text: string) => string;
|
|
16
|
+
yellow: (text: string) => string;
|
|
17
|
+
dim: (text: string) => string;
|
|
18
|
+
bold: (text: string) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const paint = (code: number, reset: number) => {
|
|
22
|
+
return (text: string) => `\u001b[${code}m${text}\u001b[${reset}m`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const plain = (text: string) => text;
|
|
26
|
+
|
|
27
|
+
export const createColors = (enabled: boolean): Colors => {
|
|
28
|
+
if (!enabled) {
|
|
29
|
+
return { green: plain, red: plain, yellow: plain, dim: plain, bold: plain };
|
|
30
|
+
}
|
|
31
|
+
const green = paint(32, 39);
|
|
32
|
+
const red = paint(31, 39);
|
|
33
|
+
const yellow = paint(33, 39);
|
|
34
|
+
return { green, red, yellow, dim: paint(2, 22), bold: paint(1, 22) };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Every byte the CLI prints goes through the redactor.
|
|
38
|
+
export const createOutput = (
|
|
39
|
+
out: Writer,
|
|
40
|
+
err: Writer,
|
|
41
|
+
redactor: Redactor,
|
|
42
|
+
colors: boolean
|
|
43
|
+
): Output => {
|
|
44
|
+
const line = (text = "") => out.write(`${redactor.redact(text)}\n`);
|
|
45
|
+
const error = (text: string) => err.write(`${redactor.redact(text)}\n`);
|
|
46
|
+
return { line, error, color: createColors(colors) };
|
|
47
|
+
};
|