kafox-cli 0.9.8 → 0.10.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/bin/kafox.js +78 -0
- package/package.json +18 -1
- package/src/agent.js +24 -0
- package/src/auth.js +25 -6
- package/src/cli.js +3 -0
- package/src/compact.js +346 -0
- package/src/gateway.js +119 -53
- package/src/mobile/auth.js +352 -0
- package/src/mobile/bridge.js +285 -0
- package/src/mobile/index.js +538 -0
- package/src/mobile/protocol.js +51 -0
- package/src/mobile/qr.js +487 -0
- package/src/mobile/server.js +297 -0
- package/src/mobile/tui-bridge.js +289 -0
- package/src/mobile/view.js +734 -0
- package/src/models.js +19 -0
- package/src/providers-cli.js +82 -0
- package/src/providers.js +87 -0
- package/src/storage.js +39 -0
- package/src/tools.js +13 -2
- package/src/tui.js +549 -44
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kafox mobile`: abre a sessao da CLI no navegador do celular.
|
|
3
|
+
*
|
|
4
|
+
* Dois jeitos de usar, mesma base:
|
|
5
|
+
*
|
|
6
|
+
* 1. HOSPEDADO NA TUI (padrao, caminho do `kafox mobile`):
|
|
7
|
+
* `attachMobile({ state, api, options })` — chamado de dentro do runTUI.
|
|
8
|
+
* A TUI continua dona da sessao; o celular vira mais um cliente (TuiBridge).
|
|
9
|
+
* PC e celular operam a MESMA conversa: o que um digita, o outro ve.
|
|
10
|
+
*
|
|
11
|
+
* 2. HEADLESS (`runMobile`, sem TUI):
|
|
12
|
+
* sobe um servidor ligado a uma conversa salva, sem abrir a interface do
|
|
13
|
+
* terminal. Util para rodar em segundo plano / ssh. Usa SessionBridge.
|
|
14
|
+
*
|
|
15
|
+
* Exposicao de rede:
|
|
16
|
+
* --lan: serve em 0.0.0.0 e o QR usa http://<ip>:<porta>. Bom para testar.
|
|
17
|
+
* --public <url> (padrao https://web.kafox.dev/): bind em 127.0.0.1; o Caddy
|
|
18
|
+
* (com TLS) faz reverse_proxy de web.kafox.dev -> 127.0.0.1:<porta>. Assim
|
|
19
|
+
* o QR aponta para uma URL https valida — o navegador do celular exige
|
|
20
|
+
* HTTPS para varios recursos (e a camera para o proprio QR).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const os = require("os");
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const chalk = require("chalk");
|
|
27
|
+
const inquirer = require("inquirer");
|
|
28
|
+
const qr = require("./qr");
|
|
29
|
+
const { SessionBridge } = require("./bridge");
|
|
30
|
+
const { TuiBridge } = require("./tui-bridge");
|
|
31
|
+
const { createMobileServer, listenWithFallback } = require("./server");
|
|
32
|
+
const { PairingManager } = require("./auth");
|
|
33
|
+
const authStore = require("./auth");
|
|
34
|
+
const sessions = require("../sessions");
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PORT = 7788;
|
|
37
|
+
const DEFAULT_PUBLIC = "https://web.kafox.dev";
|
|
38
|
+
|
|
39
|
+
function localIPs() {
|
|
40
|
+
const out = [];
|
|
41
|
+
const ifaces = os.networkInterfaces();
|
|
42
|
+
for (const name of Object.keys(ifaces)) {
|
|
43
|
+
for (const net of ifaces[name] || []) {
|
|
44
|
+
if (net.family === "IPv4" && !net.internal) out.push({ name, address: net.address });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pickLanIP() {
|
|
51
|
+
const ips = localIPs();
|
|
52
|
+
const preferred = ips.find((i) => /^(192\.168|10\.|172\.(1[6-9]|2\d|3[01]))/.test(i.address));
|
|
53
|
+
return (preferred || ips[0])?.address || "127.0.0.1";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Seletor de conversa do `kafox mobile` headless.
|
|
58
|
+
* Mesmo espirito do picker do `kafox resume`: lista as conversas salvas
|
|
59
|
+
* (sessions.list(), de ~/.kafox/chats) da mais nova para a mais antiga, com
|
|
60
|
+
* titulo, data e o codigo para retomar — e uma opcao de comecar uma nova.
|
|
61
|
+
*/
|
|
62
|
+
async function chooseSession() {
|
|
63
|
+
const chats = sessions.list();
|
|
64
|
+
const choices = [{ name: chalk.green("+ nova conversa"), value: "__new__" }];
|
|
65
|
+
for (const chat of chats) {
|
|
66
|
+
const when = sessions.dayMonth(chat.updatedAt);
|
|
67
|
+
const title = String(chat.title || "(sem titulo)").slice(0, 60);
|
|
68
|
+
choices.push({
|
|
69
|
+
name: `${title}${when ? chalk.dim(` - ${when}`) : ""} ${chalk.dim(chat.id)}`,
|
|
70
|
+
value: chat.id,
|
|
71
|
+
short: title,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!chats.length) {
|
|
76
|
+
console.log(chalk.dim("Nenhuma conversa salva ainda — começando uma nova.\n"));
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { picked } = await inquirer.prompt([
|
|
81
|
+
{
|
|
82
|
+
type: "list",
|
|
83
|
+
name: "picked",
|
|
84
|
+
message: "Qual conversa tunnelar para o celular?",
|
|
85
|
+
pageSize: 12,
|
|
86
|
+
choices,
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
if (picked === "__new__") return null;
|
|
90
|
+
return picked;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Monta o texto do QR + moldura para o terminal (ou para o modal da TUI). */
|
|
94
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
95
|
+
const visibleLen = (s) => [...String(s).replace(ANSI_RE, "")].length;
|
|
96
|
+
|
|
97
|
+
function qrPanel(qrUrl, { sessionId, readonly, scale = 2, color = true } = {}) {
|
|
98
|
+
// quietZone=4 (minimo da spec) + scale=2: cada modulo fica 2 chars de largura
|
|
99
|
+
// x 2 meias-linhas de altura (bloco cheio) => QUADRADO e 2x maior que
|
|
100
|
+
// scale=1. Camera de celular le bem melhor de perto e de longe. Nunca
|
|
101
|
+
// "estica": largura e altura escalam juntas.
|
|
102
|
+
//
|
|
103
|
+
// color=true forca preto-no-branco em cores ANSI: camera de celular costuma
|
|
104
|
+
// NAO decodificar QR invertido (claro no fundo escuro do terminal), entao
|
|
105
|
+
// sem isso o codigo muitas vezes simplesmente nao e lido.
|
|
106
|
+
const lines = qr.toAscii(qrUrl, { quietZone: 4, scale, color }).split("\n");
|
|
107
|
+
const width = Math.max(...lines.map(visibleLen), qrUrl.length);
|
|
108
|
+
const bar = "─".repeat(width + 2);
|
|
109
|
+
const out = [];
|
|
110
|
+
out.push(`┌${bar}┐`);
|
|
111
|
+
out.push("│" + " Escaneie com a câmera do celular".padEnd(width + 2) + "│");
|
|
112
|
+
out.push(`│${" ".repeat(width + 2)}│`);
|
|
113
|
+
for (const line of lines) out.push("│ " + line + " ".repeat(Math.max(0, width - visibleLen(line))) + " │");
|
|
114
|
+
out.push(`│${" ".repeat(width + 2)}│`);
|
|
115
|
+
out.push("│ " + qrUrl.padEnd(width) + " │");
|
|
116
|
+
out.push(
|
|
117
|
+
"│ " +
|
|
118
|
+
`sessão ${sessionId || "?"}${readonly ? " · somente leitura" : ""} · expira em 120s`.padEnd(width) +
|
|
119
|
+
" │"
|
|
120
|
+
);
|
|
121
|
+
out.push(`└${bar}┘`);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sobe o servidor mobile preso a um `bridge` qualquer (TuiBridge ou
|
|
127
|
+
* SessionBridge). Devolve { server, pairing, port, base, qrUrl, watch }.
|
|
128
|
+
*
|
|
129
|
+
* Usa fallback de porta: se a porta pedida estiver ocupada, tenta as seguintes
|
|
130
|
+
* (7789, 7790…) em vez de estourar EADDRINUSE — assim varios `kafox mobile`
|
|
131
|
+
* podem coexistir.
|
|
132
|
+
*/
|
|
133
|
+
function serveBridge({ bridge, port = DEFAULT_PORT, lan = false, publicUrl, register = true, readonly = false, log = null }) {
|
|
134
|
+
const pairing = new PairingManager();
|
|
135
|
+
const { server } = createMobileServer({ bridge, pairing });
|
|
136
|
+
const host = lan ? "0.0.0.0" : "127.0.0.1";
|
|
137
|
+
|
|
138
|
+
return listenWithFallback(server, { port, host }).then((usedPort) => {
|
|
139
|
+
// Base publica:
|
|
140
|
+
// - --lan: IP da maquina (teste local na rede);
|
|
141
|
+
// - publicUrl explicito (--public-url): usa o que o usuario mandou;
|
|
142
|
+
// - porta base 7788: o dominio principal https://web.kafox.dev;
|
|
143
|
+
// - porta extra (fallback p/ varias sessoes): MESMA URL, com path
|
|
144
|
+
// https://web.kafox.dev/s/<porta>, que o Caddy roteia pra
|
|
145
|
+
// 127.0.0.1:<porta> (strip_prefix). Assim um so cert serve tudo.
|
|
146
|
+
let base;
|
|
147
|
+
if (lan) base = `http://${pickLanIP()}:${usedPort}`;
|
|
148
|
+
else if (publicUrl) base = String(publicUrl).replace(/\/$/, "");
|
|
149
|
+
else if (usedPort === DEFAULT_PORT) base = DEFAULT_PUBLIC;
|
|
150
|
+
else base = `${DEFAULT_PUBLIC}/s/${usedPort}`;
|
|
151
|
+
|
|
152
|
+
const code = pairing.issueCode();
|
|
153
|
+
const qrUrl = `${base}/#p=${code}`;
|
|
154
|
+
let sessionKey = null;
|
|
155
|
+
if (register) {
|
|
156
|
+
try {
|
|
157
|
+
sessionKey = authStore.registerSession({
|
|
158
|
+
port: usedPort,
|
|
159
|
+
sessionId: bridge.sessionId,
|
|
160
|
+
base,
|
|
161
|
+
readonly,
|
|
162
|
+
log,
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
/* registro best-effort */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { server, pairing, port: usedPort, base, qrUrl, sessionKey };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Redireciona o console para nao colidir com o desenho da TUI. */
|
|
173
|
+
function quietConsole() {
|
|
174
|
+
const noop = () => {};
|
|
175
|
+
const saved = { log: console.log, warn: console.warn, error: console.error };
|
|
176
|
+
console.log = console.warn = console.error = noop;
|
|
177
|
+
return () => {
|
|
178
|
+
console.log = saved.log;
|
|
179
|
+
console.warn = saved.warn;
|
|
180
|
+
console.error = saved.error;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Modo hospedado: chamado de dentro da TUI. Sobe o servidor e mostra o QR em
|
|
186
|
+
* um modal. Nao bloqueia o terminal — a interface continua usavel e o celular
|
|
187
|
+
* entra na mesma sessao.
|
|
188
|
+
*/
|
|
189
|
+
async function attachMobile({ state, api, options = {}, print } = {}) {
|
|
190
|
+
const bridge = new TuiBridge({
|
|
191
|
+
state,
|
|
192
|
+
api,
|
|
193
|
+
sessionId: state.sessionId,
|
|
194
|
+
cwd: process.cwd(),
|
|
195
|
+
readonly: Boolean(options.readonly),
|
|
196
|
+
});
|
|
197
|
+
bridge.start();
|
|
198
|
+
|
|
199
|
+
const { server, pairing, base, qrUrl, sessionKey } = await serveBridge({
|
|
200
|
+
bridge,
|
|
201
|
+
port: options.port || DEFAULT_PORT,
|
|
202
|
+
lan: Boolean(options.lan),
|
|
203
|
+
publicUrl: options.publicUrl,
|
|
204
|
+
readonly: Boolean(options.readonly),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const panel = qrPanel(qrUrl, { sessionId: state.sessionId, readonly: options.readonly });
|
|
208
|
+
const title = "kafox mobile";
|
|
209
|
+
const lines = [
|
|
210
|
+
`servidor em ${base}`,
|
|
211
|
+
"",
|
|
212
|
+
...panel,
|
|
213
|
+
"",
|
|
214
|
+
"aguardando pareamento… (Ctrl+C encerra)",
|
|
215
|
+
];
|
|
216
|
+
print ? print(lines) : console.log(`${title}\n${lines.join("\n")}`);
|
|
217
|
+
|
|
218
|
+
// Avisa no modal (se ainda estiver mostrando o QR) quando o celular pareia.
|
|
219
|
+
const watcher = setInterval(() => {
|
|
220
|
+
if (pairing.activeTokens() > 0) {
|
|
221
|
+
clearInterval(watcher);
|
|
222
|
+
if (state.modal?.title === title) {
|
|
223
|
+
state.modal = {
|
|
224
|
+
title,
|
|
225
|
+
text: [`✓ celular pareado — abra ${base}`, "", "kafox mobile rodando; Ctrl+C encerra."].join("\n"),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, 600);
|
|
230
|
+
watcher.unref?.();
|
|
231
|
+
|
|
232
|
+
const shutdown = () => {
|
|
233
|
+
bridge.stop();
|
|
234
|
+
if (sessionKey) authStore.unregisterSession(sessionKey);
|
|
235
|
+
try {
|
|
236
|
+
server.close();
|
|
237
|
+
} catch {
|
|
238
|
+
/* ja fechado */
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
process.once("SIGINT", shutdown);
|
|
242
|
+
process.once("SIGTERM", shutdown);
|
|
243
|
+
|
|
244
|
+
return { bridge, server, pairing, base, qrUrl, sessionKey, stop: shutdown };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Entry do `kafox mobile` headless: escolhe a conversa (seletor igual ao
|
|
249
|
+
* resume quando nao vem codigo), monta o bridge e sobe o servidor. Nao abre a TUI.
|
|
250
|
+
*/
|
|
251
|
+
async function runMobile({
|
|
252
|
+
code = null,
|
|
253
|
+
port = DEFAULT_PORT,
|
|
254
|
+
lan = false,
|
|
255
|
+
publicUrl,
|
|
256
|
+
allowAll = false,
|
|
257
|
+
readonly = false,
|
|
258
|
+
} = {}) {
|
|
259
|
+
let resolved = code;
|
|
260
|
+
if (!resolved && process.stdin.isTTY) {
|
|
261
|
+
resolved = await chooseSession();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let saved = null;
|
|
265
|
+
if (resolved === "last") saved = sessions.latest();
|
|
266
|
+
else if (resolved) saved = sessions.find(resolved);
|
|
267
|
+
|
|
268
|
+
const bridge = new SessionBridge({
|
|
269
|
+
cwd: process.cwd(),
|
|
270
|
+
sessionId: saved?.id || sessions.newId(),
|
|
271
|
+
sessionStartedAt: saved?.startedAt || new Date().toISOString(),
|
|
272
|
+
messages: saved?.messages || [],
|
|
273
|
+
context: saved?.context || [],
|
|
274
|
+
model: saved?.model || null,
|
|
275
|
+
allowAll: allowAll || Boolean(saved?.allowAll),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (saved) console.log(chalk.dim(` retomando: ${chalk.bold(saved.title || saved.id)} ${chalk.dim(saved.id)}`));
|
|
279
|
+
else console.log(chalk.dim(" nova conversa"));
|
|
280
|
+
|
|
281
|
+
// Log em arquivo para o `kafox mobile log <id>` funcionar tambem em
|
|
282
|
+
// primeiro plano (o daemon ja redireciona via KAFOX_MOBILE_LOG).
|
|
283
|
+
const logFile = process.env.KAFOX_MOBILE_LOG || path.join(os.homedir(), ".kafox", `mobile-${process.pid}.log`);
|
|
284
|
+
const daemon = Boolean(process.env.KAFOX_MOBILE_LOG); // daemon ja redireciona stdout
|
|
285
|
+
function note(line) {
|
|
286
|
+
if (daemon) return; // o redirect do daemon grava tudo; evita duplicar
|
|
287
|
+
try {
|
|
288
|
+
fs.appendFileSync(logFile, line.replace(/\x1b\[[0-9;]*m/g, "") + "\n");
|
|
289
|
+
} catch {
|
|
290
|
+
/* best-effort */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { server, pairing, base, qrUrl, sessionKey } = await serveBridge({
|
|
295
|
+
bridge,
|
|
296
|
+
port,
|
|
297
|
+
lan,
|
|
298
|
+
publicUrl,
|
|
299
|
+
readonly,
|
|
300
|
+
log: logFile,
|
|
301
|
+
});
|
|
302
|
+
console.log("");
|
|
303
|
+
note("");
|
|
304
|
+
for (const line of qrPanel(qrUrl, { sessionId: bridge.sessionId, readonly })) {
|
|
305
|
+
console.log(chalk.cyan(line));
|
|
306
|
+
note(line);
|
|
307
|
+
}
|
|
308
|
+
const tail = ` servidor em ${base} · aguardando pareamento… (Ctrl+C sai)`;
|
|
309
|
+
console.log(chalk.dim(`\n${tail}\n`));
|
|
310
|
+
note(tail);
|
|
311
|
+
|
|
312
|
+
await new Promise((resolve) => {
|
|
313
|
+
const shutdown = () => {
|
|
314
|
+
pairing.audit("shutdown", {});
|
|
315
|
+
bridge.save();
|
|
316
|
+
if (sessionKey) authStore.unregisterSession(sessionKey);
|
|
317
|
+
server.close(() => resolve());
|
|
318
|
+
setTimeout(resolve, 700).unref?.();
|
|
319
|
+
};
|
|
320
|
+
process.once("SIGINT", shutdown);
|
|
321
|
+
process.once("SIGTERM", shutdown);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* `kafox mobile list`: lista os servidores mobile de pe (do registro em disco),
|
|
327
|
+
* marcando os que ainda tem processo vivo.
|
|
328
|
+
*/
|
|
329
|
+
function printMobileList() {
|
|
330
|
+
const list = authStore.listSessions();
|
|
331
|
+
if (!list.length) {
|
|
332
|
+
console.log(chalk.dim("Nenhum servidor `kafox mobile` ativo."));
|
|
333
|
+
console.log(chalk.dim("Suba um com: kafox mobile"));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
console.log(chalk.bold("\n Servidores mobile ativos\n"));
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
for (const s of list) {
|
|
339
|
+
const age = Math.max(0, Math.round((now - (s.startedAt || now)) / 1000));
|
|
340
|
+
const ago = age < 60 ? `${age}s` : age < 3600 ? `${Math.round(age / 60)}min` : `${Math.round(age / 3600)}h`;
|
|
341
|
+
const state = s.alive ? chalk.green("● ativo") : chalk.red("○ morto");
|
|
342
|
+
console.log(
|
|
343
|
+
` ${state} ${chalk.cyan(String(s.id).padEnd(8))} porta ${String(s.port).padEnd(5)} ` +
|
|
344
|
+
`sessão ${chalk.dim(s.sessionId || "?")} ${chalk.dim(`há ${ago}`)}`
|
|
345
|
+
);
|
|
346
|
+
if (s.base) console.log(chalk.dim(` ${s.base}${s.readonly ? " (somente leitura)" : ""}`));
|
|
347
|
+
}
|
|
348
|
+
console.log(chalk.dim("\n Encerrar: kafox mobile kill <id> (ou `kafox mobile kill all`)"));
|
|
349
|
+
console.log(chalk.dim(" Ver QR: kafox mobile log <id>\n"));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* `kafox mobile kill <id|all>`: encerra o(s) servidor(es) pelo PID registrado.
|
|
354
|
+
* `all` mata todos. Sem argumento, pergunta (quando interativo).
|
|
355
|
+
*/
|
|
356
|
+
async function killMobileSessions(target) {
|
|
357
|
+
let list = authStore.listSessions();
|
|
358
|
+
if (!list.length) {
|
|
359
|
+
console.log(chalk.dim("Nenhum servidor `kafox mobile` ativo."));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let chosen = null;
|
|
364
|
+
if (!target) {
|
|
365
|
+
if (!process.stdin.isTTY) {
|
|
366
|
+
console.log(chalk.yellow("Informe o id (ou `all`): kafox mobile kill <id|all>"));
|
|
367
|
+
printMobileList();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const { picked } = await inquirer.prompt([
|
|
371
|
+
{
|
|
372
|
+
type: "list",
|
|
373
|
+
name: "picked",
|
|
374
|
+
message: "Encerrar qual servidor mobile?",
|
|
375
|
+
choices: [
|
|
376
|
+
{ name: chalk.red("✕ todos"), value: "__all__" },
|
|
377
|
+
...list.map((s) => ({
|
|
378
|
+
name: `${s.alive ? chalk.green("●") : chalk.red("○")} ${s.id} porta ${s.port} ${s.sessionId || "?"}`,
|
|
379
|
+
value: s.id,
|
|
380
|
+
})),
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
]);
|
|
384
|
+
chosen = picked;
|
|
385
|
+
} else {
|
|
386
|
+
chosen = String(target);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const targets =
|
|
390
|
+
chosen === "all" || chosen === "__all__"
|
|
391
|
+
? list
|
|
392
|
+
: list.filter((s) => s.id === chosen || String(s.id).startsWith(chosen));
|
|
393
|
+
|
|
394
|
+
if (!targets.length) {
|
|
395
|
+
console.log(chalk.yellow(`Nenhum servidor mobile com id "${chosen}".`));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
for (const s of targets) {
|
|
400
|
+
let killed = false;
|
|
401
|
+
if (s.alive) {
|
|
402
|
+
try {
|
|
403
|
+
process.kill(s.pid, "SIGTERM");
|
|
404
|
+
killed = true;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.log(chalk.red(` não deu pra encerrar ${s.id} (pid ${s.pid}): ${error.message}`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
authStore.unregisterSession(s.id);
|
|
410
|
+
console.log(
|
|
411
|
+
killed
|
|
412
|
+
? chalk.green(` ✓ encerrado ${s.id} (pid ${s.pid}, porta ${s.port})`)
|
|
413
|
+
: chalk.dim(` • ${s.id} já estava morto (registro limpo)`)
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* `kafox mobile --daemon`: relanca o fluxo mobile em background (detached) e
|
|
420
|
+
* devolve o terminal. O filho:
|
|
421
|
+
* - e destacado com setsid (sobrevive ao fechar o SSH/terminal);
|
|
422
|
+
* - tem stdout/stderr redirecionados para ~/.kafox/mobile-<pid>.log;
|
|
423
|
+
* - grava PID/porta/sessao no registro (mobile-sessions.json) via serveBridge,
|
|
424
|
+
* entao `kafox mobile list` / `kill` / `log` o encontram.
|
|
425
|
+
*
|
|
426
|
+
* Reconstroi o argv a partir das opcoes (sem a flag -d) porque o filho roda o
|
|
427
|
+
* comando normal, em primeiro plano, mas destacado do terminal.
|
|
428
|
+
*/
|
|
429
|
+
function spawnMobileDaemon({
|
|
430
|
+
code = null,
|
|
431
|
+
port,
|
|
432
|
+
lan = false,
|
|
433
|
+
publicUrl,
|
|
434
|
+
readonly = false,
|
|
435
|
+
allowAll = false,
|
|
436
|
+
} = {}) {
|
|
437
|
+
const { spawn } = require("child_process");
|
|
438
|
+
const fs = require("fs");
|
|
439
|
+
const path = require("path");
|
|
440
|
+
|
|
441
|
+
const bin = path.join(__dirname, "..", "..", "bin", "kafox.js");
|
|
442
|
+
const args = [bin, "mobile"];
|
|
443
|
+
|
|
444
|
+
// Repassa o alvo: 'last', codigo da conversa, ou nada (-> seletor nao roda
|
|
445
|
+
// em daemon porque nao ha TTY; nesse caso o filho cai em conversa nova).
|
|
446
|
+
if (code === "last") args.push("--last");
|
|
447
|
+
else if (code) args.push(code);
|
|
448
|
+
|
|
449
|
+
if (port) args.push("--port", String(port));
|
|
450
|
+
if (lan) args.push("--lan");
|
|
451
|
+
if (publicUrl) args.push("--public-url", publicUrl);
|
|
452
|
+
if (readonly) args.push("--readonly");
|
|
453
|
+
if (allowAll) args.push("-y");
|
|
454
|
+
|
|
455
|
+
// O daemon nao tem TTY: o runMobile nao deve tentar abrir o seletor.
|
|
456
|
+
const logDir = path.join(os.homedir(), ".kafox");
|
|
457
|
+
try {
|
|
458
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
459
|
+
} catch {
|
|
460
|
+
/* ok */
|
|
461
|
+
}
|
|
462
|
+
// Nome do log inclui o timestamp pra nao sobrescrever sessoes antigas; o
|
|
463
|
+
// mapa pid->log fica no registro (campo log). Aqui usamos um sufixo curto.
|
|
464
|
+
const stamp = Date.now().toString(36);
|
|
465
|
+
const logFile = path.join(logDir, `mobile-${stamp}.log`);
|
|
466
|
+
const out = fs.openSync(logFile, "a");
|
|
467
|
+
|
|
468
|
+
const child = spawn(process.execPath, args, {
|
|
469
|
+
detached: true,
|
|
470
|
+
stdio: ["ignore", out, out],
|
|
471
|
+
env: { ...process.env, KAFOX_MOBILE_DAEMON: "1", KAFOX_MOBILE_LOG: logFile },
|
|
472
|
+
cwd: process.cwd(),
|
|
473
|
+
});
|
|
474
|
+
child.unref();
|
|
475
|
+
|
|
476
|
+
console.log(chalk.green(` ✓ kafox mobile rodando em background (pid ${child.pid})`));
|
|
477
|
+
console.log(chalk.dim(` porta/log: ~/.kafox/${path.basename(logFile)}`));
|
|
478
|
+
console.log(chalk.dim(` veja o QR depois com: ${chalk.bold(`kafox mobile log ${child.pid}`)}`));
|
|
479
|
+
console.log(chalk.dim(` encerrar: ${chalk.bold(`kafox mobile kill ${child.pid}`)} (ou kill all)`));
|
|
480
|
+
return child.pid;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* `kafox mobile log <id>` (alias `qr`): reimprime o QR/URL de um servidor
|
|
485
|
+
* mobile em execucao, pra parear mais um celular. Se nao achar no registro,
|
|
486
|
+
* mostra o log bruto do processo (util pra ver erro de subida).
|
|
487
|
+
*/
|
|
488
|
+
function printMobileLog(id) {
|
|
489
|
+
const fs = require("fs");
|
|
490
|
+
const path = require("path");
|
|
491
|
+
const list = authStore.listSessions();
|
|
492
|
+
|
|
493
|
+
let entry = null;
|
|
494
|
+
if (id) {
|
|
495
|
+
entry =
|
|
496
|
+
list.find((s) => String(s.id) === String(id)) ||
|
|
497
|
+
list.find((s) => String(s.id).startsWith(String(id)));
|
|
498
|
+
} else if (list.length === 1) {
|
|
499
|
+
entry = list[0];
|
|
500
|
+
} else if (list.length > 1) {
|
|
501
|
+
console.log(chalk.yellow("Há mais de um servidor ativo. Informe o id:\n"));
|
|
502
|
+
printMobileList();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!entry) {
|
|
507
|
+
console.log(chalk.dim("Nenhum servidor `kafox mobile` ativo com esse id."));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const logFile = entry.log || null;
|
|
512
|
+
if (logFile && fs.existsSync(logFile)) {
|
|
513
|
+
const text = fs.readFileSync(logFile, "utf8");
|
|
514
|
+
// Reproduz as ultimas linhas do log, onde fica o QR do pareamento.
|
|
515
|
+
const lines = text.split("\n").filter(Boolean).slice(-40).join("\n");
|
|
516
|
+
console.log(chalk.dim(`── log de ${entry.id} (porta ${entry.port}) ──\n`));
|
|
517
|
+
console.log(lines);
|
|
518
|
+
} else {
|
|
519
|
+
console.log(chalk.dim(`servidor ${entry.id} na porta ${entry.port} · ${entry.base || ""}`));
|
|
520
|
+
console.log(chalk.dim("(log nao encontrado; use `kafox mobile list` para os detalhes)"));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
module.exports = {
|
|
525
|
+
attachMobile,
|
|
526
|
+
runMobile,
|
|
527
|
+
serveBridge,
|
|
528
|
+
qrPanel,
|
|
529
|
+
visibleLen,
|
|
530
|
+
chooseSession,
|
|
531
|
+
quietConsole,
|
|
532
|
+
printMobileList,
|
|
533
|
+
killMobileSessions,
|
|
534
|
+
spawnMobileDaemon,
|
|
535
|
+
printMobileLog,
|
|
536
|
+
DEFAULT_PORT,
|
|
537
|
+
DEFAULT_PUBLIC,
|
|
538
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocolo do kafox mobile: envelope de eventos com sequencia (seq) e um
|
|
3
|
+
* ring buffer para replay no reconnect.
|
|
4
|
+
*
|
|
5
|
+
* Por que seq + buffer: o celular dorme, troca Wi-Fi/4G e o SSE cai. Quando ele
|
|
6
|
+
* volta, manda `?since=<ultimoSeq>` e recebe so o que perdeu, em vez de perder
|
|
7
|
+
* o turno inteiro (ou ter que recarregar o historico).
|
|
8
|
+
*
|
|
9
|
+
* Envelope (sempre JSON em uma linha):
|
|
10
|
+
* { seq, type, at, ...payload }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RING_SIZE = 500;
|
|
14
|
+
|
|
15
|
+
class EventLog {
|
|
16
|
+
constructor(size = RING_SIZE) {
|
|
17
|
+
this.size = size;
|
|
18
|
+
this.seq = 0;
|
|
19
|
+
this.buffer = [];
|
|
20
|
+
this.listeners = new Set();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Registra um evento, guarda no ring buffer e entrega aos assinantes. */
|
|
24
|
+
push(type, payload = {}) {
|
|
25
|
+
this.seq += 1;
|
|
26
|
+
const event = { seq: this.seq, type, at: Date.now(), ...payload };
|
|
27
|
+
this.buffer.push(event);
|
|
28
|
+
if (this.buffer.length > this.size) this.buffer.shift();
|
|
29
|
+
for (const listener of this.listeners) {
|
|
30
|
+
try {
|
|
31
|
+
listener(event);
|
|
32
|
+
} catch {
|
|
33
|
+
// um cliente com erro nao derruba os outros nem a sessao.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return event;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Eventos a partir de um seq (exclusivo), para replay no reconnect. */
|
|
40
|
+
since(seq = 0) {
|
|
41
|
+
if (!seq) return this.buffer.slice();
|
|
42
|
+
return this.buffer.filter((event) => event.seq > seq);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
subscribe(listener) {
|
|
46
|
+
this.listeners.add(listener);
|
|
47
|
+
return () => this.listeners.delete(listener);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { EventLog, RING_SIZE };
|