kafox-cli 0.9.9 → 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 +1 -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/tui.js +517 -27
package/src/gateway.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const chalk = require("chalk");
|
|
2
2
|
const storage = require("./storage");
|
|
3
3
|
const { ensureSession, fetchApi } = require("./auth");
|
|
4
|
+
const { providerForModel } = require("./providers");
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Modelo do free tier: o nome carrega o sufixo " (free)" (ou ":free"). Esses
|
|
@@ -11,8 +12,10 @@ function isFreeModel(model) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Garante sessao, saldo, modelo e chave antes de qualquer chamada
|
|
15
|
-
* A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
|
|
15
|
+
* Garante sessao, saldo, modelo e chave antes de qualquer chamada ao gateway
|
|
16
|
+
* kafox. A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
|
|
17
|
+
* Provedores alternativos (OpenAI-compatible) tem seu proprio caminho em
|
|
18
|
+
* `attemptOpenAI`: nao passam por login/saldo, so precisam da chave do provedor.
|
|
16
19
|
*/
|
|
17
20
|
async function ensureReady(options = {}) {
|
|
18
21
|
const token = await ensureSession();
|
|
@@ -133,48 +136,10 @@ function errorFrom(data, status) {
|
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
139
|
+
* Le um stream SSE de chat completions (OpenAI-compatible) e chama os
|
|
140
|
+
* callbacks a cada delta. Devolve o resultado final.
|
|
138
141
|
*/
|
|
139
|
-
async function
|
|
140
|
-
const ready = await ensureReady({ model });
|
|
141
|
-
if (!ready.ok) return ready;
|
|
142
|
-
|
|
143
|
-
const started = Date.now();
|
|
144
|
-
let response;
|
|
145
|
-
try {
|
|
146
|
-
response = await fetchApi("/api/chat", {
|
|
147
|
-
method: "POST",
|
|
148
|
-
headers: { accept: "text/event-stream" },
|
|
149
|
-
body: JSON.stringify({
|
|
150
|
-
api_key: ready.apiKey,
|
|
151
|
-
model: ready.model,
|
|
152
|
-
messages,
|
|
153
|
-
tools: tools && tools.length ? tools : undefined,
|
|
154
|
-
stream: true,
|
|
155
|
-
}),
|
|
156
|
-
signal,
|
|
157
|
-
});
|
|
158
|
-
} catch (error) {
|
|
159
|
-
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
160
|
-
return {
|
|
161
|
-
ok: false,
|
|
162
|
-
error: `Falha de rede: ${error.message}`,
|
|
163
|
-
retryable: retryableMessage(error.message),
|
|
164
|
-
keyCreated: ready.keyCreated,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (!response.ok) {
|
|
169
|
-
const data = await response.json().catch(() => ({}));
|
|
170
|
-
return {
|
|
171
|
-
...errorFrom(data, response.status),
|
|
172
|
-
status: response.status,
|
|
173
|
-
keyCreated: ready.keyCreated,
|
|
174
|
-
retryable: RETRYABLE_STATUS.has(response.status),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
142
|
+
async function readStream({ response, started, onDelta, onReasoning }) {
|
|
178
143
|
const decoder = new TextDecoder();
|
|
179
144
|
let buffer = "";
|
|
180
145
|
let text = "";
|
|
@@ -238,16 +203,114 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
|
|
|
238
203
|
};
|
|
239
204
|
}
|
|
240
205
|
|
|
241
|
-
return {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
206
|
+
return { ok: true, text, reasoning, toolCalls: toolCalls.filter(Boolean), usage, ms: Date.now() - started };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Uma tentativa no provedor kafox (gateway proprio): login/saldo/chave da conta
|
|
211
|
+
* e POST em `/api/chat`.
|
|
212
|
+
*/
|
|
213
|
+
async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
|
|
214
|
+
const socket = providerForModel(model);
|
|
215
|
+
if (socket.provider.kind === "openai") {
|
|
216
|
+
return attemptOpenAI({ messages, model: socket.model, provider: socket.provider, tools, onDelta, onReasoning, signal });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ready = await ensureReady({ model });
|
|
220
|
+
if (!ready.ok) return ready;
|
|
221
|
+
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
let response;
|
|
224
|
+
try {
|
|
225
|
+
response = await fetchApi("/api/chat", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { accept: "text/event-stream" },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
api_key: ready.apiKey,
|
|
230
|
+
model: ready.model,
|
|
231
|
+
messages,
|
|
232
|
+
tools: tools && tools.length ? tools : undefined,
|
|
233
|
+
stream: true,
|
|
234
|
+
}),
|
|
235
|
+
signal,
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: `Falha de rede: ${error.message}`,
|
|
242
|
+
retryable: retryableMessage(error.message),
|
|
243
|
+
keyCreated: ready.keyCreated,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
const data = await response.json().catch(() => ({}));
|
|
249
|
+
return {
|
|
250
|
+
...errorFrom(data, response.status),
|
|
251
|
+
status: response.status,
|
|
252
|
+
keyCreated: ready.keyCreated,
|
|
253
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const result = await readStream({ response, started, onDelta, onReasoning });
|
|
258
|
+
return { ...result, model: ready.model, keyCreated: ready.keyCreated };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Uma tentativa num provedor OpenAI-compatible: POST direto em
|
|
263
|
+
* `{baseUrl}/chat/completions` com a chave do usuario. Sem login/saldo da conta
|
|
264
|
+
* kafox; o custo e cobrado pelo proprio provedor.
|
|
265
|
+
*/
|
|
266
|
+
async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReasoning, signal } = {}) {
|
|
267
|
+
const apiKey = storage.getProviderKey(provider.id);
|
|
268
|
+
if (!apiKey) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
code: "no_provider_key",
|
|
272
|
+
error: `Provedor ${provider.label} sem chave. Rode: kafox key ${provider.id} <chave>`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const started = Date.now();
|
|
277
|
+
let response;
|
|
278
|
+
try {
|
|
279
|
+
response = await fetch(`${provider.baseUrl}/chat/completions`, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: {
|
|
282
|
+
"content-type": "application/json",
|
|
283
|
+
accept: "text/event-stream",
|
|
284
|
+
authorization: `Bearer ${apiKey}`,
|
|
285
|
+
},
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
model,
|
|
288
|
+
messages,
|
|
289
|
+
tools: tools && tools.length ? tools : undefined,
|
|
290
|
+
stream: true,
|
|
291
|
+
}),
|
|
292
|
+
signal,
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
error: `Falha de rede: ${error.message}`,
|
|
299
|
+
retryable: retryableMessage(error.message),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!response.ok) {
|
|
304
|
+
const data = await response.json().catch(() => ({}));
|
|
305
|
+
return {
|
|
306
|
+
...errorFrom(data, response.status),
|
|
307
|
+
status: response.status,
|
|
308
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const result = await readStream({ response, started, onDelta, onReasoning });
|
|
313
|
+
return { ...result, model, provider: provider.id };
|
|
251
314
|
}
|
|
252
315
|
|
|
253
316
|
/**
|
|
@@ -324,6 +387,9 @@ module.exports = {
|
|
|
324
387
|
askModel,
|
|
325
388
|
chat,
|
|
326
389
|
ensureReady,
|
|
390
|
+
attemptChat,
|
|
391
|
+
attemptOpenAI,
|
|
392
|
+
readStream,
|
|
327
393
|
isFreeModel,
|
|
328
394
|
mergeToolCalls,
|
|
329
395
|
backoffMs,
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pareamento do kafox mobile.
|
|
3
|
+
*
|
|
4
|
+
* Fluxo (seguro por construcao):
|
|
5
|
+
* 1. a CLI gera um `pair_code` curto, valido por 120s, de uso unico;
|
|
6
|
+
* 2. o QR carrega SO esse codigo — nunca o token de sessao;
|
|
7
|
+
* 3. o celular troca o codigo por um `session_token` (12h) via POST /pair;
|
|
8
|
+
* 4. todas as rotas seguintes exigem o token no header `x-kafox-token`.
|
|
9
|
+
*
|
|
10
|
+
* O codigo e o token sao guardados apenas como SHA-256 em memoria (o par do
|
|
11
|
+
* arquivo de auditoria guarda so o hash, nunca o valor em claro).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require("crypto");
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
|
|
19
|
+
const PAIR_TTL_MS = 120_000;
|
|
20
|
+
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
|
21
|
+
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // base32 sem 0/O/1/I
|
|
22
|
+
|
|
23
|
+
function randomCode(length = 8) {
|
|
24
|
+
const bytes = crypto.randomBytes(length);
|
|
25
|
+
let out = "";
|
|
26
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sha256(value) {
|
|
31
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Registro dos pareamentos em disco: { hash(token): { createdAt, expiresAt, device } }.
|
|
35
|
+
// Guarda so o SHA-256 do token (nunca o valor em claro), para o `--revoke`
|
|
36
|
+
// funcionar mesmo depois de reiniciar o CLI.
|
|
37
|
+
const SESSIONS_FILE =
|
|
38
|
+
process.env.KAFOX_MOBILE_SESSIONS || path.join(os.homedir(), ".kafox", "mobile-sessions.json");
|
|
39
|
+
|
|
40
|
+
function loadSessions() {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(SESSIONS_FILE, "utf8");
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveSessions(sessions) {
|
|
51
|
+
try {
|
|
52
|
+
fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true, mode: 0o700 });
|
|
53
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), { mode: 0o600 });
|
|
54
|
+
} catch {
|
|
55
|
+
// persistencia nao pode derrubar o pareamento.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Revoga os pareamentos salvos em disco. `{ all: true }` limpa tudo e devolve
|
|
61
|
+
* quantos foram removidos. `{ token }` remove so o hash daquele token.
|
|
62
|
+
*/
|
|
63
|
+
function isTokenEntry(entry) {
|
|
64
|
+
// entradas de servidor tem kind:"server"; as antigas (sem kind) sao tokens.
|
|
65
|
+
return entry && entry.kind !== "server";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function revoke({ all = false, token = null } = {}) {
|
|
69
|
+
const sessions = loadSessions();
|
|
70
|
+
if (all) {
|
|
71
|
+
let count = 0;
|
|
72
|
+
for (const key of Object.keys(sessions)) {
|
|
73
|
+
// NAO apaga entradas de servidor (kind:"server") — elas nao sao pareamento.
|
|
74
|
+
if (isTokenEntry(sessions[key])) {
|
|
75
|
+
delete sessions[key];
|
|
76
|
+
count += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
saveSessions(sessions);
|
|
80
|
+
return count;
|
|
81
|
+
}
|
|
82
|
+
if (token) {
|
|
83
|
+
const hash = sha256(token);
|
|
84
|
+
if (!sessions[hash] || !isTokenEntry(sessions[hash])) return 0;
|
|
85
|
+
delete sessions[hash];
|
|
86
|
+
saveSessions(sessions);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
// sem alvo: limpa os que ja expiraram (apenas pareamentos).
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
let removed = 0;
|
|
92
|
+
for (const key of Object.keys(sessions)) {
|
|
93
|
+
const entry = sessions[key];
|
|
94
|
+
if (!isTokenEntry(entry)) continue;
|
|
95
|
+
if (now > (entry?.expiresAt || 0)) {
|
|
96
|
+
delete sessions[key];
|
|
97
|
+
removed += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (removed) saveSessions(sessions);
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class PairingManager {
|
|
105
|
+
constructor({ auditLog = path.join(os.homedir(), ".kafox", "audit.log") } = {}) {
|
|
106
|
+
this.auditLog = auditLog;
|
|
107
|
+
this.pending = new Map(); // hash(pair_code) -> { createdAt, expiresAt, claims }
|
|
108
|
+
// Tokens pareados: carrega do disco (so o hash) e marca como ativos.
|
|
109
|
+
this.tokens = new Map();
|
|
110
|
+
const saved = loadSessions();
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
for (const [hash, entry] of Object.entries(saved)) {
|
|
113
|
+
if (!isTokenEntry(entry)) continue; // pula entradas de servidor.
|
|
114
|
+
if (entry && now < (entry.expiresAt || 0)) this.tokens.set(hash, entry);
|
|
115
|
+
}
|
|
116
|
+
this.currentCode = null;
|
|
117
|
+
this.currentCodeHash = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Persiste o mapa de tokens (apenas hashes) no arquivo de sessoes,
|
|
122
|
+
* PRESERVANDO as entradas de servidor (kind:"server") ja registradas.
|
|
123
|
+
*/
|
|
124
|
+
persistTokens() {
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const [key, entry] of Object.entries(loadSessions())) {
|
|
127
|
+
if (isServerEntry(entry)) out[key] = entry;
|
|
128
|
+
}
|
|
129
|
+
for (const [hash, entry] of this.tokens) out[hash] = entry;
|
|
130
|
+
saveSessions(out);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Gera um par codigo+hash, expira o anterior e devolve o codigo em claro. */
|
|
134
|
+
issueCode() {
|
|
135
|
+
const code = randomCode(8);
|
|
136
|
+
const hash = sha256(code);
|
|
137
|
+
// um pareamento por vez: o codigo novo invalida o anterior.
|
|
138
|
+
if (this.currentCodeHash) this.pending.delete(this.currentCodeHash);
|
|
139
|
+
this.pending.set(hash, {
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
expiresAt: Date.now() + PAIR_TTL_MS,
|
|
142
|
+
claims: 0,
|
|
143
|
+
});
|
|
144
|
+
this.currentCode = code;
|
|
145
|
+
this.currentCodeHash = hash;
|
|
146
|
+
return code;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get expiresAt() {
|
|
150
|
+
const entry = this.currentCodeHash ? this.pending.get(this.currentCodeHash) : null;
|
|
151
|
+
return entry ? entry.expiresAt : 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True se o codigo e valido, nao expirou e nao foi usado. */
|
|
155
|
+
validateCode(code) {
|
|
156
|
+
const entry = this.pending.get(sha256(code));
|
|
157
|
+
if (!entry) return false;
|
|
158
|
+
if (Date.now() > entry.expiresAt) {
|
|
159
|
+
this.pending.delete(sha256(code));
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Troca um pair_code valido por um token de sessao. Uso unico. */
|
|
166
|
+
claim(code, device = {}) {
|
|
167
|
+
const hash = sha256(code);
|
|
168
|
+
const entry = this.pending.get(hash);
|
|
169
|
+
if (!entry || Date.now() > entry.expiresAt) return null;
|
|
170
|
+
// single-use: consome o codigo do servidor.
|
|
171
|
+
this.pending.delete(hash);
|
|
172
|
+
if (this.currentCodeHash === hash) this.currentCode = null;
|
|
173
|
+
|
|
174
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
175
|
+
this.tokens.set(sha256(token), {
|
|
176
|
+
createdAt: Date.now(),
|
|
177
|
+
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
178
|
+
code,
|
|
179
|
+
device,
|
|
180
|
+
});
|
|
181
|
+
this.persistTokens();
|
|
182
|
+
this.audit("pair", { device, codeHash: hash.slice(0, 12) });
|
|
183
|
+
return { token, expiresAt: Date.now() + TOKEN_TTL_MS };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** True se o token de sessao existe e nao expirou. */
|
|
187
|
+
validateToken(token) {
|
|
188
|
+
if (!token) return false;
|
|
189
|
+
const entry = this.tokens.get(sha256(token));
|
|
190
|
+
if (!entry) return false;
|
|
191
|
+
if (Date.now() > entry.expiresAt) {
|
|
192
|
+
this.tokens.delete(sha256(token));
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
/** Revoga tudo (usado por `kafox mobile --revoke`). */
|
|
201
|
+
revokeAll() {
|
|
202
|
+
const count = this.tokens.size;
|
|
203
|
+
this.tokens.clear();
|
|
204
|
+
this.pending.clear();
|
|
205
|
+
this.currentCode = null;
|
|
206
|
+
this.currentCodeHash = null;
|
|
207
|
+
// preserva servidores registrados; apaga so os pareamentos.
|
|
208
|
+
const registry = loadSessions();
|
|
209
|
+
for (const key of Object.keys(registry)) {
|
|
210
|
+
if (isTokenEntry(registry[key])) delete registry[key];
|
|
211
|
+
}
|
|
212
|
+
saveSessions(registry);
|
|
213
|
+
this.audit("revoke-all", { tokens: count });
|
|
214
|
+
return count;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
activeTokens() {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const [hash, entry] of [...this.tokens]) if (now > entry.expiresAt) this.tokens.delete(hash);
|
|
220
|
+
return this.tokens.size;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
audit(action, meta = {}) {
|
|
224
|
+
try {
|
|
225
|
+
fs.mkdirSync(path.dirname(this.auditLog), { recursive: true, mode: 0o700 });
|
|
226
|
+
fs.appendFileSync(
|
|
227
|
+
this.auditLog,
|
|
228
|
+
`${JSON.stringify({ at: new Date().toISOString(), action, ...meta })}\n`,
|
|
229
|
+
{ mode: 0o600 }
|
|
230
|
+
);
|
|
231
|
+
} catch {
|
|
232
|
+
// auditoria nao pode quebrar o pareamento.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sessao mobile ativa: representa UM servidor `kafox mobile` de pe.
|
|
239
|
+
*
|
|
240
|
+
* O arquivo `mobile-sessions.json` guarda dois tipos de entrada (discriminadas
|
|
241
|
+
* pelo campo `kind`):
|
|
242
|
+
* - `kind: "token"` -> pareamento do celular (hash -> { createdAt, ... }).
|
|
243
|
+
* - `kind: "server"` -> servidor rodando (id -> { pid, port, sessionId, ... }).
|
|
244
|
+
*
|
|
245
|
+
* Entradas antigas (sem `kind`) sao tratadas como token, para nao perder os
|
|
246
|
+
* pareamentos ja existentes.
|
|
247
|
+
*/
|
|
248
|
+
const SERVER_STATE_FILE =
|
|
249
|
+
process.env.KAFOX_MOBILE_SESSIONS || path.join(os.homedir(), ".kafox", "mobile-sessions.json");
|
|
250
|
+
|
|
251
|
+
function isServerEntry(entry) {
|
|
252
|
+
return entry && entry.kind === "server";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Le o registro bruto (tokens + servidores). */
|
|
256
|
+
function readRegistry() {
|
|
257
|
+
return loadSessions();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Grava o registro bruto. */
|
|
261
|
+
function writeRegistry(registry) {
|
|
262
|
+
saveSessions(registry);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** True se o processo `pid` esta vivo (sinal 0). */
|
|
266
|
+
function pidAlive(pid) {
|
|
267
|
+
if (!pid) return false;
|
|
268
|
+
try {
|
|
269
|
+
process.kill(pid, 0);
|
|
270
|
+
return true;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// EPERM = existe mas sem permissao; tratar como vivo.
|
|
273
|
+
return error.code === "EPERM";
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Registra um servidor `kafox mobile` no disco. Id estavel = pid.
|
|
279
|
+
* Devolve o id usado.
|
|
280
|
+
*/
|
|
281
|
+
function registerSession({ id = null, pid = process.pid, port, sessionId, base, readonly, log = null } = {}) {
|
|
282
|
+
const registry = readRegistry();
|
|
283
|
+
const key = String(id || pid);
|
|
284
|
+
registry[key] = {
|
|
285
|
+
kind: "server",
|
|
286
|
+
id: key,
|
|
287
|
+
pid: Number(pid),
|
|
288
|
+
port: Number(port),
|
|
289
|
+
sessionId: sessionId || null,
|
|
290
|
+
base: base || null,
|
|
291
|
+
readonly: Boolean(readonly),
|
|
292
|
+
log: log || process.env.KAFOX_MOBILE_LOG || null,
|
|
293
|
+
startedAt: Date.now(),
|
|
294
|
+
};
|
|
295
|
+
writeRegistry(registry);
|
|
296
|
+
return key;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Remove um servidor do registro. Devolve true se algo foi removido. */
|
|
300
|
+
function unregisterSession(id) {
|
|
301
|
+
const registry = readRegistry();
|
|
302
|
+
const key = String(id);
|
|
303
|
+
if (!registry[key] || !isServerEntry(registry[key])) return false;
|
|
304
|
+
delete registry[key];
|
|
305
|
+
writeRegistry(registry);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Lista os servidores registrados. Marca `alive` conforme o PID e poda os que
|
|
311
|
+
* ja morreram (limpeza do registro). Devolve array ordenado por start.
|
|
312
|
+
*/
|
|
313
|
+
function listSessions({ prune = true } = {}) {
|
|
314
|
+
const registry = readRegistry();
|
|
315
|
+
const out = [];
|
|
316
|
+
let changed = false;
|
|
317
|
+
for (const [key, entry] of Object.entries(registry)) {
|
|
318
|
+
if (!isServerEntry(entry)) continue;
|
|
319
|
+
const alive = pidAlive(entry.pid);
|
|
320
|
+
if (!alive && prune) {
|
|
321
|
+
delete registry[key];
|
|
322
|
+
changed = true;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
out.push({ ...entry, alive });
|
|
326
|
+
}
|
|
327
|
+
if (changed) writeRegistry(registry);
|
|
328
|
+
out.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0));
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Encontra um servidor pelo id (ou pelo prefixo do id). */
|
|
333
|
+
function findSession(id) {
|
|
334
|
+
const needle = String(id);
|
|
335
|
+
const all = listSessions({ prune: false });
|
|
336
|
+
return all.find((s) => s.id === needle) || all.find((s) => s.id.startsWith(needle)) || null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
PairingManager,
|
|
341
|
+
PAIR_TTL_MS,
|
|
342
|
+
TOKEN_TTL_MS,
|
|
343
|
+
revoke,
|
|
344
|
+
loadSessions,
|
|
345
|
+
saveSessions,
|
|
346
|
+
// registro de servidores `kafox mobile` (list/kill)
|
|
347
|
+
registerSession,
|
|
348
|
+
unregisterSession,
|
|
349
|
+
listSessions,
|
|
350
|
+
findSession,
|
|
351
|
+
pidAlive,
|
|
352
|
+
};
|