Todos los proyectos
Open Source · v0.1.0Proyecto 08
DevToolsCLI + MCPOpen Source

pm

pm run -- npm run dev mete el proceso en una unit de systemd, descubre solo el puerto que abrió, resuelve colisiones y publica proyecto.localhost vía Caddy. Un core, cuatro caras: CLI, web, API HTTP y 13 tools MCP. Construido para WSL2 + systemd + Chrome.

GoCobrasystemdCaddyMCPSSEMIT

El puerto 3000 lo quieren todos. pm le da a cada proyecto un nombre y se ocupa él del puerto.

Un binario Go que sabe qué corre en qué puerto, arranca y para proyectos de cualquier stack y los publica como proyecto.localhost. Lo usan personas por CLI y web, y agentes por MCP, con los mismos verbos.

Cuatro terminales · sin pm
tienda Next:3000
landing Next:3000 ocupado
panel Vite:5173
docs Vite:5173 ocupado
Dos arrancan. Dos fallan o se van a un puerto que nadie apuntó.
Cuatro nombres · con pm
tienda.localhost→ :3000
landing.localhost→ :3001
panel.localhost→ :5173
docs.localhost→ :5174
Colisión → siguiente libre, inyectado según el stack. El puerto pasa a ser un detalle.
Ejemplo con los puertos preferidos que pm infiere por stack: 3000 para Next, 5173 para Vite.
13 toolsMCP, espejo de los verbos de la CLI
31proyectos registrados en la captura del README, 5 corriendo
89 %cobertura media de tests en internal/, 11 paquetes
01 · Cómo funciona

De un comando en la terminal a un nombre en el navegador

Pulsa los pasos o deja que avance. Cada paso ilumina la pieza de la que habla.

pm run --
cualquier comando
Puerto
preferido o siguiente libre
systemd
pm-<proj>.service
Detector
ss + /proc
Caddy :80
proyecto.localhost
Paso 1 de 5 · avanza solo
Un prefijo, no un flag
pm run -- pnpm dev desde la raíz del proyecto. Si no está registrado, internal/scan mira package.json, manage.py, go.mod o pubspec.yaml y guarda un borrador: stack, comando, puerto preferido y cómo inyectarlo. Un campo editado a mano siempre gana a un rescan.

La versión mínima que funciona: el mismo comando de siempre, con un prefijo delante.

# antes
$ pnpm dev
# después
$ pm run -- pnpm dev
tienda: up · http://tienda.localhost (:3000)
02 · Arquitectura

Un core, cuatro caras

CLI, dashboard web, API HTTP y servidor MCP son proyecciones de la misma capa internal/api. Una feature se implementa una vez y aparece en las cuatro.

Topología en la máquina de desarrollo
Clientes
pm (CLI)
cobra · pm run, ps, stop, promote…
pm.localhost
dashboard en un HTML, SSE
Agente (MCP)
/mcp · streamable HTTP
HTTP · 127.0.0.1:9797
Daemon
api
internal/api · capa compartida
un core
runner
units, puertos, colisión, tiers
detector
estado real del sistema
registry
catálogo JSON en ~/.pm
systemd-run · ss + /proc · docker ps · caddy reload
Sistema
systemd --user
pm-<proj>.service por proyecto
Docker
labels de compose
Caddy :80
Caddyfile regenerado del estado
Host: proyecto.localhost → 127.0.0.1:<puerto>
Navegador
*.localhost
Chrome lo resuelve a 127.0.0.1 sin configurar nada
CLI
cobra · verbo primero, estilo docker
Web
un HTML de 350 líneas, go:embed, SSE
HTTP API
daemon en 127.0.0.1:9797
MCP
streamable HTTP en /mcp · 13 tools
03 · Un ejemplo de punta a punta

Una mañana con tres proyectos y un contenedor

Un caso inventado con números pequeños, para ver qué pasa en cada tramo antes de hablar de los retos.

