Estas son las líneas para las que existe todo el proyecto. Todo lo demás en docker-mql-compiler es preparación para ellas, o limpieza después de ellas:
# MetaEditor's exit code is backwards (1 usually means success); the log's
# Result line is the verdict, and its stderr is Wine noise.
wine "$editor" \
"/compile:$(winepath -w "$STAGED_SOURCE")" \
"/log:$(winepath -w "$log_file")" \
"/inc:$(winepath -w "$MERGED_ROOT")" >/dev/null 2>&1 || trueEso es MetaEditor de MetaQuotes, lo único en el planeta que convierte un .mq5 en un .ex5, una GUI de Windows que jamás ha oído hablar de Linux, ejecutada por un script de bash dentro de un contenedor con Wine y Xvfb como pantalla falsa que no mira nadie. Fíjate en el || true. El código de salida se tira a la basura a propósito, porque miente. Mi editor, mis agentes y mi CI viven todos en Linux, y estaba hasta los cojones de que alguien tuviera que ir a toquetear una máquina Windows para darle a “Compile” y copiar a mano los errores de un panel de log. Así que voy a leer entrypoint.sh en el orden en que se ejecuta: desde la primera descarga hasta la línea JSON que lee un agente al final.
Primero un poco de contexto. mt5-httpapi ya tiene una ruta POST /compile, entra código MQL5 como JSON y sale un .ex5 en base64. Pero vive dentro de una VM de Windows entera bajo KVM que ejecuta terminales en vivo y opera con dinero de verdad, solo sabe MQL5, rechaza los includes con rutas absolutas o con .., y las bibliotecas compartidas tienes que subirlas antes a su árbol de compilación. Este compila un directorio de código MT4 y MT5 en un portátil o en un runner de GitHub sin arrancar Windows en ningún momento.
verify_installer: firmado por MetaQuotes o rechazado
La imagen es Debian trixie con Wine (64 bits más la mitad de 32 bits, porque el MetaEditor prehistórico de MT4 es código Windows de 32 bits) y ni un solo byte de MetaQuotes. MetaTrader no es mío para redistribuirlo, así que la primera ejecución descarga los instaladores (los de RoboForex por defecto, se cambian con MQL_COMPILER_MT4_URL y MQL_COMPILER_MT5_URL). Solo baja lo que la ejecución necesita: si solo hay archivos .mq5, solo se instala MT5. Más o menos minuto y medio la primera vez, segundos después.
Antes de que wine toque un instalador, osslsigncode comprueba su firma Authenticode contra el bundle de CA de la imagen, y luego esto comprueba quién lo firmó de verdad:
require_signer() {
local name="$1" report="$2" subject
subject="$(awk '/^Signer.s certificate:/ { found = 1 } found && /Subject:/ { print; exit }' <<<"$report")"
if [[ "$subject" != *"/CN=$SIGNER_PATTERN"* ]]; then
log ERROR "installer is validly signed but not by $SIGNER_PATTERN file=$name signer=${subject#*Subject: }"
return 1
fi
log INFO "installer signature ok signer=$SIGNER_PATTERN file=$name"
}SIGNER_PATTERN es MetaQuotes. Solo cuenta el certificado del firmante, no la cadena ni los certificados de sellado de tiempo que hay en el mismo informe. Una firma válida de otro sigue siendo un rechazo. Si el servidor de revocación de la CA está caído, verify_installer lo reintenta tres veces, luego comprueba el digest, la cadena y el firmante sin la consulta de revocación y registra un warning con reason=crl_unreachable para que sepas que ha pasado.
unpack_stdlib: arrancar el terminal una vez para que exista Trade.mqh
El instalador no deja la biblioteca estándar en el disco. La desempaqueta el terminal la primera vez que arranca. Instala MetaEditor bajo Wine, sáltate eso, y cada #include <Trade/Trade.mqh> revienta. Así que el setup arranca cada terminal en headless, en modo portable, y espera al archivo marcador:
(cd "$terminal" && wine "$(terminal_name "$platform")" /portable >/dev/null 2>&1 &)
while [[ ! -f "$marker" ]] && ((waited < STDLIB_UNPACK_TIMEOUT_SECONDS)); do
sleep "$POLL_SECONDS"
waited=$((waited + POLL_SECONDS))
doneEl marcador es Include/Trade/Trade.mqh para MT5 y stdlib.mqh para MT4. En cuanto aparece, wineserver -k mata el terminal, que nunca se cierra solo. Después los terminales instalados se sacan de Program Files y se mueven a directorios portables normales, drive_c/terminals/mt4 y drive_c/terminals/mt5, dentro del volumen de Docker mql-compiler-cache. Unos 2 GB, un subdirectorio por ID de usuario. El contenedor corre con tu UID, así que los archivos compilados son tuyos, no de root.
build_merged_root: cuatro capas de include, la tuya encima
MetaEditor lee los #include <...> de un único directorio Include, uno y punto. Tú necesitas al menos dos, la biblioteca estándar de MetaQuotes y tus propios headers. Esta es la función que hace que la herramienta sirva de verdad:
cp -rs "$(stdlib_root "$platform")/Include" "$MERGED_ROOT/Include"
for include_dir in ${EXTRA_INCLUDES[@]+"${EXTRA_INCLUDES[@]}"}; do
cp -rs --remove-destination "$include_dir/." "$MERGED_ROOT/Include/"
done
MERGED_ROOTS[$key]="$MERGED_ROOT"
[[ -n "$user_root" ]] || return 0
for entry in "$user_root"/*; do
[[ -e "$entry" ]] || continue
name="$(basename "$entry")"
if [[ "${name,,}" == "$INCLUDE_FOLDER" && -d "$entry" ]]; then
cp -rs --remove-destination "$entry/." "$MERGED_ROOT/Include/"
continue
fi
ln -s "$entry" "$MERGED_ROOT/$name"
doneLéela de arriba abajo y ya tienes el orden de las capas:
- El
Includeestándar del terminal (Trade/,Arrays/,stdlib.mqh, todo el lote). - Cada directorio
-I, en el orden en que los pasaste. - Tu propio
Include.--remove-destinationsignifica que en un choque gana la capa posterior, y la tuya va la última. ¿Tienes unTrade/Trade.mqhparcheado en tu árbol? Se compila el tuyo. - Enlaces al resto de tu directorio
MQL4/MQL5(Files,Imagesy compañía), para que#resource "\\Files\\data.txt"se resuelva.
Todo son symlinks (cp -rs) en una zona de staging, construida una vez por plataforma y directorio de usuario en cada pasada. No se escribe nada en tus directorios y todo desaparece cuando acaba la ejecución. Qué directorio MQL4 o MQL5 cuenta como tuyo es simplemente el más cercano por encima del archivo fuente. La extensión sola elige el compilador, .mq4 al metaeditor.exe de MT4, .mq5 al MetaEditor64.exe de MT5.
stage_source: un enlace para esquivar los espacios
¿Tu terminal vive en MT5 - SomeBroker? El /compile y el /inc de MetaEditor se atragantan los dos con los espacios. Así que una fuente con espacios en la ruta se compila a través de un enlace:
[[ "$source" =~ [[:space:]] ]] || return 0
link="${SOURCE_LINKS[$root]:-}"
if [[ -z "$link" ]]; then
link="$STAGING_DIR/sources/${#SOURCE_LINKS[@]}"
mkdir -p "$STAGING_DIR/sources"
ln -s "$root" "$link"
SOURCE_LINKS[$root]="$link"
fi
STAGED_SOURCE="$link${source#"$root"}"El enlace apunta a todo el directorio MQL4/MQL5, no al archivo, así que los includes con "../" que haya dentro siguen resolviéndose. Por lo mismo la raíz de include combinada vive en la zona de staging: el directorio de un terminal montado suele llevar espacios.
compile_one: primero borrar, luego no fiarse del código de salida
Justo antes de la llamada a wine del principio de este post, está esto:
# An old .ex4/.ex5 must never outlive a compile that did not produce a new
# one. MetaEditor removes it on compile errors, but not when it crashes or
# writes no result.
rm -f "$output"Cualquier .ex4/.ex5 que haya ahí después de la ejecución ha salido de esta ejecución. Nada de un binario rancio haciéndose pasar por recién hecho después de que MetaEditor se haya ido a la mierda. Los archivos se compilan de uno en uno, nunca en paralelo, porque dos MetaEditor bajo Wine a la vez es la forma de invocar demonios.
Después de la llamada, el veredicto sale del log, no del código de salida:
result="$(grep -E '^Result: ' <<<"$lines" | tail -n 1)" || result=""El modo de línea de comandos de MetaEditor suele devolver 1 cuando todo ha ido bien. Engánchalo así a lo bruto en una CI y tienes un pipeline que falla cuando sale bien. La línea Result: lleva el número de errores y de warnings, dos llamadas a sed los sacan, y --werror convierte los warnings en fallo. Si no hay ninguna línea Result:, MetaEditor ha petado o no ha escrito nada, y el registro lleva sus líneas de log en bruto en vez de hacer como si nada.
read_log: rutas de Windows de vuelta a las tuyas
MetaEditor escribe su log en UTF-16 con rutas de Windows que apuntan a una zona de staging por la que nunca preguntaste. read_log lo pasa por iconv, convierte Z:\ otra vez en / y resuelve cada ruta de staging al archivo real que hay detrás con readlink. Tu header para un header tuyo, el de la toolchain para uno estándar, y el directorio actual se quita del principio. Es reemplazo de cadenas en bash puro, no sed, así que ningún carácter de una ruta puede romperlo. La recompensa: un error en un header apunta al header, no al EA que lo incluyó.
parse_messages: una regex, un registro JSON
Cada línea de error y de warning del log tiene la pinta path(line,column) : error 256: text, y un solo capture de jq las convierte en objetos:
| capture("^(?<file>.*)\\((?<line>[0-9]+),(?<column>[0-9]+)\\) : (?<severity>error|warning) (?<code>[0-9]+): (?<text>.*)$")?Sin --text, stdout es JSON Lines: un registro file por cada fuente según va terminando, y luego un summary. El progreso va a stderr, también como líneas JSON, así que 2>/dev/null deja solo los resultados.
{"type":"file","source":"MQL5/Experts/Scalper.mq5","platform":"mt5","status":"failed","output":null,"errors":1,"warnings":0,"messages":[{"severity":"error","file":"MQL5/Experts/Scalper.mq5","line":41,"column":7,"code":256,"text":"undeclared identifier 'lots'"}]}
{"type":"summary","total":1,"compiled":0,"failed":1,"werror":false}Cada mensaje es {severity, file, line, column, code, text}, que es lo que de verdad necesita un LLM que escribe EAs en lugar de una captura de un panel de log. --format gnu pinta el mismo registro como líneas path:line:column: error: text [code] a las que VS Code, vim y emacs saltan directamente. Los códigos de salida son aburridos a propósito: 0 todo compiló, 1 al menos uno falló, 2 argumentos incorrectos o un setup que no pudo instalar.
mql-compiler 2>/dev/null | jq -r 'select(.status == "failed") | .source'Ese es todo el informe de “qué se ha roto”. El repo además trae un skill para agentes, publicado en ClawHub en cada release, que enseña a Claude Code, Codex, OpenClaw y peen a instalar el comando, compilar y leer de verdad esos registros, en vez de alucinar que el EA compiló sin problemas.
El wrapper que lo llama todo
Nada de lo anterior se ejecuta hasta que algo arranca el contenedor. Necesitas Docker en una máquina x86-64. No hay imagen ARM, porque el MetaEditor de MT4 necesita la mitad i386 de Wine, así que Apple Silicon se queda fuera salvo que Docker te emule amd64.
# per user, lands in ~/.local/bin
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-mql-compiler/master/install.sh | bash
# system wide, /usr/local/bin
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-mql-compiler/master/install.sh | sudo bash -s -- --systemEl instalador fija la imagen de release más reciente y escribe un único archivo, el comando mql-compiler, que no es más que un docker run fino. Vuelve a ejecutarlo para actualizar, --rolling fija :latest en su lugar, --uninstall quita el comando y su imagen. El volumen de caché se queda, porque lo comparten todos los usuarios de la máquina.
mql-compiler # every .mq4 and .mq5 under the current directory
mql-compiler MQL5/Experts/Scalper.mq5 # one source
mql-compiler --text MQL5 MQL4 # both platforms, human output
mql-compiler --watch MQL5/Experts # recompile on save until Ctrl-C
mql-compiler --werror MQL5 # warnings fail the run
mql-compiler -I ~/libs/mql5 MQL5 # shared headers outside your tree
mql-compiler --mt5-terminal ~/mt5 MQL5 # use a portable terminal already on disk
mql-compiler --setup # install the toolchain, compile nothing
mql-compiler --version # image version and MetaEditor builds
mql-compiler --update # reinstall from the newest installersMete las fuentes bajo un directorio llamado MQL4 o MQL5, la misma estructura que el directorio de datos de un terminal, con los headers en su Include. Los argumentos pueden ser archivos o directorios, relativos o absolutos, en cualquier parte de la máquina, con plataformas mezcladas. El wrapper monta cada directorio necesario en la misma ruta dentro del contenedor y arranca un solo contenedor para toda la ejecución. Ejecútalo sin nada desde / y se niega, porque escanear el disco entero buscando EAs es una idea de mierda.
–watch
--watch mantiene el contenedor abierto, compila, luego se queda esperando en inotifywait y hace otra pasada completa cada vez que cambia un .mq4, .mq5 o .mqh bajo tus rutas o en un directorio -I. Entre pasadas suelta el lock de la caché, así que una compilación suelta desde otra terminal también tiene su turno. Ctrl-C sale con 130, limpio.
–mt4-terminal and –mt5-terminal
¿El terminal de tu broker usa un build concreto de MetaEditor y quieres exactamente ese? Apunta estas opciones a un directorio de terminal portable que ya esté en el disco y compile_one ejecuta su MetaEditor contra su biblioteca estándar. Esa plataforma no se descarga nunca, el terminal se monta en solo lectura, y si la próxima vez no pones la opción, vuelves al build de la caché. El terminal tiene que haberse arrancado una vez para que haya desempaquetado sus headers, si no es exit 2 antes de compilar nada. Como los terminales de la caché se mueven a la misma forma portable, no hay ninguna diferencia entre los míos y el tuyo.
La caché se queda para siempre con el primer build de MetaEditor que instaló. --version te dice en qué builds estás, --update reinstala desde los instaladores más nuevos, y si eso falla, la toolchain vieja vuelve intacta.
El mismo entrypoint en un runner de GitHub
- uses: actions/checkout@v6
- uses: psyb0t/docker-mql-compiler@master
with:
paths: |
MQL5/Experts
MQL4/Indicators/Bands.mq4Esos registros JSON son lo que lee la Action. Los errores de compilación hacen fallar el job y aparecen como anotaciones en las líneas exactas del código en la PR. Los inputs son paths, include, werror, image, cache y cache-dir. Con cache activado (por defecto) la toolchain de 2 GB se queda en la caché de Actions, así que solo la primera ejecución se come la instalación de 90 segundos. Los outputs son results (el archivo JSON Lines), total, compiled y failed, y el resumen del job recibe una tabla con todos los archivos. Un EA que no compila no vuelve a llegar a main, y nadie tocó Windows para enterarse.
La regex que se ganó su propio commit de fix
La primera release salió sin ella. El siguiente commit de fix del historial existe por esta única línea:
# osslsigncode's words when it could not get a CRL (its own typo included).
readonly CRL_FETCH_FAILURE="Fai(le)?d to (decode|download|get|fetch) CRL"En los runners de GitHub, la lista de revocación de Sectigo que bajaba osslsigncode llegaba truncada, y cada primera ejecución fallaba la comprobación de firma con un instalador de MetaQuotes perfectamente bueno, el job de test de la v0.1.0 incluido. Reintentar eso sin reintentar también una firma mala de verdad obligaba a casar el texto de error de osslsigncode, que en ese camino dice “Faild to decode CRL”. Ese (le)? es el trozo de código más honesto del repo, una regex que se dobla ante la errata de otro para que la comprobación no tenga que doblarse nada. Y todo el pipeline que protege compila tu EA. No lo hace bueno. Si el EA compila limpio y luego te hunde la cuenta a base de martingala, eso ya es cosa tuya.
El código está aquí: github.com/psyb0t/docker-mql-compiler. WTFPL, haz lo que te salga de los cojones.