estela 0.2.2 → 0.2.4
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 +33 -0
- package/dist/cli.js +121 -2
- package/dist/cloud/client.d.ts +2 -0
- package/dist/cloud/client.js +21 -27
- package/dist/db/store.d.ts +2 -0
- package/dist/db/store.js +5 -0
- package/dist/export/panel.d.ts +69 -0
- package/dist/export/panel.js +47 -10
- package/dist/i18n/en.js +29 -0
- package/dist/offline.d.ts +10 -0
- package/dist/offline.js +16 -0
- package/dist/publish.d.ts +38 -0
- package/dist/publish.js +101 -20
- package/dist/rate-history.d.ts +63 -0
- package/dist/rate-history.js +193 -0
- package/dist/server.js +18 -7
- package/dist/statusline-cache.d.ts +10 -0
- package/dist/statusline-run.js +8 -0
- package/dist/updateCheck.d.ts +6 -1
- package/dist/updateCheck.js +25 -4
- package/package.json +1 -1
- package/web/app.js +77 -38
- package/web/i18n.js +16 -0
package/dist/publish.js
CHANGED
|
@@ -35,6 +35,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.NoAccountError = void 0;
|
|
37
37
|
exports.publishPanel = publishPanel;
|
|
38
|
+
exports.preparePanel = preparePanel;
|
|
39
|
+
exports.uploadPanel = uploadPanel;
|
|
40
|
+
exports.unpublishPanel = unpublishPanel;
|
|
41
|
+
exports.unpublishAll = unpublishAll;
|
|
42
|
+
exports.listPublishedPanels = listPublishedPanels;
|
|
38
43
|
const shared_1 = require("@estela/shared");
|
|
39
44
|
const invoice_js_1 = require("./billing/invoice.js");
|
|
40
45
|
const client_js_1 = require("./cloud/client.js");
|
|
@@ -82,9 +87,21 @@ function commitsOfProject(db, repoPaths) {
|
|
|
82
87
|
}));
|
|
83
88
|
}
|
|
84
89
|
async function publishPanel(db, options) {
|
|
90
|
+
return uploadPanel(db, await preparePanel(db, options));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Construye el panel sin subirlo: el HTML y el resumen de lo que va a quedar
|
|
94
|
+
* público. `estela publish` enseña el resumen y pide confirmación antes de
|
|
95
|
+
* `uploadPanel`.
|
|
96
|
+
*/
|
|
97
|
+
async function preparePanel(db, options) {
|
|
85
98
|
const project = store.getProject(db, options.projectId);
|
|
86
99
|
if (!project)
|
|
87
100
|
throw new Error((0, index_js_1.tr) `No existe el proyecto "${options.projectId}".`);
|
|
101
|
+
// Alojarlo exige sesión: es la cuenta la que decide cuántos paneles caben
|
|
102
|
+
// en tu plan, y sin ella no hay a quién cargarle esa cuota. Se mira antes de
|
|
103
|
+
// construir nada, para no enseñar un resumen de algo que no se va a subir.
|
|
104
|
+
requireAccount(db);
|
|
88
105
|
const client = store.getClient(db, project.clientId);
|
|
89
106
|
const rates = store.getRates(db, options.projectId);
|
|
90
107
|
// Reutilizar el token si se republica: el cliente ya tiene ese enlace
|
|
@@ -93,10 +110,7 @@ async function publishPanel(db, options) {
|
|
|
93
110
|
const projectCommits = commitsOfProject(db, project.repoPaths);
|
|
94
111
|
const merged = project.repoPaths[0] ? await (0, git_js_1.mergedBranches)(project.repoPaths[0]) : null;
|
|
95
112
|
const team = await buildTeam(db, project, projectCommits);
|
|
96
|
-
|
|
97
|
-
// publicar, y republicar es lo que lo cambia.
|
|
98
|
-
const lang = (0, index_js_1.documentLang)({ explicit: options.language, clientLanguage: client.language });
|
|
99
|
-
const html = (0, index_js_1.withLang)(lang, () => (0, panel_js_1.buildPanel)({
|
|
113
|
+
const panelOptions = {
|
|
100
114
|
project, client,
|
|
101
115
|
entries: store.getTimeEntries(db, options.projectId),
|
|
102
116
|
...(options.includeTeam !== false ? { team } : {}),
|
|
@@ -110,33 +124,100 @@ async function publishPanel(db, options) {
|
|
|
110
124
|
commitsOf: (hashes) => hashes.length
|
|
111
125
|
? db.prepare(`SELECT hash, subject FROM commits WHERE hash IN (${hashes.map(() => "?").join(",")})`).all(...hashes)
|
|
112
126
|
: [],
|
|
127
|
+
};
|
|
128
|
+
// El panel es un fichero estático: el idioma queda fijado en el momento de
|
|
129
|
+
// publicar, y republicar es lo que lo cambia.
|
|
130
|
+
const lang = (0, index_js_1.documentLang)({ explicit: options.language, clientLanguage: client.language });
|
|
131
|
+
const { html, summary } = (0, index_js_1.withLang)(lang, () => ({
|
|
132
|
+
html: (0, panel_js_1.buildPanel)(panelOptions),
|
|
133
|
+
summary: (0, panel_js_1.summarizePanel)((0, panel_js_1.panelData)(panelOptions)),
|
|
113
134
|
}));
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
return {
|
|
136
|
+
projectId: project.id, projectName: project.name, token, html,
|
|
137
|
+
summary: { ...summary, clients: options.clients ?? [] },
|
|
138
|
+
...(options.clients ? { clients: options.clients } : {}),
|
|
139
|
+
hideCredit: options.hideCredit === true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function uploadPanel(db, panel) {
|
|
143
|
+
const account = requireAccount(db);
|
|
121
144
|
let result;
|
|
122
145
|
try {
|
|
123
|
-
result = await (0, client_js_1.cloudPost)(account.apiBaseUrl, "/panels", { token, projectName:
|
|
124
|
-
...(
|
|
125
|
-
...(
|
|
146
|
+
result = await (0, client_js_1.cloudPost)(account.apiBaseUrl, "/panels", { token: panel.token, projectName: panel.projectName, projectId: panel.projectId, html: panel.html,
|
|
147
|
+
...(panel.clients ? { clients: panel.clients } : {}),
|
|
148
|
+
...(panel.hideCredit ? { hideCredit: true } : {}) }, { deviceToken: account.deviceToken });
|
|
126
149
|
}
|
|
127
150
|
catch (error) {
|
|
128
151
|
// Igual que en sync.ts: el 402 se deja pasar. El mensaje ya viene
|
|
129
152
|
// redactado del servidor (panels.ts), que es la única fuente de la verdad
|
|
130
153
|
// sobre cuántos paneles permite cada plan, y el CLI imprime CloudError
|
|
131
154
|
// limpio. Envolverlo lo convertía en un volcado de pila.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
throw sessionError(error);
|
|
156
|
+
}
|
|
157
|
+
store.recordPublication(db, panel.projectId, panel.token, "https://getestela.dev");
|
|
158
|
+
return { token: panel.token, url: result.url, adopted: result.adopted };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Despublica un panel: el servidor borra el fichero y el enlace deja de
|
|
162
|
+
* funcionar. `target` es el id de un proyecto (el panel que se publicó desde
|
|
163
|
+
* esta máquina) o el token del enlace.
|
|
164
|
+
*/
|
|
165
|
+
async function unpublishPanel(db, target) {
|
|
166
|
+
const account = requireAccount(db);
|
|
167
|
+
const panels = await listPublishedPanels(db);
|
|
168
|
+
const token = /^[0-9a-f]{32}$/.test(target) ? target : store.getPublicationToken(db, target);
|
|
169
|
+
const panel = panels.find((p) => p.token === token)
|
|
170
|
+
?? panels.find((p) => p.projectId === target);
|
|
171
|
+
if (!panel)
|
|
172
|
+
throw new Error((0, index_js_1.tr) `No hay ningún panel publicado de "${target}". Míralos con: estela unpublish`);
|
|
173
|
+
try {
|
|
174
|
+
await (0, client_js_1.cloudDelete)(account.apiBaseUrl, `/panels/${panel.token}`, { deviceToken: account.deviceToken });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw sessionError(error);
|
|
178
|
+
}
|
|
179
|
+
store.forgetPublication(db, panel.token);
|
|
180
|
+
return panel;
|
|
181
|
+
}
|
|
182
|
+
/** Despublica todos los paneles de la cuenta. Devuelve los que quitó. */
|
|
183
|
+
async function unpublishAll(db) {
|
|
184
|
+
const account = requireAccount(db);
|
|
185
|
+
const panels = await listPublishedPanels(db);
|
|
186
|
+
for (const panel of panels) {
|
|
187
|
+
try {
|
|
188
|
+
await (0, client_js_1.cloudDelete)(account.apiBaseUrl, `/panels/${panel.token}`, { deviceToken: account.deviceToken });
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
throw sessionError(error);
|
|
135
192
|
}
|
|
136
|
-
|
|
193
|
+
store.forgetPublication(db, panel.token);
|
|
194
|
+
}
|
|
195
|
+
return panels;
|
|
196
|
+
}
|
|
197
|
+
async function listPublishedPanels(db) {
|
|
198
|
+
const account = requireAccount(db);
|
|
199
|
+
try {
|
|
200
|
+
const res = await (0, client_js_1.cloudGet)(account.apiBaseUrl, "/panels", { deviceToken: account.deviceToken });
|
|
201
|
+
return res.panels.map((p) => ({ token: p.token, projectName: p.projectName, projectId: p.projectId ?? null }));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
throw sessionError(error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function requireAccount(db) {
|
|
208
|
+
const account = store.getCloudAccount(db);
|
|
209
|
+
if (!account) {
|
|
210
|
+
throw new NoAccountError((0, index_js_1.tr) `Necesitas una cuenta para publicar. Vincúlala con:\n\n` +
|
|
211
|
+
(0, index_js_1.tr) ` estela login --email tu@correo.com\n`);
|
|
212
|
+
}
|
|
213
|
+
return account;
|
|
214
|
+
}
|
|
215
|
+
function sessionError(error) {
|
|
216
|
+
if (error instanceof client_js_1.CloudError && error.status === 401) {
|
|
217
|
+
return new NoAccountError((0, index_js_1.tr) `Tu sesión ya no vale. Vuelve a vincular la máquina:\n\n` +
|
|
218
|
+
(0, index_js_1.tr) ` estela login --email tu@correo.com\n`);
|
|
137
219
|
}
|
|
138
|
-
|
|
139
|
-
return { token, url: result.url, adopted: result.adopted };
|
|
220
|
+
return error;
|
|
140
221
|
}
|
|
141
222
|
/**
|
|
142
223
|
* El equipo del proyecto, acotado al periodo que cubre el panel. Sin acotar
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.estela/rate-limits.jsonl`: el historial de los porcentajes de límite que
|
|
3
|
+
* Claude Code manda a la status line. Es lo que analiza `estela plan`.
|
|
4
|
+
*
|
|
5
|
+
* Solo se guarda lo que manda Claude Code, tal cual: el porcentaje usado y el
|
|
6
|
+
* reinicio de cada ventana, y su versión. Ni sesión, ni rutas, ni proyectos.
|
|
7
|
+
* Estela no estima ningún límite: sin `rate_limits` en la entrada (API key, o
|
|
8
|
+
* la primera respuesta de la sesión aún no llegó), no se guarda nada.
|
|
9
|
+
*
|
|
10
|
+
* Los transcripts no traen estos porcentajes, así que no hay forma de
|
|
11
|
+
* recuperar el pasado: el historial empieza el día que se instala la 0.2.4. Y
|
|
12
|
+
* solo se recoge con Claude Code en la terminal; la extensión de VS Code no
|
|
13
|
+
* ejecuta la status line.
|
|
14
|
+
*
|
|
15
|
+
* Va por el camino rápido de la status line (100 ms): sin `node:sqlite`, una
|
|
16
|
+
* línea por `appendFileSync`, y cualquier error se ignora.
|
|
17
|
+
*/
|
|
18
|
+
export interface RateSample {
|
|
19
|
+
/** Epoch en ms de la muestra. */
|
|
20
|
+
readonly ts: number;
|
|
21
|
+
readonly src: "claude-code";
|
|
22
|
+
readonly w5?: number;
|
|
23
|
+
/** `resets_at` tal cual (epoch en s). */
|
|
24
|
+
readonly w5_reset?: number;
|
|
25
|
+
readonly w7?: number;
|
|
26
|
+
readonly w7_reset?: number;
|
|
27
|
+
/** Versión de Claude Code, si viene. */
|
|
28
|
+
readonly cc?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const KEEP_DAYS = 120;
|
|
31
|
+
export declare function historyPath(dbPath: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* La muestra que sale del JSON de la status line, o null si no trae ningún
|
|
34
|
+
* porcentaje. Cada ventana que falte se omite: nunca se escribe un 0.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sampleOf(raw: unknown, now: Date): RateSample | null;
|
|
37
|
+
/**
|
|
38
|
+
* Si vale la pena guardar la muestra: han pasado 5 minutos desde la última,
|
|
39
|
+
* algún porcentaje cambió al menos un punto, o empezó una ventana nueva.
|
|
40
|
+
*/
|
|
41
|
+
export declare function shouldWrite(last: RateSample | undefined, s: RateSample): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Lo que hace la status line en cada evento. Nunca lanza: es un historial, y
|
|
44
|
+
* la línea tiene que salir igual aunque no se pueda escribir.
|
|
45
|
+
*
|
|
46
|
+
* Solo escribe si ya existe el directorio de Estela: la status line no es
|
|
47
|
+
* quien empieza a guardar datos.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordSample(dbPath: string, raw: unknown, now: Date): void;
|
|
50
|
+
/** Lee el historial. Las líneas rotas se saltan. */
|
|
51
|
+
export declare function readSamples(file: string): RateSample[];
|
|
52
|
+
/**
|
|
53
|
+
* Quita las muestras de hace más de 120 días. Reescribe en un fichero aparte y
|
|
54
|
+
* lo renombra encima: un corte a mitad no deja el historial a medias.
|
|
55
|
+
* Devuelve cuántas quitó.
|
|
56
|
+
*
|
|
57
|
+
* Corre desde la status line una vez al día, así que lo normal (nada que
|
|
58
|
+
* quitar) tiene que costar casi nada: el fichero se escribe en orden, y si la
|
|
59
|
+
* primera muestra no ha caducado, ninguna lo ha hecho. Solo entonces se lee
|
|
60
|
+
* entero, y el `ts` se saca del principio de cada línea sin parsear el JSON.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pruneSamples(file: string, now: Date, keepDays?: number): number;
|
|
63
|
+
//# sourceMappingURL=rate-history.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KEEP_DAYS = void 0;
|
|
4
|
+
exports.historyPath = historyPath;
|
|
5
|
+
exports.sampleOf = sampleOf;
|
|
6
|
+
exports.shouldWrite = shouldWrite;
|
|
7
|
+
exports.recordSample = recordSample;
|
|
8
|
+
exports.readSamples = readSamples;
|
|
9
|
+
exports.pruneSamples = pruneSamples;
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
const statusline_cache_js_1 = require("./statusline-cache.js");
|
|
13
|
+
const MIN_GAP_MS = 5 * 60_000;
|
|
14
|
+
const MIN_DELTA = 1;
|
|
15
|
+
exports.KEEP_DAYS = 120;
|
|
16
|
+
const PRUNE_EVERY_MS = 24 * 60 * 60_000;
|
|
17
|
+
/** Cada cuánto se reescribe en la caché "se vio una sesión sin rate_limits". */
|
|
18
|
+
const NO_LIMITS_EVERY_MS = 60 * 60_000;
|
|
19
|
+
function historyPath(dbPath) {
|
|
20
|
+
return (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), "rate-limits.jsonl");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* La muestra que sale del JSON de la status line, o null si no trae ningún
|
|
24
|
+
* porcentaje. Cada ventana que falte se omite: nunca se escribe un 0.
|
|
25
|
+
*/
|
|
26
|
+
function sampleOf(raw, now) {
|
|
27
|
+
const o = obj(raw);
|
|
28
|
+
const limits = obj(o["rate_limits"]);
|
|
29
|
+
const five = obj(limits["five_hour"]);
|
|
30
|
+
const seven = obj(limits["seven_day"]);
|
|
31
|
+
const w5 = num(five["used_percentage"]);
|
|
32
|
+
const w7 = num(seven["used_percentage"]);
|
|
33
|
+
if (w5 === undefined && w7 === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
const w5Reset = num(five["resets_at"]);
|
|
36
|
+
const w7Reset = num(seven["resets_at"]);
|
|
37
|
+
const cc = typeof o["version"] === "string" && o["version"] ? o["version"] : undefined;
|
|
38
|
+
return {
|
|
39
|
+
ts: now.getTime(), src: "claude-code",
|
|
40
|
+
...(w5 !== undefined ? { w5 } : {}),
|
|
41
|
+
...(w5Reset !== undefined ? { w5_reset: w5Reset } : {}),
|
|
42
|
+
...(w7 !== undefined ? { w7 } : {}),
|
|
43
|
+
...(w7Reset !== undefined ? { w7_reset: w7Reset } : {}),
|
|
44
|
+
...(cc ? { cc } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Si vale la pena guardar la muestra: han pasado 5 minutos desde la última,
|
|
49
|
+
* algún porcentaje cambió al menos un punto, o empezó una ventana nueva.
|
|
50
|
+
*/
|
|
51
|
+
function shouldWrite(last, s) {
|
|
52
|
+
if (!last)
|
|
53
|
+
return true;
|
|
54
|
+
if (s.ts - last.ts >= MIN_GAP_MS)
|
|
55
|
+
return true;
|
|
56
|
+
if (moved(last.w5, s.w5) || moved(last.w7, s.w7))
|
|
57
|
+
return true;
|
|
58
|
+
return last.w5_reset !== s.w5_reset || last.w7_reset !== s.w7_reset;
|
|
59
|
+
}
|
|
60
|
+
function moved(a, b) {
|
|
61
|
+
if (a === undefined || b === undefined)
|
|
62
|
+
return a !== b;
|
|
63
|
+
return Math.abs(a - b) >= MIN_DELTA;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Lo que hace la status line en cada evento. Nunca lanza: es un historial, y
|
|
67
|
+
* la línea tiene que salir igual aunque no se pueda escribir.
|
|
68
|
+
*
|
|
69
|
+
* Solo escribe si ya existe el directorio de Estela: la status line no es
|
|
70
|
+
* quien empieza a guardar datos.
|
|
71
|
+
*/
|
|
72
|
+
function recordSample(dbPath, raw, now) {
|
|
73
|
+
try {
|
|
74
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.dirname)(dbPath)))
|
|
75
|
+
return;
|
|
76
|
+
const cache = (0, statusline_cache_js_1.loadCache)(dbPath);
|
|
77
|
+
let changed = false;
|
|
78
|
+
const sample = sampleOf(raw, now);
|
|
79
|
+
if (!sample) {
|
|
80
|
+
// Para que `estela plan` distinga "sin datos" de "esto es una API key".
|
|
81
|
+
if (obj(raw)["rate_limits"] === undefined && stale(cache.noLimitsAt, now, NO_LIMITS_EVERY_MS)) {
|
|
82
|
+
cache.noLimitsAt = now.toISOString();
|
|
83
|
+
changed = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (shouldWrite(cache.lastSample, sample)) {
|
|
87
|
+
(0, node_fs_1.appendFileSync)(historyPath(dbPath), JSON.stringify(sample) + "\n");
|
|
88
|
+
cache.lastSample = sample;
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
if (sample && stale(cache.prunedAt, now, PRUNE_EVERY_MS)) {
|
|
92
|
+
pruneSamples(historyPath(dbPath), now);
|
|
93
|
+
cache.prunedAt = now.toISOString();
|
|
94
|
+
changed = true;
|
|
95
|
+
}
|
|
96
|
+
if (changed)
|
|
97
|
+
(0, statusline_cache_js_1.saveCache)(dbPath, cache);
|
|
98
|
+
}
|
|
99
|
+
catch { /* sin historial esta vez; la línea sale igual */ }
|
|
100
|
+
}
|
|
101
|
+
function stale(iso, now, ms) {
|
|
102
|
+
if (!iso)
|
|
103
|
+
return true;
|
|
104
|
+
const t = Date.parse(iso);
|
|
105
|
+
return Number.isNaN(t) || now.getTime() - t >= ms;
|
|
106
|
+
}
|
|
107
|
+
/** Lee el historial. Las líneas rotas se saltan. */
|
|
108
|
+
function readSamples(file) {
|
|
109
|
+
let text;
|
|
110
|
+
try {
|
|
111
|
+
text = (0, node_fs_1.readFileSync)(file, "utf8");
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const line of text.split("\n")) {
|
|
118
|
+
if (!line)
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
const s = JSON.parse(line);
|
|
122
|
+
if (s && typeof s.ts === "number")
|
|
123
|
+
out.push(s);
|
|
124
|
+
}
|
|
125
|
+
catch { /* línea a medias: se ignora */ }
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Quita las muestras de hace más de 120 días. Reescribe en un fichero aparte y
|
|
131
|
+
* lo renombra encima: un corte a mitad no deja el historial a medias.
|
|
132
|
+
* Devuelve cuántas quitó.
|
|
133
|
+
*
|
|
134
|
+
* Corre desde la status line una vez al día, así que lo normal (nada que
|
|
135
|
+
* quitar) tiene que costar casi nada: el fichero se escribe en orden, y si la
|
|
136
|
+
* primera muestra no ha caducado, ninguna lo ha hecho. Solo entonces se lee
|
|
137
|
+
* entero, y el `ts` se saca del principio de cada línea sin parsear el JSON.
|
|
138
|
+
*/
|
|
139
|
+
function pruneSamples(file, now, keepDays = exports.KEEP_DAYS) {
|
|
140
|
+
const cutoff = now.getTime() - keepDays * 24 * 60 * 60_000;
|
|
141
|
+
const first = firstLine(file);
|
|
142
|
+
if (first === null)
|
|
143
|
+
return 0;
|
|
144
|
+
const firstTs = tsOf(first);
|
|
145
|
+
if (firstTs !== null && firstTs >= cutoff)
|
|
146
|
+
return 0;
|
|
147
|
+
const lines = (0, node_fs_1.readFileSync)(file, "utf8").split("\n").filter(Boolean);
|
|
148
|
+
const kept = lines.filter((line) => {
|
|
149
|
+
const ts = tsOf(line);
|
|
150
|
+
return ts !== null && ts >= cutoff;
|
|
151
|
+
});
|
|
152
|
+
if (kept.length === lines.length)
|
|
153
|
+
return 0;
|
|
154
|
+
const tmp = `${file}.tmp`;
|
|
155
|
+
(0, node_fs_1.writeFileSync)(tmp, kept.map((l) => l + "\n").join(""));
|
|
156
|
+
(0, node_fs_1.renameSync)(tmp, file);
|
|
157
|
+
return lines.length - kept.length;
|
|
158
|
+
}
|
|
159
|
+
/** El `ts` de una línea: siempre es el primer campo, porque así la escribe `sampleOf`. Null si está rota. */
|
|
160
|
+
function tsOf(line) {
|
|
161
|
+
const m = /^\{"ts":(\d+),/.exec(line);
|
|
162
|
+
if (!m || !line.endsWith("}"))
|
|
163
|
+
return null;
|
|
164
|
+
return Number(m[1]);
|
|
165
|
+
}
|
|
166
|
+
function firstLine(file) {
|
|
167
|
+
let fd;
|
|
168
|
+
try {
|
|
169
|
+
fd = (0, node_fs_1.openSync)(file, "r");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const buf = Buffer.alloc(512);
|
|
176
|
+
const n = (0, node_fs_1.readSync)(fd, buf, 0, buf.length, 0);
|
|
177
|
+
if (n === 0)
|
|
178
|
+
return null;
|
|
179
|
+
const text = buf.subarray(0, n).toString("utf8");
|
|
180
|
+
const nl = text.indexOf("\n");
|
|
181
|
+
return nl >= 0 ? text.slice(0, nl) : text;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
(0, node_fs_1.closeSync)(fd);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function obj(v) {
|
|
188
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
189
|
+
}
|
|
190
|
+
function num(v) {
|
|
191
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=rate-history.js.map
|
package/dist/server.js
CHANGED
|
@@ -53,6 +53,7 @@ const sync_js_1 = require("./sync.js");
|
|
|
53
53
|
const team_js_1 = require("./sync/team.js");
|
|
54
54
|
const billing_js_1 = require("./billing.js");
|
|
55
55
|
const client_js_1 = require("./cloud/client.js");
|
|
56
|
+
const offline_js_1 = require("./offline.js");
|
|
56
57
|
const team_js_2 = require("./metrics/team.js");
|
|
57
58
|
const agents_js_1 = require("./watchers/agents.js");
|
|
58
59
|
const git_js_1 = require("./watchers/git.js");
|
|
@@ -390,13 +391,19 @@ async function api(req, res, url, dbPath) {
|
|
|
390
391
|
}
|
|
391
392
|
if (path === "/api/publish" && req.method === "POST") {
|
|
392
393
|
const body = await readJson(req);
|
|
394
|
+
const options = {
|
|
395
|
+
projectId: String(body["projectId"] ?? ""),
|
|
396
|
+
...(body["token"] ? { token: String(body["token"]) } : {}),
|
|
397
|
+
...(body["author"] ? { authorName: String(body["author"]) } : {}),
|
|
398
|
+
withAmounts: body["withAmounts"] === true,
|
|
399
|
+
};
|
|
393
400
|
try {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
// Primero el resumen de lo que va a quedar público; el botón del panel
|
|
402
|
+
// lo enseña y solo publica al confirmar, igual que `estela publish`.
|
|
403
|
+
if (body["preview"] === true) {
|
|
404
|
+
return json(res, 200, { summary: (await (0, publish_js_1.preparePanel)(db, options)).summary });
|
|
405
|
+
}
|
|
406
|
+
const result = await (0, publish_js_1.publishPanel)(db, options);
|
|
400
407
|
return json(res, 200, result);
|
|
401
408
|
}
|
|
402
409
|
catch (error) {
|
|
@@ -455,8 +462,12 @@ async function api(req, res, url, dbPath) {
|
|
|
455
462
|
// internet, getestela.dev caído) hace que el banner simplemente no
|
|
456
463
|
// aparezca, nunca un error visible en el panel.
|
|
457
464
|
if (path === "/api/banner" && req.method === "GET") {
|
|
465
|
+
// Solo con sesión (`estela login`): sin cuenta, el panel local no hace
|
|
466
|
+
// ninguna llamada de red, tampoco para un aviso. Ni con ESTELA_OFFLINE=1.
|
|
458
467
|
const account = store.getCloudAccount(db);
|
|
459
|
-
|
|
468
|
+
if (!account || (0, offline_js_1.isOffline)())
|
|
469
|
+
return json(res, 200, { enabled: false });
|
|
470
|
+
const base = account.apiBaseUrl.replace(/\/$/, "");
|
|
460
471
|
const controller = new AbortController();
|
|
461
472
|
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
462
473
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RateSample } from "./rate-history.js";
|
|
1
2
|
/**
|
|
2
3
|
* `statusline.json`, junto a la base: lo que la status line recuerda entre
|
|
3
4
|
* llamadas.
|
|
@@ -7,6 +8,9 @@
|
|
|
7
8
|
* (porcentaje y reinicio) y cuándo se vio. La status line lo enseña al
|
|
8
9
|
* arrancar una sesión, antes de que lleguen datos nuevos, y `estela quota`
|
|
9
10
|
* usa el reinicio de la 5 h para saber dónde empieza de verdad la ventana.
|
|
11
|
+
* - `lastSample`, `prunedAt`, `noLimitsAt`: del historial de
|
|
12
|
+
* `rate-limits.jsonl` (ver rate-history.ts), para decidir si se guarda una
|
|
13
|
+
* muestra sin tener que leer el fichero.
|
|
10
14
|
*
|
|
11
15
|
* Es una caché: si se pierde o se corrompe, todo sigue funcionando, solo que
|
|
12
16
|
* relee más o vuelve a estimar.
|
|
@@ -25,6 +29,12 @@ export interface StatuslineCache {
|
|
|
25
29
|
}>;
|
|
26
30
|
fiveHour?: SeenLimit;
|
|
27
31
|
sevenDay?: SeenLimit;
|
|
32
|
+
/** La última muestra guardada en rate-limits.jsonl. */
|
|
33
|
+
lastSample?: RateSample;
|
|
34
|
+
/** Cuándo se quitaron por última vez las muestras de más de 120 días. */
|
|
35
|
+
prunedAt?: string;
|
|
36
|
+
/** La última vez que llegó una entrada sin rate_limits (API key, o sesión recién abierta). */
|
|
37
|
+
noLimitsAt?: string;
|
|
28
38
|
}
|
|
29
39
|
export declare function cachePath(dbPath: string): string;
|
|
30
40
|
export declare function loadCache(dbPath: string): StatuslineCache;
|
package/dist/statusline-run.js
CHANGED
|
@@ -43,6 +43,7 @@ const store = __importStar(require("./db/store.js"));
|
|
|
43
43
|
const quota_js_1 = require("./metrics/quota.js");
|
|
44
44
|
const paths_js_1 = require("./paths.js");
|
|
45
45
|
const project_of_js_1 = require("./project-of.js");
|
|
46
|
+
const rate_history_js_1 = require("./rate-history.js");
|
|
46
47
|
const statusline_cache_js_1 = require("./statusline-cache.js");
|
|
47
48
|
const statusline_js_1 = require("./statusline.js");
|
|
48
49
|
const claude_js_1 = require("./watchers/claude.js");
|
|
@@ -65,6 +66,13 @@ async function runStatusline(argv) {
|
|
|
65
66
|
}, now);
|
|
66
67
|
}
|
|
67
68
|
catch { /* es una caché: sin ella, la línea sale con lo que llegó */ }
|
|
69
|
+
// El historial de porcentajes para `estela plan`. No lanza nunca.
|
|
70
|
+
if (text.trim()) {
|
|
71
|
+
try {
|
|
72
|
+
(0, rate_history_js_1.recordSample)(flags.db, JSON.parse(text), now);
|
|
73
|
+
}
|
|
74
|
+
catch { /* entrada rota: ya lo cubre lo de abajo */ }
|
|
75
|
+
}
|
|
68
76
|
const data = computeStatus({ ...input, ...limits }, flags.db, now);
|
|
69
77
|
const ours = (0, statusline_js_1.renderStatus)(data, { columns, color: flags.color, reserved });
|
|
70
78
|
process.stdout.write((extra ? `${ours}${flags.color ? "\x1b[90m | \x1b[0m" : " | "}${extra}` : ours) + "\n");
|
package/dist/updateCheck.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/** `1.2.3` > `1.10.0`: comparar como texto ordenaría mal el "10". */
|
|
2
2
|
export declare function isNewer(latest: string, current: string): boolean;
|
|
3
|
+
/** Si quien ejecuta ha pedido que no se compruebe, o es una máquina de CI. */
|
|
4
|
+
export declare function updateCheckDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
3
5
|
/**
|
|
4
6
|
* Enseña el aviso si el caché dice que hay algo más nuevo, y refresca el
|
|
5
7
|
* caché si ya toca. Se llama una vez al arrancar cualquier comando.
|
|
8
|
+
*
|
|
9
|
+
* Desactivada no hace nada, tampoco el aviso del caché: un caché viejo diría
|
|
10
|
+
* "hay versión nueva" para siempre, sin poder comprobar si sigue siendo así.
|
|
6
11
|
*/
|
|
7
|
-
export declare function checkForUpdate(currentVersion: string): void;
|
|
12
|
+
export declare function checkForUpdate(currentVersion: string, env?: NodeJS.ProcessEnv): void;
|
|
8
13
|
//# sourceMappingURL=updateCheck.d.ts.map
|
package/dist/updateCheck.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isNewer = isNewer;
|
|
4
|
+
exports.updateCheckDisabled = updateCheckDisabled;
|
|
4
5
|
exports.checkForUpdate = checkForUpdate;
|
|
5
6
|
const node_fs_1 = require("node:fs");
|
|
6
7
|
const node_os_1 = require("node:os");
|
|
7
8
|
const node_path_1 = require("node:path");
|
|
8
9
|
const index_js_1 = require("./i18n/index.js");
|
|
10
|
+
const offline_js_1 = require("./offline.js");
|
|
9
11
|
/**
|
|
10
12
|
* Avisa cuando hay una versión más nueva en npm.
|
|
11
13
|
*
|
|
@@ -22,13 +24,18 @@ const index_js_1 = require("./i18n/index.js");
|
|
|
22
24
|
* igual. Un `estela log` no puede tardar más porque el registro esté lento.
|
|
23
25
|
* - **Como mucho una vez al día.** Consultar npm en cada `estela status` de
|
|
24
26
|
* un script sería ruido para el registro y para quien lo mira.
|
|
27
|
+
*
|
|
28
|
+
* Es la única llamada de red del plan gratuito, y se apaga con
|
|
29
|
+
* `ESTELA_NO_UPDATE_CHECK=1` (o con `ESTELA_OFFLINE=1`, que apaga toda la red). En CI (con `CI` definida) no se hace nunca: ahí
|
|
30
|
+
* nadie lee el aviso y la máquina se tira al acabar.
|
|
25
31
|
*/
|
|
26
|
-
|
|
32
|
+
/** Se calcula en cada llamada: con una constante, un test que cambia HOME no tendría efecto. */
|
|
33
|
+
const cachePath = () => (0, node_path_1.join)((0, node_os_1.homedir)(), ".estela", "update-check.json");
|
|
27
34
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
28
35
|
const FETCH_TIMEOUT_MS = 1500;
|
|
29
36
|
function readCache() {
|
|
30
37
|
try {
|
|
31
|
-
return JSON.parse((0, node_fs_1.readFileSync)(
|
|
38
|
+
return JSON.parse((0, node_fs_1.readFileSync)(cachePath(), "utf8"));
|
|
32
39
|
}
|
|
33
40
|
catch {
|
|
34
41
|
return null;
|
|
@@ -37,7 +44,7 @@ function readCache() {
|
|
|
37
44
|
function writeCache(latest) {
|
|
38
45
|
try {
|
|
39
46
|
(0, node_fs_1.mkdirSync)((0, node_path_1.join)((0, node_os_1.homedir)(), ".estela"), { recursive: true });
|
|
40
|
-
(0, node_fs_1.writeFileSync)(
|
|
47
|
+
(0, node_fs_1.writeFileSync)(cachePath(), JSON.stringify({ checkedAt: new Date().toISOString(), latest }));
|
|
41
48
|
}
|
|
42
49
|
catch {
|
|
43
50
|
// Sin permiso de escritura o disco lleno: el aviso de hoy se pierde, pero
|
|
@@ -76,11 +83,25 @@ function refreshInBackground() {
|
|
|
76
83
|
.catch(() => { })
|
|
77
84
|
.finally(() => clearTimeout(timeout));
|
|
78
85
|
}
|
|
86
|
+
/** Si quien ejecuta ha pedido que no se compruebe, o es una máquina de CI. */
|
|
87
|
+
function updateCheckDisabled(env = process.env) {
|
|
88
|
+
if ((0, offline_js_1.isOffline)(env))
|
|
89
|
+
return true;
|
|
90
|
+
const off = env["ESTELA_NO_UPDATE_CHECK"];
|
|
91
|
+
if (off !== undefined && off !== "" && off !== "0")
|
|
92
|
+
return true;
|
|
93
|
+
return env["CI"] !== undefined;
|
|
94
|
+
}
|
|
79
95
|
/**
|
|
80
96
|
* Enseña el aviso si el caché dice que hay algo más nuevo, y refresca el
|
|
81
97
|
* caché si ya toca. Se llama una vez al arrancar cualquier comando.
|
|
98
|
+
*
|
|
99
|
+
* Desactivada no hace nada, tampoco el aviso del caché: un caché viejo diría
|
|
100
|
+
* "hay versión nueva" para siempre, sin poder comprobar si sigue siendo así.
|
|
82
101
|
*/
|
|
83
|
-
function checkForUpdate(currentVersion) {
|
|
102
|
+
function checkForUpdate(currentVersion, env = process.env) {
|
|
103
|
+
if (updateCheckDisabled(env))
|
|
104
|
+
return;
|
|
84
105
|
const cache = readCache();
|
|
85
106
|
if (cache && isNewer(cache.latest, currentVersion)) {
|
|
86
107
|
console.log((0, index_js_1.tr) `\n ↑ Hay una versión nueva de Estela (${cache.latest}, tienes ${currentVersion}).`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "estela",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"time-tracking",
|