@cat-indev/catops-cli
v0.0.1-alpha.45
Published
Framework CLI para pipelines DevOps (shell, docker, git, kubectl, helm, npm, terraform, ansible, argocd, tekton, oc, az) sobre un ExecutionContext compartido, con menus interactivos y selectores automaticos por flag. Escrito en TypeScript, 100% usable des
Maintainers
Readme
catops-cli
Framework para pipelines DevOps, escrito en TypeScript (100% usable desde JavaScript puro), empaquetado como librería npm instalable en cualquier proyecto.
Trae:
- Un ExecutionContext compartido (
flags,params,env,vars,results,logger,services,notifier) para que ninguna task tenga que recibir parámetros manualmente.ctx.envestá sincronizado conshell.environment— cualquier variable que agregues se refleja en todos los comandos. - 16 servicios listos (
shell,docker,git,kubectl,helm,npm,archive,terraform,ansible,argocd,tekton,oc,az,azdo,http,yaml) + un cliente REST API para Azure DevOps (AzureDevOpsApi) + un motor de pipelines declarativo con dependencias (Pipeline). - Servicio HTTP con interceptores de request/response, registry de agentes nombrados, configuración global, query params, dry-run y timeout. Los interceptores también se pueden registrar sobre el agente que integra
AzureDevOpsApi. Modo TLS inseguro (insecureTls) para servidores con certificado autofirmado o CA interna no confiable. - Parser de errores de Azure DevOps: cada fallo de la REST API se convierte en un
AzdoApiErrorlegible con el mensaje del servidor, código TF/VS, tipo de excepción y sugerencias accionables (parseAzdoError/formatAzdoError). - Menús interactivos con navegación anidada y selección automática por flag (para correr pipelines sin prompts, ideal para CI).
- retry / timeout / dryRun / env / cwd en cada comando de shell, más shell compartido (
runner) para ejecutar servicios en un mismo directorio/entorno. - Validación cíclica de rollouts de Kubernetes/OpenShift (
waitForDeployment,waitForDeploymentGroup). - Callbacks de éxito/error por tarea + un sistema de notificaciones clasificadas por área de TI, con mensajes personalizables y senders (
log,file,http,webhook,websocket). Incluyectx.wrap()para monitoreo automático de servicios con metadata de servicio/método/argumentos.
Instalación
Opción A — publicado en tu registro npm (público o privado tipo Verdaccio/Artifactory/GitHub Packages):
npm install catops-cli
# o si lo publicas con scope propio, p.ej. @miorg/catops-cli
npm install @miorg/catops-cliOpción B — sin publicar, directo desde este proyecto (útil mientras lo maduras):
# Dentro del repo de catops-cli
npm pack # genera catops-cli-<version>.tgz
# Dentro del proyecto que lo va a consumir
npm install /ruta/a/catops-cli-<version>.tgzOpción C — enlazado local con npm link (para desarrollar la librería y el proyecto que la consume al mismo tiempo):
# Dentro del repo de catops-cli
npm link
# Dentro del proyecto consumidor
npm link catops-cliOpción D — como dependencia de Git (monorepo o repo privado, sin registro npm):
npm install git+https://github.com/tu-org/catops-cli.gitCualquiera de las 4 deja disponibles dos cosas en el proyecto consumidor:
- La librería, tanto desde TS como desde JS puro:
import { Context, Menu, services, YamlService, type MenuDefinition } from "catops-cli";const { Context, Menu, services, YamlService } = require("catops-cli"); - El binario:
npx catops-cli(ocatops-clisi lo instalaste global con-g).
Publicar una nueva versión
npm version patch # o minor / major
npm publish # agrega --access public si usas un scope (@miorg/catops-cli)prepublishOnly corre el build y los tests automáticamente antes de publicar.
TypeScript
Todo src/ está escrito en TypeScript, con strict: true. npm run build compila a dist/ (JS + .d.ts + source maps por archivo) — eso es lo único que se publica (ver files en package.json).
src/
core/
Context.ts -> ExecutionContext singleton (Context.current() / Context.parseArgv() / ctx.wrap())
Menu.ts -> Menu.render() con navegación anidada + selección automática por flag
Notifier.ts -> clasificación de errores por área + canales + senders
classifiers.ts -> fábricas de ErrorClassifier: byCommand, byPattern, byService
messages.ts -> fábricas de ErrorMessageFormatter: byPattern, byCommand, byRule, byService
senders.ts -> fábricas de Sender: log, file, http, webhook, websocket
prompt.ts -> ctx.ask / ctx.confirm / ctx.select (sin dependencias externas)
logger.ts -> logger usado por Context y por shell.ts
types.ts -> tipos compartidos (MenuDefinition, ExecOptions, NotificationEvent, ServiceError, ...)
services/
shell.ts -> motor base (spawn), con retry/timeout/dryRun
http.ts -> cliente HTTP con interceptores de request/response, múltiples instancias
http-types.ts -> tipos del servicio HTTP (HttpRequest, HttpResponse, interceptors, ...)
docker.ts, git.ts, kubectl.ts, helm.ts, npm.ts, archive.ts
terraform.ts, ansible.ts, argocd.ts, tekton.ts, oc.ts, az.ts, azdo.ts, azdo-api.ts, azdo-errors.ts, pipeline.ts
yaml.ts -> YamlService: manipulación de archivos YAML con prepare(), multidocument, comentarios
index.ts -> registra todos los servicios anteriores (ServicesRegistry)
index.ts -> entry point público: Context, Menu, Notifier, senders, classifiers, messages, services, http, tipos
bin/
devops-cli.ts -> CLI ejecutable (busca devops.pipeline.js en el proyecto consumidor)
examples/
pipeline-example.js -> pipeline + menú + notificaciones de ejemplo, corre contra dist/
test/
context.test.js, shell.test.js, services.test.js, menu-selector.test.js,
notifier.test.js, senders.test.js, hooks-integration.test.js,
http.test.js, kubectl.test.js, oc.test.js, deployment-group.test.js,
exec-options-passthrough.test.js, azdo-api.test.js, pipeline.test.js,
service-notify.test.js, yaml.test.jsUso rápido: menú con devops.pipeline.js + el bin
Crea un devops.pipeline.js (o devops.config.js / .catops-cli.js) en la raíz de tu proyecto:
// devops.pipeline.js
module.exports = (ctx) => ({
title: "Pipeline",
options: {
Build: async () => {
await ctx.services.docker.build({ image: "registry/app:v1", dockerfile: "Dockerfile" });
},
Deploy: async () => {
await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
}
}
});npx catops-cli --debug --env=prodcatops-cli detecta el archivo, arma el Context a partir de los flags/params de argv, y renderiza el menú.
Uso directo en tu propio script (p. ej. con tsx)
// src/index.ts
import { Context, Menu, type MenuDefinition } from "catops-cli";
const ctx = Context.parseArgv();
const mainMenu: MenuDefinition = {
title: "Pipeline",
"flag-selector": "--menu-selector",
options: {
Build: { selector: "build", action: () => ctx.services.docker.build({ image: "app:v1" }) }
}
};
Menu.render(mainMenu, ctx);{ "scripts": { "dev": "tsx src/index.ts" } }npx tsx src/index.ts --menu-selector=build
npm run dev -- --menu-selector=build # con npm hace falta el "--" para reenviar flagsUso como librería sin menú (pipeline lineal)
const { Context } = require("catops-cli"); // o require("./dist") dentro de este repo
const ctx = Context.parseArgv(); // llena flags/params desde argv
ctx.set("image", "registry/api:v1");
await ctx.services.git.checkout("develop");
await ctx.services.npm.ci();
await ctx.services.docker.build({ image: ctx.get("image"), dockerfile: "Dockerfile" });
await ctx.services.docker.push(ctx.get("image"));
await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });Menús: definición, anidamiento y selectores automáticos por flag
Un MenuDefinition es { title, options }, donde cada entrada de options puede ser:
- una función — task directa:
Build: () => {...} - otro
MenuDefinition— submenú directo:Docker: dockerMenu - un objeto largo — para poder darle
selector,onSuccess/onError, o envolver un submenú:Build: { selector: "build", action: () => {...}, onSuccess: (r, ctx) => {...}, onError: (e, ctx) => {...} } Docker: { selector: "docker", menu: dockerMenu } // también podés inlinear el submenú directo con su propio selector al lado: Docker: { selector: "docker", title: "Docker", "flag-selector": "--docker-action", options: {...} }
Selección automática por flag
Cualquier MenuDefinition puede declarar "flag-selector": "--algun-flag". Si el Context trae un param que matchea el selector de alguno de sus items, esa opción se ejecuta automáticamente, sin ningún prompt:
const dockerMenu = {
title: "Docker",
"flag-selector": "--docker-action",
options: {
Build: { selector: "build", action: buildTask },
Push: { selector: "push", action: pushTask }
}
};
const mainMenu = {
title: "Pipeline",
"flag-selector": "--menu-selector",
options: {
Docker: { selector: "docker", menu: dockerMenu },
Deploy: { selector: "deploy", action: deployTask }
}
};
Menu.render(mainMenu, ctx);# encadena ambos niveles en un solo comando, sin ningún prompt interactivo:
catops-cli --menu-selector=docker --docker-action=build
# un solo nivel:
catops-cli --menu-selector=deploy
# sin flags -> menú interactivo normal
catops-cliSi el valor del flag no matchea ningún selector del nivel actual, cae de vuelta al menú interactivo (con un warning), en vez de fallar en seco. Cada submenú revisa su propio flag-selector de forma independiente, así que podés automatizar tantos niveles como quieras encadenando flags.
Callbacks de éxito/error por item
Deploy: {
selector: "deploy",
action: () => ctx.services.kubectl.apply("deployment.yaml"),
onSuccess: (result, ctx) => ctx.logger.success("Deploy OK"),
onError: (error, ctx) => ctx.logger.error(`Deploy falló: ${error.message}`)
}Mismo patrón con ctx.run() fuera de un menú:
await ctx.run(
"deploy",
() => ctx.services.kubectl.apply("deployment.yaml"),
{
onSuccess: (result, ctx) => ctx.logger.success("Deploy OK"),
onError: (error, ctx) => ctx.logger.error(`Deploy falló: ${error.message}`)
}
);En ambos casos, además de tus callbacks, el resultado se reporta automáticamente al ctx.notifier (ver más abajo) — no hay que llamarlo a mano.
retry / timeout / dryRun / env / cwd — en shell.exec y en TODOS los servicios
shell.exec(command, ...args) sigue aceptando exactamente los mismos argumentos de siempre. Si el último argumento es un objeto plano, se interpreta como opciones solo para esa llamada:
await ctx.services.docker.push(image); // igual que siempre
await ctx.services.shell.exec("curl", "https://flaky-api.internal", {
retry: 3, // reintentos totales (default: 1 = sin retry)
retryDelay: 1000, // ms entre reintentos
timeout: 5000, // ms antes de matar el proceso con SIGTERM
dryRun: true, // solo loguea el comando, no lo ejecuta
env: { // variables de entorno extra para esta llamada (se fusionan con ctx.env)
HTTP_PROXY: "http://proxy:3128"
}
});Variables de entorno (env)
ctx.env es la misma referencia que shell.environment — cualquier cambio en uno se refleja en el otro y afecta a todos los comandos:
// Global: afecta a TODOS los comandos (docker, kubectl, git, ...)
ctx.env["DOCKER_CONFIG"] = "/ruta/custom/config";
ctx.env["DOCKER_BUILDKIT"] = "0";
await ctx.services.docker.build({ image: "app:v1" }); // recibe ambas
await ctx.services.docker.push("app:v1"); // recibe ambas
await ctx.services.git.clone("url", "path"); // también las recibePara override puntual (por llamada), usá exec.env — se fusiona con ctx.env:
// Solo para esta llamada,叠加 sobre ctx.env
await ctx.services.docker.build({
image: "app:v1",
exec: { env: { DOCKER_BUILDKIT: "0" } }
});
// Equivalente vía shell
ctx.services.shell.env("DOCKER_CONFIG", "/ruta/config");La jerarquía es: process.env → ctx.env (global) → exec.env (por llamada).
Directorio de trabajo (cwd)
cwd ejecuta el comando en una carpeta puntual, como si hicieras cd antes del comando. Vale para cualquier comando de cualquier servicio, directo en shell.exec o vía exec.cwd:
// Directo en shell
await ctx.services.shell.exec("git", "status", { cwd: "/ruta/del/repo" });
// En un servicio, vía exec (funciona con kubectl, docker, helm, oc, ...)
await ctx.services.kubectl.apply("deploy.yaml", {
exec: { cwd: "/ruta/repo/infra", retry: 3 }
});
// Git acepta cwd directamente en su objeto de opciones
await ctx.services.git.status({ cwd: "./repo" });
await ctx.services.git.pull({ remote: "origin", branch: "main", cwd: "/ruta/repo" });cwd puede ser absoluto o relativo (resuelve contra el directorio actual del shell). También se puede fijar como default global:
ctx.services.shell.configure({ cwd: "/ruta/repo" }); // todos los comandos corren ahí por defectoShell compartido (runner)
Para flujos donde varios servicios deben correr en un mismo directorio/entorno (p. ej. despliegue GitOps: clonar, configurar, aplicar), podés crear un shell compartido con su propio cd y pasárselo a los siguientes comandos. Se crea con shell.clone(), se hace cd con sh.cwd(...), y se pasa como runner:
// Un shell compartido apuntando al repo
const sh = ctx.services.shell.clone();
sh.cwd("/ruta/repo"); // cd a la carpeta que tiene el .git
sh.env("MY_CONTEXT", "prod"); // entorno propio del shell compartido
// El siguiente servicio usa ESE shell (mismo directorio + entorno)
await ctx.services.git.status({ runner: sh });
await ctx.services.git.branchCreate("feat/gitops", { runner: sh, checkout: true });
await ctx.services.npm.ci({ exec: { runner: sh } }); // también vía exec
await ctx.services.docker.build({ image: "app:v1", exec: { runner: sh } });El runner conserva su propio cd/env entre llamadas (hasta que lo cambies con sh.cwd(...)), mientras que cwd solo aplica a esa llamada puntual. Ambos coexisten: cwd tiene prioridad sobre el directorio del runner. El shell compartido hace lo mismo que el default (clone, cwd, env, configure, exec), y al pasarlo como runner no tocas el shell global.
// Combinado: runner para varios pasos + cwd puntual para uno solo
await ctx.services.git.add(".", { runner: sh });
await ctx.services.git.commit("fix: quota", { runner: sh, exec: { cwd: "/tmp/otro-repo" } });Todos los comandos de todos los servicios (docker, git, kubectl, helm, npm, archive, terraform, ansible, argocd, tekton, oc, az) aceptan este mismo control, sin que cambie nada de lo que ya usabas:
- Si la función ya recibía un objeto de opciones (la mayoría), agregale la clave
exec:await ctx.services.terraform.apply({ autoApprove: true, exec: { retry: 3 } }); await ctx.services.argocd.appSync("mi-app", { prune: true, exec: { retry: 3 } }); - Si la función recibe argumentos posicionales (strings sueltos),
execva como el último argumento:await ctx.services.docker.push("registry/app:v1", { retry: 3 }); await ctx.services.git.push({ retry: 3 }); await ctx.services.helm.uninstall("mi-app", { retry: 3 }); kubectlyoccombinanexecen el mismo objeto que ya usás parakubeconfig/namespace:// 10 reintentos en un login inestable de OpenShift await ctx.services.oc.login({ server: "https://api.cluster:6443", token: process.env.OC_TOKEN, namespace: "prod", exec: { retry: 10, retryDelay: 2000 } }); await ctx.services.kubectl.apply("deploy.yaml", { namespace: "prod", exec: { retry: 5, timeout: 30000 } });
Defaults globales para todo el proceso (afecta a todos los comandos que no pasen su propio exec/opciones puntuales):
ctx.services.shell.configure({ retry: 3, timeout: 30000 });Context.parseArgv() ya conecta flags de línea de comandos automáticamente a esos defaults globales:
npx catops-cli --dry-run # activa dryRun global
npx catops-cli --retry=3 --timeout=15000Servicios de infraestructura incluidos
await ctx.services.terraform.plan({ varFile: "prod.tfvars" });
await ctx.services.terraform.apply();
await ctx.services.ansible.playbook("site.yml", { inventory: "hosts.ini" });
await ctx.services.argocd.appSync("mi-app", { prune: true });
await ctx.services.tekton.pipelineStart("build-pipeline", { params: { image: "app:v1" } });
await ctx.services.az.acrBuild({ registry: "miregistro", image: "app:v1" });Docker (ctx.services.docker)
ctx.services.docker agrupa las operaciones habituales de Docker (build, push, tag, login, pull, rm, rmi, prune) más utilidades de validación de disco y caché (systemDf, buildxDu, validateCache, validateSpace).
// build — `--pull=false/true` y `--no-cache`
await ctx.services.docker.build({
image: "registry/app:v1",
context: ".",
dockerfile: "Dockerfile",
buildArgs: { NODE_ENV: "production" },
pull: false, // docker build --pull=false ...
noCache: true, // ... --no-cache
exec: { // override de env solo para este build
env: { DOCKER_BUILDKIT: "0", DOCKER_CONFIG: "/tmp/docker" }
}
});
// Variables de entorno globales para TODOS los comandos docker
ctx.env["DOCKER_CONFIG"] = "/ruta/custom/config";
ctx.env["DOCKER_BUILDKIT"] = "0";
await ctx.services.docker.push("registry/app:v1");
await ctx.services.docker.tag("app:v1", "registry/app:v1");
await ctx.services.docker.login({ registry, username, password });
await ctx.services.docker.pull("registry/app:v1");
// rm / rmi — eliminar contenedor / imagen (rm con -f si lo deseas)
await ctx.services.docker.rm("mycontainer");
await ctx.services.docker.rm("mycontainer", { force: true });
await ctx.services.docker.rmi("registry/app:v1");
// prune — docker system prune [-a] [--volumes] [--filter ...]
await ctx.services.docker.prune({ all: true, volumes: true });
await ctx.services.docker.prune({ all: true, filter: "until=24h" });
// systemDf — docker system df con tamaños normalizados a bytes
const usage = await ctx.services.docker.systemDf();
// → { rows: [{ type: "Images", total, active, size, reclaimable }, ...],
// total: { size, reclaimable, ... }, raw }
console.log(usage.total.size); // bytes totales ocupados
console.log(usage.rows[0].reclaimable); // bytes reclaimables por tipo
// buildxDu — tamaño de la caché de build (buildkit)
await ctx.services.docker.buildxDu();
// validateCache — cuánto ocupa la caché de build y cuánto es recuperable
const cache = await ctx.services.docker.validateCache();
// → { cache, reclaimable, usage }
// validateSpace — compara cada métrica contra umbrales configurados (bytes)
const { exceeded } = await ctx.services.docker.validateSpace({
images: 10 * 1e9, // 10 GB de imágenes
containers: 2 * 1e9, // 2 GB de contenedores
volumes: 5 * 1e9, // 5 GB de volúmenes
buildCache: 3 * 1e9, // 3 GB de caché de build
reclaimable: 6 * 1e9 // 6 GB reclaimables en total
});
if (exceeded.length) {
await ctx.services.docker.prune({ all: true });
}
// Con throwOnExceeded=true lanza un Error si se supera algún umbral
await ctx.services.docker.validateSpace({ images: 8 * 1e9 }, undefined, true);Los umbrales y los tamaños del systemDf se expresan en bytes (con parseSize que normaliza KB/MB/GB/TB). Todas las funciones aceptan exec (retry/timeout/dryRun) como antes.
Git (ctx.services.git)
ctx.services.git cubre el flujo completo de trabajo con git: clone, checkout, creación/borrado/listado de ramas, pull/fetch, add/commit/push, tag, merge/rebase, status/log/diff/show, remote, stash, reset, init, config y revParse.
Todas las funciones aceptan exec (retry/timeout/dryRun) como último argumento plano (git.push({ retry: 3 })) o embebido en el objeto de opciones (git.commit({ exec: { retry: 3 } })). También aceptan cwd y runner a nivel de opciones (git.status({ cwd: "./repo" })), sin necesidad de anidarlos en exec.
// clone — con rama y profundidad opcionales
await ctx.services.git.clone("https://github.com/org/repo.git", "./repo");
await ctx.services.git.clone("https://github.com/org/repo.git", "./repo", {
branch: "dev", depth: 1, singleBranch: true
});
// checkout — pasarse de rama, o crearla y pasarse
await ctx.services.git.checkout("develop");
await ctx.services.git.checkout("feat/api", { create: true }); // git checkout -b feat/api
await ctx.services.git.checkout("dev", { create: true, track: true, startPoint: "origin/dev" });Ramas — branch (genérico) y helpers dedicados branchCreate, branchDelete, branchRename, branchList, branchShowCurrent, branchSetUpstream, branchUnsetUpstream:
// Crear
await ctx.services.git.branchCreate("feat/x", { startPoint: "main" });
await ctx.services.git.branchCreate("feat/x", { startPoint: "origin/main", track: true });
await ctx.services.git.branchCreate("hotfix", { force: true }); // git branch -f hotfix
// Borrar (con -D si usás force) y renombrar
await ctx.services.git.branchDelete("feat/x");
await ctx.services.git.branchDelete("legacy", { force: true }); // git branch -D legacy
await ctx.services.git.branchRename("feature/x"); // renombra la rama actual
await ctx.services.git.branchRename("feature/x", { oldName: "feat/x" });
await ctx.services.git.branchRename("feature/x", { oldName: "feat/x", force: true }); // -M
// Listar
await ctx.services.git.branchList({ all: true }); // git branch -a
await ctx.services.git.branchList({ remote: true, verbose: true }); // git branch -r -vv
await ctx.services.git.branchList({ merged: "main" }); // las ya fusionadas en main
await ctx.services.git.branchList({ noMerged: true, pattern: "feat*" });
await ctx.services.git.branchList({ contains: "abc123", sort: "-committerdate" });
// Actual y upstream
const current = await ctx.services.git.branchShowCurrent(); // git branch --show-current
await ctx.services.git.branchSetUpstream({ upstream: "origin/main" }); // git branch -u origin/main
await ctx.services.git.branchSetUpstream({ upstream: "origin/main", name: "dev" });
await ctx.services.git.branchUnsetUpstream(); // git branch --unset-upstreampull / fetch — con remote, rama y estrategia:
await ctx.services.git.pull(); // git pull
await ctx.services.git.pull({ remote: "origin", branch: "main", rebase: true });
await ctx.services.git.pull({ remote: "origin", branch: "main", ffOnly: true });
await ctx.services.git.pull({ prune: true, tags: true });
await ctx.services.git.fetch(); // git fetch --all (default)
await ctx.services.git.fetch({ remote: "origin", branch: "develop" });
await ctx.services.git.fetch({ depth: 1, tags: true });
await ctx.services.git.fetch({ prune: true });add / commit / push — add acepta un arreglo de archivos o "."; commit hace el add automáticamente si le pasás archivos:
// add
await ctx.services.git.add(".");
await ctx.services.git.add(["src/", "package.json"]);
await ctx.services.git.add(".", { all: true }); // git add -A .
await ctx.services.git.add(["src/"], { force: true }); // git add -f src/
// commit — si recibís archivos, los agrega solos antes de commitear
await ctx.services.git.commit("fix: corrección de login");
await ctx.services.git.commit("feat: api", ["src/api/**"], { amend: false });
await ctx.services.git.commit("feat: todo", { files: ".", allowEmpty: true });
await ctx.services.git.commit("wip", ".", { exec: { retry: 2 } });
await ctx.services.git.commit("release", { all: true, amend: true }); // git commit -a --amend
// push — remote y rama posicionales u opcionales
await ctx.services.git.push(); // git push
await ctx.services.git.push("origin"); // git push origin
await ctx.services.git.push("origin", "main"); // git push origin main
await ctx.services.git.push({ remote: "origin", branch: "main", setUpstream: true }); // -u
await ctx.services.git.push({ remote: "origin", branch: "main", force: true }); // --force
await ctx.services.git.push({ remote: "origin", branch: "main", forceWithLease: true });
await ctx.services.git.push({ tags: true }); // git push --tagstags:
await ctx.services.git.tag("v1.0.0"); // git tag v1.0.0
await ctx.services.git.tag("v1.0.0", { message: "release 1.0" }); // anotada
await ctx.services.git.tag("v1.0.0", { message: "release", force: true });
await ctx.services.git.tagDelete("v1.0.0"); // git tag -d v1.0.0
await ctx.services.git.tagList({ pattern: "v1.*", sort: "-creatordate" });merge / rebase:
await ctx.services.git.merge("develop");
await ctx.services.git.merge("develop", { noEdit: true });
await ctx.services.git.merge("main", { ffOnly: true }); // aborta si no es fast-forward
await ctx.services.git.merge({ abort: true }); // aborta el merge en conflicto
await ctx.services.git.rebase({ branch: "main" }); // git rebase main
await ctx.services.git.rebase({ branch: "dev", onto: "main" });
await ctx.services.git.rebase({ interactive: true });
await ctx.services.git.rebase({ abort: true }); // aborta el rebase en cursostatus / log / diff / show:
await ctx.services.git.status();
await ctx.services.git.status({ short: true, branch: true }); // git status -sb
await ctx.services.git.status({ porcelain: true }); // para scripting
await ctx.services.git.log({ maxCount: 10, oneline: true }); // git log --max-count 10 --oneline
await ctx.services.git.log({ since: "2 weeks ago", author: "miuser" });
await ctx.services.git.log({ graph: true, allBranches: true });
await ctx.services.git.diff({ stat: true }); // git diff --stat
await ctx.services.git.diff({ cached: true }); // staged
await ctx.services.git.diff({ nameOnly: true }); // solo archivos
await ctx.services.git.show("HEAD"); // git show HEAD
await ctx.services.git.show("abc123", { stat: true }); // git show --stat abc123remote / stash / reset / init / config / revParse:
// remote
await ctx.services.git.remote({ verbose: true }); // git remote -v
await ctx.services.git.remote({ show: "origin" }); // git remote show origin
await ctx.services.git.remoteAdd("upstream", "https://github.com/org/repo.git");
await ctx.services.git.remoteRemove("upstream");
await ctx.services.git.remoteSetUrl("origin", "[email protected]:org/repo.git");
// stash
await ctx.services.git.stashPush({ message: "wip", includeUntracked: true });
await ctx.services.git.stashPush({ keepIndex: true });
await ctx.services.git.stashList();
await ctx.services.git.stashPop(); // git stash pop
await ctx.services.git.stashPop({ index: 1 }); // git stash pop stash@{1}
await ctx.services.git.stashApply({ index: 0 });
await ctx.services.git.stashDrop({ index: 2 });
// reset / init / config
await ctx.services.git.reset({ mode: "hard", commit: "HEAD~1" }); // git reset --hard HEAD~1
await ctx.services.git.init({ initialBranch: "main" }); // git init -b main
await ctx.services.git.configSet("user.name", "Cat");
await ctx.services.git.configGet("user.name");
await ctx.services.git.configList();
// revParse — resuelve refs (default HEAD)
const sha = await ctx.services.git.revParse(); // git rev-parse HEAD
await ctx.services.git.revParse("HEAD~1");
await ctx.services.git.revParse({ short: true }); // SHA abreviado
await ctx.services.git.revParse({ abbrevRef: true }); // nombre de la rama actual
await ctx.services.git.revParse({ showTopLevel: true }); // raíz absoluta del repokubectl / oc: kubeconfig, namespace, retry/timeout, y espera cíclica del rollout
kubectl y oc aceptan { kubeconfig, namespace, exec } como último argumento en todos sus comandos (retrocompatible, sigue funcionando sin ese argumento — ver la sección anterior para el detalle de exec):
await ctx.services.kubectl.apply("deploy.yaml", { kubeconfig: "/etc/kube/prod.yaml", namespace: "prod" });
await ctx.services.kubectl.get("pods", "-o", "wide", { namespace: "staging", exec: { retry: 3 } });
await ctx.services.oc.login({ server: "https://api.cluster:6443", token, namespace: "prod", exec: { retry: 10 } });
await ctx.services.oc.apply("deploy.yaml", { namespace: "prod" });waitForDeployment — validación cíclica del rollout
Sondea el Deployment (o DeploymentConfig con oc + resourceType: "dc") hasta que:
- llega a estado exitoso (réplicas listas/actualizadas == deseadas) → resuelve con
{ status: "success", ... }, - se queda en estado Failed más de
failedGracePeriodsin recuperarse → lanzaDeploymentRolloutError, - supera
maxRestartsreinicios acumulados entre todos sus pods → lanzaDeploymentRolloutErrorde inmediato, sin esperar el grace period, - o se cumple el
timeoutglobal sin éxito → lanzaDeploymentRolloutError.
En los tres casos de fallo, el polling se detiene y el error se re-lanza — listo para que ctx.run(...) lo capture y lo reporte automáticamente vía ctx.notifier (el error ya trae command: "kubectl" / command: "oc", así que classifiers.byCommand({ kubectl: "kubernetes" }) lo clasifica sin configuración extra).
await ctx.run("deploy-api", async () => {
await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
return ctx.services.kubectl.waitForDeployment({
deployment: "api",
namespace: "prod",
timeout: 5 * 60 * 1000, // 5 min totales antes de abortar
pollInterval: 5000, // chequea cada 5s
failedGracePeriod: 30_000, // si entra en Failed, espera 30s a que se recupere
maxRestarts: 5 // si supera 5 reinicios acumulados, aborta ya
});
});waitForDeploymentGroup — validar todas las instancias de un mismo despliegue GitOps
Pensada para el caso de GitOps donde un mismo repo termina desplegado como varios Deployments (una instancia por región/config/cliente, etc.), todos marcados con un label común:
metadata:
labels:
deployment-group: repository-14Descubre todas las instancias que compartan ese label y corre waitForDeployment sobre cada una en paralelo, con el mismo timeout/pollInterval/failedGracePeriod/maxRestarts para todas:
await ctx.run("deploy-repo-14", () =>
ctx.services.kubectl.waitForDeploymentGroup({
label: { "deployment-group": "repository-14" }, // o el string ya armado: "deployment-group=repository-14"
namespace: "prod",
timeout: 5 * 60 * 1000,
pollInterval: 5000,
failedGracePeriod: 30_000,
maxRestarts: 5
})
);- Si todas llegan a estado exitoso → resuelve con
{ status: "success", deployments: [...] }(el detalle de cada una). - Si alguna falla → espera a que las demás terminen, y lanza
DeploymentGroupRolloutErrorconsucceeded(nombres que sí llegaron) yfailed(nombre + status + mensaje de cada una que no). - Si el label no matchea ningún deployment, también lanza
DeploymentGroupRolloutError(grupo vacío = error, no éxito silencioso).
Con oc, ambas funciones aceptan resourceType: "dc" para apuntar a DeploymentConfig clásico en vez de Deployment nativo (default: "deployment").
Logging commands de Azure Pipelines (ctx.services.azdo)
ctx.services.azdo.setVariable("BUILD_TAG", "v1.2.3");
ctx.services.azdo.logWarning("El caché de npm no se encontró, se reconstruye desde cero.");
ctx.services.azdo.group("Build");
// ... pasos ...
ctx.services.azdo.endGroup();Cliente HTTP de Azure DevOps REST API (AzureDevOpsApi)
Un cliente tipado para la REST API de Azure DevOps (Repos, Builds, Pipelines, Work Items). Usa autenticación Basic con PAT, project-level por defecto, y soporta overrides por llamada.
Configuración
import { AzureDevOpsApi } from "catops-cli";
const azdo = new AzureDevOpsApi().configure({
baseUrl: "https://dev.azure.com/miorg",
pat: process.env.AZDO_PAT,
project: "mi-proyecto", // project por defecto (opcional)
apiVersion: "7.1", // default
// insecureTls: true // https con certificado autofirmado o CA interna
});Se puede pasar un agente HTTP existente con agent para reusar interceptores/configuración:
const azdo = new AzureDevOpsApi().configure({
baseUrl: "https://dev.azure.com/miorg",
pat: process.env.AZDO_PAT,
agent: ctx.services.http.agent("azdo") // o un HttpService nuevo
});Interceptores del agente HTTP
AzureDevOpsApi integra su propio agente HTTP (se crea perezosamente en la primera petición) y permite registrar interceptores de request/response sobre él, igual que en ctx.services.http:
const azdo = new AzureDevOpsApi().configure({
baseUrl: "https://dev.azure.com/miorg",
pat: process.env.AZDO_PAT,
project: "mi-proyecto",
// Se registran sobre el agente activo (interno o inyectado)
requestInterceptors: [
ctx => { ctx.request.headers["x-correlation-id"] = crypto.randomUUID(); }
],
responseInterceptors: [
ctx => { console.log(`← ${ctx.response.status} ${ctx.request.url}`); }
]
});También hay métodos chainable para gestionarlos en cualquier momento — si el agente interno aún no existe, quedan en cola y se aplican al crearlo:
azdo
.addRequestInterceptor(ctx => { /* tracing, headers extra, ... */ })
.addResponseInterceptor(ctx => { /* logging, métricas, ... */ });
// Eliminar uno específico o todos
azdo.removeRequestInterceptor(myInterceptor);
azdo.clearRequestInterceptors();
azdo.clearResponseInterceptors();Detalles a tener en cuenta:
- Si inyectas un
agentexterno (HttpService), los interceptores se registran en ese agente, así que también afectan al resto de consumidores que compartan la instancia. - El auth Basic del PAT se aplica siempre, independientemente de los interceptores.
- Un agente custom que no exponga los métodos de interceptores lanza un error descriptivo en vez de ignorarlos silenciosamente.
Conexiones TLS inseguras (insecureTls)
Si tu Azure DevOps Server usa HTTPS con un certificado autofirmado o una CA corporativa que tu máquina no confía, activa insecureTls para que el cliente acepte el certificado sin validarlo. No requiere instalar ni configurar ningún certificado:
const azdo = new AzureDevOpsApi().configure({
baseUrl: "https://azdos.internal:8080/miorg",
pat: process.env.AZDO_PAT,
insecureTls: true // acepta cualquier certificado TLS del servidor
});La opción se propaga al agente que sea:
- Agente interno: se crea ya configurado en la primera petición (el agente es perezoso).
- Agente externo (
agent: ctx.services.http.agent(...)): se configura al llamarconfigure(), así que el resto de consumidores de esa misma instanciaHttpServicetambién quedan en modo inseguro. - Por llamada: tiene prioridad sobre la configuración global, igual que los demás overrides:
await azdo.listRepos({ insecureTls: true }); // solo esta llamada es insegura
const err = await azdo.listProjects().catch(e => e); // sin override -> falla el handshakeazdo.isInsecureTls() consulta el estado actual.
Importante: desactivar la validación TLS te expone a ataques man-in-the-middle. Úsalo solo contra servidores de confianza dentro de tu red (Azure DevOps Server on-premise, proxies corporativos, etc.).
Proyectos
const res = await azdo.listProjects();
const proj = await azdo.getProject("mi-proyecto");Git Repos
const repos = await azdo.listRepos();
const repo = await azdo.getRepo("frontend");Archivos
const file = await azdo.fileExists("mi-proyecto", "frontend", "main", "/src/index.ts");
// → { exists: true, data } si existe · { exists: false } si no
await azdo.createFileInRepo("mi-proyecto", "frontend", "main", "README.md", "/", "$README:TEMPLATE");
// crea solo si no existe; usa overrideFileInRepo() para sobrescribir
#### Basing (`mergeWithTemplate`)
`mergeWithTemplate()` basa un repositorio destino en un template: descarga
origen y destino, copia los archivos del template sobre el destino — respetando
`overwrite`, `deleteExtra` y `filters.omit`—, aplica opcionalmente una
transformación y publica el resultado en un único commit. Si no hay
diferencias, no hace push.
```typescript
await azdo.mergeWithTemplate({
project: "mi-proyecto",
repository: "frontend",
targetBranch: "main",
template: { project: "shared", repository: "camaleon", branch: "main" },
filters: { omit: ["**/azure-pipelines*.y*", "infra"] }, // no copiar pipelines ni la carpeta infra
overwrite: true, // true por defecto: pisa archivos existentes
deleteExtra: false, // false por defecto: no borra archivos que el template no tiene
transform: (file) => {
if (file.path === "/azure-pipelines.yml") {
file.content = Buffer.from("name: " + repo);
}
},
comment: "feat: sincronizar con camaleon"
});
// → { message: "Applied N changes", changes: [...] } | { message: "No changes", changes: [] }Los patrones de filters.omit soportan globs tipo gitignore:
* no cruza carpetas, un globstar (**) cruza cualquier profundidad y,
seguido de /, cubre también la raíz. Los nombres de carpeta ("infra")
omiten la carpeta y todo su contenido.
Branches
const branches = await azdo.listBranches("frontend");
const exists = await azdo.branchExists("frontend", "feature/login"); // → exists.body.exists → true | false
const branch = await azdo.getBranch("frontend", "feature/login");
// → branch.body.aheadCount, .behindCount, .commit.commitId, ...Commits
const commits = await azdo.listCommits("frontend", { branch: "main", top: 10 });
const commit = await azdo.getCommit("frontend", "abc123");Pull Requests
const prs = await azdo.listPullRequests("frontend", { status: "active" });
const pr = await azdo.getPullRequest("frontend", 42);
const newPr = await azdo.createPullRequest("frontend", {
sourceRefName: "refs/heads/feature/login",
targetRefName: "refs/heads/main",
title: "feat: login",
description: "Agrega pantalla de login"
});
// Si ya existe un PR activo con la misma fuente/destino, no lanza el error 409:
// devuelve el PR existente con newPr.body.exists === true.Build Definitions & Builds
const defs = await azdo.listBuildDefinitions();
const def = await azdo.getBuildDefinition(1);
const builds = await azdo.listBuilds({ definitionId: 5, top: 10 });
const build = await azdo.getBuild(100);
const queued = await azdo.queueBuild(5, { branch: "main", parameters: { config: "Release" } });
// → queued.body.id, .status, .buildNumberPipelines
const pipelines = await azdo.listPipelines();
const pipeline = await azdo.getPipeline(10);
const run = await azdo.runPipeline(10, {
branch: "main",
variables: { ENV: { value: "production" } }
});Work Items
const wi = await azdo.getWorkItem(123, { fields: ["System.Title", "System.State"] });
const query = await azdo.queryWorkItems(
"SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active'"
);
// → query.body.workItems → [{ id, url }, ...]Environments
const env = await azdo.environmentExists("mi-proyecto", "staging");
// → { exists: true, data } si existe · { exists: false } si no
const created = await azdo.createEnvironment("mi-proyecto", "staging", "Staging environment");
// si ya existe un environment con ese nombre, devuelve el existente con
// created.body.exists === true en lugar de lanzar el 409 de AzureCreación idempotente: todos los métodos
create*validan existencia antes de crear y devuelven el recurso existente conexists: trueen lugar de lanzar el error 409 de Azure (y si Azure responde 409 por un race, recuperan el existente). Esto aplica acreatePullRequest(),createEnvironment(),createEnvironmentWithApprovals(),createEnvironmentApproval(),createWebhook(),createPipeline(),createVariableGroup(),createBuildFolder(),createPipelinesFolder(),createFileInRepo(),addAgentPoolToProject(),overrideAgentPoolToProject()yshareServiceConnection().
Overrides por llamada
Cada método acepta un objeto de opciones con project, apiVersion, query, insecureTls y exec:
// project override → usa otro proyecto solo para esta llamada
await azdo.listRepos({ project: "otro-proyecto" });
// organization-level → omite el project de la URL
await azdo.listProjects({ organizationLevel: true });
// TLS inseguro solo para esta llamada (certificado autofirmado / CA interna)
await azdo.listBuilds({ definitionId: 5, insecureTls: true });
// dry-run — solo loguea la petición HTTP sin enviarla
await azdo.listRepos({ exec: { dryRun: true } });Los tipos de respuesta completos (AzdoProject, AzdoGitRepository, AzdoBuild, etc.) se exportan desde la raíz del paquete para tipado en TypeScript.
Errores legibles (AzdoApiError)
La REST API de Azure DevOps devuelve errores con cuerpo JSON rico (message, typeKey, errorCode, innerException, ...), pero el detalle se perdía en un genérico HTTP 403 responded with 403. Cualquier error HTTP de AzureDevOpsApi se lanza ahora como AzdoApiError, que parsea ese cuerpo y lo presenta de forma legible:
try {
await azdo.pushChanges({ project: "mi-proyecto", repository: "api", branch: "main", comment: "x", changes: [] });
} catch (err) {
console.log(String(err));
}✖ Azure DevOps permisos — HTTP 403 Forbidden
Petición : POST https://dev.azure.com/miorg/mi-proyecto/_apis/git/repositories/api/pushes?api-version=7.1
Mensaje : TF401027: You need the Git 'ForcePush' permission to perform this operation.
Detalle : código=TF401027 · tipo=RequestNotAuthorizedException · errorCode=0 · eventId=3000
Qué significa: Falta el permiso 'ForcePush' de Git.
Sugerencias:
• Un admin debe conceder 'Force push (rewrite history)' en Repos > Security, o evita reescribir historial.
• Pide al administrador los permisos necesarios sobre el proyecto/repo/pipeline.
• Si el PAT tiene restricciones de scope, amplíalo (ej. Code Read & Write, Build).Qué aporta cada pieza:
| API | Uso |
|---|---|
| err.message | Resumen de una línea: [AZDO] HTTP 403 Forbidden — POST ... — TF401027: You need... |
| String(err) / formatAzdoError(err) | Bloque multi-línea legible (el del ejemplo) |
| err.detail | Detalle estructurado: { status, kind, serverMessage, code, typeKey, errorCode, eventId, innerMessages, hints, ... } |
| parseAzdoError(err) | Parsea cualquier error con forma HTTP y devuelve el detalle (o null) |
| formatAzdoError(err) | Devuelve el texto legible; acepta el error o el detalle ya parseado |
Notas:
- El error conserva
status,headers,bodyyrequestdel error HTTP original, así que los checks tipoerr.status === 404siguen funcionando igual que siempre. Los helpers de existencia (branchExists(),repoExists(),fileExists(),webhookExists(),environmentExists(),pipelineExists()) devuelven una propiedadexistsen lugar de lanzar:branchExists()la expone enbody.exists, y el resto enresult.exists(conresult.datacuando existe). - Detecta códigos TF/VS conocidos (
TF400813,TF401019,TF401027,TF401320,VS800075, ...) con explicación y sugerencia específica; los desconocidos caen a sugerencias por status HTTP (401 → PAT expirado/org incorrecta, 404 → puede ser falta de permisos, 409 →oldObjectIddesactualizado, 429 → throttling, etc.). - Aplana la cadena
innerExceptiondel servidor endetail.innerMessages. - Soporta cuerpos no-JSON (proxies que devuelven HTML) y clasifica errores de red/timeout (
status: 0) comoRed / Timeout.
Motor de pipelines declarativo (ctx.services.pipeline)
Un motor de ejecución de pipelines con stages → jobs → tasks, dependencias entre entidades, acceso a resultados jerárquico por contexto (stage.job.task), tipos de task extensibles, y registro global de pipelines reutilizables.
Estructura flexible
La estructura es completamente opcional en cada nivel — podés definir un pipeline con stages completos, solo jobs, o solo tasks:
// Pipeline completo: stages → jobs → tasks
const fullPipeline = new Pipeline("deploy");
fullPipeline
.stage("build")
.job("compile")
.task("install-deps", { exec: async (ctx) => { /* ... */ } })
.task("compile", { exec: async (ctx) => { /* ... */ } })
.stage("test")
.job("unit-tests")
.task("run-tests", { exec: async (ctx) => { /* ... */ } })
.stage("deploy")
.job("push")
.task("upload", { exec: async (ctx, results) => { /* results.build.compile */ }, depends: ["build.compile"] });
await fullPipeline.run(ctx);// Solo tasks (sin stages ni jobs) — acceso flat dentro del mismo job
const simple = new Pipeline("simple", {
tasks: {
build: { exec: async (ctx) => { await ctx.services.docker.build({...}); } },
test: { exec: async (ctx) => { await ctx.services.shell.exec("npm", "test"); } },
push: { exec: async (ctx, results) => { await ctx.services.docker.push({...}); }, depends: ["build"] }
}
});
await simple.run(ctx);// Solo jobs (sin stages)
const jobsOnly = new Pipeline("ci", {
jobs: {
build: { tasks: { compile: { exec: () => "ok" } } },
test: { tasks: { unit: { exec: () => "pass" } } }
}
});
await jobsOnly.run(ctx);Dependencias
Cada task, job, o stage puede declarar depends: ["nombre"] — el motor resuelve el orden automáticamente:
const pipeline = new Pipeline("ordered");
pipeline
.stage("build")
.job("compile")
.task("install", { exec: () => "deps installed" })
.task("compile", { exec: () => "compiled", depends: ["install"] })
.stage("test")
.job("unit")
.task("test", {
exec: (_, results) => `testing ${results.build.compile.compile}`,
depends: ["build.compile.compile"] // cross-stage: stage.job.task
})
.stage("deploy")
.job("push")
.task("upload", {
exec: (_, results) => `deployed ${results.unit.test}`,
depends: ["unit.test"] // cross-job mismo stage: job.task
});
await pipeline.run(ctx);Las dependencias son cross-level — una task en un stage puede depender de una task de otro stage usando paths con dot notation:
// Cross-stage: deploy necesita un resultado de build
.task("upload", {
exec: (_, results) => `uploaded ${results.build.compile.artifact}`,
depends: ["build.compile.artifact"] // stage.job.task
})
// Cross-job mismo stage: test necesita algo de build
.task("verify", {
exec: (_, results) => `verified ${results.compile.output}`,
depends: ["compile.output"] // job.task
})
// Mismo job: dependencia directa por nombre
.task("deploy", {
exec: (_, results) => `deploy-${results.build}`,
depends: ["build"] // task (flat)
})Los stages y jobs se ejecutan en orden secuencial por defecto (definition order).
Acceso a resultados
Los resultados se organizan jerárquicamente: stage → job → task. Cada callback recibe (ctx, results) donde results es un proxy que resuelve por contexto:
pipeline
.stage("build")
.job("compile")
.task("compile", { exec: () => "artifact-v1" })
.stage("deploy")
.job("push")
.task("upload", {
exec: (_, results) => {
// Mismo job: acceso directo por nombre de task
// results.myTask = "valor"
// Mismo stage, otro job: job.task
// results.compile.compile = "artifact-v1"
// Otro stage: stage.job.task
// results.build.compile = { compile: "artifact-v1" }
return `uploaded ${results.build.compile.compile}`;
},
depends: ["build.compile.compile"]
});Reglas de resolución:
| Contexto | Sintaxis | Ejemplo |
|---|---|---|
| Misma task (otro task en el mismo job) | results.<task> | results.build |
| Mismo stage, otro job | results.<job>.<task> | results.compile.output |
| Otro stage | results.<stage>.<job>.<task> | results.build.compile.artifact |
El proxy intenta resolver en este orden: flat → job path → stage path. El primer match gana.
Cada entidad también expone sus resultados vía .results (PipelineTask), .results (PipelineJob — mapa anidado), y .getResults() (PipelineStage/Pipeline).
Registry global de pipelines
ctx.services.pipeline es un registry — definís pipelines al inicio y los ejecutás por nombre:
// Definir pipelines globales
ctx.services.pipeline.define("build-and-test", {
tasks: {
build: { exec: async (ctx) => { await ctx.services.docker.build({...}); } },
test: { exec: async (ctx) => { await ctx.services.shell.exec("npm", "test"); } }
}
});
ctx.services.pipeline.define("deploy-prod", {
stages: {
build: { jobs: { compile: { tasks: { step: { exec: async (ctx) => { /* ... */ } } } } } },
deploy: { jobs: { push: { tasks: { step: { exec: async (ctx) => { /* ... */ } } } } } }
}
});
// Ejecutar por nombre
await ctx.services.pipeline.run("build-and-test", ctx);
await ctx.services.pipeline.run("deploy-prod", ctx);Gestión de pipelines:
// Listar todos los pipelines registrados
ctx.services.pipeline.list(); // ["build-and-test", "deploy-prod"]
// Obtener un pipeline para modificarlo
const p = ctx.services.pipeline.get("build-and-test");
// Eliminar un pipeline
ctx.services.pipeline.remove("deploy-prod");Context access
Cada task recibe ctx como primer argumento — acceso completo a servicios, flags, params, logger, etc.:
pipeline.stage("build").job("compile").task("step1", {
exec: async (ctx) => {
ctx.logger.info(`Building with env: ${ctx.params.env}`);
await ctx.services.docker.build({ image: `app:${ctx.params.version}` });
ctx.set("image", `app:${ctx.params.version}`);
}
});PipelineRunResult
pipeline.run() devuelve un objeto con status, results (jerárquico), error, y duration:
const result = await pipeline.run(ctx);
if (result.status === "success") {
ctx.logger.success(`Pipeline completed in ${result.duration}ms`);
console.log(result.results);
// {
// build: { // stage
// compile: { // job
// step1: "compiled" // task
// }
// },
// deploy: {
// push: {
// upload: "uploaded-v1"
// }
// }
// }
} else {
ctx.logger.error(`Pipeline failed: ${result.error.message}`);
}Task types
El tipo de ejecución se determina por la propiedad presente en la config. No hay campo type — la propiedad misma es el tipo:
// exec = callback (único type actualmente)
.task("compile", {
exec: async (ctx, results) => { /* ... */ }
})Para agregar un nuevo tipo en el futuro, solo se agrega la propiedad al config y el case en _execute:
// Futuro: shell
.task("test", {
shell: { command: "npm", args: ["test"] }
})
// Futuro: docker
.task("build", {
docker: { action: "build", image: "app:v1" }
})El engine detecta "exec" in config, "shell" in config, etc. y ejecuta la estrategia correspondiente.
Reset y reutilización
Los pipelines son reutilizables — reset() restaura el estado de todas las entidades:
const pipeline = new Pipeline("reusable", {
tasks: { step: { exec: () => ++count } }
});
await pipeline.run(ctx); // count = 1
pipeline.reset();
await pipeline.run(ctx); // count = 2Servicio HTTP con interceptores (ctx.services.http)
Un cliente HTTP completo con soporte para interceptors de request y response, configurable como servicio global o como instancias independientes.
Uso básico
// GET
const res = await ctx.services.http.get("https://api.example.com/users");
console.log(res.body); // { users: [...] }
// POST
const res = await ctx.services.http.post("https://api.example.com/users", {
name: "John",
email: "[email protected]"
});
// PUT / PATCH / DELETE
await ctx.services.http.put("/users/1", { name: "Jane" });
await ctx.services.http.patch("/users/1", { email: "[email protected]" });
await ctx.services.http.delete("/users/1");Configuración global
ctx.services.http.configure({
baseUrl: "https://api.example.com",
defaultHeaders: {
"Authorization": `Bearer ${process.env.API_TOKEN}`,
"Accept": "application/json"
},
defaultTimeout: 10000, // 10 segundos
insecureTls: false // true = acepta cualquier certificado TLS del servidor
});
// Ahora las peticiones son relativas
await ctx.services.http.get("/users"); // -> GET https://api.example.com/users
await ctx.services.http.post("/users", data); // -> POST https://api.example.com/usersQuery params
await ctx.services.http.get("/search", {
query: { q: "hello", page: 1, active: true }
});
// -> GET /search?q=hello&page=1&active=trueErrores HTTP
Las respuestas con status 4xx/5xx lanzan un error con metadata completa:
try {
await ctx.services.http.get("/missing");
} catch (err) {
console.log(err.status); // 404
console.log(err.body); // { error: "not found" }
console.log(err.headers); // { ... }
console.log(err.request); // { url, method, headers, ... }
}Interceptores de request
Los interceptors se ejecutan antes de cada petición. Pueden mutar el request (headers, auth, logging) o abortarlo:
// Agregar token de auth a todas las peticiones
ctx.services.http.addRequestInterceptor((ctx) => {
ctx.request.headers["Authorization"] = `Bearer ${process.env.TOKEN}`;
});
// Logging de cada petición
ctx.services.http.addRequestInterceptor((ctx) => {
console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
});
// Abortar peticiones a ciertos dominios
ctx.services.http.addRequestInterceptor((ctx) => {
if (ctx.request.url.includes("internal")) {
ctx.abort("blocked by policy");
}
});Los interceptors se ejecutan en orden. Si uno aborta, se lanza un error y no se envía la petición.
Interceptores de response
Los interceptors se ejecutan después de cada respuesta. Pueden transformar el body, loguear, o hacer retry:
// Logging de cada respuesta
ctx.services.http.addResponseInterceptor((ctx) => {
console.log(`← ${ctx.response.status} ${ctx.request.url}`);
});
// Transformar la respuesta
ctx.services.http.addResponseInterceptor((ctx) => {
if (ctx.response.body?.data) {
ctx.response.body = ctx.response.body.data;
}
});Gestión de interceptors
// Agregar
const myInterceptor = (ctx) => { /* ... */ };
ctx.services.http.addRequestInterceptor(myInterceptor);
ctx.services.http.addResponseInterceptor(myInterceptor);
// Eliminar uno específico
ctx.services.http.removeRequestInterceptor(myInterceptor);
ctx.services.http.removeResponseInterceptor(myInterceptor);
// Limpiar todos
ctx.services.http.clearRequestInterceptors();
ctx.services.http.clearResponseInterceptors();Agentes HTTP nombrados
ctx.services.http es un registry que gestiona agentes HTTP. Cada agente tiene su propia configuración, interceptores y defaults aislados.
Default agent — directamente en ctx.services.http:
await ctx.services.http.get("/users");
await ctx.services.http.post("/users", data);Agentes nombrados — para APIs distintas con configuración propia:
// Crear agentes a partir de parámetros — el HttpService interno se crea solo
ctx.services.http.createAgent("github", {
baseUrl: "https://api.github.com",
defaultHeaders: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
});
ctx.services.http.createAgent("internal", {
baseUrl: "https://internal.mycompany.com/api",
defaultHeaders: { "X-API-Key": process.env.INTERNAL_KEY }
});
// Usar por nombre — cada uno tiene interceptores y config aislados
await ctx.services.http.agent("github").get("/repos/org/repo");
await ctx.services.http.agent("internal").get("/services/status");Gestión de agentes:
// Listar todos los agentes registrados
ctx.services.http.listAgents(); // ["github", "internal"]
// Eliminar un agente
ctx.services.http.removeAgent("github");
// Reemplazar un agente existente (mismo nombre)
ctx.services.http.createAgent("internal", { baseUrl: "https://nueva-api.mycompany.com" });Ejemplo completo — interceptores por agente:
ctx.services.http.createAgent("github", {
baseUrl: "https://api.github.com",
defaultHeaders: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
requestInterceptors: [
(ctx) => {
ctx.request.headers["Accept"] = "application/vnd.github.v3+json";
}
],
responseInterceptors: [
(ctx) => {
if (ctx.response.body?.data) {
ctx.response.body = ctx.response.body.data;
}
}
]
});
ctx.services.http.createAgent("internal", {
baseUrl: "https://internal.mycompany.com/api",
requestInterceptors: [
async (ctx) => {
const token = await fetchTokenFromVault();
ctx.request.headers["Authorization"] = `Bearer ${token}`;
}
]
});
// Cada agente usa sus propios interceptores
await ctx.services.http.agent("github").get("/repos/org/repo");
await ctx.services.http.agent("internal").get("/services/status");Opciones por llamada
await ctx.services.http.get("/slow-endpoint", {
timeout: 30000, // override del default
headers: { "X-Request-Id": "123" },
query: { includeDeleted: false }
});
await ctx.services.http.post("/data", payload, {
exec: { dryRun: true } // soporte dry-run
});TLS inseguro (insecureTls)
Para servidores con certificado autofirmado o firmado por una CA interna que tu máquina no confía (Azure DevOps Server on-premise, proxies corporativos, ...), activa insecureTls y el agente omitirá la validación del certificado TLS. No requiere gestionar ningún certificado:
// Global para el agente (y para cada agente nombrado que lo declare en su config)
ctx.services.http.configure({ baseUrl: "https://azdos.internal:8080", insecureTls: true });
// O solo para una llamada puntual — prioridad sobre el default del agente
await ctx.services.http.get("https://azdos.internal:8080/ping", { insecureTls: true });
// Agentes nombrados también aceptan la opción en createAgent()
ctx.services.http.createAgent("onprem", {
baseUrl: "https://azdos.internal:8080",
insecureTls: true
});
// Consultar el estado de un agente
ctx.services.http.agent("onprem").isInsecureTls(); // trueSin insecureTls, un certificado no confiable falla con un error descriptivo (status: 0, causa real en el mensaje — no un genérico "fetch failed").
Importante: desactivar la validación TLS te expone a ataques man-in-the-middle. Úsalo solo contra servidores de confianza dentro de tu red.
Interceptors con async/await
Los interceptors soportan operaciones asíncronas (base de datos, llamadas a servicios, etc.):
ctx.services.http.addRequestInterceptor(async (ctx) => {
const token = await fetchTokenFromVault();
ctx.request.headers["Authorization"] = `Bearer ${token}`;
});Notificaciones: clasificar errores por área de TI, personalizar el mensaje, y enviarlos
ctx.notifier tiene tres responsabilidades independientes:
classify()— decide a qué área de TI pertenece un error (para elegir a qué canal mandarlo).describeError()— decide el mensaje a reportar (reemplaza el stderr/stdout crudo por algo humano).channel()/onSuccess()— a qué senders se manda cada área.
const { classifiers, messages, senders } = require("catops-cli");
// 1. ¿A qué área de TI pertenece este error?
ctx.notifier.classify(classifiers.byCommand({
docker: "containers",
kubectl: "kubernetes",
oc: "kubernetes",
terraform: "infra",
ansible: "infra",
git: "scm",
argocd: "cd-pipeline",
tkn: "cd-pipeline",
az: "cloud-azure"
}));
// también podés clasificar por el texto del error:
ctx.notifier.classify(classifiers.byPattern([
[/permission denied|unauthorized/i, "security"],
[/timeout|ECONNREFUSED/i, "networking"],
[/no space left|ENOSPC/i, "infra"]
]));
// 2. ¿qué mensaje se reporta? (opcional — sin esto, se usa el stderr/stdout crudo)
ctx.notifier.describeError(messages.byRule([
{
command: "docker", args: "push", pattern: /500 Internal Server Error/,
message: "Se ha reportado a infraestructura: falta de espacio en el registry"
},
{
command: "kubectl", pattern: /500/,
message: "El API server de Kubernetes devolvió 500, reintenta en unos minutos"
},
{
command: "terraform", pattern: /500/,
message: (error, ctx) => `Backend remoto de Terraform no respondió (env: ${ctx.params.env ?? "?"})`
}
]));
// 3. ¿a dónde se manda cada área?
ctx.notifier.channel("kubernetes", senders.webhook({ url: process.env.TEAMS_WEBHOOK }));
ctx.notifier.channel("security", senders.http({ url: "https://security.miempresa.com/incidents" }));
ctx.notifier.cha