1
09:10 · ~/tienda
El primer proyecto se registra solo
Ana escribe pm run -- pnpm dev. No hay registro: scan ve next en package.json y guarda un borrador con puerto 3000 y estrategia -p. Arranca pm-tienda.service, el detector ve el LISTEN en 3000 y Caddy publica tienda.localhost.
2
09:12 · ~/landing
El segundo quiere el mismo puerto
Otro Next, otro 3000. pm responde port 3000 taken → using 3001 e inyecta -- -p 3001. landing.localhost funciona igual. Ana no abrió ningún fichero de configuración.
3
09:15 · ~/api
Django recibe el puerto de otra forma
Para manage.py runserver el puerto va posicional: 0.0.0.0:8000. El stack lo decide, no Ana. api.localhost queda enrutado.
4
11:40 · pm ps
Aparece algo que pm no arrancó
La tabla lista los tres proyectos con su puerto, URL y tier. Debajo, un contenedor que Ana levantó a mano en el 8080 aparece como unmanaged, con su cwd. pm claim 8080 lo adopta y desde ese momento tiene nombre y botón de stop.
5
18:00 · pm stop landing
Parar mata el árbol, no solo el padre
systemctl --user stop pm-landing se lleva pnpm, node y sus workers de una vez. Antes de cerrar, pm promote api: la unit pasa a permanente y arrancará sola al siguiente boot.
6
al día siguiente · desde un agente
Los mismos verbos, por MCP
El agente de código llama a pm_list antes de asumir que un puerto está libre, arranca tienda con pm_start en vez de improvisar un dev server en segundo plano, y lee pm_logs cuando algo no levanta.
04 · Los retos

Cinco veces en que la solución obvia estaba mal

Cada reto sigue el mismo patrón: lo que cualquiera haría primero, por qué no sirve, lo que se hizo y el número que lo resume. El detalle técnico queda plegado.

01

Atribuir un puerto a un proyecto sin que nadie lo declare

Lo obvio
Pedir el puerto en un fichero de configuración por proyecto y confiar en él.
Por qué falla
El puerto declarado y el puerto real se separan en cuanto hay una colisión, un PORT= en el entorno o un dev server que decide saltar solo. Y lo que se arrancó fuera de la herramienta, a mano o con Docker, no está en ningún fichero. El problema central no es el puerto: es saber que el PID que escucha en el 5173 es de tal proyecto.
cgroup
/proc/<pid>/cgroup
Un pm-<proj> en cualquier nivel del cgroup. Todo lo que pm lanzó lo tiene, incluidos los workers.
certeza
PM_PROJECT
/proc/<pid>/environ
La variable que systemd-run inyectó. Cubre el caso raro de un proceso que se re-ejecuta fuera de su cgroup.
certeza
cwd
/proc/<pid>/cwd
Para lo que pm no arrancó: si el directorio coincide con un proyecto registrado, se propone. Si no, es unmanaged.
heurística
Lo que se hizo
El puerto no se declara, se descubre. pm lanza con un prefijo que traduce a systemd-run --user --unit=pm-<proj> --setenv=PM_PROJECT=<proj>, y después mira qué bindea el cgroup en ss -tlnp. Tres señales redundantes en /proc atribuyen cada listener, y el estado que muestra es el del sistema, no el que pm cree tener.
3 señalescgroup, PM_PROJECT y cwd. Cero puertos declarados: el primer pm run auto-registra el proyecto con lo que encontró.
Ver detalle técnico

El detector parsea la salida de ss y lee /proc/<pid>/cgroup, environ y cwd con funciones puras, separadas del exec, que es lo que permite testearlas con fixtures. Docker es un detector aparte: docker ps más las labels de compose.

Un listener sin PID visible es un proceso de otro usuario o de root: aparece como system y pm no intenta gestionarlo. Requisitos verificados en la máquina objetivo: systemd como PID 1, systemctl --user corriendo, cgroup v2 unificado y /proc/<pid>/environ legible.

02

Colisión de puertos con inyección según el stack

