execonvert 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +79 -0
- package/dist/cli/app/public/exelearning/app/common/common_i18n.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ca.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.de.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.en.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eo.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.es.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eu.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.gl.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.it.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.pt.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ro.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.va.js +4 -1
- package/dist/cli/app/public/exelearning/bundles/common.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/content-css.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/idevices.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/libs.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/manifest.json +76 -75
- package/dist/cli/app/public/exelearning/bundles/themes/base.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/flux.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/neo.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/nova.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/universal.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/zen.zip +0 -0
- package/dist/cli/app/public/exelearning/exporters.bundle.js +7250 -353
- package/dist/cli/app/public/exelearning/importers.bundle.js +693 -163
- package/dist/cli/app/public/exelearning/runtime-source.json +6 -6
- package/dist/cli/app/public/info/cli.html +105 -8
- package/dist/cli/cli/execonvert.js +47 -7
- package/dist/cli/cli/updates.js +315 -0
- package/package.json +9 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"sourceRepo": "https://github.com/exelearning/exelearning",
|
|
3
3
|
"sourceMode": "release",
|
|
4
|
-
"sourceReleaseTag": "v4.0.
|
|
5
|
-
"sourceReleaseAsset": "exelearning-static-v4.0.
|
|
6
|
-
"sourceReleasePublishedAt": "2026-
|
|
7
|
-
"sourceCommit": "
|
|
8
|
-
"sourceVersion": "4.0.
|
|
9
|
-
"syncedAt": "2026-
|
|
4
|
+
"sourceReleaseTag": "v4.0.3",
|
|
5
|
+
"sourceReleaseAsset": "exelearning-static-v4.0.3.zip",
|
|
6
|
+
"sourceReleasePublishedAt": "2026-08-06T11:24:07Z",
|
|
7
|
+
"sourceCommit": "3c7c7e82163e812f04cbb033240942f8ac1214a0",
|
|
8
|
+
"sourceVersion": "4.0.3",
|
|
9
|
+
"syncedAt": "2026-09-05T05:49:44Z"
|
|
10
10
|
}
|
|
@@ -133,6 +133,61 @@
|
|
|
133
133
|
flex-wrap: wrap;
|
|
134
134
|
gap: 12px;
|
|
135
135
|
margin-top: 18px;
|
|
136
|
+
align-items: center;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.npm-install-inline {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 10px;
|
|
143
|
+
flex-wrap: wrap;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.npm-or {
|
|
147
|
+
color: var(--muted);
|
|
148
|
+
font-size: 0.98rem;
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.npm-cmd-wrap {
|
|
153
|
+
display: inline-flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 0;
|
|
156
|
+
border-radius: 999px;
|
|
157
|
+
border: 1.5px solid var(--line);
|
|
158
|
+
background: #182117;
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
height: 48px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.npm-cmd {
|
|
164
|
+
padding: 0 14px 0 18px;
|
|
165
|
+
color: #eef7eb;
|
|
166
|
+
font-size: 0.92rem;
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
line-height: 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.copy-btn {
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
justify-content: center;
|
|
175
|
+
height: 100%;
|
|
176
|
+
padding: 0 14px;
|
|
177
|
+
background: rgba(255,255,255,0.08);
|
|
178
|
+
border: none;
|
|
179
|
+
border-left: 1px solid rgba(255,255,255,0.12);
|
|
180
|
+
color: #a8d890;
|
|
181
|
+
font-family: inherit;
|
|
182
|
+
font-size: 0.85rem;
|
|
183
|
+
font-weight: 700;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
white-space: nowrap;
|
|
186
|
+
transition: background 0.15s;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.copy-btn:hover {
|
|
190
|
+
background: rgba(255,255,255,0.16);
|
|
136
191
|
}
|
|
137
192
|
|
|
138
193
|
.button {
|
|
@@ -356,6 +411,13 @@
|
|
|
356
411
|
|
|
357
412
|
<div class="cta-row">
|
|
358
413
|
<a id="cli-download-button" class="button primary" href="https://github.com/eXeConvert/eXeConvert.github.io/releases/latest" target="_blank" rel="noopener">Descargar la última versión</a>
|
|
414
|
+
<div class="npm-install-inline">
|
|
415
|
+
<span id="npm-or-label" class="npm-or">o con Node.js:</span>
|
|
416
|
+
<div class="npm-cmd-wrap">
|
|
417
|
+
<code id="npm-cmd-text" class="npm-cmd">npm install -g execonvert</code>
|
|
418
|
+
<button id="npm-copy-btn" class="copy-btn">Copiar</button>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
359
421
|
</div>
|
|
360
422
|
|
|
361
423
|
</section>
|
|
@@ -517,6 +579,9 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
517
579
|
intro:
|
|
518
580
|
"eXeConvert convierte archivos entre los formatos de eXeLearning (<code>.elpx</code>, <code>.elp</code>), Word (<code>.docx</code>), PDF y Markdown (<code>.md</code>). Esta es la versión de terminal o consola. Se usa desde una ventana de Terminal, PowerShell o cmd y es útil para convertir muchos archivos o automatizar tareas (si prefieres una interfaz gráfica, puedes usar <a href=\"../index.html\">la versión web de eXeConvert</a>).",
|
|
519
581
|
downloadButton: "Descargar la última versión",
|
|
582
|
+
npmOrLabel: "o con Node.js:",
|
|
583
|
+
npmCopyBtn: "Copiar",
|
|
584
|
+
npmCopied: "Copiado",
|
|
520
585
|
downloadsTitle: "Qué descargar",
|
|
521
586
|
debTitle: "Ubuntu y otros sistemas basados en Debian",
|
|
522
587
|
debText: "Descarga el paquete <code>.deb</code> desde la última release y ábrelo con el instalador del sistema.",
|
|
@@ -526,6 +591,8 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
526
591
|
windowsText: "Descarga el instalador <code>.exe</code> y sigue el asistente.",
|
|
527
592
|
macosTitle: "macOS",
|
|
528
593
|
macosText: "Descarga el instalador <code>.pkg</code> y sigue el proceso habitual de instalación.",
|
|
594
|
+
npmTitle: "Con Node.js (todas las plataformas)",
|
|
595
|
+
npmText: "Si tienes Node.js 22 o superior, instala con <code>npm install -g execonvert</code> o úsalo sin instalarlo con <code>npx execonvert</code>. Funciona en Linux, macOS y Windows.",
|
|
529
596
|
note:
|
|
530
597
|
"<strong>Importante:</strong> la CLI está pensada para terminal. Si prefieres trabajar con una interfaz gráfica, usa <a href=\"../index.html\">la página principal de eXeConvert</a>.",
|
|
531
598
|
firstUseTitle: "Primer uso",
|
|
@@ -583,6 +650,9 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
583
650
|
intro:
|
|
584
651
|
"eXeConvert converteix fitxers entre els formats d'eXeLearning (<code>.elpx</code>, <code>.elp</code>), Word (<code>.docx</code>), PDF i Markdown (<code>.md</code>). Aquesta és la versió de terminal o consola. Es fa servir des d'una finestra de Terminal, PowerShell o cmd i és útil per convertir molts fitxers o automatitzar tasques (si prefereixes una interfície gràfica, pots fer servir <a href=\"../index.html\">la versió web d'eXeConvert</a>).",
|
|
585
652
|
downloadButton: "Descarregar l'última versió",
|
|
653
|
+
npmOrLabel: "o amb Node.js:",
|
|
654
|
+
npmCopyBtn: "Copiar",
|
|
655
|
+
npmCopied: "Copiat",
|
|
586
656
|
downloadsTitle: "Què descarregar",
|
|
587
657
|
debTitle: "Ubuntu i altres sistemes basats en Debian",
|
|
588
658
|
debText: "Descarrega el paquet <code>.deb</code> des de l'última release i obre'l amb l'instal·lador del sistema.",
|
|
@@ -592,6 +662,8 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
592
662
|
windowsText: "Descarrega l'instal·lador <code>.exe</code> i segueix l'assistent.",
|
|
593
663
|
macosTitle: "macOS",
|
|
594
664
|
macosText: "Descarrega l'instal·lador <code>.pkg</code> i segueix el procés habitual d'instal·lació.",
|
|
665
|
+
npmTitle: "Amb Node.js (totes les plataformes)",
|
|
666
|
+
npmText: "Si tens Node.js 22 o superior, instal·la amb <code>npm install -g execonvert</code> o utilitza'l sense instal·lar amb <code>npx execonvert</code>. Funciona a Linux, macOS i Windows.",
|
|
595
667
|
note:
|
|
596
668
|
"<strong>Important:</strong> la CLI està pensada per al terminal. Si prefereixes treballar amb una interfície gràfica, fes servir <a href=\"../index.html\">la pàgina principal d'eXeConvert</a>.",
|
|
597
669
|
firstUseTitle: "Primer ús",
|
|
@@ -649,6 +721,9 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
649
721
|
intro:
|
|
650
722
|
"eXeConvert converts files between eXeLearning formats (<code>.elpx</code>, <code>.elp</code>), Word (<code>.docx</code>), PDF, and Markdown (<code>.md</code>). This is the terminal or console version. It is used from a Terminal, PowerShell, or cmd window and is useful for converting many files or automating tasks (if you prefer a graphical interface, you can use <a href=\"../index.html\">the web version of eXeConvert</a>).",
|
|
651
723
|
downloadButton: "Download the latest version",
|
|
724
|
+
npmOrLabel: "or with Node.js:",
|
|
725
|
+
npmCopyBtn: "Copy",
|
|
726
|
+
npmCopied: "Copied",
|
|
652
727
|
downloadsTitle: "What to download",
|
|
653
728
|
debTitle: "Ubuntu and other Debian-based systems",
|
|
654
729
|
debText: "Download the <code>.deb</code> package from the latest release and open it with the system installer.",
|
|
@@ -658,6 +733,8 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
658
733
|
windowsText: "Download the <code>.exe</code> installer and follow the setup wizard.",
|
|
659
734
|
macosTitle: "macOS",
|
|
660
735
|
macosText: "Download the <code>.pkg</code> installer and follow the usual installation process.",
|
|
736
|
+
npmTitle: "With Node.js (all platforms)",
|
|
737
|
+
npmText: "If you have Node.js 22 or higher, install with <code>npm install -g execonvert</code> or run without installing with <code>npx execonvert</code>. Works on Linux, macOS, and Windows.",
|
|
661
738
|
note:
|
|
662
739
|
"<strong>Important:</strong> the CLI is intended for terminal use. If you prefer a graphical interface, use <a href=\"../index.html\">the main eXeConvert page</a>.",
|
|
663
740
|
firstUseTitle: "First steps",
|
|
@@ -756,6 +833,8 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
756
833
|
setText("cli-subtitle", copy.subtitle);
|
|
757
834
|
setHtml("cli-intro", copy.intro);
|
|
758
835
|
setText("cli-download-button", copy.downloadButton);
|
|
836
|
+
setText("npm-or-label", copy.npmOrLabel);
|
|
837
|
+
setText("npm-copy-btn", copy.npmCopyBtn);
|
|
759
838
|
setText("downloads-title", copy.downloadsTitle);
|
|
760
839
|
setText("download-deb-title", copy.debTitle);
|
|
761
840
|
setHtml("download-deb-text", copy.debText);
|
|
@@ -914,17 +993,21 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
914
993
|
}
|
|
915
994
|
|
|
916
995
|
function scheduleAnalyticsLoad() {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
const
|
|
922
|
-
|
|
996
|
+
// Un <script async> inyectado antes de que se dispare «load» retrasa ese
|
|
997
|
+
// evento hasta que la peticion termina. Si el servidor de estadisticas se
|
|
998
|
+
// cuelga, «load» no llegaria a dispararse nunca. Por eso se espera siempre
|
|
999
|
+
// a «load» antes de programar nada.
|
|
1000
|
+
const programar = () => {
|
|
1001
|
+
if ("requestIdleCallback" in window) {
|
|
1002
|
+
window.requestIdleCallback(loadAnalyticsInBackground, { timeout: 2000 });
|
|
1003
|
+
} else {
|
|
1004
|
+
window.setTimeout(loadAnalyticsInBackground, 0);
|
|
1005
|
+
}
|
|
923
1006
|
};
|
|
924
1007
|
if (document.readyState === "complete") {
|
|
925
|
-
|
|
1008
|
+
programar();
|
|
926
1009
|
} else {
|
|
927
|
-
window.addEventListener("load",
|
|
1010
|
+
window.addEventListener("load", programar, { once: true });
|
|
928
1011
|
}
|
|
929
1012
|
}
|
|
930
1013
|
|
|
@@ -942,6 +1025,20 @@ execonvert inspect proyecto.elpx</pre>
|
|
|
942
1025
|
applyTranslations(locale);
|
|
943
1026
|
});
|
|
944
1027
|
|
|
1028
|
+
document.getElementById("npm-copy-btn")?.addEventListener("click", () => {
|
|
1029
|
+
const cmd = document.getElementById("npm-cmd-text")?.textContent || "npm install -g execonvert";
|
|
1030
|
+
navigator.clipboard.writeText(cmd).then(() => {
|
|
1031
|
+
const btn = document.getElementById("npm-copy-btn");
|
|
1032
|
+
const locale = document.documentElement.lang || activeLocale;
|
|
1033
|
+
const copy = translations[locale] || translations.es;
|
|
1034
|
+
const original = copy.npmCopyBtn;
|
|
1035
|
+
if (btn) {
|
|
1036
|
+
btn.textContent = copy.npmCopied;
|
|
1037
|
+
setTimeout(() => { btn.textContent = original; }, 2000);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
945
1042
|
document.getElementById("footer-privacy-link")?.addEventListener("click", event => {
|
|
946
1043
|
event.preventDefault();
|
|
947
1044
|
const privacy = document.getElementById("footer-privacy");
|
|
@@ -9,7 +9,10 @@ import { convertElpxToMarkdown } from '../src/elpx-markdown.js';
|
|
|
9
9
|
import { convertElpToElpx } from '../src/legacy-elp.js';
|
|
10
10
|
import { convertMarkdownToElpx } from '../src/markdown-import.js';
|
|
11
11
|
import { installCliRuntime } from './runtime.js';
|
|
12
|
-
|
|
12
|
+
import { automaticCheck, autoCheckAllowed, runUpdate, updateText } from './updates.js';
|
|
13
|
+
const CLI_VERSION = '0.5.0'.startsWith('__')
|
|
14
|
+
? String(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version)
|
|
15
|
+
: '0.5.0';
|
|
13
16
|
const cliMessages = {
|
|
14
17
|
es: {
|
|
15
18
|
'help.title': 'CLI de eXeConvert',
|
|
@@ -216,6 +219,7 @@ ${t('help.usage')}:
|
|
|
216
219
|
execonvert <input> <output> [options]
|
|
217
220
|
execonvert <input1> [input2 ...] --to <format> [--out-dir <dir>] [options]
|
|
218
221
|
execonvert inspect <input> [--json]
|
|
222
|
+
execonvert update [--check] [--json] [--out-dir <dir>]
|
|
219
223
|
|
|
220
224
|
${t('help.inputs')}:
|
|
221
225
|
${t('help.input.elp')}
|
|
@@ -244,6 +248,8 @@ ${t('help.options')}:
|
|
|
244
248
|
--lang <code> ${t('help.option.lang')}
|
|
245
249
|
--help ${t('help.option.help')}
|
|
246
250
|
--version ${t('help.option.version')}
|
|
251
|
+
update [--check] ${updateText(currentCliTranslator.locale, 'help')}
|
|
252
|
+
--no-update-check ${updateText(currentCliTranslator.locale, 'disable')}
|
|
247
253
|
|
|
248
254
|
${t('help.examples')}:
|
|
249
255
|
execonvert notes.md notes.elpx --h1 resource-title --h2 subpage --h3 idevice
|
|
@@ -260,6 +266,8 @@ function parseArgs(argv, t, locale) {
|
|
|
260
266
|
locale,
|
|
261
267
|
json: false,
|
|
262
268
|
includeImages: false,
|
|
269
|
+
noUpdateCheck: false,
|
|
270
|
+
checkUpdate: false,
|
|
263
271
|
h1: 'page',
|
|
264
272
|
h2: 'idevice',
|
|
265
273
|
h3: 'idevice',
|
|
@@ -274,6 +282,14 @@ function parseArgs(argv, t, locale) {
|
|
|
274
282
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
275
283
|
return { ...defaults, command: 'help' };
|
|
276
284
|
}
|
|
285
|
+
if (args[0] === 'update') {
|
|
286
|
+
args.shift();
|
|
287
|
+
const updateDefaults = { ...defaults, command: 'update' };
|
|
288
|
+
const parsed = parseOptionFlags(args, updateDefaults, t);
|
|
289
|
+
if (parsed.positionals.length)
|
|
290
|
+
throw new Error('Usage: execonvert update [--check] [--json] [--out-dir <dir>]');
|
|
291
|
+
return { ...updateDefaults, ...parsed.options };
|
|
292
|
+
}
|
|
277
293
|
if (args[0] === 'inspect') {
|
|
278
294
|
args.shift();
|
|
279
295
|
const parsed = parseOptionFlags(args, defaults, t);
|
|
@@ -330,8 +346,19 @@ function parseOptionFlags(args, defaults, t) {
|
|
|
330
346
|
positionals.push(value);
|
|
331
347
|
continue;
|
|
332
348
|
}
|
|
349
|
+
if (defaults.command === 'update' && !['--check', '--json', '--out-dir', '--lang', '--locale'].includes(value)) {
|
|
350
|
+
throw new Error(t('error.unknownOption', { value }));
|
|
351
|
+
}
|
|
333
352
|
const nextValue = args[index + 1];
|
|
334
353
|
switch (value) {
|
|
354
|
+
case '--no-update-check':
|
|
355
|
+
options.noUpdateCheck = true;
|
|
356
|
+
break;
|
|
357
|
+
case '--check':
|
|
358
|
+
if (defaults.command !== 'update')
|
|
359
|
+
throw new Error(t('error.unknownOption', { value }));
|
|
360
|
+
options.checkUpdate = true;
|
|
361
|
+
break;
|
|
335
362
|
case '--lang':
|
|
336
363
|
case '--locale':
|
|
337
364
|
ensureOptionValue(value, nextValue, t);
|
|
@@ -790,15 +817,28 @@ async function main() {
|
|
|
790
817
|
stdout.write(`${CLI_VERSION}\n`);
|
|
791
818
|
return;
|
|
792
819
|
}
|
|
793
|
-
if (args.command === '
|
|
794
|
-
await
|
|
820
|
+
if (args.command === 'update') {
|
|
821
|
+
await runUpdate({ current: CLI_VERSION, locale, check: args.checkUpdate, json: args.json, outDir: args.outDir });
|
|
795
822
|
return;
|
|
796
823
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
824
|
+
const updateController = new AbortController();
|
|
825
|
+
if (autoCheckAllowed({ ...args, disabled: args.noUpdateCheck })) {
|
|
826
|
+
void automaticCheck(CLI_VERSION, locale, updateController.signal);
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
if (args.command === 'inspect') {
|
|
830
|
+
await runInspect(args.inputPath, args.json);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (args.command === 'batch') {
|
|
834
|
+
await runBatch(args);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
await runConvert(args);
|
|
838
|
+
}
|
|
839
|
+
finally {
|
|
840
|
+
updateController.abort();
|
|
800
841
|
}
|
|
801
|
-
await runConvert(args);
|
|
802
842
|
}
|
|
803
843
|
await main().catch(error => {
|
|
804
844
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
export const RELEASE_REPO = 'eXeConvert/eXeConvert.github.io';
|
|
9
|
+
const RELEASE_URL = `https://github.com/${RELEASE_REPO}/releases`;
|
|
10
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
11
|
+
const nativeFetch = globalThis.fetch.bind(globalThis);
|
|
12
|
+
const messages = {
|
|
13
|
+
es: {
|
|
14
|
+
available: 'Hay una actualización de eXeConvert: {current} → {latest}. Ejecuta execonvert update.',
|
|
15
|
+
current: 'eXeConvert {current} está actualizado (última versión estable: {latest}).',
|
|
16
|
+
downloaded: 'Descargado y verificado: {path}',
|
|
17
|
+
installed: 'eXeConvert {latest} instalado mediante npm.',
|
|
18
|
+
source: 'Esta copia es un repositorio de desarrollo. Actualízala mediante Git y recompílala. Versiones publicadas: {url}',
|
|
19
|
+
unknown: 'No se reconoce el tipo de instalación. Descarga el paquete adecuado desde {url}',
|
|
20
|
+
apply: 'Instala el archivo descargado con el instalador del sistema. La instalación actual sigue disponible.',
|
|
21
|
+
appimage: 'Da permiso de ejecución al archivo descargado y úsalo en lugar del AppImage anterior.',
|
|
22
|
+
missing: 'La versión {latest} aún no tiene un paquete para {kind}/{platform}/{arch}. Consulta {url}',
|
|
23
|
+
network: 'No se han podido comprobar las actualizaciones: {message}',
|
|
24
|
+
help: 'Comprueba o descarga actualizaciones de eXeConvert',
|
|
25
|
+
disable: 'Desactiva la comprobación automática de actualizaciones',
|
|
26
|
+
},
|
|
27
|
+
ca: {
|
|
28
|
+
available: 'Hi ha una actualització d’eXeConvert: {current} → {latest}. Executa execonvert update.',
|
|
29
|
+
current: 'eXeConvert {current} està actualitzat (darrera versió estable: {latest}).',
|
|
30
|
+
downloaded: 'Descarregat i verificat: {path}',
|
|
31
|
+
installed: 'eXeConvert {latest} instal·lat mitjançant npm.',
|
|
32
|
+
source: 'Aquesta còpia és un repositori de desenvolupament. Actualitza-la amb Git i recompila-la. Versions publicades: {url}',
|
|
33
|
+
unknown: 'No es reconeix el tipus d’instal·lació. Descarrega el paquet adequat des de {url}',
|
|
34
|
+
apply: 'Instal·la el fitxer descarregat amb l’instal·lador del sistema. La instal·lació actual continua disponible.',
|
|
35
|
+
appimage: 'Dona permís d’execució al fitxer descarregat i fes-lo servir en lloc de l’AppImage anterior.',
|
|
36
|
+
missing: 'La versió {latest} encara no té cap paquet per a {kind}/{platform}/{arch}. Consulta {url}',
|
|
37
|
+
network: 'No s’han pogut comprovar les actualitzacions: {message}',
|
|
38
|
+
help: 'Comprova o descarrega actualitzacions d’eXeConvert',
|
|
39
|
+
disable: 'Desactiva la comprovació automàtica d’actualitzacions',
|
|
40
|
+
},
|
|
41
|
+
en: {
|
|
42
|
+
available: 'eXeConvert update available: {current} → {latest}. Run execonvert update.',
|
|
43
|
+
current: 'eXeConvert {current} is up to date (latest stable: {latest}).',
|
|
44
|
+
downloaded: 'Downloaded and verified: {path}',
|
|
45
|
+
installed: 'eXeConvert {latest} installed using npm.',
|
|
46
|
+
source: 'This is a development checkout. Update it using Git and rebuild. Published releases: {url}',
|
|
47
|
+
unknown: 'Unrecognized installation type. Download the appropriate package from {url}',
|
|
48
|
+
apply: 'Install the downloaded file using the system installer. Your current installation remains available.',
|
|
49
|
+
appimage: 'Make the downloaded file executable and use it in place of the previous AppImage.',
|
|
50
|
+
missing: 'Release {latest} does not have a package for {kind}/{platform}/{arch} yet. See {url}',
|
|
51
|
+
network: 'Could not check for updates: {message}',
|
|
52
|
+
help: 'Check for or download eXeConvert updates',
|
|
53
|
+
disable: 'Disable automatic update checks',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export function updateText(locale, key, vars = {}) {
|
|
57
|
+
return messages[locale][key].replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? `{${name}}`);
|
|
58
|
+
}
|
|
59
|
+
// Release tags must be stable SemVer; never install prereleases or compare lexically.
|
|
60
|
+
export function stableVersion(value) {
|
|
61
|
+
const match = /^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:\+[\w.-]+)?$/.exec(value);
|
|
62
|
+
if (!match)
|
|
63
|
+
throw new Error(`Invalid stable version: ${value}`);
|
|
64
|
+
return match[1];
|
|
65
|
+
}
|
|
66
|
+
export function isNewer(latest, current) {
|
|
67
|
+
const a = stableVersion(latest).split('.').map(BigInt);
|
|
68
|
+
const b = stableVersion(current).split('.').map(BigInt);
|
|
69
|
+
for (let i = 0; i < 3; i++)
|
|
70
|
+
if (a[i] !== b[i])
|
|
71
|
+
return a[i] > b[i];
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
export function packageRoot() {
|
|
75
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
76
|
+
return basename(dirname(dir)) === 'cli' && basename(dirname(dirname(dir))) === 'dist'
|
|
77
|
+
? resolve(dir, '../../..') : resolve(dir, '..');
|
|
78
|
+
}
|
|
79
|
+
export async function detectInstallation(root = packageRoot(), env = process.env) {
|
|
80
|
+
const base = { root, platform: process.platform, arch: process.arch };
|
|
81
|
+
if (existsSync(join(root, 'scripts/build-cli.mjs')))
|
|
82
|
+
return { ...base, kind: 'source' };
|
|
83
|
+
try {
|
|
84
|
+
const marker = JSON.parse(await readFile(join(root, 'install-channel.json'), 'utf8'));
|
|
85
|
+
if (['deb', 'appimage', 'pkg', 'windows'].includes(marker.kind) &&
|
|
86
|
+
marker.platform === process.platform && marker.arch === process.arch)
|
|
87
|
+
return { ...base, kind: marker.kind };
|
|
88
|
+
}
|
|
89
|
+
catch { /* Older packages did not include a marker. */ }
|
|
90
|
+
if (env.EXECONVERT_RUNTIME_ROOT) {
|
|
91
|
+
if (env.APPIMAGE && process.platform === 'linux')
|
|
92
|
+
return { ...base, kind: 'appimage' };
|
|
93
|
+
if (process.platform === 'linux' && root === '/opt/execonvert')
|
|
94
|
+
return { ...base, kind: 'deb' };
|
|
95
|
+
if (process.platform === 'darwin' && root === '/usr/local/lib/execonvert')
|
|
96
|
+
return { ...base, kind: 'pkg' };
|
|
97
|
+
if (process.platform === 'win32')
|
|
98
|
+
return { ...base, kind: 'windows' };
|
|
99
|
+
}
|
|
100
|
+
if (basename(dirname(root)) === 'node_modules')
|
|
101
|
+
return { ...base, kind: 'npm' };
|
|
102
|
+
return { ...base, kind: 'unknown' };
|
|
103
|
+
}
|
|
104
|
+
export function validateRelease(data) {
|
|
105
|
+
if (!data || data.draft || data.prerelease || !Array.isArray(data.assets))
|
|
106
|
+
throw new Error('Invalid stable release response');
|
|
107
|
+
stableVersion(data.tag_name);
|
|
108
|
+
if (data.html_url !== `${RELEASE_URL}/tag/${data.tag_name}`)
|
|
109
|
+
throw new Error('Unexpected release URL');
|
|
110
|
+
return data;
|
|
111
|
+
}
|
|
112
|
+
export async function latestRelease(fetcher = nativeFetch, signal) {
|
|
113
|
+
const response = await fetcher(`https://api.github.com/repos/${RELEASE_REPO}/releases/latest`, {
|
|
114
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'execonvert-update', 'X-GitHub-Api-Version': '2022-11-28' },
|
|
115
|
+
signal: signal ?? AbortSignal.timeout(10000),
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok)
|
|
118
|
+
throw new Error(`GitHub HTTP ${response.status}`);
|
|
119
|
+
return validateRelease(await response.json());
|
|
120
|
+
}
|
|
121
|
+
export function selectAsset(release, install) {
|
|
122
|
+
const version = stableVersion(release.tag_name);
|
|
123
|
+
const { kind, platform, arch } = install;
|
|
124
|
+
let name;
|
|
125
|
+
if (kind === 'npm')
|
|
126
|
+
name = `execonvert-${version}.tgz`;
|
|
127
|
+
if (kind === 'deb' && platform === 'linux' && arch === 'x64')
|
|
128
|
+
name = `execonvert_${version}_amd64.deb`;
|
|
129
|
+
if (kind === 'appimage' && platform === 'linux' && arch === 'x64')
|
|
130
|
+
name = `execonvert-${version}-x86_64.AppImage`;
|
|
131
|
+
if (kind === 'pkg' && platform === 'darwin')
|
|
132
|
+
name = `execonvert-${version}-${arch}.pkg`;
|
|
133
|
+
if (kind === 'windows' && platform === 'win32')
|
|
134
|
+
name = `execonvert-${version}-${arch}-setup.exe`;
|
|
135
|
+
// Old Windows releases were built on x64. Old macOS assets do not identify
|
|
136
|
+
// their architecture, so do not guess which processor they support.
|
|
137
|
+
if (kind === 'windows' && arch === 'x64' && !release.assets.some(a => a.name === name))
|
|
138
|
+
name = `execonvert-${version}-setup.exe`;
|
|
139
|
+
return release.assets.find(asset => asset.name === name);
|
|
140
|
+
}
|
|
141
|
+
export function updateCacheDir(env = process.env, platform = process.platform) {
|
|
142
|
+
const base = platform === 'win32' ? (env.LOCALAPPDATA || join(homedir(), 'AppData/Local'))
|
|
143
|
+
: platform === 'darwin' ? join(homedir(), 'Library/Caches') : (env.XDG_CACHE_HOME || join(homedir(), '.cache'));
|
|
144
|
+
return join(base, 'execonvert');
|
|
145
|
+
}
|
|
146
|
+
export function autoCheckAllowed(options, env = process.env, interactive = Boolean(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
147
|
+
return interactive && !options.json && !options.disabled && !env.CI && env.EXECONVERT_NO_UPDATE_CHECK !== '1' &&
|
|
148
|
+
!['help', 'version', 'update'].includes(options.command);
|
|
149
|
+
}
|
|
150
|
+
// The caller aborts on completion: checking cannot hold up a conversion's exit.
|
|
151
|
+
// A lock serializes concurrent CLI launches. Failed requests are throttled too.
|
|
152
|
+
export async function automaticCheck(current, locale, signal, options = {}) {
|
|
153
|
+
const cacheDir = options.cacheDir ?? updateCacheDir();
|
|
154
|
+
const lock = join(cacheDir, 'update-check.lock');
|
|
155
|
+
let ownsLock = false;
|
|
156
|
+
try {
|
|
157
|
+
if (signal.aborted)
|
|
158
|
+
return;
|
|
159
|
+
const now = options.now ?? Date.now();
|
|
160
|
+
await mkdir(cacheDir, { recursive: true });
|
|
161
|
+
// An expired lock can only belong to a crashed process: requests last <= 3s.
|
|
162
|
+
try {
|
|
163
|
+
if (Date.now() - (await stat(lock)).mtimeMs > DAY)
|
|
164
|
+
await rm(lock, { force: true });
|
|
165
|
+
}
|
|
166
|
+
catch { /* No lock. */ }
|
|
167
|
+
const handle = await open(lock, 'wx');
|
|
168
|
+
ownsLock = true;
|
|
169
|
+
try {
|
|
170
|
+
await handle.writeFile(String(now));
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await handle.close();
|
|
174
|
+
}
|
|
175
|
+
const statePath = join(cacheDir, 'update-check.json');
|
|
176
|
+
try {
|
|
177
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'));
|
|
178
|
+
if (Number.isFinite(state.checkedAt) && now >= state.checkedAt && now - state.checkedAt < DAY)
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
catch { /* Missing or corrupt cache. */ }
|
|
182
|
+
if (signal.aborted)
|
|
183
|
+
return;
|
|
184
|
+
await writeFile(statePath, JSON.stringify({ checkedAt: now }));
|
|
185
|
+
const release = await latestRelease(options.fetcher, AbortSignal.any([signal, AbortSignal.timeout(3000)]));
|
|
186
|
+
if (!signal.aborted && isNewer(release.tag_name, current)) {
|
|
187
|
+
(options.notify ?? (message => process.stderr.write(`${message}\n`)))(updateText(locale, 'available', { current, latest: stableVersion(release.tag_name) }));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch { /* Update availability must never make a conversion fail. */ }
|
|
191
|
+
finally {
|
|
192
|
+
if (ownsLock)
|
|
193
|
+
await rm(lock, { force: true }).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function downloadAsset(release, asset, directory, fetcher = nativeFetch) {
|
|
197
|
+
const expected = `https://github.com/${RELEASE_REPO}/releases/download/${release.tag_name}/${asset.name}`;
|
|
198
|
+
if (!/^[\w.-]+$/.test(asset.name) || asset.browser_download_url !== expected)
|
|
199
|
+
throw new Error('Unexpected asset URL or filename');
|
|
200
|
+
if (!Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > 2_000_000_000)
|
|
201
|
+
throw new Error('Invalid download size');
|
|
202
|
+
if (!/^sha256:[a-f0-9]{64}$/i.test(asset.digest ?? ''))
|
|
203
|
+
throw new Error('Release asset is missing its SHA-256 digest');
|
|
204
|
+
await mkdir(directory, { recursive: true });
|
|
205
|
+
const target = join(directory, asset.name);
|
|
206
|
+
const temporary = join(directory, `.${asset.name}.${randomUUID()}.part`);
|
|
207
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
208
|
+
try {
|
|
209
|
+
const response = await fetcher(expected, { signal: AbortSignal.timeout(10 * 60 * 1000) });
|
|
210
|
+
if (!response.ok || !response.body)
|
|
211
|
+
throw new Error(`Download HTTP ${response.status}`);
|
|
212
|
+
const hash = createHash('sha256');
|
|
213
|
+
let size = 0;
|
|
214
|
+
for await (const chunk of response.body) {
|
|
215
|
+
size += chunk.byteLength;
|
|
216
|
+
if (size > asset.size)
|
|
217
|
+
throw new Error('Download exceeds expected size');
|
|
218
|
+
hash.update(chunk);
|
|
219
|
+
await file.writeFile(chunk);
|
|
220
|
+
}
|
|
221
|
+
if (size !== asset.size || `sha256:${hash.digest('hex')}` !== asset.digest?.toLowerCase())
|
|
222
|
+
throw new Error('Download checksum or size mismatch');
|
|
223
|
+
await file.close();
|
|
224
|
+
await rename(temporary, target);
|
|
225
|
+
if (asset.name.endsWith('.AppImage'))
|
|
226
|
+
await chmod(target, 0o700);
|
|
227
|
+
return target;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
await file.close().catch(() => { });
|
|
231
|
+
await rm(temporary, { force: true });
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function runNpm(args, capture, cwd) {
|
|
236
|
+
return new Promise((done, fail) => {
|
|
237
|
+
// Windows npm.cmd requires cmd.exe; only fixed arguments and validated
|
|
238
|
+
// versions are passed to it. Paths are supplied via cwd or npm config env.
|
|
239
|
+
const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
|
|
240
|
+
cwd, shell: process.platform === 'win32', stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
241
|
+
});
|
|
242
|
+
let output = '';
|
|
243
|
+
child.stdout?.on('data', chunk => { if (capture)
|
|
244
|
+
output += chunk;
|
|
245
|
+
else
|
|
246
|
+
process.stderr.write(chunk); });
|
|
247
|
+
child.stderr?.on('data', chunk => { if (!capture)
|
|
248
|
+
process.stderr.write(chunk); });
|
|
249
|
+
child.on('error', fail);
|
|
250
|
+
child.on('exit', code => code === 0 ? done(output.trim()) : fail(new Error(`npm exited with code ${code}`)));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
export async function installNpm(version, root, runner = runNpm) {
|
|
254
|
+
// Use the same npm scope/prefix that owns this installation. Never silently
|
|
255
|
+
// install a second global copy when this one belongs to a local project.
|
|
256
|
+
const globalRoot = await runner(['root', '--global'], true);
|
|
257
|
+
const owner = await realpath(dirname(root));
|
|
258
|
+
if (!globalRoot)
|
|
259
|
+
throw new Error('npm did not report its global installation directory');
|
|
260
|
+
const globalOwner = await realpath(globalRoot).catch(() => resolve(globalRoot));
|
|
261
|
+
if (owner === globalOwner) {
|
|
262
|
+
await runner(['install', '--global', `execonvert@${stableVersion(version)}`], false);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const project = dirname(owner);
|
|
266
|
+
// A different npm prefix must not be mistaken for a local project.
|
|
267
|
+
try {
|
|
268
|
+
await readFile(join(project, 'package.json'), 'utf8');
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
throw new Error(`Cannot locate the npm installation scope for ${root}. Use the npm/prefix that installed this copy.`);
|
|
272
|
+
}
|
|
273
|
+
await runner(['install', `execonvert@${stableVersion(version)}`], false, project);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export async function runUpdate(options) {
|
|
277
|
+
const { current, locale, check, json } = options;
|
|
278
|
+
let release;
|
|
279
|
+
try {
|
|
280
|
+
release = await latestRelease();
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
throw new Error(updateText(locale, 'network', { message: String(error instanceof Error ? error.message : error) }));
|
|
284
|
+
}
|
|
285
|
+
const latest = stableVersion(release.tag_name);
|
|
286
|
+
const installation = await detectInstallation();
|
|
287
|
+
const available = isNewer(latest, current);
|
|
288
|
+
const result = { current, latest, available, installation: installation.kind, url: release.html_url, action: 'checked' };
|
|
289
|
+
const emit = (text) => { if (!json)
|
|
290
|
+
process.stdout.write(`${text}\n`); };
|
|
291
|
+
if (check || !available)
|
|
292
|
+
emit(updateText(locale, available ? 'available' : 'current', { current, latest }));
|
|
293
|
+
else if (installation.kind === 'source' || installation.kind === 'unknown') {
|
|
294
|
+
result.action = 'manual';
|
|
295
|
+
emit(updateText(locale, installation.kind, { url: release.html_url }));
|
|
296
|
+
}
|
|
297
|
+
else if (installation.kind === 'npm') {
|
|
298
|
+
await installNpm(latest, installation.root);
|
|
299
|
+
result.action = 'installed';
|
|
300
|
+
emit(updateText(locale, 'installed', { latest }));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const asset = selectAsset(release, installation);
|
|
304
|
+
if (!asset)
|
|
305
|
+
throw new Error(updateText(locale, 'missing', { latest, ...installation, url: release.html_url }));
|
|
306
|
+
const path = await downloadAsset(release, asset, resolve(options.outDir ?? join(updateCacheDir(), 'downloads', latest)));
|
|
307
|
+
result.action = 'downloaded';
|
|
308
|
+
result.path = path;
|
|
309
|
+
result.installationRequired = true;
|
|
310
|
+
emit(updateText(locale, 'downloaded', { path }));
|
|
311
|
+
emit(updateText(locale, installation.kind === 'appimage' ? 'appimage' : 'apply'));
|
|
312
|
+
}
|
|
313
|
+
if (json)
|
|
314
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
315
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "execonvert",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/eXeConvert/eXeConvert.github.io.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"bin": {
|
|
7
11
|
"execonvert": "./bin/execonvert.js"
|
|
@@ -31,7 +35,10 @@
|
|
|
31
35
|
"check:cli": "tsc -p tsconfig.cli.json --noEmit",
|
|
32
36
|
"cli": "node ./bin/execonvert.js",
|
|
33
37
|
"sync:exe": "bash scripts/sync-exe-bundles.sh",
|
|
34
|
-
"prepack": "npm run build:cli"
|
|
38
|
+
"prepack": "npm run build:cli",
|
|
39
|
+
"test:updates": "node --import tsx --test tests/updates.test.ts",
|
|
40
|
+
"test:runtime": "node scripts/test-runtime.mjs",
|
|
41
|
+
"test:cli": "node --test tests/cli-update.test.mjs"
|
|
35
42
|
},
|
|
36
43
|
"dependencies": {
|
|
37
44
|
"@resvg/resvg-js": "^2.6.2",
|