execonvert 0.4.1 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +79 -0
  3. package/dist/cli/app/public/exelearning/app/common/common_i18n.js +4 -1
  4. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ca.js +4 -1
  5. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.de.js +4 -1
  6. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.en.js +4 -1
  7. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eo.js +4 -1
  8. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.es.js +4 -1
  9. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eu.js +4 -1
  10. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.gl.js +4 -1
  11. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.it.js +4 -1
  12. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.pt.js +4 -1
  13. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ro.js +4 -1
  14. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.va.js +4 -1
  15. package/dist/cli/app/public/exelearning/bundles/common.zip +0 -0
  16. package/dist/cli/app/public/exelearning/bundles/content-css.zip +0 -0
  17. package/dist/cli/app/public/exelearning/bundles/idevices.zip +0 -0
  18. package/dist/cli/app/public/exelearning/bundles/libs.zip +0 -0
  19. package/dist/cli/app/public/exelearning/bundles/manifest.json +76 -75
  20. package/dist/cli/app/public/exelearning/bundles/themes/base.zip +0 -0
  21. package/dist/cli/app/public/exelearning/bundles/themes/flux.zip +0 -0
  22. package/dist/cli/app/public/exelearning/bundles/themes/neo.zip +0 -0
  23. package/dist/cli/app/public/exelearning/bundles/themes/nova.zip +0 -0
  24. package/dist/cli/app/public/exelearning/bundles/themes/universal.zip +0 -0
  25. package/dist/cli/app/public/exelearning/bundles/themes/zen.zip +0 -0
  26. package/dist/cli/app/public/exelearning/exporters.bundle.js +7250 -353
  27. package/dist/cli/app/public/exelearning/importers.bundle.js +693 -163
  28. package/dist/cli/app/public/exelearning/runtime-source.json +6 -6
  29. package/dist/cli/app/public/info/cli.html +105 -8
  30. package/dist/cli/cli/execonvert.js +47 -7
  31. package/dist/cli/cli/updates.js +315 -0
  32. 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.1",
5
- "sourceReleaseAsset": "exelearning-static-v4.0.1.zip",
6
- "sourceReleasePublishedAt": "2026-06-09T09:20:07Z",
7
- "sourceCommit": "91d855aa799313aa77fc1f07771eb85ea17ccf94",
8
- "sourceVersion": "4.0.1",
9
- "syncedAt": "2026-06-25T06:16:41Z"
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
- if ("requestIdleCallback" in window) {
918
- window.requestIdleCallback(loadAnalyticsInBackground, { timeout: 2000 });
919
- return;
920
- }
921
- const run = () => {
922
- window.setTimeout(loadAnalyticsInBackground, 0);
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
- run();
1008
+ programar();
926
1009
  } else {
927
- window.addEventListener("load", run, { once: true });
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
- const CLI_VERSION = '0.4.0';
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 === 'inspect') {
794
- await runInspect(args.inputPath, args.json);
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
- if (args.command === 'batch') {
798
- await runBatch(args);
799
- return;
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.4.1",
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",