Lo obvio
Si el puerto está ocupado, buscar el siguiente libre y pasárselo al comando con --port.
Por qué falla
Encontrar el puerto libre es lo fácil. Lo difícil es decírselo al proceso: cada stack lo recibe de una forma distinta. Vite quiere --port, Next quiere -p, muchos servidores leen PORT=, Django lo espera posicional y un docker compose up no acepta nada: el puerto lo fija el compose. Y con npm o pnpm el flag va después de un -- o se lo queda el gestor de paquetes.
StackPrefiereCómo recibe otro puerto
Vite5173-- --port 5174
Next3000-- -p 3001
Nuxt / Go / otros—PORT=… en el entorno
Django80000.0.0.0:8001 posicional
Flutter web8080--web-port 8081
docker compose—no se toca: el puerto lo fija el compose
Lo que se hizo
La estrategia de inyección se infiere con el stack en internal/scan y se guarda en el registro: flag, variable de entorno o plantilla. resolvePort decide el puerto contra la foto viva del sistema antes de arrancar; el comando se reescribe una vez con la estrategia que toque. Los contenedores no participan de la colisión.
6 estrategiasvite, next, django, flutter, env y docker. Verificado end-to-end: el segundo servicio saltó al siguiente puerto y siguió respondiendo por su nombre.
Ver detalle técnico

Después de lanzar, waitForPort sondea el estado hasta que la unit expone un LISTEN, y ese puerto real es el que va al registro y al Caddyfile, aunque el proceso hubiera ignorado la sugerencia. --port N fija el puerto y --force convierte la colisión en error en vez de saltar.

Todo lo que scan produce es un borrador. Un campo editado a mano gana siempre a un rescan; el gestor de paquetes se elige por el lockfile (bun, pnpm, yarn, npm).

03

Nombres .localhost con Caddy, y el 403 de Vite

Lo obvio
Editar /etc/hosts por proyecto y apuntar un proxy a cada puerto.
Por qué falla
El navegador vive en Windows y los servidores en WSL2: el hosts de un lado no sirve en el otro. Y aunque el proxy funcione, los dev servers de Vite responden 403 host not allowed a cualquier hostname que no esté en server.allowedHosts. Enseñar a cada proyecto a aceptar su nombre es justo la configuración por proyecto que se quería evitar.
# ~/.pm/Caddyfile · generado desde el estado vivo
http://tienda.localhost:80, http://tienda.pm:80 {
reverse_proxy 127.0.0.1:3000 {
header_up Host localhost:3000 # Vite ve un host permitido
}
}
Lo que se hizo
Chrome resuelve *.localhost a 127.0.0.1 sin tocar DNS ni hosts, así que basta un Caddy en :80 dentro de WSL que enrute por Host. El daemon regenera el Caddyfile desde el estado y lo empuja con caddy reload sin cortar conexiones. El generador reescribe Host a localhost:<puerto> en cada reverse_proxy; el hostname original sigue llegando en X-Forwarded-Host.
403 → 200en cada dev server de Vite, sin que ningún proyecto toque su configuración.
Ver detalle técnico

Cada proyecto se publica por dos hostnames a la vez: <nombre>.localhost y <nombre>.pm, el sufijo remoto (PM_REMOTE_SUFFIX) pensado para llegar desde el teléfono a través de una VPN de malla. El Caddyfile también incluye una ruta estable para el dashboard, pm.localhost. Si una app necesitara el Host real, la puerta es un flag host_passthrough futuro.

Dos problemas reales al instalarlo. apt install caddy deja un caddy.service de sistema dueño de :80 y del admin :2019; la unit propia pm-caddy chocaba y crasheaba en bucle, así que pm reutiliza el Caddy del sistema. Y con el modo de red mirrored de WSL, Caddy moría al arrancar por una carrera transitoria en :80: un drop-in de systemd con Restart=on-failure y After=network-online.target lo resolvió.
04

Parar lo que pm no arrancó

Lo obvio
Parar es systemctl --user stop pm-<proj>. Siempre.
Por qué falla
Un proyecto adoptado con pm claim no tiene unit: el stop fallaba con Unit not loaded. Y matar solo al PID que escucha deja huérfanos a sus workers, que es exactamente la situación que la herramienta existe para evitar.
¿Existe la unit pm-<proj>?
→
systemctl --user stop
No. ¿Es un contenedor con id?
→
docker stop <id>
No. ¿Hay un PID visible?
→
SIGTERM al PID y a sus descendientes vía /proc
No: puerto de otro usuario o de root.
→
error claro, no se toca
Lo que se hizo
Stop comprueba primero si la unit existe. Si no, busca la instancia del proyecto en el estado vivo y la para por la vía que corresponda: docker stop por id de contenedor, o SIGTERM al PID y a todos sus descendientes, recorriendo /proc. Lo que no tiene PID visible no se toca y se dice por qué.
3 víasde parada: unit, contenedor o árbol de PIDs. Verificado end-to-end sobre un proyecto reclamado.
Ver detalle técnico

