npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.env está sincronizado con shell.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 AzdoApiError legible 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). Incluye ctx.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-cli

Opció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>.tgz

Opció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-cli

Opción D — como dependencia de Git (monorepo o repo privado, sin registro npm):

npm install git+https://github.com/tu-org/catops-cli.git

Cualquiera de las 4 deja disponibles dos cosas en el proyecto consumidor:

  1. 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");
  2. El binario: npx catops-cli (o catops-cli si 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.js

Uso 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=prod

catops-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 flags

Uso 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-cli

Si 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 recibe

Para 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 defecto

Shell 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), exec va 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 });
  • kubectl y oc combinan exec en el mismo objeto que ya usás para kubeconfig/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=15000

Servicios 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-upstream

pull / 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 --tags

tags:

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 curso

status / 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 abc123

remote / 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 repo

kubectl / 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 failedGracePeriod sin recuperarse → lanza DeploymentRolloutError,
  • supera maxRestarts reinicios acumulados entre todos sus pods → lanza DeploymentRolloutError de inmediato, sin esperar el grace period,
  • o se cumple el timeout global sin éxito → lanza DeploymentRolloutError.

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-14

Descubre 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 DeploymentGroupRolloutError con succeeded (nombres que sí llegaron) y failed (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 agent externo (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 llamar configure(), así que el resto de consumidores de esa misma instancia HttpService tambié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 handshake

azdo.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, .buildNumber

Pipelines

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 Azure

Creación idempotente: todos los métodos create* validan existencia antes de crear y devuelven el recurso existente con exists: true en lugar de lanzar el error 409 de Azure (y si Azure responde 409 por un race, recuperan el existente). Esto aplica a createPullRequest(), createEnvironment(), createEnvironmentWithApprovals(), createEnvironmentApproval(), createWebhook(), createPipeline(), createVariableGroup(), createBuildFolder(), createPipelinesFolder(), createFileInRepo(), addAgentPoolToProject(), overrideAgentPoolToProject() y shareServiceConnection().

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, body y request del error HTTP original, así que los checks tipo err.status === 404 siguen funcionando igual que siempre. Los helpers de existencia (branchExists(), repoExists(), fileExists(), webhookExists(), environmentExists(), pipelineExists()) devuelven una propiedad exists en lugar de lanzar: branchExists() la expone en body.exists, y el resto en result.exists (con result.data cuando 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 → oldObjectId desactualizado, 429 → throttling, etc.).
  • Aplana la cadena innerException del servidor en detail.innerMessages.
  • Soporta cuerpos no-JSON (proxies que devuelven HTML) y clasifica errores de red/timeout (status: 0) como Red / 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 = 2

Servicio 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/users

Query params

await ctx.services.http.get("/search", {
    query: { q: "hello", page: 1, active: true }
});
// -> GET /search?q=hello&page=1&active=true

Errores 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(); // true

Sin 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:

  1. classify() — decide a qué área de TI pertenece un error (para elegir a qué canal mandarlo).
  2. describeError() — decide el mensaje a reportar (reemplaza el stderr/stdout crudo por algo humano).
  3. 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