estela 0.2.1 → 0.2.3

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/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
- // El panel es un fichero estático: el idioma queda fijado en el momento de
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
- // Alojarlo exige sesión: es la cuenta la que decide cuántos paneles caben
115
- // en tu plan, y sin ella no hay a quién cargarle esa cuota.
116
- const account = store.getCloudAccount(db);
117
- if (!account) {
118
- throw new NoAccountError((0, index_js_1.tr) `Necesitas una cuenta para publicar. Vincúlala con:\n\n` +
119
- (0, index_js_1.tr) ` estela login --email tu@correo.com\n`);
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: project.name, projectId: project.id, html,
124
- ...(options.clients ? { clients: options.clients } : {}),
125
- ...(options.hideCredit ? { hideCredit: true } : {}) }, { deviceToken: account.deviceToken });
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
- if (error instanceof client_js_1.CloudError && error.status === 401) {
133
- throw new NoAccountError((0, index_js_1.tr) `Tu sesión ya no vale. Vuelve a vincular la máquina:\n\n` +
134
- (0, index_js_1.tr) ` estela login --email tu@correo.com\n`);
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
- throw error;
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
- store.recordPublication(db, options.projectId, token, "https://getestela.dev");
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
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
- const result = await (0, publish_js_1.publishPanel)(db, {
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
- });
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
- const base = (account?.apiBaseUrl ?? "https://getestela.dev").replace(/\/$/, "");
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 {
@@ -3,31 +3,60 @@
3
3
  * llamadas.
4
4
  *
5
5
  * - `files`: hasta qué byte se leyó cada transcript, para leer solo lo nuevo.
6
- * - `fiveHour`: el último `rate_limits.five_hour.resets_at` que mandó Claude
7
- * Code, y cuándo se vio. `estela quota` lo usa para saber dónde empieza de
8
- * verdad la ventana de 5 h en vez de estimarla.
6
+ * - `fiveHour` y `sevenDay`: lo último que mandó Claude Code en rate_limits
7
+ * (porcentaje y reinicio) y cuándo se vio. La status line lo enseña al
8
+ * arrancar una sesión, antes de que lleguen datos nuevos, y `estela quota`
9
+ * usa el reinicio de la 5 h para saber dónde empieza de verdad la ventana.
9
10
  *
10
11
  * Es una caché: si se pierde o se corrompe, todo sigue funcionando, solo que
11
12
  * relee más o vuelve a estimar.
12
13
  */
14
+ /** Una ventana de límite tal como la mandó Claude Code, y cuándo se vio. */
15
+ export interface SeenLimit {
16
+ resetsAt: string;
17
+ seenAt: string;
18
+ /** El porcentaje usado. Falta si Claude Code mandó el reinicio sin él. */
19
+ usedPercentage?: number;
20
+ }
13
21
  export interface StatuslineCache {
14
22
  files: Record<string, {
15
23
  offset: number;
16
24
  seenAt: string;
17
25
  }>;
18
- fiveHour?: {
19
- resetsAt: string;
20
- seenAt: string;
21
- };
26
+ fiveHour?: SeenLimit;
27
+ sevenDay?: SeenLimit;
22
28
  }
23
29
  export declare function cachePath(dbPath: string): string;
24
30
  export declare function loadCache(dbPath: string): StatuslineCache;
25
31
  export declare function saveCache(dbPath: string, cache: StatuslineCache): void;
32
+ /** Lo que llega de rate_limits para una ventana (cualquiera de los dos puede faltar). */
33
+ export interface LimitInput {
34
+ readonly usedPercentage: number | null;
35
+ readonly resetsAt: Date | null;
36
+ }
26
37
  /**
27
- * Apunta el `resets_at` de la ventana de 5 h. Solo si existe ya el directorio
28
- * de Estela: la status line no es quien empieza a guardar datos.
38
+ * Guarda lo nuevo de rate_limits y, para la ventana que no venga, devuelve el
39
+ * último porcentaje guardado si su reinicio aún no ha llegado.
40
+ *
41
+ * Es para el arranque de una sesión: Claude Code no manda rate_limits hasta la
42
+ * primera respuesta, y mientras tanto la línea perdía la semana y la 5 h que se
43
+ * sabían hace un momento. Un valor guardado cuyo reinicio ya pasó es de una
44
+ * ventana que terminó: no se enseña. En cuanto llega un dato nuevo, lo
45
+ * reemplaza.
46
+ *
47
+ * Usar lo guardado no cuenta como verlo otra vez: `seenAt` solo cambia con un
48
+ * dato nuevo, que es lo que `estela quota` mira para fiarse de la ventana.
49
+ *
50
+ * Solo escribe si existe ya el directorio de Estela: la status line no es
51
+ * quien empieza a guardar datos.
29
52
  */
30
- export declare function recordFiveHourReset(dbPath: string, resetsAt: Date, now: Date): void;
53
+ export declare function rememberLimits(dbPath: string, input: {
54
+ readonly fiveHour: LimitInput;
55
+ readonly sevenDay: LimitInput;
56
+ }, now: Date): {
57
+ fiveHour: number | null;
58
+ sevenDay: number | null;
59
+ };
31
60
  export declare function readFiveHourReset(dbPath: string): {
32
61
  resetsAt: Date;
33
62
  seenAt: Date;
@@ -3,13 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cachePath = cachePath;
4
4
  exports.loadCache = loadCache;
5
5
  exports.saveCache = saveCache;
6
- exports.recordFiveHourReset = recordFiveHourReset;
6
+ exports.rememberLimits = rememberLimits;
7
7
  exports.readFiveHourReset = readFiveHourReset;
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = require("node:path");
10
10
  const MAX_FILES = 30;
11
- /** Con el mismo reinicio, no se reescribe más a menudo que esto. */
12
- const FIVE_HOUR_REFRESH_MS = 10 * 60_000;
11
+ /** Con el mismo reinicio y el mismo porcentaje, no se reescribe más a menudo que esto. */
12
+ const REFRESH_MS = 10 * 60_000;
13
13
  function cachePath(dbPath) {
14
14
  return (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), "statusline.json");
15
15
  }
@@ -33,19 +33,52 @@ function saveCache(dbPath, cache) {
33
33
  catch { /* sin caché, la próxima vez se relee: más lento, no incorrecto */ }
34
34
  }
35
35
  /**
36
- * Apunta el `resets_at` de la ventana de 5 h. Solo si existe ya el directorio
37
- * de Estela: la status line no es quien empieza a guardar datos.
36
+ * Guarda lo nuevo de rate_limits y, para la ventana que no venga, devuelve el
37
+ * último porcentaje guardado si su reinicio aún no ha llegado.
38
+ *
39
+ * Es para el arranque de una sesión: Claude Code no manda rate_limits hasta la
40
+ * primera respuesta, y mientras tanto la línea perdía la semana y la 5 h que se
41
+ * sabían hace un momento. Un valor guardado cuyo reinicio ya pasó es de una
42
+ * ventana que terminó: no se enseña. En cuanto llega un dato nuevo, lo
43
+ * reemplaza.
44
+ *
45
+ * Usar lo guardado no cuenta como verlo otra vez: `seenAt` solo cambia con un
46
+ * dato nuevo, que es lo que `estela quota` mira para fiarse de la ventana.
47
+ *
48
+ * Solo escribe si existe ya el directorio de Estela: la status line no es
49
+ * quien empieza a guardar datos.
38
50
  */
39
- function recordFiveHourReset(dbPath, resetsAt, now) {
40
- if (!(0, node_fs_1.existsSync)((0, node_path_1.dirname)(dbPath)))
41
- return;
51
+ function rememberLimits(dbPath, input, now) {
42
52
  const cache = loadCache(dbPath);
43
- const prev = cache.fiveHour;
44
- const same = prev && new Date(prev.resetsAt).getTime() === resetsAt.getTime();
45
- if (same && now.getTime() - new Date(prev.seenAt).getTime() < FIVE_HOUR_REFRESH_MS)
46
- return;
47
- cache.fiveHour = { resetsAt: resetsAt.toISOString(), seenAt: now.toISOString() };
48
- saveCache(dbPath, cache);
53
+ let changed = false;
54
+ const pick = (key, fresh) => {
55
+ const prev = cache[key];
56
+ if (fresh.resetsAt) {
57
+ const sameReset = !!prev && new Date(prev.resetsAt).getTime() === fresh.resetsAt.getTime();
58
+ // Un reinicio sin porcentaje de la misma ventana no borra el que había.
59
+ const pct = fresh.usedPercentage ?? (sameReset ? prev.usedPercentage ?? null : null);
60
+ const samePct = (prev?.usedPercentage ?? null) === pct;
61
+ const recent = !!prev && now.getTime() - new Date(prev.seenAt).getTime() < REFRESH_MS;
62
+ if (!(sameReset && samePct && recent)) {
63
+ cache[key] = {
64
+ resetsAt: fresh.resetsAt.toISOString(), seenAt: now.toISOString(),
65
+ ...(pct !== null ? { usedPercentage: pct } : {}),
66
+ };
67
+ changed = true;
68
+ }
69
+ }
70
+ if (fresh.usedPercentage !== null)
71
+ return fresh.usedPercentage;
72
+ // Sin dato nuevo: lo último que se vio, si su ventana sigue abierta.
73
+ const last = cache[key];
74
+ if (!last || last.usedPercentage === undefined)
75
+ return null;
76
+ return new Date(last.resetsAt) > now ? last.usedPercentage : null;
77
+ };
78
+ const out = { fiveHour: pick("fiveHour", input.fiveHour), sevenDay: pick("sevenDay", input.sevenDay) };
79
+ if (changed && (0, node_fs_1.existsSync)((0, node_path_1.dirname)(dbPath)))
80
+ saveCache(dbPath, cache);
81
+ return out;
49
82
  }
50
83
  function readFiveHourReset(dbPath) {
51
84
  const f = loadCache(dbPath).fiveHour;
@@ -55,13 +55,17 @@ async function runStatusline(argv) {
55
55
  const reserved = extra ? (0, statusline_js_1.stripAnsi)(extra).length + 3 : 0;
56
56
  const columns = Number(process.env["COLUMNS"]) || null;
57
57
  const now = new Date();
58
- if (input.fiveHourResetsAt) {
59
- try {
60
- (0, statusline_cache_js_1.recordFiveHourReset)(flags.db, input.fiveHourResetsAt, now);
61
- }
62
- catch { /* es una caché */ }
58
+ // Lo nuevo de rate_limits se guarda; lo que no venga (al arrancar una
59
+ // sesión) se toma de lo último visto, si su ventana sigue abierta.
60
+ let limits = { fiveHour: input.fiveHour, sevenDay: input.sevenDay };
61
+ try {
62
+ limits = (0, statusline_cache_js_1.rememberLimits)(flags.db, {
63
+ fiveHour: { usedPercentage: input.fiveHour, resetsAt: input.fiveHourResetsAt ?? null },
64
+ sevenDay: { usedPercentage: input.sevenDay, resetsAt: input.sevenDayResetsAt ?? null },
65
+ }, now);
63
66
  }
64
- const data = computeStatus(input, flags.db, now);
67
+ catch { /* es una caché: sin ella, la línea sale con lo que llegó */ }
68
+ const data = computeStatus({ ...input, ...limits }, flags.db, now);
65
69
  const ours = (0, statusline_js_1.renderStatus)(data, { columns, color: flags.color, reserved });
66
70
  process.stdout.write((extra ? `${ours}${flags.color ? "\x1b[90m | \x1b[0m" : " | "}${extra}` : ours) + "\n");
67
71
  }
@@ -83,7 +87,7 @@ function parseFlags(argv) {
83
87
  }
84
88
  /** Ejecutado a mano en una terminal, sin nada por stdin: el directorio actual. */
85
89
  function manualInput() {
86
- return { dir: process.cwd(), transcriptPath: null, fiveHour: null, sevenDay: null, fiveHourResetsAt: null };
90
+ return { dir: process.cwd(), transcriptPath: null, fiveHour: null, sevenDay: null, fiveHourResetsAt: null, sevenDayResetsAt: null };
87
91
  }
88
92
  function readStdin() {
89
93
  if (process.stdin.isTTY)
@@ -21,6 +21,8 @@ export interface StatusInput {
21
21
  * atrás): se guarda para que `estela quota` sepa dónde empieza la ventana.
22
22
  */
23
23
  readonly fiveHourResetsAt?: Date | null;
24
+ /** `rate_limits.seven_day.resets_at`: tampoco se enseña nunca. */
25
+ readonly sevenDayResetsAt?: Date | null;
24
26
  }
25
27
  /**
26
28
  * Lee la entrada. Lanza si no es un objeto JSON: quien llama imprime
@@ -23,6 +23,7 @@ function parseStatusInput(text) {
23
23
  fiveHour: pct(obj(limits["five_hour"])["used_percentage"]),
24
24
  sevenDay: pct(obj(limits["seven_day"])["used_percentage"]),
25
25
  fiveHourResetsAt: epoch(obj(limits["five_hour"])["resets_at"]),
26
+ sevenDayResetsAt: epoch(obj(limits["seven_day"])["resets_at"]),
26
27
  };
27
28
  }
28
29
  /** Segundos desde 1970 (lo que manda Claude Code); por si acaso, también milisegundos. */
@@ -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
@@ -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
- const CACHE_PATH = (0, node_path_1.join)((0, node_os_1.homedir)(), ".estela", "update-check.json");
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)(CACHE_PATH, "utf8"));
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)(CACHE_PATH, JSON.stringify({ checkedAt: new Date().toISOString(), latest }));
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.1",
3
+ "version": "0.2.3",
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",