El mismo principio abrió otra puerta: registrar servicios que viven en otra instancia WSL, cuyo directorio no es accesible localmente. Add() exige solo el nombre; Dir pasó a ser opcional y solo se usa como --working-directory al arrancar. pm los trata como externos: los enruta por hostname mientras el puerto sea alcanzable, y devuelve un error claro si se intenta arrancar un proyecto registrado solo por nombre.

05

Tests que nunca tocan un daemon real

Lo obvio
Una herramienta que lanza units de systemd y ejecuta docker se prueba a mano, en la máquina, y se deja sin tests.
Por qué falla
Cada paquete que toca el sistema queda sin cobertura, que son justo los que rompen: el runner, el detector, el proxy. Y un test que arranque units reales ensucia ~/.pm, deja scopes fantasma y no puede correr en CI.
mcp100 %
scan100 %
webui100 %
runner94,5 %
registry94,1 %
config91,3 %
detector90,4 %
api89 %
proxy84,6 %
daemon83,3 %
cli54,8 %
Lo que se hizo
La suite corre aislada del todo: PM_HOME temporal, estado del sistema inyectado en el detector, y systemctl, systemd-run y docker sustituidos por fakes a través de un seam de exec. El parseo de ss, cgroups y environ se extrajo a funciones puras para probarlo con fixtures. go test -race ./... no toca un daemon de verdad.
3 477líneas de test por 4 125 de código Go. Cobertura por paquete medida con go test -cover en v0.1.0; cli es la única por debajo del 80 % y está en la retrospectiva.
Ver detalle técnico

Los tests de MCP hacen round-trips reales sobre streamable HTTP contra un daemon de prueba; los del daemon cubren la API, los streams SSE, el fallback del SPA y el ciclo de vida de serve. El dashboard embebido tiene un test que comprueba que el bundle existe y sirve.

"Qué corre, en qué puerto y cómo lo alcanzo por nombre."
Toda la herramienta responde esa pregunta para la máquina entera, y la responde igual a una persona en la terminal, a una pestaña del navegador y a un agente por MCP. Lo que ya resuelve el sistema (systemd, Caddy, Chrome con *.localhost) no se reimplementa.
05 · Decisiones

Un solo filtro: ¿esto ya lo hace el sistema?

Si la respuesta era sí, se usó el sistema. Si no, se escribió lo mínimo y se dejó una costura para probarlo con fakes.

WHY
¿Por qué units de systemd y no un supervisor propio?
La máquina objetivo ya tiene systemd como PID 1. Un scope transitorio da gratis lo que un supervisor tendría que reimplementar: identidad por cgroup, muerte del árbol completo al parar y logs por fichero. Y sobre todo los tiers: un proyecto efímero es una unit transitoria; uno permanente es una unit de usuario habilitada que arranca al boot.
Profundizar

pm promote y pm demote son un cambio de tipo de unit, no una máquina de estados propia. Desde Windows, un shim pm.cmd en el PATH reenvía a wsl.exe -e pm; pm ui y pm open abren el Chrome de Windows en pm.localhost.

Trade-off asumido: pm solo funciona donde hay systemd de usuario y cgroup v2. Está en el nombre del README: construido para WSL2 + systemd + Chrome.
WHY
¿Por qué Caddy en :80 y no un proxy propio?
El navegador está en Windows y los servidores en WSL2. *.localhost lo resuelve Chrome solo, y un proxy en :80 dentro de WSL convierte tienda.localhost en el puerto real. Caddy ya sabe recargar configuración por su admin API sin cortar conexiones; escribir eso a mano sería mantener un reverse proxy dentro de un gestor de puertos.
Profundizar

