kafox-cli 0.3.0 → 0.7.0
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/README.md +130 -11
- package/bin/kafox.js +73 -2
- package/package.json +4 -3
- package/src/agent.js +385 -0
- package/src/auth.js +77 -30
- package/src/cli.js +83 -10
- package/src/diff.js +193 -0
- package/src/gateway.js +235 -48
- package/src/login-screen.js +203 -0
- package/src/logo.js +108 -0
- package/src/mascot.js +3 -31
- package/src/mcp.js +557 -0
- package/src/models.js +1 -4
- package/src/runner.js +98 -0
- package/src/sessions.js +138 -0
- package/src/storage.js +2 -1
- package/src/subagents.js +156 -0
- package/src/theme.js +327 -3
- package/src/tools.js +371 -0
- package/src/tui.js +1707 -213
package/src/gateway.js
CHANGED
|
@@ -3,26 +3,20 @@ const storage = require("./storage");
|
|
|
3
3
|
const { ensureSession, fetchApi } = require("./auth");
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* A
|
|
6
|
+
* Garante sessao, saldo, modelo e chave antes de qualquer chamada.
|
|
7
|
+
* A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
|
|
8
8
|
*/
|
|
9
|
-
async function
|
|
10
|
-
const text = typeof question === "string" ? question : (question || []).join(" ");
|
|
11
|
-
if (!text.trim()) {
|
|
12
|
-
return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
|
|
13
|
-
}
|
|
14
|
-
|
|
9
|
+
async function ensureReady(options = {}) {
|
|
15
10
|
const token = await ensureSession();
|
|
16
11
|
if (!token) {
|
|
17
12
|
return { ok: false, error: "Login first: `kafox login`" };
|
|
18
13
|
}
|
|
19
14
|
|
|
20
|
-
// Saldo decide se a CLI responde: sem credito, nem chega no gateway.
|
|
21
15
|
const boot = await fetchApi("/api/bootstrap");
|
|
22
16
|
if (!boot.ok) {
|
|
23
17
|
return { ok: false, error: "Session invalid. Run `kafox login`" };
|
|
24
18
|
}
|
|
25
|
-
const account = await boot.json();
|
|
19
|
+
const account = await boot.json().catch(() => null);
|
|
26
20
|
const balance = Number(account?.user?.balance_micros || 0);
|
|
27
21
|
if (balance <= 0) {
|
|
28
22
|
return {
|
|
@@ -38,9 +32,7 @@ async function askModel(question, options = {}) {
|
|
|
38
32
|
.then((response) => response.json())
|
|
39
33
|
.catch(() => null);
|
|
40
34
|
model = pricing?.pricing?.[0]?.model;
|
|
41
|
-
if (!model) {
|
|
42
|
-
return { ok: false, error: "Could not load model catalog." };
|
|
43
|
-
}
|
|
35
|
+
if (!model) return { ok: false, error: "Could not load model catalog." };
|
|
44
36
|
}
|
|
45
37
|
|
|
46
38
|
let keyCreated = false;
|
|
@@ -59,51 +51,237 @@ async function askModel(question, options = {}) {
|
|
|
59
51
|
keyCreated = true;
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
return { ok: true, model, apiKey, keyCreated, balance };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mergeToolCalls(target, deltas) {
|
|
58
|
+
for (const delta of deltas) {
|
|
59
|
+
const index = Number.isInteger(delta?.index) ? delta.index : target.length;
|
|
60
|
+
const current = target[index] || {
|
|
61
|
+
id: "",
|
|
62
|
+
type: "function",
|
|
63
|
+
function: { name: "", arguments: "" },
|
|
64
|
+
};
|
|
65
|
+
if (delta.id) current.id = delta.id;
|
|
66
|
+
if (delta.type) current.type = delta.type;
|
|
67
|
+
if (delta.function?.name) current.function.name += delta.function.name;
|
|
68
|
+
if (delta.function?.arguments) current.function.arguments += delta.function.arguments;
|
|
69
|
+
target[index] = current;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ---------------------------------------------------------------- retry */
|
|
74
|
+
|
|
75
|
+
// Rede do provedor cai e o fetch devolve "fetch failed": em vez de morrer na
|
|
76
|
+
// primeira tentativa, espera e tenta de novo dobrando o intervalo.
|
|
77
|
+
const RETRY_MAX = 20;
|
|
78
|
+
const RETRY_BASE_MS = Number(process.env.KAFOX_RETRY_BASE_MS) || 3000;
|
|
79
|
+
const RETRY_MAX_MS = Number(process.env.KAFOX_RETRY_MAX_MS) || 60_000;
|
|
80
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 521, 522, 523, 524]);
|
|
81
|
+
|
|
82
|
+
/** Espera do retry: 3s, 6s, 12s, 24s... ate 60s. */
|
|
83
|
+
function backoffMs(attempt) {
|
|
84
|
+
return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Vale a pena tentar de novo? Erro de rede, timeout e 5xx sim; 4xx de
|
|
88
|
+
* conteudo (saldo, chave, modelo) nao adianta repetir. */
|
|
89
|
+
function retryableMessage(message) {
|
|
90
|
+
return /fetch failed|failed to fetch|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up|network|stream interrompido|terminated|other side closed/i.test(
|
|
91
|
+
String(message || "")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Dorme respeitando o abort: o Esc mata a espera na hora. */
|
|
96
|
+
function wait(ms, signal) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
if (signal?.aborted) return resolve(false);
|
|
99
|
+
const done = (value) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
102
|
+
resolve(value);
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(() => done(true), ms);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
function onAbort() {
|
|
107
|
+
done(false);
|
|
108
|
+
}
|
|
109
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
65
110
|
});
|
|
66
|
-
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorFrom(data, status) {
|
|
114
|
+
const message =
|
|
115
|
+
data?.error?.message || data?.error || data?.body?.error?.message || `gateway error (${status})`;
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
code: data?.code || (status === 402 ? "no_balance" : "gateway_error"),
|
|
119
|
+
error: String(message),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Uma tentativa: request + leitura do stream.
|
|
125
|
+
* Devolve `retryable` quando o erro foi de rede/servidor e ainda nao veio texto.
|
|
126
|
+
*/
|
|
127
|
+
async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
|
|
128
|
+
const ready = await ensureReady({ model });
|
|
129
|
+
if (!ready.ok) return ready;
|
|
130
|
+
|
|
131
|
+
const started = Date.now();
|
|
132
|
+
let response;
|
|
133
|
+
try {
|
|
134
|
+
response = await fetchApi("/api/chat", {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { accept: "text/event-stream" },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
api_key: ready.apiKey,
|
|
139
|
+
model: ready.model,
|
|
140
|
+
messages,
|
|
141
|
+
tools: tools && tools.length ? tools : undefined,
|
|
142
|
+
stream: true,
|
|
143
|
+
}),
|
|
144
|
+
signal,
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error: `Falha de rede: ${error.message}`,
|
|
151
|
+
retryable: retryableMessage(error.message),
|
|
152
|
+
keyCreated: ready.keyCreated,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
67
155
|
|
|
68
156
|
if (!response.ok) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
return { ok: false, error: `Failed: ${data.error || "gateway error"}` };
|
|
157
|
+
const data = await response.json().catch(() => ({}));
|
|
158
|
+
return {
|
|
159
|
+
...errorFrom(data, response.status),
|
|
160
|
+
status: response.status,
|
|
161
|
+
keyCreated: ready.keyCreated,
|
|
162
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
163
|
+
};
|
|
77
164
|
}
|
|
78
165
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
166
|
+
const decoder = new TextDecoder();
|
|
167
|
+
let buffer = "";
|
|
168
|
+
let text = "";
|
|
169
|
+
let reasoning = "";
|
|
170
|
+
let usage = null;
|
|
171
|
+
const toolCalls = [];
|
|
172
|
+
|
|
173
|
+
const handleLine = (line) => {
|
|
174
|
+
if (!line.startsWith("data:")) return null;
|
|
175
|
+
const data = line.slice(5).trim();
|
|
176
|
+
if (!data || data === "[DONE]") return null;
|
|
177
|
+
let event;
|
|
178
|
+
try {
|
|
179
|
+
event = JSON.parse(data);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
88
182
|
}
|
|
89
|
-
|
|
183
|
+
if (event?.error) {
|
|
184
|
+
const message = event.error.message || String(event.error);
|
|
185
|
+
return { error: message, retryable: retryableMessage(message) };
|
|
186
|
+
}
|
|
187
|
+
if (event?.usage) usage = event.usage;
|
|
188
|
+
const delta = event?.choices?.[0]?.delta;
|
|
189
|
+
if (!delta) return null;
|
|
190
|
+
// Cada provedor manda o "pensamento" com um nome diferente.
|
|
191
|
+
const think = delta.reasoning_content ?? delta.reasoning ?? delta.thinking;
|
|
192
|
+
if (typeof think === "string" && think) {
|
|
193
|
+
reasoning += think;
|
|
194
|
+
onReasoning?.(think);
|
|
195
|
+
}
|
|
196
|
+
if (delta.content) {
|
|
197
|
+
text += delta.content;
|
|
198
|
+
onDelta?.(delta.content);
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(delta.tool_calls)) mergeToolCalls(toolCalls, delta.tool_calls);
|
|
201
|
+
return null;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
for await (const chunk of response.body) {
|
|
206
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
207
|
+
let index = buffer.indexOf("\n");
|
|
208
|
+
while (index >= 0) {
|
|
209
|
+
const line = buffer.slice(0, index).replace(/\r$/, "");
|
|
210
|
+
buffer = buffer.slice(index + 1);
|
|
211
|
+
const failure = handleLine(line);
|
|
212
|
+
if (failure) return { ok: false, error: failure.error, retryable: failure.retryable };
|
|
213
|
+
index = buffer.indexOf("\n");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (buffer.trim()) handleLine(buffer.trim());
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error?.name === "AbortError") {
|
|
219
|
+
return { ok: true, text, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: `Stream interrompido: ${error.message}`,
|
|
224
|
+
text,
|
|
225
|
+
retryable: retryableMessage(error.message),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
text,
|
|
232
|
+
reasoning,
|
|
233
|
+
toolCalls: toolCalls.filter(Boolean),
|
|
234
|
+
usage,
|
|
235
|
+
ms: Date.now() - started,
|
|
236
|
+
model: ready.model,
|
|
237
|
+
keyCreated: ready.keyCreated,
|
|
238
|
+
provider: response.headers?.get?.("x-kafox-provider") || "",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Chat completions com stream (SSE) via /api/chat, com retry em falha de rede.
|
|
244
|
+
* onRetry avisa a TUI (tentativa, espera) para ela mostrar a contagem.
|
|
245
|
+
*/
|
|
246
|
+
async function chat({ messages, model, tools, onDelta, onReasoning, onRetry, signal, retries = RETRY_MAX } = {}) {
|
|
247
|
+
const maxAttempts = Math.max(1, Number(retries) || RETRY_MAX);
|
|
248
|
+
let attempt = 0;
|
|
249
|
+
let last = null;
|
|
250
|
+
|
|
251
|
+
while (attempt < maxAttempts) {
|
|
252
|
+
attempt += 1;
|
|
253
|
+
const result = await attemptChat({ messages, model, tools, onDelta, onReasoning, signal });
|
|
254
|
+
|
|
255
|
+
if (result.ok || result.code === "aborted") return result;
|
|
256
|
+
// Ja saiu texto: repetir duplicaria a resposta na tela.
|
|
257
|
+
if (!result.retryable || result.text) return result;
|
|
258
|
+
last = result;
|
|
259
|
+
|
|
260
|
+
if (attempt >= maxAttempts) break;
|
|
261
|
+
|
|
262
|
+
const delay = backoffMs(attempt);
|
|
263
|
+
onRetry?.({ attempt, maxAttempts, delay, error: result.error });
|
|
264
|
+
const slept = await wait(delay, signal);
|
|
265
|
+
if (!slept) return { ok: false, code: "aborted", error: "Cancelado.", keyCreated: result.keyCreated };
|
|
90
266
|
}
|
|
91
267
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
typeof body === "string"
|
|
95
|
-
? body
|
|
96
|
-
: body?.choices?.[0]?.message?.content ||
|
|
97
|
-
body?.choices?.[0]?.text ||
|
|
98
|
-
JSON.stringify(body, null, 2);
|
|
268
|
+
return { ...last, attempts: attempt };
|
|
269
|
+
}
|
|
99
270
|
|
|
271
|
+
/** Compatibilidade: resposta unica, sem callbacks (usada por `kafox ask`). */
|
|
272
|
+
async function askModel(question, options = {}) {
|
|
273
|
+
const text = typeof question === "string" ? question : (question || []).join(" ");
|
|
274
|
+
if (!text.trim()) {
|
|
275
|
+
return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
|
|
276
|
+
}
|
|
277
|
+
const result = await chat({ messages: [{ role: "user", content: text }], model: options.model });
|
|
278
|
+
if (!result.ok) return result;
|
|
100
279
|
return {
|
|
101
280
|
ok: true,
|
|
102
|
-
text: String(
|
|
103
|
-
ms:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
keyCreated,
|
|
281
|
+
text: String(result.text || "").trim() || "(resposta vazia)",
|
|
282
|
+
ms: result.ms,
|
|
283
|
+
model: result.model,
|
|
284
|
+
keyCreated: result.keyCreated,
|
|
107
285
|
};
|
|
108
286
|
}
|
|
109
287
|
|
|
@@ -124,4 +302,13 @@ async function ask(question, options = {}) {
|
|
|
124
302
|
}
|
|
125
303
|
}
|
|
126
304
|
|
|
127
|
-
module.exports = {
|
|
305
|
+
module.exports = {
|
|
306
|
+
ask,
|
|
307
|
+
askModel,
|
|
308
|
+
chat,
|
|
309
|
+
ensureReady,
|
|
310
|
+
mergeToolCalls,
|
|
311
|
+
backoffMs,
|
|
312
|
+
RETRY_MAX,
|
|
313
|
+
RETRY_BASE_MS,
|
|
314
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tela de login do kafox (estilo Cline): ocupa o terminal inteiro, com a arte
|
|
3
|
+
* grande em cima, o link centralizado e o status de aprovacao animado.
|
|
4
|
+
*
|
|
5
|
+
* Sem TTY (pipe/CI) quem chama usa o fluxo de texto do auth.
|
|
6
|
+
*/
|
|
7
|
+
const chalk = require("chalk");
|
|
8
|
+
const { logoLines } = require("./logo");
|
|
9
|
+
const { borderColor } = require("./theme");
|
|
10
|
+
|
|
11
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
12
|
+
const visible = (value) => String(value).replace(ANSI, "").length;
|
|
13
|
+
const center = (text, width) => {
|
|
14
|
+
const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
|
|
15
|
+
return " ".repeat(pad) + text;
|
|
16
|
+
};
|
|
17
|
+
const rule = (width) => "─".repeat(Math.max(0, width));
|
|
18
|
+
|
|
19
|
+
function boxed(lines, width, theme) {
|
|
20
|
+
const border = borderColor(theme);
|
|
21
|
+
const inner = Math.max(20, Math.min(width, Math.max(...lines.map(visible)) + 4));
|
|
22
|
+
const out = [border(`┌${rule(inner)}┐`)];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
out.push(border("│") + " " + line + " ".repeat(Math.max(0, inner - visible(line) - 1)) + border("│"));
|
|
25
|
+
}
|
|
26
|
+
out.push(border(`└${rule(inner)}┘`));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} options.url URL que o usuario abre no navegador
|
|
33
|
+
* @param {string} options.code codigo curto (para digitar no site)
|
|
34
|
+
* @param {number} options.interval segundos entre consultas
|
|
35
|
+
* @param {number} options.expiresIn segundos de validade
|
|
36
|
+
* @param {(signal:AbortSignal) => Promise<{ok:boolean, session?:string, error?:string}>} options.poll
|
|
37
|
+
* @param {string} [options.theme]
|
|
38
|
+
* @param {string} [options.account] email ja conhecido (opcional)
|
|
39
|
+
*/
|
|
40
|
+
function runLoginScreen({
|
|
41
|
+
url,
|
|
42
|
+
code,
|
|
43
|
+
interval = 3,
|
|
44
|
+
expiresIn = 300,
|
|
45
|
+
poll,
|
|
46
|
+
theme = "default",
|
|
47
|
+
version = "",
|
|
48
|
+
} = {}) {
|
|
49
|
+
const stdout = process.stdout;
|
|
50
|
+
const stdin = process.stdin;
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const columns = Math.max(40, stdout.columns || 80);
|
|
53
|
+
const rows = Math.max(16, stdout.rows || 24);
|
|
54
|
+
const startedAt = Date.now();
|
|
55
|
+
const deadline = startedAt + (Number(expiresIn) || 300) * 1000;
|
|
56
|
+
|
|
57
|
+
let dots = 0;
|
|
58
|
+
let done = false;
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
let status = "aguardando aprovacao";
|
|
61
|
+
let statusColor = chalk.cyan;
|
|
62
|
+
|
|
63
|
+
const logo = logoLines(theme);
|
|
64
|
+
const logoWidth = Math.max(...logo.map(visible));
|
|
65
|
+
|
|
66
|
+
function lines() {
|
|
67
|
+
const out = [];
|
|
68
|
+
const spin = "⠋⠙⠹⠸⠼⠴⠦⠧".charAt(dots % 8);
|
|
69
|
+
if (columns >= logoWidth + 4 && rows >= 22) {
|
|
70
|
+
out.push("");
|
|
71
|
+
for (const line of logo) out.push(center(line, columns));
|
|
72
|
+
out.push("");
|
|
73
|
+
}
|
|
74
|
+
out.push(center(chalk.bold("Entre na sua conta"), columns));
|
|
75
|
+
out.push(center(chalk.dim("Aprove o acesso no navegador para continuar."), columns));
|
|
76
|
+
out.push("");
|
|
77
|
+
for (const line of boxed([chalk.cyan(url)], columns - 4, theme)) out.push(center(line, columns));
|
|
78
|
+
out.push("");
|
|
79
|
+
out.push(center(chalk.dim("codigo") + " " + chalk.bold(code), columns));
|
|
80
|
+
out.push("");
|
|
81
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
82
|
+
const left = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
|
|
83
|
+
out.push(
|
|
84
|
+
center(
|
|
85
|
+
statusColor(`${spin} ${status}${".".repeat(dots % 4)}`) +
|
|
86
|
+
chalk.dim(` ${left}s para expirar · ${elapsed}s esperando`),
|
|
87
|
+
columns
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
out.push("");
|
|
91
|
+
out.push(
|
|
92
|
+
center(
|
|
93
|
+
chalk.dim("Esc cancela") + " · " + chalk.dim("abra o link em qualquer navegador"),
|
|
94
|
+
columns
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
if (version) out.push(center(chalk.dim(`kafox v${version}`), columns));
|
|
98
|
+
|
|
99
|
+
// Centraliza o bloco inteiro na vertical, sem estourar a altura.
|
|
100
|
+
const slice = out.slice(0, rows - 1);
|
|
101
|
+
const top = Math.max(0, Math.floor((rows - slice.length) / 2));
|
|
102
|
+
return [...Array(top).fill(""), ...slice].slice(0, rows);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function paint() {
|
|
106
|
+
const frame = lines();
|
|
107
|
+
// Atualizacao sincronizada: o terminal troca o frame inteiro de uma vez,
|
|
108
|
+
// sem piscar entre as linhas.
|
|
109
|
+
stdout.write(
|
|
110
|
+
"\x1b[?2026h\x1b[?25l\x1b[H" +
|
|
111
|
+
frame.map((line) => line + "\x1b[K").join("\n") +
|
|
112
|
+
"\x1b[?2026l"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const enter = () => stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
|
|
117
|
+
const leave = () => stdout.write("\x1b[?25h\x1b[?1049l");
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
let ticker = null;
|
|
121
|
+
let poller = null;
|
|
122
|
+
let finished = false;
|
|
123
|
+
|
|
124
|
+
function finish(result) {
|
|
125
|
+
if (finished) return;
|
|
126
|
+
finished = true;
|
|
127
|
+
done = result.ok;
|
|
128
|
+
cancelled = result.cancelled;
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
status = "aprovado";
|
|
131
|
+
statusColor = chalk.green;
|
|
132
|
+
paint();
|
|
133
|
+
}
|
|
134
|
+
clearInterval(ticker);
|
|
135
|
+
clearInterval(poller);
|
|
136
|
+
stdin.removeListener("data", onData);
|
|
137
|
+
stdin.removeListener("end", onCancel);
|
|
138
|
+
try {
|
|
139
|
+
stdin.setRawMode(false);
|
|
140
|
+
} catch {}
|
|
141
|
+
stdin.pause();
|
|
142
|
+
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
leave();
|
|
145
|
+
resolve(result);
|
|
146
|
+
}, result.ok ? 450 : 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function onCancel() {
|
|
150
|
+
controller.abort();
|
|
151
|
+
finish({ ok: false, cancelled: true, error: "login cancelado" });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onData(chunk) {
|
|
155
|
+
const text = String(chunk);
|
|
156
|
+
// Ctrl+C ou Esc solto cancelam (setas tambem comecam com ESC, entao so
|
|
157
|
+
// um chunk que e exatamente o Esc conta).
|
|
158
|
+
if (text.includes("\x03") || text === "\x1b") return onCancel();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
162
|
+
stdin.setEncoding("utf8");
|
|
163
|
+
stdin.resume();
|
|
164
|
+
stdin.on("data", onData);
|
|
165
|
+
stdin.on("end", onCancel);
|
|
166
|
+
|
|
167
|
+
enter();
|
|
168
|
+
paint();
|
|
169
|
+
|
|
170
|
+
ticker = setInterval(() => {
|
|
171
|
+
dots += 1;
|
|
172
|
+
if (Date.now() > deadline) {
|
|
173
|
+
status = "codigo expirado";
|
|
174
|
+
statusColor = chalk.red;
|
|
175
|
+
paint();
|
|
176
|
+
return finish({ ok: false, error: "codigo expirado" });
|
|
177
|
+
}
|
|
178
|
+
if (typeof stdout.rows === "number") paint();
|
|
179
|
+
}, 140);
|
|
180
|
+
|
|
181
|
+
async function check() {
|
|
182
|
+
if (finished) return;
|
|
183
|
+
try {
|
|
184
|
+
const result = await poll(controller.signal);
|
|
185
|
+
if (finished) return;
|
|
186
|
+
if (result?.ok) return finish({ ok: true, session: result.session });
|
|
187
|
+
if (result?.cancelled) return finish(result);
|
|
188
|
+
if (result?.error) {
|
|
189
|
+
status = result.error;
|
|
190
|
+
statusColor = chalk.yellow;
|
|
191
|
+
paint();
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// erro passageiro: continua tentando ate expirar
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
poller = setInterval(check, Math.max(1, Number(interval) || 3) * 1000);
|
|
199
|
+
check();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { runLoginScreen };
|
package/src/logo.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logo do kafox: a arte "KAFOX" em blocos (fundo azul), adaptada por tema.
|
|
3
|
+
*
|
|
4
|
+
* A arte original vem com codigos ANSI por trecho; aqui ela e decodificada em
|
|
5
|
+
* segmentos {fg, bg, texto} e repintada com chalk, para que o fundo do tema
|
|
6
|
+
* entre nos espacos transparentes e o texto continue legivel no tema claro.
|
|
7
|
+
*/
|
|
8
|
+
const chalk = require("chalk");
|
|
9
|
+
const { themeColors } = require("./theme");
|
|
10
|
+
|
|
11
|
+
const SGR = /\x1b\[([0-9;]*)m/g;
|
|
12
|
+
|
|
13
|
+
// Paleta da arte. 97 = tinta clara, 94 = azul, 34 = azul fundo, 37 = neutro,
|
|
14
|
+
// 44 = fundo azul (aplicado junto de 94).
|
|
15
|
+
const PALETTES = {
|
|
16
|
+
dark: { ink: "#ffffff", gray: "#c8c8c8", blue: "#3f6ef0", deep: "#1c2fc4", fill: "#1c2fc4" },
|
|
17
|
+
light: { ink: "#2323b8", gray: "#6f6f6f", blue: "#4b52e8", deep: "#2a2ab0", fill: "#c7cfff" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const STYLE_KEYS = {
|
|
21
|
+
37: { fg: "gray" },
|
|
22
|
+
34: { fg: "deep" },
|
|
23
|
+
94: { fg: "blue" },
|
|
24
|
+
97: { fg: "ink" },
|
|
25
|
+
44: { bg: "fill" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RAW_LOGO = [
|
|
29
|
+
"\x1b[0;97m█\x1b[0;94m█\x1b[0;94;44m▓▓\x1b[0;37m \x1b[0;94;44m█▓▒░\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m███▄\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m████\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m███▄\x1b[0;37m \x1b[0;97m█\x1b[0;94m█\x1b[0;94;44m▓▓\x1b[0;37m \x1b[0;94;44m▓▒░\x1b[0;34m█\x1b[0m",
|
|
30
|
+
"\x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;94;44m▓▒░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m \x1b[0;34m██\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m░░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;94;44m▒░░\x1b[0;34m█\x1b[0m",
|
|
31
|
+
"\x1b[0;94;44m▒▒░░\x1b[0;34m█\x1b[0;94;44m░▒░▒\x1b[0;34m▄\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;34m██████\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▒▒\x1b[0;34m█\x1b[0;37m \x1b[0;94m▀\x1b[0;94;44m▒░░\x1b[0;34m██\x1b[0;94;44m░▒▒\x1b[0;94m▀\x1b[0m",
|
|
32
|
+
"\x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;94;44m░▓▓ \x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓ \x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓ \x1b[0m",
|
|
33
|
+
"\x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m▀████████▀\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0m",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
37
|
+
|
|
38
|
+
/** Decodifica uma linha crua em segmentos com estilo e texto. */
|
|
39
|
+
function parseLine(raw) {
|
|
40
|
+
const segments = [];
|
|
41
|
+
let style = { fg: null, bg: null };
|
|
42
|
+
let last = 0;
|
|
43
|
+
let match;
|
|
44
|
+
SGR.lastIndex = 0;
|
|
45
|
+
|
|
46
|
+
while ((match = SGR.exec(raw)) !== null) {
|
|
47
|
+
if (match.index > last) {
|
|
48
|
+
segments.push({ ...style, text: raw.slice(last, match.index) });
|
|
49
|
+
}
|
|
50
|
+
const codes = match[1].split(";").filter(Boolean).map(Number);
|
|
51
|
+
for (const code of codes) {
|
|
52
|
+
if (code === 0) style = { fg: null, bg: null };
|
|
53
|
+
else if (STYLE_KEYS[code]) Object.assign(style, STYLE_KEYS[code]);
|
|
54
|
+
}
|
|
55
|
+
last = match.index + match[0].length;
|
|
56
|
+
}
|
|
57
|
+
if (last < raw.length) segments.push({ ...style, text: raw.slice(last) });
|
|
58
|
+
return segments;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SEGMENTS = RAW_LOGO.map(parseLine);
|
|
62
|
+
|
|
63
|
+
const LOGO_TEXT = SEGMENTS.map((line) => line.map((part) => part.text).join(""));
|
|
64
|
+
const LOGO_WIDTH = Math.max(...LOGO_TEXT.map((line) => line.length));
|
|
65
|
+
const LOGO_HEIGHT = LOGO_TEXT.length;
|
|
66
|
+
|
|
67
|
+
/** Temas de fundo claro precisam de tinta escura para o desenho nao sumir. */
|
|
68
|
+
function paletteFor(theme) {
|
|
69
|
+
const { bg } = themeColors(theme);
|
|
70
|
+
if (!bg) return PALETTES.dark;
|
|
71
|
+
const [r, g, b] = [1, 3, 5].map((index) => parseInt(bg.slice(index, index + 2), 16));
|
|
72
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
73
|
+
return luminance > 0.6 ? PALETTES.light : PALETTES.dark;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function paintSegment(text, style, palette) {
|
|
77
|
+
if (!text) return "";
|
|
78
|
+
const fg = style.fg ? palette[style.fg] : null;
|
|
79
|
+
const bg = style.bg ? palette[style.bg] : null;
|
|
80
|
+
if (fg && bg) return chalk.bgHex(bg).hex(fg)(text);
|
|
81
|
+
if (bg) return chalk.bgHex(bg)(text);
|
|
82
|
+
if (fg) return chalk.hex(fg)(text);
|
|
83
|
+
return text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Linhas da arte ja coloridas (sem a cor de fundo da tela). */
|
|
87
|
+
function logoLines(theme = "default") {
|
|
88
|
+
const palette = paletteFor(theme);
|
|
89
|
+
return SEGMENTS.map((segments) =>
|
|
90
|
+
segments.map((part) => paintSegment(part.text, part, palette)).join("")
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Versao em texto puro (sem cor), para saidas nao-TTY e testes. */
|
|
95
|
+
function logoText() {
|
|
96
|
+
return LOGO_TEXT.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
logoLines,
|
|
101
|
+
logoText,
|
|
102
|
+
parseLine,
|
|
103
|
+
LOGO_WIDTH,
|
|
104
|
+
LOGO_HEIGHT,
|
|
105
|
+
PALETTES,
|
|
106
|
+
paletteFor,
|
|
107
|
+
stripAnsi: (value) => String(value).replace(ANSI_RE, ""),
|
|
108
|
+
};
|
package/src/mascot.js
CHANGED
|
@@ -1,32 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
const
|
|
3
|
-
"████ ████",
|
|
4
|
-
" ▒▒░░░░▓▓ ▓▓░░░░▒▒",
|
|
5
|
-
" ██░░▒▒░░██ ██░░▒▒░░██",
|
|
6
|
-
" ██░░▓▓▒▒░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░▒▒▓▓░░██",
|
|
7
|
-
" ██░░▓▓▓▓▒▒▓▓ ▓▓▒▒▓▓▓▓░░██",
|
|
8
|
-
" ██░░▒▒▒▒▓▓░░░░░░░░░░░░░░▓▓▒▒▒▒░░██",
|
|
9
|
-
" ██▒▒▓▓▓▓░░░░░░░░░░░░░░░░░░▓▓▓▓▒▒██",
|
|
10
|
-
"▒▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓▓",
|
|
11
|
-
"██ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ██",
|
|
12
|
-
"██ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ██",
|
|
13
|
-
"██ ░░▓▓▓▓░░░░░░░░░░░░░░░░░░▓▓▓▓░░ ██",
|
|
14
|
-
"██ ░░░░░░▓▓░░░░░░▒▒░░░░░░▓▓░░░░░░ ██",
|
|
15
|
-
"██░░░░░░░░░░▓▓░░▒▒▒▒▒▒░░▓▓░░░░░░░░░░██",
|
|
16
|
-
"██░░ ░░░░░░░░░░░░░░ ░░██ ▒▒▓▓",
|
|
17
|
-
" ██ ▓▓▓▓▓▓ ██ ██ ██",
|
|
18
|
-
" ▓▓ ▓▓ ▓▓ ▓▓▒▒ ██",
|
|
19
|
-
" ██ ▓▓ ▓▓ ██ ██ ██",
|
|
20
|
-
" ▓▓▓▓ ▓▓▓▓ ▓▓░░░░ ██",
|
|
21
|
-
" ██▒▒░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░▒▒██░░▒▒▒▒░░░░░░██",
|
|
22
|
-
" ██░░░░ ░░░░▓▓▒▒▒▒▒▒▒▒░░░░██",
|
|
23
|
-
" ██░░░░░░ ░░░░░░▓▓▒▒▒▒▒▒░░██",
|
|
24
|
-
" ██░░░░ ▒▒ ▒▒ ░░░░▓▓▒▒▒▒▒▒▓▓",
|
|
25
|
-
" ██░░░░ ▒▒ ▒▒ ░░░░▓▓▒▒████",
|
|
26
|
-
" ██▒▒░░ ▓▓ ▒▒ ▓▓ ░░▒▒██▓▓",
|
|
27
|
-
" ████ ▓▓ ▓▓ ▓▓ ████",
|
|
28
|
-
" ▒▒██▓▓ ▓▓ ▓▓██▒▒",
|
|
29
|
-
" ██████████",
|
|
30
|
-
].join("\n");
|
|
1
|
+
// Compatibilidade: o "mascote" agora e o logo KAFOX em blocos (logo.js).
|
|
2
|
+
const { logoText, logoLines } = require("./logo");
|
|
31
3
|
|
|
32
|
-
module.exports = { mascot:
|
|
4
|
+
module.exports = { mascot: logoText(), logoLines };
|