estela 0.2.2 → 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/README.md +23 -0
- package/dist/cli.js +117 -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 +27 -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/server.js +18 -7
- 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/README.md
CHANGED
|
@@ -197,6 +197,11 @@ and Teams have no limit.
|
|
|
197
197
|
The dashboard's footer says "Hours filled in by Estela", linking to
|
|
198
198
|
getestela.dev. On Pro and Teams, `--no-credit` removes that line.
|
|
199
199
|
|
|
200
|
+
Before uploading, `estela publish` shows what will become public — authors,
|
|
201
|
+
branches, file paths, client emails — and asks you to confirm (`--yes` skips
|
|
202
|
+
it). `estela unpublish <project>` deletes the dashboard from the server and the
|
|
203
|
+
link stops working; `--all` takes them all down.
|
|
204
|
+
|
|
200
205
|
## Showing what you built
|
|
201
206
|
|
|
202
207
|
A badge for your project's README, with its measured hours and what the AI
|
|
@@ -319,6 +324,24 @@ from them and marks it as **estimated**, so you know which part of your hours is
|
|
|
319
324
|
measured and which is assumed. The estimate errs on the short side on purpose:
|
|
320
325
|
these hours end up in a report someone pays for.
|
|
321
326
|
|
|
327
|
+
## Privacy
|
|
328
|
+
|
|
329
|
+
On the free plan, your sessions, your code and your hours never leave your
|
|
330
|
+
machine: everything lives in `~/.estela/estela.db`, and the `estela web`
|
|
331
|
+
dashboard only listens on `127.0.0.1`. Estela never stores the text of your
|
|
332
|
+
prompts, not even locally.
|
|
333
|
+
|
|
334
|
+
The only network connection without an account is a check against npm, at most
|
|
335
|
+
once a day, for a new version. Two environment variables:
|
|
336
|
+
|
|
337
|
+
| Variable | Effect |
|
|
338
|
+
|---|---|
|
|
339
|
+
| `ESTELA_NO_UPDATE_CHECK=1` | Skips the version check (it's also skipped when `CI` is set). |
|
|
340
|
+
| `ESTELA_OFFLINE=1` | No network connections at all: no version check, no banner, no account. |
|
|
341
|
+
|
|
342
|
+
With an account, only what you turn on is uploaded — a dashboard, sync, Teams.
|
|
343
|
+
What each feature sends, one by one: [getestela.dev/en/privacy](https://getestela.dev/en/privacy).
|
|
344
|
+
|
|
322
345
|
## Plans
|
|
323
346
|
|
|
324
347
|
| | Free | Pro | Teams |
|
package/dist/cli.js
CHANGED
|
@@ -203,6 +203,11 @@ estela — registro de horas para desarrollo asistido por IA
|
|
|
203
203
|
republicaciones no desvinculan a quien ya lo tenía.
|
|
204
204
|
--no-credit quita la línea "Horas rellenadas por Estela" del pie
|
|
205
205
|
(Pro y Teams).
|
|
206
|
+
Antes de subir enseña lo que va a quedar público (autores, ramas,
|
|
207
|
+
ficheros, correos del cliente) y pide confirmación; --yes la salta.
|
|
208
|
+
estela unpublish [<proyecto|token>] [--all] [--yes]
|
|
209
|
+
Borra el panel del servidor: el enlace deja de funcionar. Sin
|
|
210
|
+
argumentos, lista tus paneles publicados.
|
|
206
211
|
|
|
207
212
|
estela team ai-cost --project <id> --allow | --deny
|
|
208
213
|
Si quien te invitó pide ver el coste de IA del proyecto, esta es tu
|
|
@@ -270,6 +275,12 @@ Opciones globales: --db <ruta> (por defecto ${schema_js_1.DEFAULT_DB_PATH})
|
|
|
270
275
|
--lang es|en Idioma. Sin ella se usa el del sistema;
|
|
271
276
|
ESTELA_LANG=en lo fija para siempre.
|
|
272
277
|
--version Solo imprime la versión instalada.
|
|
278
|
+
|
|
279
|
+
Red: Sin cuenta, Estela solo consulta npm una vez al día por si hay
|
|
280
|
+
versión nueva.
|
|
281
|
+
ESTELA_NO_UPDATE_CHECK=1 no la consulta (tampoco con CI definida).
|
|
282
|
+
ESTELA_OFFLINE=1 ninguna conexión de red: ni versión, ni
|
|
283
|
+
banner, ni nada de la cuenta.
|
|
273
284
|
`;
|
|
274
285
|
const HELP_EN = `
|
|
275
286
|
estela — time tracking for AI-assisted development
|
|
@@ -394,6 +405,11 @@ estela — time tracking for AI-assisted development
|
|
|
394
405
|
isn't touched: republishing never unlinks anyone who already had it.
|
|
395
406
|
--no-credit removes the "Hours filled in by Estela" line from the
|
|
396
407
|
footer (Pro and Teams).
|
|
408
|
+
Before uploading it shows what will become public (authors,
|
|
409
|
+
branches, files, client emails) and asks to confirm; --yes skips it.
|
|
410
|
+
estela unpublish [<project|token>] [--all] [--yes]
|
|
411
|
+
Deletes the dashboard from the server: the link stops working.
|
|
412
|
+
With no arguments, lists your published dashboards.
|
|
397
413
|
|
|
398
414
|
estela team ai-cost --project <id> --allow | --deny
|
|
399
415
|
If whoever invited you asks to see the project's AI cost, this is
|
|
@@ -461,6 +477,12 @@ Global options: --db <path> (default ${schema_js_1.DEFAULT_DB_PATH})
|
|
|
461
477
|
--lang es|en Language. Without it, your system's is used;
|
|
462
478
|
ESTELA_LANG=en sets it for good.
|
|
463
479
|
--version Only prints the installed version.
|
|
480
|
+
|
|
481
|
+
Network: Without an account, Estela only checks npm once a day for a new
|
|
482
|
+
version.
|
|
483
|
+
ESTELA_NO_UPDATE_CHECK=1 skips that check (so does having CI set).
|
|
484
|
+
ESTELA_OFFLINE=1 no network connections at all: no
|
|
485
|
+
version check, no banner, no account.
|
|
464
486
|
`;
|
|
465
487
|
function helpText() {
|
|
466
488
|
return (0, index_js_1.getLang)() === "en" ? HELP_EN : HELP_ES;
|
|
@@ -1398,7 +1420,7 @@ async function cmdPublish(args, dbPath) {
|
|
|
1398
1420
|
const token = str(args, "token") ?? store.getPublicationToken(db, projectId) ?? undefined;
|
|
1399
1421
|
let result;
|
|
1400
1422
|
try {
|
|
1401
|
-
|
|
1423
|
+
const panel = await (0, publish_js_1.preparePanel)(db, {
|
|
1402
1424
|
projectId,
|
|
1403
1425
|
...(token ? { token } : {}),
|
|
1404
1426
|
...(str(args, "author") ? { authorName: str(args, "author") } : {}),
|
|
@@ -1408,6 +1430,19 @@ async function cmdPublish(args, dbPath) {
|
|
|
1408
1430
|
hideCredit: args.flags["no-credit"] === true,
|
|
1409
1431
|
...(explicitLang(args) ? { language: explicitLang(args) } : {}),
|
|
1410
1432
|
});
|
|
1433
|
+
// Lo que va a quedar público, antes de subirlo: quien tenga el enlace
|
|
1434
|
+
// verá todo esto, y publicar no se deshace solo.
|
|
1435
|
+
printPanelSummary(panel.summary);
|
|
1436
|
+
if (args.flags["yes"] !== true) {
|
|
1437
|
+
if (!process.stdin.isTTY) {
|
|
1438
|
+
throw new UserError((0, index_js_1.tr) `No se ha publicado nada. Sin terminal no se puede confirmar: añade --yes para publicar.`);
|
|
1439
|
+
}
|
|
1440
|
+
if (!await confirm((0, index_js_1.tr) `¿Publicar? [s/N] `)) {
|
|
1441
|
+
console.log((0, index_js_1.tr) `\nNo se ha publicado nada.`);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
result = await (0, publish_js_1.uploadPanel)(db, panel);
|
|
1411
1446
|
}
|
|
1412
1447
|
catch (error) {
|
|
1413
1448
|
if (error instanceof publish_js_1.NoAccountError)
|
|
@@ -1433,6 +1468,82 @@ async function cmdPublish(args, dbPath) {
|
|
|
1433
1468
|
db.close();
|
|
1434
1469
|
}
|
|
1435
1470
|
}
|
|
1471
|
+
/** Hasta `max` elementos de una lista, y cuántos quedan fuera. */
|
|
1472
|
+
function shortList(items, max = 8) {
|
|
1473
|
+
if (items.length === 0)
|
|
1474
|
+
return "—";
|
|
1475
|
+
const shown = items.slice(0, max).join(", ");
|
|
1476
|
+
return items.length > max ? (0, index_js_1.tr) `${shown} y ${items.length - max} más` : shown;
|
|
1477
|
+
}
|
|
1478
|
+
function printPanelSummary(s) {
|
|
1479
|
+
console.log((0, index_js_1.tr) `\nEsto va a quedar público. Quien tenga el enlace lo ve todo:\n`);
|
|
1480
|
+
const row = (label, value) => console.log(` ${label.padEnd(14)}${value}`);
|
|
1481
|
+
row((0, index_js_1.tr) `Proyecto`, `${s.project} · ${s.client}`);
|
|
1482
|
+
row((0, index_js_1.tr) `Trabajo`, (0, index_js_1.tr) `${s.days} días, ${s.blocks} bloques, ${s.commitSubjects} mensajes de commit`);
|
|
1483
|
+
row((0, index_js_1.tr) `Autores`, shortList(s.authors));
|
|
1484
|
+
row((0, index_js_1.tr) `Ramas`, shortList(s.branches));
|
|
1485
|
+
row((0, index_js_1.tr) `Ficheros`, shortList(s.files));
|
|
1486
|
+
row((0, index_js_1.tr) `Importes`, s.amounts ? (0, index_js_1.tr) `sí (--with-amounts)` : (0, index_js_1.tr) `no`);
|
|
1487
|
+
row((0, index_js_1.tr) `Clientes`, s.clients.length ? (0, index_js_1.tr) `${s.clients.join(", ")} (recibirán un correo)` : "—");
|
|
1488
|
+
console.log("");
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* `estela unpublish <proyecto|token>` y `estela unpublish --all`.
|
|
1492
|
+
*
|
|
1493
|
+
* Borra el panel del servidor: el enlace deja de funcionar para todos al
|
|
1494
|
+
* momento. Sin argumentos, lista lo que tienes publicado.
|
|
1495
|
+
*/
|
|
1496
|
+
async function cmdUnpublish(args, dbPath) {
|
|
1497
|
+
const db = (0, schema_js_1.openDatabase)(dbPath);
|
|
1498
|
+
try {
|
|
1499
|
+
const target = args._[1] ?? str(args, "project") ?? str(args, "token");
|
|
1500
|
+
try {
|
|
1501
|
+
if (args.flags["all"] === true) {
|
|
1502
|
+
const panels = await (0, publish_js_1.listPublishedPanels)(db);
|
|
1503
|
+
if (panels.length === 0) {
|
|
1504
|
+
console.log((0, index_js_1.tr) `No tienes ningún panel publicado.`);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
for (const p of panels)
|
|
1508
|
+
console.log(` ${p.projectName} · ${p.token}`);
|
|
1509
|
+
if (args.flags["yes"] !== true) {
|
|
1510
|
+
if (!process.stdin.isTTY) {
|
|
1511
|
+
throw new UserError((0, index_js_1.tr) `No se ha despublicado nada. Sin terminal no se puede confirmar: añade --yes.`);
|
|
1512
|
+
}
|
|
1513
|
+
if (!await confirm((0, index_js_1.tr) `\n¿Despublicar estos ${panels.length} paneles? Los enlaces dejarán de funcionar. [s/N] `)) {
|
|
1514
|
+
console.log((0, index_js_1.tr) `\nNo se ha despublicado nada.`);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const gone = await (0, publish_js_1.unpublishAll)(db);
|
|
1519
|
+
console.log((0, index_js_1.tr) `\n${gone.length} paneles despublicados. Sus enlaces ya no funcionan.`);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (!target) {
|
|
1523
|
+
const panels = await (0, publish_js_1.listPublishedPanels)(db);
|
|
1524
|
+
if (panels.length === 0) {
|
|
1525
|
+
console.log((0, index_js_1.tr) `No tienes ningún panel publicado.`);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
console.log((0, index_js_1.tr) `Tus paneles publicados:\n`);
|
|
1529
|
+
for (const p of panels)
|
|
1530
|
+
console.log(` ${p.projectId ?? "—"} ${p.projectName} · ${p.token}`);
|
|
1531
|
+
console.log((0, index_js_1.tr) `\nPara despublicar uno: estela unpublish <proyecto o token>. Todos: estela unpublish --all`);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const gone = await (0, publish_js_1.unpublishPanel)(db, target);
|
|
1535
|
+
console.log((0, index_js_1.tr) `Panel de ${gone.projectName} despublicado. El enlace ya no funciona.`);
|
|
1536
|
+
}
|
|
1537
|
+
catch (error) {
|
|
1538
|
+
if (error instanceof publish_js_1.NoAccountError)
|
|
1539
|
+
throw new UserError(error.message);
|
|
1540
|
+
throw error;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
finally {
|
|
1544
|
+
db.close();
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1436
1547
|
/**
|
|
1437
1548
|
* `estela sync enable --project <id>` — opt-in explícito, proyecto por
|
|
1438
1549
|
* proyecto. Sin esta fila, ese proyecto no sale de esta máquina nunca, sea
|
|
@@ -2350,7 +2461,8 @@ async function main() {
|
|
|
2350
2461
|
}
|
|
2351
2462
|
(0, client_js_1.setClientVersion)(VERSION);
|
|
2352
2463
|
const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
|
|
2353
|
-
|
|
2464
|
+
// `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
|
|
2465
|
+
const command = args._[0] === "unpublish" ? "unpublish" : args._.join(" ");
|
|
2354
2466
|
// Se llama aquí, no al final: un comando que falla a medias (por ejemplo
|
|
2355
2467
|
// "estela import" sin repositorios) debe avisar igual de que hay una
|
|
2356
2468
|
// versión nueva, no solo los que terminan bien.
|
|
@@ -2434,6 +2546,9 @@ async function main() {
|
|
|
2434
2546
|
case "publish":
|
|
2435
2547
|
await cmdPublish(args, dbPath);
|
|
2436
2548
|
break;
|
|
2549
|
+
case "unpublish":
|
|
2550
|
+
await cmdUnpublish(args, dbPath);
|
|
2551
|
+
break;
|
|
2437
2552
|
case "export":
|
|
2438
2553
|
await cmdExport(args, dbPath);
|
|
2439
2554
|
break;
|
package/dist/cloud/client.d.ts
CHANGED
|
@@ -22,4 +22,6 @@ export declare function cloudPost<T>(baseUrl: string, path: string, body: unknow
|
|
|
22
22
|
* proyecto es la primera vez que hace falta leer algo.
|
|
23
23
|
*/
|
|
24
24
|
export declare function cloudGet<T>(baseUrl: string, path: string, options?: CloudPostOptions): Promise<T>;
|
|
25
|
+
/** Para borrar lo que se subió: hoy, despublicar un panel. */
|
|
26
|
+
export declare function cloudDelete<T>(baseUrl: string, path: string, options?: CloudPostOptions): Promise<T>;
|
|
25
27
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/cloud/client.js
CHANGED
|
@@ -12,7 +12,9 @@ exports.CloudError = void 0;
|
|
|
12
12
|
exports.setClientVersion = setClientVersion;
|
|
13
13
|
exports.cloudPost = cloudPost;
|
|
14
14
|
exports.cloudGet = cloudGet;
|
|
15
|
+
exports.cloudDelete = cloudDelete;
|
|
15
16
|
const index_js_1 = require("../i18n/index.js");
|
|
17
|
+
const offline_js_1 = require("../offline.js");
|
|
16
18
|
class CloudError extends Error {
|
|
17
19
|
status;
|
|
18
20
|
constructor(message, status) {
|
|
@@ -29,32 +31,7 @@ exports.CloudError = CloudError;
|
|
|
29
31
|
let clientVersion = "0.0.0";
|
|
30
32
|
function setClientVersion(v) { clientVersion = v; }
|
|
31
33
|
async function cloudPost(baseUrl, path, body, options = {}) {
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
res = await fetch(`${baseUrl}${path}`, {
|
|
35
|
-
method: "POST",
|
|
36
|
-
headers: {
|
|
37
|
-
"content-type": "application/json",
|
|
38
|
-
"estela-version": clientVersion,
|
|
39
|
-
// Para que la API conteste en el idioma de la terminal.
|
|
40
|
-
"x-estela-lang": (0, index_js_1.getLang)(),
|
|
41
|
-
...(options.deviceToken ? { authorization: `Bearer ${options.deviceToken}` } : {}),
|
|
42
|
-
},
|
|
43
|
-
body: JSON.stringify(body),
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
throw new CloudError((0, index_js_1.tr) `Sin respuesta de ${baseUrl}. Puede ser tu conexión o que el servicio esté caído; ` +
|
|
48
|
-
(0, index_js_1.tr) `en ambos casos, nada de lo tuyo se ha perdido: vuelve a intentarlo.`);
|
|
49
|
-
}
|
|
50
|
-
const data = (await res.json().catch(() => ({})));
|
|
51
|
-
if (!res.ok) {
|
|
52
|
-
// El status viaja aparte del mensaje: quien llama necesita distinguir
|
|
53
|
-
// "no tienes sesión" (401, pide login) de "cuota agotada" (402, pide
|
|
54
|
-
// upgrade) sin tener que analizar el texto en español.
|
|
55
|
-
throw new CloudError(typeof data["error"] === "string" ? data["error"] : (0, index_js_1.tr) `Error ${res.status}`, res.status);
|
|
56
|
-
}
|
|
57
|
-
return data;
|
|
34
|
+
return request("POST", baseUrl, path, body, options);
|
|
58
35
|
}
|
|
59
36
|
/**
|
|
60
37
|
* Primer GET autenticado de verdad del CLI. Hasta Teams, todo lo que hablaba
|
|
@@ -62,16 +39,30 @@ async function cloudPost(baseUrl, path, body, options = {}) {
|
|
|
62
39
|
* proyecto es la primera vez que hace falta leer algo.
|
|
63
40
|
*/
|
|
64
41
|
async function cloudGet(baseUrl, path, options = {}) {
|
|
42
|
+
return request("GET", baseUrl, path, undefined, options);
|
|
43
|
+
}
|
|
44
|
+
/** Para borrar lo que se subió: hoy, despublicar un panel. */
|
|
45
|
+
async function cloudDelete(baseUrl, path, options = {}) {
|
|
46
|
+
return request("DELETE", baseUrl, path, undefined, options);
|
|
47
|
+
}
|
|
48
|
+
async function request(method, baseUrl, path, body, options) {
|
|
49
|
+
// Todas las llamadas a la cuenta pasan por aquí: con ESTELA_OFFLINE=1 no
|
|
50
|
+
// sale ninguna, y quien llama recibe un error que dice por qué.
|
|
51
|
+
if ((0, offline_js_1.isOffline)()) {
|
|
52
|
+
throw new CloudError((0, index_js_1.tr) `ESTELA_OFFLINE=1: Estela no se conecta a la red. Quita la variable para usar tu cuenta.`);
|
|
53
|
+
}
|
|
65
54
|
let res;
|
|
66
55
|
try {
|
|
67
56
|
res = await fetch(`${baseUrl}${path}`, {
|
|
68
|
-
method
|
|
57
|
+
method,
|
|
69
58
|
headers: {
|
|
59
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
70
60
|
"estela-version": clientVersion,
|
|
71
61
|
// Para que la API conteste en el idioma de la terminal.
|
|
72
62
|
"x-estela-lang": (0, index_js_1.getLang)(),
|
|
73
63
|
...(options.deviceToken ? { authorization: `Bearer ${options.deviceToken}` } : {}),
|
|
74
64
|
},
|
|
65
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
75
66
|
});
|
|
76
67
|
}
|
|
77
68
|
catch {
|
|
@@ -80,6 +71,9 @@ async function cloudGet(baseUrl, path, options = {}) {
|
|
|
80
71
|
}
|
|
81
72
|
const data = (await res.json().catch(() => ({})));
|
|
82
73
|
if (!res.ok) {
|
|
74
|
+
// El status viaja aparte del mensaje: quien llama necesita distinguir
|
|
75
|
+
// "no tienes sesión" (401, pide login) de "cuota agotada" (402, pide
|
|
76
|
+
// upgrade) sin tener que analizar el texto en español.
|
|
83
77
|
throw new CloudError(typeof data["error"] === "string" ? data["error"] : (0, index_js_1.tr) `Error ${res.status}`, res.status);
|
|
84
78
|
}
|
|
85
79
|
return data;
|
package/dist/db/store.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface Publication {
|
|
|
62
62
|
*/
|
|
63
63
|
export declare function getPublicationToken(db: DatabaseSync, projectId: string): string | null;
|
|
64
64
|
export declare function recordPublication(db: DatabaseSync, projectId: string, token: string, baseUrl: string | null, at?: Date): void;
|
|
65
|
+
/** Tras despublicar: el proyecto deja de constar como publicado en esta máquina. */
|
|
66
|
+
export declare function forgetPublication(db: DatabaseSync, token: string): void;
|
|
65
67
|
/**
|
|
66
68
|
* Estado de las publicaciones.
|
|
67
69
|
*
|
package/dist/db/store.js
CHANGED
|
@@ -17,6 +17,7 @@ exports.getProjectAuthors = getProjectAuthors;
|
|
|
17
17
|
exports.setProjectAuthors = setProjectAuthors;
|
|
18
18
|
exports.getPublicationToken = getPublicationToken;
|
|
19
19
|
exports.recordPublication = recordPublication;
|
|
20
|
+
exports.forgetPublication = forgetPublication;
|
|
20
21
|
exports.listPublications = listPublications;
|
|
21
22
|
exports.authorsByRepo = authorsByRepo;
|
|
22
23
|
exports.projectForRepo = projectForRepo;
|
|
@@ -246,6 +247,10 @@ function recordPublication(db, projectId, token, baseUrl, at = new Date()) {
|
|
|
246
247
|
base_url = COALESCE(excluded.base_url, publications.base_url)
|
|
247
248
|
`).run(projectId, token, iso(at), baseUrl);
|
|
248
249
|
}
|
|
250
|
+
/** Tras despublicar: el proyecto deja de constar como publicado en esta máquina. */
|
|
251
|
+
function forgetPublication(db, token) {
|
|
252
|
+
db.prepare("DELETE FROM publications WHERE token = ?").run(token);
|
|
253
|
+
}
|
|
249
254
|
/**
|
|
250
255
|
* Estado de las publicaciones.
|
|
251
256
|
*
|
package/dist/export/panel.d.ts
CHANGED
|
@@ -83,5 +83,74 @@ export interface PanelTeamMember {
|
|
|
83
83
|
* porque una caducidad que comprueba el navegador no protege de nada.
|
|
84
84
|
*/
|
|
85
85
|
export declare function newPanelToken(): string;
|
|
86
|
+
interface DayData {
|
|
87
|
+
date: string;
|
|
88
|
+
seconds: number;
|
|
89
|
+
items: {
|
|
90
|
+
what: string;
|
|
91
|
+
seconds: number;
|
|
92
|
+
kind: string;
|
|
93
|
+
amount: string | null;
|
|
94
|
+
commits: {
|
|
95
|
+
hash: string;
|
|
96
|
+
subject: string;
|
|
97
|
+
}[];
|
|
98
|
+
}[];
|
|
99
|
+
}
|
|
86
100
|
export declare function buildPanel(options: PanelOptions): string;
|
|
101
|
+
/** Los datos que viajan dentro del panel: lo único que se publica de tu trabajo. */
|
|
102
|
+
export type PanelData = ReturnType<typeof panelData>;
|
|
103
|
+
/**
|
|
104
|
+
* Lo que va a quedar público, contado antes de subirlo.
|
|
105
|
+
*
|
|
106
|
+
* Sale de los mismos datos que se meten en el HTML, no de un cálculo aparte:
|
|
107
|
+
* un resumen que pudiera decir algo distinto de lo que se publica no
|
|
108
|
+
* protegería a nadie. `estela publish` lo enseña y pide confirmación.
|
|
109
|
+
*/
|
|
110
|
+
export declare function summarizePanel(data: PanelData): {
|
|
111
|
+
project: string;
|
|
112
|
+
client: string;
|
|
113
|
+
days: number;
|
|
114
|
+
blocks: number;
|
|
115
|
+
commitSubjects: number;
|
|
116
|
+
authors: string[];
|
|
117
|
+
branches: string[];
|
|
118
|
+
files: string[];
|
|
119
|
+
amounts: boolean;
|
|
120
|
+
};
|
|
121
|
+
export declare function panelData(options: PanelOptions): {
|
|
122
|
+
project: string;
|
|
123
|
+
client: string;
|
|
124
|
+
author: string | null;
|
|
125
|
+
generatedAt: string;
|
|
126
|
+
totalSeconds: number;
|
|
127
|
+
totalAmount: string | null;
|
|
128
|
+
days: DayData[];
|
|
129
|
+
features: {
|
|
130
|
+
name: string;
|
|
131
|
+
seconds: number;
|
|
132
|
+
days: number;
|
|
133
|
+
merged: boolean | null;
|
|
134
|
+
delivered: {
|
|
135
|
+
hash: string;
|
|
136
|
+
subject: string;
|
|
137
|
+
}[];
|
|
138
|
+
moreDelivered: number;
|
|
139
|
+
}[];
|
|
140
|
+
rhythm: {
|
|
141
|
+
activeDays: number;
|
|
142
|
+
spanDays: number;
|
|
143
|
+
medianSeconds: number;
|
|
144
|
+
longestGapDays: number;
|
|
145
|
+
};
|
|
146
|
+
rework: {
|
|
147
|
+
file: string;
|
|
148
|
+
touches: number;
|
|
149
|
+
spanDays: number;
|
|
150
|
+
}[];
|
|
151
|
+
open: import("../metrics/index.js").OpenWork[];
|
|
152
|
+
team: readonly PanelTeamMember[];
|
|
153
|
+
site: string;
|
|
154
|
+
};
|
|
155
|
+
export {};
|
|
87
156
|
//# sourceMappingURL=panel.d.ts.map
|
package/dist/export/panel.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.CREDIT_MARKER = void 0;
|
|
4
4
|
exports.newPanelToken = newPanelToken;
|
|
5
5
|
exports.buildPanel = buildPanel;
|
|
6
|
+
exports.summarizePanel = summarizePanel;
|
|
7
|
+
exports.panelData = panelData;
|
|
6
8
|
const node_crypto_1 = require("node:crypto");
|
|
7
9
|
const shared_1 = require("@estela/shared");
|
|
8
10
|
const localize_js_1 = require("../billing/localize.js");
|
|
@@ -28,6 +30,50 @@ function shortPath(file) {
|
|
|
28
30
|
return file.split("/").slice(-2).join("/");
|
|
29
31
|
}
|
|
30
32
|
function buildPanel(options) {
|
|
33
|
+
const data = panelData(options);
|
|
34
|
+
// El texto de interfaz va aparte de los datos, en su propio bloque: los datos
|
|
35
|
+
// son lo único que hay que vigilar de cerca (aquí no entra nada de IA ni de
|
|
36
|
+
// costes, y hay tests que lo comprueban), y mezclarlos con frases sueltas
|
|
37
|
+
// haría esa comprobación imposible de hacer sin falsos positivos.
|
|
38
|
+
const lang = (0, index_js_1.getLang)();
|
|
39
|
+
const ui = {
|
|
40
|
+
lang, L: panelLabels(), monthNames: dates_js_1.MONTHS[lang], dayNames: dates_js_1.DAYS[lang],
|
|
41
|
+
credit: options.hideCredit !== true,
|
|
42
|
+
};
|
|
43
|
+
return render(data, ui);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Lo que va a quedar público, contado antes de subirlo.
|
|
47
|
+
*
|
|
48
|
+
* Sale de los mismos datos que se meten en el HTML, no de un cálculo aparte:
|
|
49
|
+
* un resumen que pudiera decir algo distinto de lo que se publica no
|
|
50
|
+
* protegería a nadie. `estela publish` lo enseña y pide confirmación.
|
|
51
|
+
*/
|
|
52
|
+
function summarizePanel(data) {
|
|
53
|
+
const subjects = new Set();
|
|
54
|
+
const branches = new Set();
|
|
55
|
+
for (const day of data.days) {
|
|
56
|
+
for (const item of day.items)
|
|
57
|
+
for (const c of item.commits)
|
|
58
|
+
subjects.add(c.subject);
|
|
59
|
+
}
|
|
60
|
+
for (const f of data.features)
|
|
61
|
+
branches.add(f.name);
|
|
62
|
+
for (const o of data.open)
|
|
63
|
+
branches.add(o.name);
|
|
64
|
+
return {
|
|
65
|
+
project: data.project,
|
|
66
|
+
client: data.client,
|
|
67
|
+
days: data.days.length,
|
|
68
|
+
blocks: data.days.reduce((n, d) => n + d.items.length, 0),
|
|
69
|
+
commitSubjects: subjects.size,
|
|
70
|
+
authors: data.team.map((m) => m.name),
|
|
71
|
+
branches: [...branches].sort(),
|
|
72
|
+
files: data.rework.map((r) => r.file),
|
|
73
|
+
amounts: data.totalAmount !== null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function panelData(options) {
|
|
31
77
|
const { project, client, entries } = options;
|
|
32
78
|
const showAmounts = options.withAmounts === true && options.rateAt !== undefined;
|
|
33
79
|
const included = entries
|
|
@@ -104,16 +150,7 @@ function buildPanel(options) {
|
|
|
104
150
|
team: options.team ?? [],
|
|
105
151
|
site: (options.siteUrl ?? "https://getestela.dev").replace(/\/$/, ""),
|
|
106
152
|
};
|
|
107
|
-
|
|
108
|
-
// son lo único que hay que vigilar de cerca (aquí no entra nada de IA ni de
|
|
109
|
-
// costes, y hay tests que lo comprueban), y mezclarlos con frases sueltas
|
|
110
|
-
// haría esa comprobación imposible de hacer sin falsos positivos.
|
|
111
|
-
const lang = (0, index_js_1.getLang)();
|
|
112
|
-
const ui = {
|
|
113
|
-
lang, L: panelLabels(), monthNames: dates_js_1.MONTHS[lang], dayNames: dates_js_1.DAYS[lang],
|
|
114
|
-
credit: options.hideCredit !== true,
|
|
115
|
-
};
|
|
116
|
-
return render(data, ui);
|
|
153
|
+
return data;
|
|
117
154
|
}
|
|
118
155
|
/**
|
|
119
156
|
* Todo el texto fijo del panel, en el idioma en curso (`getLang()`).
|
package/dist/i18n/en.js
CHANGED
|
@@ -550,5 +550,32 @@ exports.EN = {
|
|
|
550
550
|
" {0} recuperadas de sesiones en subcarpetas y worktrees": " {0} recovered from sessions in subfolders and worktrees",
|
|
551
551
|
"{0} (sin proyecto)": "{0} (no project)",
|
|
552
552
|
"\n{0} de esos bloques son de subcarpetas o worktrees de un proyecto: estela import --yes los imputa.": "\n{0} of those blocks are from a project's subfolders or worktrees: estela import --yes assigns them.",
|
|
553
|
+
// ── publish: resumen antes de subir, y unpublish ──
|
|
554
|
+
"No se ha publicado nada. Sin terminal no se puede confirmar: añade --yes para publicar.": "Nothing was published. Without a terminal there's no way to confirm: add --yes to publish.",
|
|
555
|
+
"¿Publicar? [s/N] ": "Publish? [y/N] ",
|
|
556
|
+
"\nNo se ha publicado nada.": "\nNothing was published.",
|
|
557
|
+
"{0} y {1} más": "{0} and {1} more",
|
|
558
|
+
"\nEsto va a quedar público. Quien tenga el enlace lo ve todo:\n": "\nThis will become public. Anyone with the link sees all of it:\n",
|
|
559
|
+
"Trabajo": "Work",
|
|
560
|
+
"{0} días, {1} bloques, {2} mensajes de commit": "{0} days, {1} blocks, {2} commit messages",
|
|
561
|
+
"Autores": "Authors",
|
|
562
|
+
"Ramas": "Branches",
|
|
563
|
+
"Ficheros": "Files",
|
|
564
|
+
"Importes": "Amounts",
|
|
565
|
+
"sí (--with-amounts)": "yes (--with-amounts)",
|
|
566
|
+
"no": "no",
|
|
567
|
+
"Clientes": "Clients",
|
|
568
|
+
"{0} (recibirán un correo)": "{0} (they'll get an email)",
|
|
569
|
+
"No tienes ningún panel publicado.": "You have no published dashboards.",
|
|
570
|
+
"No se ha despublicado nada. Sin terminal no se puede confirmar: añade --yes.": "Nothing was unpublished. Without a terminal there's no way to confirm: add --yes.",
|
|
571
|
+
"\n¿Despublicar estos {0} paneles? Los enlaces dejarán de funcionar. [s/N] ": "\nUnpublish these {0} dashboards? The links will stop working. [y/N] ",
|
|
572
|
+
"\nNo se ha despublicado nada.": "\nNothing was unpublished.",
|
|
573
|
+
"\n{0} paneles despublicados. Sus enlaces ya no funcionan.": "\n{0} dashboards unpublished. Their links no longer work.",
|
|
574
|
+
"Tus paneles publicados:\n": "Your published dashboards:\n",
|
|
575
|
+
"\nPara despublicar uno: estela unpublish <proyecto o token>. Todos: estela unpublish --all": "\nTo unpublish one: estela unpublish <project or token>. All of them: estela unpublish --all",
|
|
576
|
+
"Panel de {0} despublicado. El enlace ya no funciona.": "{0} dashboard unpublished. The link no longer works.",
|
|
577
|
+
"No hay ningún panel publicado de \"{0}\". Míralos con: estela unpublish": "There's no published dashboard for \"{0}\". See them with: estela unpublish",
|
|
578
|
+
// ── ESTELA_OFFLINE ──
|
|
579
|
+
"ESTELA_OFFLINE=1: Estela no se conecta a la red. Quita la variable para usar tu cuenta.": "ESTELA_OFFLINE=1: Estela doesn't connect to the network. Unset it to use your account.",
|
|
553
580
|
};
|
|
554
581
|
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ESTELA_OFFLINE=1`: Estela no abre ninguna conexión de red. Ni la
|
|
3
|
+
* comprobación de versión, ni el banner del panel local, ni nada de la cuenta
|
|
4
|
+
* (login, sync, Teams, paneles, pagos). Todo lo local sigue igual.
|
|
5
|
+
*
|
|
6
|
+
* Es el interruptor general; `ESTELA_NO_UPDATE_CHECK=1` apaga solo la
|
|
7
|
+
* comprobación de versión.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isOffline(env?: NodeJS.ProcessEnv): boolean;
|
|
10
|
+
//# sourceMappingURL=offline.d.ts.map
|
package/dist/offline.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isOffline = isOffline;
|
|
4
|
+
/**
|
|
5
|
+
* `ESTELA_OFFLINE=1`: Estela no abre ninguna conexión de red. Ni la
|
|
6
|
+
* comprobación de versión, ni el banner del panel local, ni nada de la cuenta
|
|
7
|
+
* (login, sync, Teams, paneles, pagos). Todo lo local sigue igual.
|
|
8
|
+
*
|
|
9
|
+
* Es el interruptor general; `ESTELA_NO_UPDATE_CHECK=1` apaga solo la
|
|
10
|
+
* comprobación de versión.
|
|
11
|
+
*/
|
|
12
|
+
function isOffline(env = process.env) {
|
|
13
|
+
const v = env["ESTELA_OFFLINE"];
|
|
14
|
+
return v !== undefined && v !== "" && v !== "0";
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=offline.js.map
|
package/dist/publish.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { summarizePanel } from "./export/panel.js";
|
|
2
3
|
import { type Lang } from "./i18n/index.js";
|
|
3
4
|
/**
|
|
4
5
|
* Publica un panel, alojado en la cuenta vinculada.
|
|
@@ -36,10 +37,47 @@ export interface PublishOptions {
|
|
|
36
37
|
/** Sin la línea "Horas rellenadas por Estela" del pie. Solo Pro y Teams: el servidor lo comprueba. */
|
|
37
38
|
readonly hideCredit?: boolean;
|
|
38
39
|
}
|
|
40
|
+
/** Lo que va a quedar público al subir el panel, para enseñarlo antes. */
|
|
41
|
+
export type PanelSummary = ReturnType<typeof summarizePanel> & {
|
|
42
|
+
/** Correos del cliente que recibirán acceso y un aviso por correo. */
|
|
43
|
+
readonly clients: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
/** Un panel ya construido, a falta de subirlo. */
|
|
46
|
+
export interface PreparedPanel {
|
|
47
|
+
readonly projectId: string;
|
|
48
|
+
readonly projectName: string;
|
|
49
|
+
readonly token: string;
|
|
50
|
+
readonly html: string;
|
|
51
|
+
readonly summary: PanelSummary;
|
|
52
|
+
readonly clients?: readonly string[];
|
|
53
|
+
readonly hideCredit: boolean;
|
|
54
|
+
}
|
|
39
55
|
export interface PublishResult {
|
|
40
56
|
readonly token: string;
|
|
41
57
|
readonly url: string;
|
|
42
58
|
readonly adopted: boolean;
|
|
43
59
|
}
|
|
44
60
|
export declare function publishPanel(db: DatabaseSync, options: PublishOptions): Promise<PublishResult>;
|
|
61
|
+
/**
|
|
62
|
+
* Construye el panel sin subirlo: el HTML y el resumen de lo que va a quedar
|
|
63
|
+
* público. `estela publish` enseña el resumen y pide confirmación antes de
|
|
64
|
+
* `uploadPanel`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function preparePanel(db: DatabaseSync, options: PublishOptions): Promise<PreparedPanel>;
|
|
67
|
+
export declare function uploadPanel(db: DatabaseSync, panel: PreparedPanel): Promise<PublishResult>;
|
|
68
|
+
/** Un panel publicado en tu cuenta, tal como lo lista el servidor. */
|
|
69
|
+
export interface PublishedPanel {
|
|
70
|
+
readonly token: string;
|
|
71
|
+
readonly projectName: string;
|
|
72
|
+
readonly projectId: string | null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Despublica un panel: el servidor borra el fichero y el enlace deja de
|
|
76
|
+
* funcionar. `target` es el id de un proyecto (el panel que se publicó desde
|
|
77
|
+
* esta máquina) o el token del enlace.
|
|
78
|
+
*/
|
|
79
|
+
export declare function unpublishPanel(db: DatabaseSync, target: string): Promise<PublishedPanel>;
|
|
80
|
+
/** Despublica todos los paneles de la cuenta. Devuelve los que quitó. */
|
|
81
|
+
export declare function unpublishAll(db: DatabaseSync): Promise<PublishedPanel[]>;
|
|
82
|
+
export declare function listPublishedPanels(db: DatabaseSync): Promise<PublishedPanel[]>;
|
|
45
83
|
//# sourceMappingURL=publish.d.ts.map
|
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
|
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 {
|
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.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",
|
package/web/app.js
CHANGED
|
@@ -1213,6 +1213,8 @@ function wireProjects() {
|
|
|
1213
1213
|
});
|
|
1214
1214
|
});
|
|
1215
1215
|
|
|
1216
|
+
// Publicar va en dos pasos, como en la terminal: primero lo que va a quedar
|
|
1217
|
+
// público (autores, ramas, ficheros, correos), y solo al confirmar se sube.
|
|
1216
1218
|
document.querySelectorAll(".share-btn").forEach((btn) => {
|
|
1217
1219
|
btn.addEventListener("click", async () => {
|
|
1218
1220
|
const card = btn.closest(".pcard");
|
|
@@ -1220,54 +1222,91 @@ function wireProjects() {
|
|
|
1220
1222
|
const out = card.querySelector(".share-out");
|
|
1221
1223
|
btn.disabled = true;
|
|
1222
1224
|
try {
|
|
1223
|
-
// El token ya vive en la base de datos local (una fila por proyecto,
|
|
1224
|
-
// se lee de state.projects.publications): guardarlo también en
|
|
1225
|
-
// localStorage sería una segunda copia que puede desincronizarse de
|
|
1226
|
-
// la real, y lo hizo — un token de una prueba vieja en el navegador
|
|
1227
|
-
// pisó el que de verdad estaba publicado.
|
|
1228
1225
|
const known = state.projects?.publications?.find((p) => p.projectId === projectId);
|
|
1229
|
-
const
|
|
1226
|
+
const { summary: s } = await api("/api/publish", {
|
|
1230
1227
|
method: "POST",
|
|
1231
|
-
body: JSON.stringify({ projectId, token: known?.token || "" }),
|
|
1228
|
+
body: JSON.stringify({ projectId, token: known?.token || "", preview: true }),
|
|
1232
1229
|
});
|
|
1233
|
-
|
|
1230
|
+
const list = (items) => items.length ? items.map(esc).join(", ") : "—";
|
|
1231
|
+
const row = (label, value) => '<p class="share-line">' + label + ' ' + value + '</p>';
|
|
1234
1232
|
out.hidden = false;
|
|
1235
1233
|
out.innerHTML =
|
|
1236
|
-
'<p class="share-line"><b>' +
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
const out3 = card2?.querySelector(".share-out");
|
|
1251
|
-
if (out3) { out3.hidden = false; out3.innerHTML = recien; }
|
|
1234
|
+
'<p class="share-line"><b>' + tr("publish.preview.title") + '</b></p>' +
|
|
1235
|
+
row(tr("publish.preview.project"), esc(s.project) + " · " + esc(s.client)) +
|
|
1236
|
+
row(tr("publish.preview.authors"), list(s.authors)) +
|
|
1237
|
+
row(tr("publish.preview.branches"), list(s.branches)) +
|
|
1238
|
+
row(tr("publish.preview.files"), list(s.files)) +
|
|
1239
|
+
row(tr("publish.preview.clients"), list(s.clients)) +
|
|
1240
|
+
'<p class="share-warn">' + tr("publish.noPassword") + '</p>' +
|
|
1241
|
+
'<p><button type="button" class="chip share-confirm">' + tr("publish.preview.confirm") + '</button> ' +
|
|
1242
|
+
'<button type="button" class="chip share-cancel">' + tr("publish.preview.cancel") + '</button></p>';
|
|
1243
|
+
out.querySelector(".share-cancel").addEventListener("click", () => { out.hidden = true; out.innerHTML = ""; });
|
|
1244
|
+
out.querySelector(".share-confirm").addEventListener("click", (e) => {
|
|
1245
|
+
e.currentTarget.disabled = true;
|
|
1246
|
+
publishNow(card, btn);
|
|
1247
|
+
});
|
|
1252
1248
|
} catch (error) {
|
|
1253
|
-
|
|
1254
|
-
// "Sin cuenta" no es un error cualquiera: se enseña con el comando
|
|
1255
|
-
// exacto para resolverlo, no como un aviso genérico que hay que
|
|
1256
|
-
// interpretar. `needsLogin` viaja en el cuerpo desde /api/publish.
|
|
1257
|
-
if (error.body?.needsLogin) {
|
|
1258
|
-
out2.hidden = false;
|
|
1259
|
-
out2.innerHTML =
|
|
1260
|
-
'<p class="share-line"><b>' + tr("publish.needsLogin.title") + '</b></p>' +
|
|
1261
|
-
'<p class="share-line">' + tr("publish.needsLogin.why") + '</p>' +
|
|
1262
|
-
'<code class="cmd">' + tr("publish.needsLogin.cmd") + '</code>' +
|
|
1263
|
-
'<p class="share-warn">' + tr("publish.needsLogin.once") + '</p>';
|
|
1264
|
-
} else {
|
|
1265
|
-
toast(error.message);
|
|
1266
|
-
}
|
|
1249
|
+
showPublishError(card, error);
|
|
1267
1250
|
} finally { btn.disabled = false; }
|
|
1268
1251
|
});
|
|
1269
1252
|
});
|
|
1270
1253
|
|
|
1254
|
+
async function publishNow(card, btn) {
|
|
1255
|
+
const projectId = card.dataset.project;
|
|
1256
|
+
const out = card.querySelector(".share-out");
|
|
1257
|
+
btn.disabled = true;
|
|
1258
|
+
try {
|
|
1259
|
+
// El token ya vive en la base de datos local (una fila por proyecto,
|
|
1260
|
+
// se lee de state.projects.publications): guardarlo también en
|
|
1261
|
+
// localStorage sería una segunda copia que puede desincronizarse de
|
|
1262
|
+
// la real, y lo hizo — un token de una prueba vieja en el navegador
|
|
1263
|
+
// pisó el que de verdad estaba publicado.
|
|
1264
|
+
const known = state.projects?.publications?.find((p) => p.projectId === projectId);
|
|
1265
|
+
const r = await api("/api/publish", {
|
|
1266
|
+
method: "POST",
|
|
1267
|
+
body: JSON.stringify({ projectId, token: known?.token || "" }),
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
out.hidden = false;
|
|
1271
|
+
out.innerHTML =
|
|
1272
|
+
'<p class="share-line"><b>' + (r.adopted ? tr("publish.linked") : tr("publish.published")) +
|
|
1273
|
+
'</b></p>' +
|
|
1274
|
+
'<p class="share-line">' + tr("publish.link") + ' <code>' + esc(r.url) + '</code></p>' +
|
|
1275
|
+
'<p class="share-warn">' + tr("publish.noPassword") + '</p>';
|
|
1276
|
+
toast(r.adopted ? tr("toast.linkedToAccount") : tr("publish.published"));
|
|
1277
|
+
|
|
1278
|
+
// Publicar pone a cero los "bloques nuevos desde que lo publicaste",
|
|
1279
|
+
// pero ese número vive en `state`, que se quedaba con el valor de
|
|
1280
|
+
// antes: el aviso seguía ahí después de resolverlo, y solo se iba
|
|
1281
|
+
// recargando la página a mano. Se vuelve a pedir el estado y se
|
|
1282
|
+
// repinta, conservando el mensaje del enlace recién generado.
|
|
1283
|
+
const recien = out.innerHTML;
|
|
1284
|
+
await loadProjects();
|
|
1285
|
+
const card2 = document.querySelector(`.pcard[data-project="${CSS.escape(projectId)}"]`);
|
|
1286
|
+
const out3 = card2?.querySelector(".share-out");
|
|
1287
|
+
if (out3) { out3.hidden = false; out3.innerHTML = recien; }
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
showPublishError(card, error);
|
|
1290
|
+
} finally { btn.disabled = false; }
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function showPublishError(card, error) {
|
|
1294
|
+
const out2 = card.querySelector(".share-out");
|
|
1295
|
+
// "Sin cuenta" no es un error cualquiera: se enseña con el comando
|
|
1296
|
+
// exacto para resolverlo, no como un aviso genérico que hay que
|
|
1297
|
+
// interpretar. `needsLogin` viaja en el cuerpo desde /api/publish.
|
|
1298
|
+
if (error.body?.needsLogin) {
|
|
1299
|
+
out2.hidden = false;
|
|
1300
|
+
out2.innerHTML =
|
|
1301
|
+
'<p class="share-line"><b>' + tr("publish.needsLogin.title") + '</b></p>' +
|
|
1302
|
+
'<p class="share-line">' + tr("publish.needsLogin.why") + '</p>' +
|
|
1303
|
+
'<code class="cmd">' + tr("publish.needsLogin.cmd") + '</code>' +
|
|
1304
|
+
'<p class="share-warn">' + tr("publish.needsLogin.once") + '</p>';
|
|
1305
|
+
} else {
|
|
1306
|
+
toast(error.message);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1271
1310
|
document.querySelectorAll(".rateform").forEach((form) => {
|
|
1272
1311
|
form.addEventListener("submit", async (e) => {
|
|
1273
1312
|
e.preventDefault();
|
package/web/i18n.js
CHANGED
|
@@ -280,6 +280,14 @@
|
|
|
280
280
|
"publish.published": "Panel publicado",
|
|
281
281
|
"publish.link": "Enlace:",
|
|
282
282
|
"publish.noPassword": "Quien tenga el enlace, entra: no hay contraseña.",
|
|
283
|
+
"publish.preview.title": "Esto va a quedar público. Quien tenga el enlace lo ve todo:",
|
|
284
|
+
"publish.preview.project": "Proyecto:",
|
|
285
|
+
"publish.preview.authors": "Autores:",
|
|
286
|
+
"publish.preview.branches": "Ramas:",
|
|
287
|
+
"publish.preview.files": "Ficheros:",
|
|
288
|
+
"publish.preview.clients": "Clientes:",
|
|
289
|
+
"publish.preview.confirm": "Publicar",
|
|
290
|
+
"publish.preview.cancel": "Cancelar",
|
|
283
291
|
"publish.needsLogin.title": "Hace falta vincular una cuenta",
|
|
284
292
|
"publish.needsLogin.why": "Publicar aloja el panel en getestela.dev, y eso exige sesión:",
|
|
285
293
|
"publish.needsLogin.cmd": "estela login --email tu@correo.com",
|
|
@@ -577,6 +585,14 @@
|
|
|
577
585
|
"publish.published": "Panel published",
|
|
578
586
|
"publish.link": "Link:",
|
|
579
587
|
"publish.noPassword": "Anyone with the link can get in: there's no password.",
|
|
588
|
+
"publish.preview.title": "This will become public. Anyone with the link sees all of it:",
|
|
589
|
+
"publish.preview.project": "Project:",
|
|
590
|
+
"publish.preview.authors": "Authors:",
|
|
591
|
+
"publish.preview.branches": "Branches:",
|
|
592
|
+
"publish.preview.files": "Files:",
|
|
593
|
+
"publish.preview.clients": "Clients:",
|
|
594
|
+
"publish.preview.confirm": "Publish",
|
|
595
|
+
"publish.preview.cancel": "Cancel",
|
|
580
596
|
"publish.needsLogin.title": "You need to link an account",
|
|
581
597
|
"publish.needsLogin.why": "Publishing hosts the panel on getestela.dev, which requires signing in:",
|
|
582
598
|
"publish.needsLogin.cmd": "estela login --email you@example.com",
|