El daemon reconcilia el Caddyfile con el estado en cada cambio y Reload es un no-op silencioso si Caddy no está instalado: todo lo demás sigue funcionando por puerto. PM_NO_PROXY=1 lo desactiva para daemons secundarios y tests.

El coste es depender de un servicio del sistema que otra instalación puede haber dejado corriendo con su propia configuración, que es exactamente lo que pasó con el admin en :2019.
WHY
¿Por qué el dashboard es un único HTML sin build step?
Es un fichero de 350 líneas sin dependencias, embebido con go:embed. Editar, make build, listo. El tiempo real va por SSE, no por polling: estado, start/stop, promote/demote, logs en streaming, adopción de listeners unmanaged, grupos y CRUD completo. El tono es el de una pestaña siempre abierta con una tabla viva.
Profundizar

Un frontend con toolchain habría metido node en el build de un binario Go cuya gracia es instalarse con make install. El precio es un HTML a mano que crece; mientras quepa en una pantalla de código, compensa.

WHY
¿Por qué MCP como espejo de la CLI, servido por el daemon?
El daemon ya está siempre vivo, así que cualquier cliente lo encuentra con una línea en .mcp.json. Las 13 tools son los verbos de la CLI sobre la misma capa internal/api: un agente puede hacer exactamente lo que haría una persona en la terminal, sin una segunda implementación que derive.
Profundizar
# .mcp.json
{ "mcpServers": { "pm": { "type": "http", "url": "http://127.0.0.1:9797/mcp" } } }
# tools
pm_list · pm_start · pm_stop · pm_restart · pm_promote · pm_demote · pm_register
pm_remove · pm_claim · pm_scan · pm_logs · pm_group_up · pm_group_down

Transporte streamable HTTP con mark3labs/mcp-go. Lo que gana un agente es libertad real para operar procesos: comprobar qué corre antes de asumir que un puerto está libre, arrancar el proyecto en vez de improvisar un dev server en segundo plano, y leer los logs cuando algo no levanta.

Alternativas · qué hacen y qué añade pm
overmind / foreman
Corren los procesos de un Procfile en una sesión. pm es de toda la máquina: cada proyecto en su unit, que sobrevive a la terminal, más descubrimiento de lo que no arrancó él.
portless
Da nombre *.localhost a servidores Node. pm nombra cualquier stack, resuelve colisiones y sincroniza Caddy desde el estado vivo, no desde un wrapper.
devbox
Entornos reproducibles con Nix. pm no gestiona toolchains; gestiona lo que está corriendo. Se componen sin conflicto.
06 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
pm setup que imprime pasos manuales
Hoy haría
Un setup guiado que detecte Caddy, escriba las units y las habilite. Hoy es una lista de comandos para copiar.
Hice
pm-caddy como unit propia
Hoy haría
Detectar un caddy.service del sistema antes de escribir la unit. El admin API en :2019 ya estaba tomado y pm-caddy crasheaba en bucle hasta deshabilitarla.
Hice
Dir obligatorio al registrar un proyecto
Hoy haría
Opcional desde el inicio. Hubo que relajarlo para servicios que viven en otra instancia WSL, y la validación solo importa al arrancar.
Hice
Botón "Adoptar" sin mostrar el cwd del listener
Hoy haría
Mostrar cwd y cmdline antes de reclamar: con varios unmanaged en la lista, un puerto solo no identifica el proyecto.
Hice
Capa cobra al 54,8 % de cobertura
Hoy haría
La misma disciplina de fakes que tiene el core, aplicada a los comandos. Es el único paquete de internal/ por debajo del 80 %.
07 · Si te llevas tres cosas

Recap

1
El puerto no se declara, se descubre: pm lanza en un cgroup con nombre, mira qué escucha y atribuye cada listener con tres señales. Lo que arrancaste fuera también aparece.
2
Cada proyecto responde por su nombre en el navegador. Si el puerto cambia, el nombre lo sigue, y ningún proyecto toca su configuración para ello.
3
Un core, cuatro caras: CLI, web, API HTTP y 13 tools MCP sobre la misma capa. Un agente opera tus dev servers con los mismos verbos que tú.
Enlaces