@cat-indev/catops-cli 0.0.1-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1254 -0
  3. package/dist/bin/devops-cli.d.ts +2 -0
  4. package/dist/bin/devops-cli.js +132 -0
  5. package/dist/bin/devops-cli.js.map +1 -0
  6. package/dist/core/Context.d.ts +56 -0
  7. package/dist/core/Context.js +197 -0
  8. package/dist/core/Context.js.map +1 -0
  9. package/dist/core/Menu.d.ts +23 -0
  10. package/dist/core/Menu.js +160 -0
  11. package/dist/core/Menu.js.map +1 -0
  12. package/dist/core/Notifier.d.ts +42 -0
  13. package/dist/core/Notifier.js +128 -0
  14. package/dist/core/Notifier.js.map +1 -0
  15. package/dist/core/classifiers.d.ts +45 -0
  16. package/dist/core/classifiers.js +90 -0
  17. package/dist/core/classifiers.js.map +1 -0
  18. package/dist/core/logger.d.ts +8 -0
  19. package/dist/core/logger.js +37 -0
  20. package/dist/core/logger.js.map +1 -0
  21. package/dist/core/messages.d.ts +102 -0
  22. package/dist/core/messages.js +163 -0
  23. package/dist/core/messages.js.map +1 -0
  24. package/dist/core/prompt.d.ts +10 -0
  25. package/dist/core/prompt.js +87 -0
  26. package/dist/core/prompt.js.map +1 -0
  27. package/dist/core/senders.d.ts +27 -0
  28. package/dist/core/senders.js +109 -0
  29. package/dist/core/senders.js.map +1 -0
  30. package/dist/core/types.d.ts +135 -0
  31. package/dist/core/types.js +33 -0
  32. package/dist/core/types.js.map +1 -0
  33. package/dist/index.d.ts +20 -0
  34. package/dist/index.js +66 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/services/ansible.d.ts +12 -0
  37. package/dist/services/ansible.js +35 -0
  38. package/dist/services/ansible.js.map +1 -0
  39. package/dist/services/archive.d.ts +4 -0
  40. package/dist/services/archive.js +16 -0
  41. package/dist/services/archive.js.map +1 -0
  42. package/dist/services/argocd.d.ts +23 -0
  43. package/dist/services/argocd.js +48 -0
  44. package/dist/services/argocd.js.map +1 -0
  45. package/dist/services/az.d.ts +38 -0
  46. package/dist/services/az.js +51 -0
  47. package/dist/services/az.js.map +1 -0
  48. package/dist/services/azdo-api.d.ts +213 -0
  49. package/dist/services/azdo-api.js +247 -0
  50. package/dist/services/azdo-api.js.map +1 -0
  51. package/dist/services/azdo.d.ts +13 -0
  52. package/dist/services/azdo.js +57 -0
  53. package/dist/services/azdo.js.map +1 -0
  54. package/dist/services/docker.d.ts +21 -0
  55. package/dist/services/docker.js +36 -0
  56. package/dist/services/docker.js.map +1 -0
  57. package/dist/services/git.d.ts +9 -0
  58. package/dist/services/git.js +36 -0
  59. package/dist/services/git.js.map +1 -0
  60. package/dist/services/helm.d.ts +4 -0
  61. package/dist/services/helm.js +24 -0
  62. package/dist/services/helm.js.map +1 -0
  63. package/dist/services/http-types.d.ts +57 -0
  64. package/dist/services/http-types.js +3 -0
  65. package/dist/services/http-types.js.map +1 -0
  66. package/dist/services/http.d.ts +64 -0
  67. package/dist/services/http.js +288 -0
  68. package/dist/services/http.js.map +1 -0
  69. package/dist/services/index.d.ts +37 -0
  70. package/dist/services/index.js +88 -0
  71. package/dist/services/index.js.map +1 -0
  72. package/dist/services/kubectl.d.ts +137 -0
  73. package/dist/services/kubectl.js +271 -0
  74. package/dist/services/kubectl.js.map +1 -0
  75. package/dist/services/npm.d.ts +5 -0
  76. package/dist/services/npm.js +20 -0
  77. package/dist/services/npm.js.map +1 -0
  78. package/dist/services/oc.d.ts +156 -0
  79. package/dist/services/oc.js +309 -0
  80. package/dist/services/oc.js.map +1 -0
  81. package/dist/services/pipeline.d.ts +116 -0
  82. package/dist/services/pipeline.js +474 -0
  83. package/dist/services/pipeline.js.map +1 -0
  84. package/dist/services/shell.d.ts +28 -0
  85. package/dist/services/shell.js +206 -0
  86. package/dist/services/shell.js.map +1 -0
  87. package/dist/services/tekton.d.ts +16 -0
  88. package/dist/services/tekton.js +46 -0
  89. package/dist/services/tekton.js.map +1 -0
  90. package/dist/services/terraform.d.ts +31 -0
  91. package/dist/services/terraform.js +63 -0
  92. package/dist/services/terraform.js.map +1 -0
  93. package/package.json +58 -0
package/README.md ADDED
@@ -0,0 +1,1254 @@
1
+ # catops-cli
2
+
3
+ Framework para pipelines DevOps, escrito en **TypeScript** (100% usable desde JavaScript puro), empaquetado como librería npm instalable en cualquier proyecto.
4
+
5
+ Trae:
6
+
7
+ - Un **ExecutionContext** compartido (`flags`, `params`, `env`, `vars`, `results`, `logger`, `services`, `notifier`) para que ninguna task tenga que recibir parámetros manualmente.
8
+ - **15 servicios** listos (`shell`, `docker`, `git`, `kubectl`, `helm`, `npm`, `archive`, `terraform`, `ansible`, `argocd`, `tekton`, `oc`, `az`, `azdo`, `http`) + un **cliente REST API** para Azure DevOps (`AzureDevOpsApi`) + un **motor de pipelines** declarativo con dependencias (`Pipeline`).
9
+ - **Servicio HTTP** con interceptores de request/response, registry de agentes nombrados, configuración global, query params, dry-run y timeout.
10
+ - **Menús interactivos** con navegación anidada y **selección automática por flag** (para correr pipelines sin prompts, ideal para CI).
11
+ - **retry / timeout / dryRun** en cada comando de shell.
12
+ - Validación cíclica de rollouts de Kubernetes/OpenShift (`waitForDeployment`, `waitForDeploymentGroup`).
13
+ - **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.
14
+
15
+ ## Instalación
16
+
17
+ **Opción A — publicado en tu registro npm (público o privado tipo Verdaccio/Artifactory/GitHub Packages):**
18
+
19
+ ```bash
20
+ npm install catops-cli
21
+ # o si lo publicas con scope propio, p.ej. @miorg/catops-cli
22
+ npm install @miorg/catops-cli
23
+ ```
24
+
25
+ **Opción B — sin publicar, directo desde este proyecto (útil mientras lo maduras):**
26
+
27
+ ```bash
28
+ # Dentro del repo de catops-cli
29
+ npm pack # genera catops-cli-<version>.tgz
30
+
31
+ # Dentro del proyecto que lo va a consumir
32
+ npm install /ruta/a/catops-cli-<version>.tgz
33
+ ```
34
+
35
+ **Opción C — enlazado local con `npm link` (para desarrollar la librería y el proyecto que la consume al mismo tiempo):**
36
+
37
+ ```bash
38
+ # Dentro del repo de catops-cli
39
+ npm link
40
+
41
+ # Dentro del proyecto consumidor
42
+ npm link catops-cli
43
+ ```
44
+
45
+ **Opción D — como dependencia de Git (monorepo o repo privado, sin registro npm):**
46
+
47
+ ```bash
48
+ npm install git+https://github.com/tu-org/catops-cli.git
49
+ ```
50
+
51
+ Cualquiera de las 4 deja disponibles dos cosas en el proyecto consumidor:
52
+
53
+ 1. La librería, tanto desde TS como desde JS puro:
54
+ ```typescript
55
+ import { Context, Menu, services, type MenuDefinition } from "catops-cli";
56
+ ```
57
+ ```javascript
58
+ const { Context, Menu, services } = require("catops-cli");
59
+ ```
60
+ 2. El binario: `npx catops-cli` (o `catops-cli` si lo instalaste global con `-g`).
61
+
62
+ ## Publicar una nueva versión
63
+
64
+ ```bash
65
+ npm version patch # o minor / major
66
+ npm publish # agrega --access public si usas un scope (@miorg/catops-cli)
67
+ ```
68
+
69
+ `prepublishOnly` corre el build y los tests automáticamente antes de publicar.
70
+
71
+ ## TypeScript
72
+
73
+ 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`).
74
+
75
+ ```
76
+ src/
77
+ core/
78
+ Context.ts -> ExecutionContext singleton (Context.current() / Context.parseArgv() / ctx.wrap())
79
+ Menu.ts -> Menu.render() con navegación anidada + selección automática por flag
80
+ Notifier.ts -> clasificación de errores por área + canales + senders
81
+ classifiers.ts -> fábricas de ErrorClassifier: byCommand, byPattern, byService
82
+ messages.ts -> fábricas de ErrorMessageFormatter: byPattern, byCommand, byRule, byService
83
+ senders.ts -> fábricas de Sender: log, file, http, webhook, websocket
84
+ prompt.ts -> ctx.ask / ctx.confirm / ctx.select (sin dependencias externas)
85
+ logger.ts -> logger usado por Context y por shell.ts
86
+ types.ts -> tipos compartidos (MenuDefinition, ExecOptions, NotificationEvent, ServiceError, ...)
87
+ services/
88
+ shell.ts -> motor base (spawn), con retry/timeout/dryRun
89
+ http.ts -> cliente HTTP con interceptores de request/response, múltiples instancias
90
+ http-types.ts -> tipos del servicio HTTP (HttpRequest, HttpResponse, interceptors, ...)
91
+ docker.ts, git.ts, kubectl.ts, helm.ts, npm.ts, archive.ts
92
+ terraform.ts, ansible.ts, argocd.ts, tekton.ts, oc.ts, az.ts, azdo.ts, azdo-api.ts, pipeline.ts
93
+ index.ts -> registra todos los servicios anteriores (ServicesRegistry)
94
+ index.ts -> entry point público: Context, Menu, Notifier, senders, classifiers, messages, services, http, tipos
95
+ bin/
96
+ devops-cli.ts -> CLI ejecutable (busca devops.pipeline.js en el proyecto consumidor)
97
+ examples/
98
+ pipeline-example.js -> pipeline + menú + notificaciones de ejemplo, corre contra dist/
99
+ test/
100
+ context.test.js, shell.test.js, services.test.js, menu-selector.test.js,
101
+ notifier.test.js, senders.test.js, hooks-integration.test.js,
102
+ http.test.js, kubectl.test.js, oc.test.js, deployment-group.test.js,
103
+ exec-options-passthrough.test.js, azdo-api.test.js, pipeline.test.js,
104
+ service-notify.test.js
105
+ ```
106
+
107
+ ## Uso rápido: menú con `devops.pipeline.js` + el bin
108
+
109
+ Crea un `devops.pipeline.js` (o `devops.config.js` / `.catops-cli.js`) en la raíz de tu proyecto:
110
+
111
+ ```javascript
112
+ // devops.pipeline.js
113
+ module.exports = (ctx) => ({
114
+ title: "Pipeline",
115
+ options: {
116
+ Build: async () => {
117
+ await ctx.services.docker.build({ image: "registry/app:v1", dockerfile: "Dockerfile" });
118
+ },
119
+ Deploy: async () => {
120
+ await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
121
+ }
122
+ }
123
+ });
124
+ ```
125
+
126
+ ```bash
127
+ npx catops-cli --debug --env=prod
128
+ ```
129
+
130
+ `catops-cli` detecta el archivo, arma el `Context` a partir de los flags/params de `argv`, y renderiza el menú.
131
+
132
+ ## Uso directo en tu propio script (p. ej. con `tsx`)
133
+
134
+ ```typescript
135
+ // src/index.ts
136
+ import { Context, Menu, type MenuDefinition } from "catops-cli";
137
+
138
+ const ctx = Context.parseArgv();
139
+
140
+ const mainMenu: MenuDefinition = {
141
+ title: "Pipeline",
142
+ "flag-selector": "--menu-selector",
143
+ options: {
144
+ Build: { selector: "build", action: () => ctx.services.docker.build({ image: "app:v1" }) }
145
+ }
146
+ };
147
+
148
+ Menu.render(mainMenu, ctx);
149
+ ```
150
+
151
+ ```json
152
+ { "scripts": { "dev": "tsx src/index.ts" } }
153
+ ```
154
+
155
+ ```bash
156
+ npx tsx src/index.ts --menu-selector=build
157
+ npm run dev -- --menu-selector=build # con npm hace falta el "--" para reenviar flags
158
+ ```
159
+
160
+ ## Uso como librería sin menú (pipeline lineal)
161
+
162
+ ```javascript
163
+ const { Context } = require("catops-cli"); // o require("./dist") dentro de este repo
164
+
165
+ const ctx = Context.parseArgv(); // llena flags/params desde argv
166
+
167
+ ctx.set("image", "registry/api:v1");
168
+
169
+ await ctx.services.git.checkout("develop");
170
+ await ctx.services.npm.ci();
171
+ await ctx.services.docker.build({ image: ctx.get("image"), dockerfile: "Dockerfile" });
172
+ await ctx.services.docker.push(ctx.get("image"));
173
+ await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
174
+ ```
175
+
176
+ ## Menús: definición, anidamiento y selectores automáticos por flag
177
+
178
+ Un `MenuDefinition` es `{ title, options }`, donde cada entrada de `options` puede ser:
179
+
180
+ - una **función** — task directa: `Build: () => {...}`
181
+ - otro **`MenuDefinition`** — submenú directo: `Docker: dockerMenu`
182
+ - un **objeto largo** — para poder darle `selector`, `onSuccess`/`onError`, o envolver un submenú:
183
+ ```javascript
184
+ Build: { selector: "build", action: () => {...}, onSuccess: (r, ctx) => {...}, onError: (e, ctx) => {...} }
185
+ Docker: { selector: "docker", menu: dockerMenu }
186
+ // también podés inlinear el submenú directo con su propio selector al lado:
187
+ Docker: { selector: "docker", title: "Docker", "flag-selector": "--docker-action", options: {...} }
188
+ ```
189
+
190
+ ### Selección automática por flag
191
+
192
+ 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**:
193
+
194
+ ```javascript
195
+ const dockerMenu = {
196
+ title: "Docker",
197
+ "flag-selector": "--docker-action",
198
+ options: {
199
+ Build: { selector: "build", action: buildTask },
200
+ Push: { selector: "push", action: pushTask }
201
+ }
202
+ };
203
+
204
+ const mainMenu = {
205
+ title: "Pipeline",
206
+ "flag-selector": "--menu-selector",
207
+ options: {
208
+ Docker: { selector: "docker", menu: dockerMenu },
209
+ Deploy: { selector: "deploy", action: deployTask }
210
+ }
211
+ };
212
+
213
+ Menu.render(mainMenu, ctx);
214
+ ```
215
+
216
+ ```bash
217
+ # encadena ambos niveles en un solo comando, sin ningún prompt interactivo:
218
+ catops-cli --menu-selector=docker --docker-action=build
219
+
220
+ # un solo nivel:
221
+ catops-cli --menu-selector=deploy
222
+
223
+ # sin flags -> menú interactivo normal
224
+ catops-cli
225
+ ```
226
+
227
+ 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.
228
+
229
+ ### Callbacks de éxito/error por item
230
+
231
+ ```javascript
232
+ Deploy: {
233
+ selector: "deploy",
234
+ action: () => ctx.services.kubectl.apply("deployment.yaml"),
235
+ onSuccess: (result, ctx) => ctx.logger.success("Deploy OK"),
236
+ onError: (error, ctx) => ctx.logger.error(`Deploy falló: ${error.message}`)
237
+ }
238
+ ```
239
+
240
+ Mismo patrón con `ctx.run()` fuera de un menú:
241
+
242
+ ```javascript
243
+ await ctx.run(
244
+ "deploy",
245
+ () => ctx.services.kubectl.apply("deployment.yaml"),
246
+ {
247
+ onSuccess: (result, ctx) => ctx.logger.success("Deploy OK"),
248
+ onError: (error, ctx) => ctx.logger.error(`Deploy falló: ${error.message}`)
249
+ }
250
+ );
251
+ ```
252
+
253
+ 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.
254
+
255
+ ## retry / timeout / dryRun — en shell.exec y en TODOS los servicios
256
+
257
+ `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**:
258
+
259
+ ```javascript
260
+ await ctx.services.docker.push(image); // igual que siempre
261
+
262
+ await ctx.services.shell.exec("curl", "https://flaky-api.internal", {
263
+ retry: 3, // reintentos totales (default: 1 = sin retry)
264
+ retryDelay: 1000, // ms entre reintentos
265
+ timeout: 5000, // ms antes de matar el proceso con SIGTERM
266
+ dryRun: true // solo loguea el comando, no lo ejecuta
267
+ });
268
+ ```
269
+
270
+ **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:
271
+
272
+ - Si la función ya recibía un **objeto de opciones** (la mayoría), agregale la clave `exec`:
273
+ ```javascript
274
+ await ctx.services.terraform.apply({ autoApprove: true, exec: { retry: 3 } });
275
+ await ctx.services.argocd.appSync("mi-app", { prune: true, exec: { retry: 3 } });
276
+ ```
277
+ - Si la función recibe **argumentos posicionales** (strings sueltos), `exec` va como el **último argumento**:
278
+ ```javascript
279
+ await ctx.services.docker.push("registry/app:v1", { retry: 3 });
280
+ await ctx.services.git.push({ retry: 3 });
281
+ await ctx.services.helm.uninstall("mi-app", { retry: 3 });
282
+ ```
283
+ - `kubectl` y `oc` combinan `exec` en el **mismo objeto** que ya usás para `kubeconfig`/`namespace`:
284
+ ```javascript
285
+ // 10 reintentos en un login inestable de OpenShift
286
+ await ctx.services.oc.login({
287
+ server: "https://api.cluster:6443",
288
+ token: process.env.OC_TOKEN,
289
+ namespace: "prod",
290
+ exec: { retry: 10, retryDelay: 2000 }
291
+ });
292
+
293
+ await ctx.services.kubectl.apply("deploy.yaml", { namespace: "prod", exec: { retry: 5, timeout: 30000 } });
294
+ ```
295
+
296
+ Defaults globales para todo el proceso (afecta a todos los comandos que no pasen su propio `exec`/opciones puntuales):
297
+
298
+ ```javascript
299
+ ctx.services.shell.configure({ retry: 3, timeout: 30000 });
300
+ ```
301
+
302
+ `Context.parseArgv()` ya conecta flags de línea de comandos automáticamente a esos defaults globales:
303
+
304
+ ```bash
305
+ npx catops-cli --dry-run # activa dryRun global
306
+ npx catops-cli --retry=3 --timeout=15000
307
+ ```
308
+
309
+ ## Servicios de infraestructura incluidos
310
+
311
+ ```javascript
312
+ await ctx.services.terraform.plan({ varFile: "prod.tfvars" });
313
+ await ctx.services.terraform.apply();
314
+
315
+ await ctx.services.ansible.playbook("site.yml", { inventory: "hosts.ini" });
316
+
317
+ await ctx.services.argocd.appSync("mi-app", { prune: true });
318
+
319
+ await ctx.services.tekton.pipelineStart("build-pipeline", { params: { image: "app:v1" } });
320
+
321
+ await ctx.services.az.acrBuild({ registry: "miregistro", image: "app:v1" });
322
+ ```
323
+
324
+ ## kubectl / oc: kubeconfig, namespace, retry/timeout, y espera cíclica del rollout
325
+
326
+ `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`):
327
+
328
+ ```javascript
329
+ await ctx.services.kubectl.apply("deploy.yaml", { kubeconfig: "/etc/kube/prod.yaml", namespace: "prod" });
330
+ await ctx.services.kubectl.get("pods", "-o", "wide", { namespace: "staging", exec: { retry: 3 } });
331
+
332
+ await ctx.services.oc.login({ server: "https://api.cluster:6443", token, namespace: "prod", exec: { retry: 10 } });
333
+ await ctx.services.oc.apply("deploy.yaml", { namespace: "prod" });
334
+ ```
335
+
336
+ ### `waitForDeployment` — validación cíclica del rollout
337
+
338
+ Sondea el Deployment (o `DeploymentConfig` con `oc` + `resourceType: "dc"`) hasta que:
339
+
340
+ - llega a **estado exitoso** (réplicas listas/actualizadas == deseadas) → resuelve con `{ status: "success", ... }`,
341
+ - se queda en **estado Failed** más de `failedGracePeriod` sin recuperarse → lanza `DeploymentRolloutError`,
342
+ - supera **`maxRestarts`** reinicios acumulados entre todos sus pods → lanza `DeploymentRolloutError` de inmediato, sin esperar el grace period,
343
+ - o se cumple el **`timeout`** global sin éxito → lanza `DeploymentRolloutError`.
344
+
345
+ 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).
346
+
347
+ ```javascript
348
+ await ctx.run("deploy-api", async () => {
349
+ await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
350
+
351
+ return ctx.services.kubectl.waitForDeployment({
352
+ deployment: "api",
353
+ namespace: "prod",
354
+ timeout: 5 * 60 * 1000, // 5 min totales antes de abortar
355
+ pollInterval: 5000, // chequea cada 5s
356
+ failedGracePeriod: 30_000, // si entra en Failed, espera 30s a que se recupere
357
+ maxRestarts: 5 // si supera 5 reinicios acumulados, aborta ya
358
+ });
359
+ });
360
+ ```
361
+
362
+ ### `waitForDeploymentGroup` — validar todas las instancias de un mismo despliegue GitOps
363
+
364
+ 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:
365
+
366
+ ```yaml
367
+ metadata:
368
+ labels:
369
+ deployment-group: repository-14
370
+ ```
371
+
372
+ 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:
373
+
374
+ ```javascript
375
+ await ctx.run("deploy-repo-14", () =>
376
+ ctx.services.kubectl.waitForDeploymentGroup({
377
+ label: { "deployment-group": "repository-14" }, // o el string ya armado: "deployment-group=repository-14"
378
+ namespace: "prod",
379
+ timeout: 5 * 60 * 1000,
380
+ pollInterval: 5000,
381
+ failedGracePeriod: 30_000,
382
+ maxRestarts: 5
383
+ })
384
+ );
385
+ ```
386
+
387
+ - Si **todas** llegan a estado exitoso → resuelve con `{ status: "success", deployments: [...] }` (el detalle de cada una).
388
+ - 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).
389
+ - Si el label **no matchea ningún deployment**, también lanza `DeploymentGroupRolloutError` (grupo vacío = error, no éxito silencioso).
390
+
391
+ Con `oc`, ambas funciones aceptan `resourceType: "dc"` para apuntar a `DeploymentConfig` clásico en vez de `Deployment` nativo (default: `"deployment"`).
392
+
393
+ ## Logging commands de Azure Pipelines (`ctx.services.azdo`)
394
+
395
+ ```javascript
396
+ ctx.services.azdo.setVariable("BUILD_TAG", "v1.2.3");
397
+ ctx.services.azdo.logWarning("El caché de npm no se encontró, se reconstruye desde cero.");
398
+ ctx.services.azdo.group("Build");
399
+ // ... pasos ...
400
+ ctx.services.azdo.endGroup();
401
+ ```
402
+
403
+ ## Cliente HTTP de Azure DevOps REST API (`AzureDevOpsApi`)
404
+
405
+ 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.
406
+
407
+ ### Configuración
408
+
409
+ ```typescript
410
+ import { AzureDevOpsApi } from "catops-cli";
411
+
412
+ const azdo = new AzureDevOpsApi().configure({
413
+ baseUrl: "https://dev.azure.com/miorg",
414
+ pat: process.env.AZDO_PAT,
415
+ project: "mi-proyecto", // project por defecto (opcional)
416
+ apiVersion: "7.1", // default
417
+ });
418
+ ```
419
+
420
+ Se puede pasar un agente HTTP existente con `agent` para reusar interceptores/configuración:
421
+
422
+ ```typescript
423
+ const azdo = new AzureDevOpsApi().configure({
424
+ baseUrl: "https://dev.azure.com/miorg",
425
+ pat: process.env.AZDO_PAT,
426
+ agent: ctx.services.http.agent("azdo") // o un HttpService nuevo
427
+ });
428
+ ```
429
+
430
+ ### Proyectos
431
+
432
+ ```typescript
433
+ const res = await azdo.listProjects();
434
+ const proj = await azdo.getProject("mi-proyecto");
435
+ ```
436
+
437
+ ### Git Repos
438
+
439
+ ```typescript
440
+ const repos = await azdo.listRepos();
441
+ const repo = await azdo.getRepo("frontend");
442
+ ```
443
+
444
+ ### Branches
445
+
446
+ ```typescript
447
+ const branches = await azdo.listBranches("frontend");
448
+
449
+ const exists = await azdo.branchExists("frontend", "feature/login"); // true | false
450
+
451
+ const branch = await azdo.getBranch("frontend", "feature/login");
452
+ // → branch.body.aheadCount, .behindCount, .commit.commitId, ...
453
+ ```
454
+
455
+ ### Commits
456
+
457
+ ```typescript
458
+ const commits = await azdo.listCommits("frontend", { branch: "main", top: 10 });
459
+ const commit = await azdo.getCommit("frontend", "abc123");
460
+ ```
461
+
462
+ ### Pull Requests
463
+
464
+ ```typescript
465
+ const prs = await azdo.listPullRequests("frontend", { status: "active" });
466
+ const pr = await azdo.getPullRequest("frontend", 42);
467
+ const newPr = await azdo.createPullRequest("frontend", {
468
+ sourceRefName: "refs/heads/feature/login",
469
+ targetRefName: "refs/heads/main",
470
+ title: "feat: login",
471
+ description: "Agrega pantalla de login"
472
+ });
473
+ ```
474
+
475
+ ### Build Definitions & Builds
476
+
477
+ ```typescript
478
+ const defs = await azdo.listBuildDefinitions();
479
+ const def = await azdo.getBuildDefinition(1);
480
+
481
+ const builds = await azdo.listBuilds({ definitionId: 5, top: 10 });
482
+ const build = await azdo.getBuild(100);
483
+
484
+ const queued = await azdo.queueBuild(5, { branch: "main", parameters: { config: "Release" } });
485
+ // → queued.body.id, .status, .buildNumber
486
+ ```
487
+
488
+ ### Pipelines
489
+
490
+ ```typescript
491
+ const pipelines = await azdo.listPipelines();
492
+ const pipeline = await azdo.getPipeline(10);
493
+
494
+ const run = await azdo.runPipeline(10, {
495
+ branch: "main",
496
+ variables: { ENV: { value: "production" } }
497
+ });
498
+ ```
499
+
500
+ ### Work Items
501
+
502
+ ```typescript
503
+ const wi = await azdo.getWorkItem(123, { fields: ["System.Title", "System.State"] });
504
+
505
+ const query = await azdo.queryWorkItems(
506
+ "SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active'"
507
+ );
508
+ // → query.body.workItems → [{ id, url }, ...]
509
+ ```
510
+
511
+ ### Overrides por llamada
512
+
513
+ Cada método acepta un objeto de opciones con `project`, `apiVersion`, `query`, y `exec`:
514
+
515
+ ```typescript
516
+ // project override → usa otro proyecto solo para esta llamada
517
+ await azdo.listRepos({ project: "otro-proyecto" });
518
+
519
+ // organization-level → omite el project de la URL
520
+ await azdo.listProjects({ organizationLevel: true });
521
+
522
+ // dry-run — solo loguea la petición HTTP sin enviarla
523
+ await azdo.listRepos({ exec: { dryRun: true } });
524
+ ```
525
+
526
+ Los tipos de respuesta completos (`AzdoProject`, `AzdoGitRepository`, `AzdoBuild`, etc.) se exportan desde la raíz del paquete para tipado en TypeScript.
527
+
528
+ ## Motor de pipelines declarativo (`ctx.services.pipeline`)
529
+
530
+ 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.
531
+
532
+ ### Estructura flexible
533
+
534
+ La estructura es **completamente opcional en cada nivel** — podés definir un pipeline con stages completos, solo jobs, o solo tasks:
535
+
536
+ ```javascript
537
+ // Pipeline completo: stages → jobs → tasks
538
+ const fullPipeline = new Pipeline("deploy");
539
+ fullPipeline
540
+ .stage("build")
541
+ .job("compile")
542
+ .task("install-deps", { exec: async (ctx) => { /* ... */ } })
543
+ .task("compile", { exec: async (ctx) => { /* ... */ } })
544
+ .stage("test")
545
+ .job("unit-tests")
546
+ .task("run-tests", { exec: async (ctx) => { /* ... */ } })
547
+ .stage("deploy")
548
+ .job("push")
549
+ .task("upload", { exec: async (ctx, results) => { /* results.build.compile */ }, depends: ["build.compile"] });
550
+
551
+ await fullPipeline.run(ctx);
552
+ ```
553
+
554
+ ```javascript
555
+ // Solo tasks (sin stages ni jobs) — acceso flat dentro del mismo job
556
+ const simple = new Pipeline("simple", {
557
+ tasks: {
558
+ build: { exec: async (ctx) => { await ctx.services.docker.build({...}); } },
559
+ test: { exec: async (ctx) => { await ctx.services.shell.exec("npm", "test"); } },
560
+ push: { exec: async (ctx, results) => { await ctx.services.docker.push({...}); }, depends: ["build"] }
561
+ }
562
+ });
563
+ await simple.run(ctx);
564
+ ```
565
+
566
+ ```javascript
567
+ // Solo jobs (sin stages)
568
+ const jobsOnly = new Pipeline("ci", {
569
+ jobs: {
570
+ build: { tasks: { compile: { exec: () => "ok" } } },
571
+ test: { tasks: { unit: { exec: () => "pass" } } }
572
+ }
573
+ });
574
+ await jobsOnly.run(ctx);
575
+ ```
576
+
577
+ ### Dependencias
578
+
579
+ Cada task, job, o stage puede declarar `depends: ["nombre"]` — el motor resuelve el orden automáticamente:
580
+
581
+ ```javascript
582
+ const pipeline = new Pipeline("ordered");
583
+ pipeline
584
+ .stage("build")
585
+ .job("compile")
586
+ .task("install", { exec: () => "deps installed" })
587
+ .task("compile", { exec: () => "compiled", depends: ["install"] })
588
+ .stage("test")
589
+ .job("unit")
590
+ .task("test", {
591
+ exec: (_, results) => `testing ${results.build.compile.compile}`,
592
+ depends: ["build.compile.compile"] // cross-stage: stage.job.task
593
+ })
594
+ .stage("deploy")
595
+ .job("push")
596
+ .task("upload", {
597
+ exec: (_, results) => `deployed ${results.unit.test}`,
598
+ depends: ["unit.test"] // cross-job mismo stage: job.task
599
+ });
600
+
601
+ await pipeline.run(ctx);
602
+ ```
603
+
604
+ Las dependencias son **cross-level** — una task en un stage puede depender de una task de otro stage usando paths con dot notation:
605
+
606
+ ```javascript
607
+ // Cross-stage: deploy necesita un resultado de build
608
+ .task("upload", {
609
+ exec: (_, results) => `uploaded ${results.build.compile.artifact}`,
610
+ depends: ["build.compile.artifact"] // stage.job.task
611
+ })
612
+
613
+ // Cross-job mismo stage: test necesita algo de build
614
+ .task("verify", {
615
+ exec: (_, results) => `verified ${results.compile.output}`,
616
+ depends: ["compile.output"] // job.task
617
+ })
618
+
619
+ // Mismo job: dependencia directa por nombre
620
+ .task("deploy", {
621
+ exec: (_, results) => `deploy-${results.build}`,
622
+ depends: ["build"] // task (flat)
623
+ })
624
+ ```
625
+
626
+ Los stages y jobs se ejecutan en **orden secuencial por defecto** (definition order).
627
+
628
+ ### Acceso a resultados
629
+
630
+ Los resultados se organizan jerárquicamente: `stage → job → task`. Cada callback recibe `(ctx, results)` donde `results` es un proxy que resuelve por contexto:
631
+
632
+ ```javascript
633
+ pipeline
634
+ .stage("build")
635
+ .job("compile")
636
+ .task("compile", { exec: () => "artifact-v1" })
637
+ .stage("deploy")
638
+ .job("push")
639
+ .task("upload", {
640
+ exec: (_, results) => {
641
+ // Mismo job: acceso directo por nombre de task
642
+ // results.myTask = "valor"
643
+
644
+ // Mismo stage, otro job: job.task
645
+ // results.compile.compile = "artifact-v1"
646
+
647
+ // Otro stage: stage.job.task
648
+ // results.build.compile = { compile: "artifact-v1" }
649
+
650
+ return `uploaded ${results.build.compile.compile}`;
651
+ },
652
+ depends: ["build.compile.compile"]
653
+ });
654
+ ```
655
+
656
+ **Reglas de resolución:**
657
+
658
+ | Contexto | Sintaxis | Ejemplo |
659
+ |---|---|---|
660
+ | Misma task (otro task en el mismo job) | `results.<task>` | `results.build` |
661
+ | Mismo stage, otro job | `results.<job>.<task>` | `results.compile.output` |
662
+ | Otro stage | `results.<stage>.<job>.<task>` | `results.build.compile.artifact` |
663
+
664
+ El proxy intenta resolver en este orden: flat → job path → stage path. El primer match gana.
665
+
666
+ Cada entidad también expone sus resultados vía `.results` (PipelineTask), `.results` (PipelineJob — mapa anidado), y `.getResults()` (PipelineStage/Pipeline).
667
+
668
+ ### Registry global de pipelines
669
+
670
+ `ctx.services.pipeline` es un registry — definís pipelines al inicio y los ejecutás por nombre:
671
+
672
+ ```javascript
673
+ // Definir pipelines globales
674
+ ctx.services.pipeline.define("build-and-test", {
675
+ tasks: {
676
+ build: { exec: async (ctx) => { await ctx.services.docker.build({...}); } },
677
+ test: { exec: async (ctx) => { await ctx.services.shell.exec("npm", "test"); } }
678
+ }
679
+ });
680
+
681
+ ctx.services.pipeline.define("deploy-prod", {
682
+ stages: {
683
+ build: { jobs: { compile: { tasks: { step: { exec: async (ctx) => { /* ... */ } } } } } },
684
+ deploy: { jobs: { push: { tasks: { step: { exec: async (ctx) => { /* ... */ } } } } } }
685
+ }
686
+ });
687
+
688
+ // Ejecutar por nombre
689
+ await ctx.services.pipeline.run("build-and-test", ctx);
690
+ await ctx.services.pipeline.run("deploy-prod", ctx);
691
+ ```
692
+
693
+ **Gestión de pipelines:**
694
+
695
+ ```javascript
696
+ // Listar todos los pipelines registrados
697
+ ctx.services.pipeline.list(); // ["build-and-test", "deploy-prod"]
698
+
699
+ // Obtener un pipeline para modificarlo
700
+ const p = ctx.services.pipeline.get("build-and-test");
701
+
702
+ // Eliminar un pipeline
703
+ ctx.services.pipeline.remove("deploy-prod");
704
+ ```
705
+
706
+ ### Context access
707
+
708
+ Cada task recibe `ctx` como primer argumento — acceso completo a servicios, flags, params, logger, etc.:
709
+
710
+ ```javascript
711
+ pipeline.stage("build").job("compile").task("step1", {
712
+ exec: async (ctx) => {
713
+ ctx.logger.info(`Building with env: ${ctx.params.env}`);
714
+ await ctx.services.docker.build({ image: `app:${ctx.params.version}` });
715
+ ctx.set("image", `app:${ctx.params.version}`);
716
+ }
717
+ });
718
+ ```
719
+
720
+ ### PipelineRunResult
721
+
722
+ `pipeline.run()` devuelve un objeto con status, results (jerárquico), error, y duration:
723
+
724
+ ```javascript
725
+ const result = await pipeline.run(ctx);
726
+
727
+ if (result.status === "success") {
728
+ ctx.logger.success(`Pipeline completed in ${result.duration}ms`);
729
+ console.log(result.results);
730
+ // {
731
+ // build: { // stage
732
+ // compile: { // job
733
+ // step1: "compiled" // task
734
+ // }
735
+ // },
736
+ // deploy: {
737
+ // push: {
738
+ // upload: "uploaded-v1"
739
+ // }
740
+ // }
741
+ // }
742
+ } else {
743
+ ctx.logger.error(`Pipeline failed: ${result.error.message}`);
744
+ }
745
+ ```
746
+
747
+ ### Task types
748
+
749
+ El tipo de ejecución se determina por la **propiedad** presente en la config. No hay campo `type` — la propiedad misma es el tipo:
750
+
751
+ ```javascript
752
+ // exec = callback (único type actualmente)
753
+ .task("compile", {
754
+ exec: async (ctx, results) => { /* ... */ }
755
+ })
756
+ ```
757
+
758
+ Para agregar un nuevo tipo en el futuro, solo se agrega la propiedad al config y el case en `_execute`:
759
+
760
+ ```javascript
761
+ // Futuro: shell
762
+ .task("test", {
763
+ shell: { command: "npm", args: ["test"] }
764
+ })
765
+
766
+ // Futuro: docker
767
+ .task("build", {
768
+ docker: { action: "build", image: "app:v1" }
769
+ })
770
+ ```
771
+
772
+ El engine detecta `"exec" in config`, `"shell" in config`, etc. y ejecuta la estrategia correspondiente.
773
+
774
+ ### Reset y reutilización
775
+
776
+ Los pipelines son reutilizables — `reset()` restaura el estado de todas las entidades:
777
+
778
+ ```javascript
779
+ const pipeline = new Pipeline("reusable", {
780
+ tasks: { step: { exec: () => ++count } }
781
+ });
782
+
783
+ await pipeline.run(ctx); // count = 1
784
+ pipeline.reset();
785
+ await pipeline.run(ctx); // count = 2
786
+ ```
787
+
788
+ ### Servicio HTTP con interceptores (`ctx.services.http`)
789
+
790
+ Un cliente HTTP completo con soporte para **interceptors de request y response**, configurable como servicio global o como instancias independientes.
791
+
792
+ ### Uso básico
793
+
794
+ ```javascript
795
+ // GET
796
+ const res = await ctx.services.http.get("https://api.example.com/users");
797
+ console.log(res.body); // { users: [...] }
798
+
799
+ // POST
800
+ const res = await ctx.services.http.post("https://api.example.com/users", {
801
+ name: "John",
802
+ email: "john@example.com"
803
+ });
804
+
805
+ // PUT / PATCH / DELETE
806
+ await ctx.services.http.put("/users/1", { name: "Jane" });
807
+ await ctx.services.http.patch("/users/1", { email: "new@example.com" });
808
+ await ctx.services.http.delete("/users/1");
809
+ ```
810
+
811
+ ### Configuración global
812
+
813
+ ```javascript
814
+ ctx.services.http.configure({
815
+ baseUrl: "https://api.example.com",
816
+ defaultHeaders: {
817
+ "Authorization": `Bearer ${process.env.API_TOKEN}`,
818
+ "Accept": "application/json"
819
+ },
820
+ defaultTimeout: 10000 // 10 segundos
821
+ });
822
+
823
+ // Ahora las peticiones son relativas
824
+ await ctx.services.http.get("/users"); // -> GET https://api.example.com/users
825
+ await ctx.services.http.post("/users", data); // -> POST https://api.example.com/users
826
+ ```
827
+
828
+ ### Query params
829
+
830
+ ```javascript
831
+ await ctx.services.http.get("/search", {
832
+ query: { q: "hello", page: 1, active: true }
833
+ });
834
+ // -> GET /search?q=hello&page=1&active=true
835
+ ```
836
+
837
+ ### Errores HTTP
838
+
839
+ Las respuestas con status 4xx/5xx lanzan un error con metadata completa:
840
+
841
+ ```javascript
842
+ try {
843
+ await ctx.services.http.get("/missing");
844
+ } catch (err) {
845
+ console.log(err.status); // 404
846
+ console.log(err.body); // { error: "not found" }
847
+ console.log(err.headers); // { ... }
848
+ console.log(err.request); // { url, method, headers, ... }
849
+ }
850
+ ```
851
+
852
+ ### Interceptores de request
853
+
854
+ Los interceptors se ejecutan **antes** de cada petición. Pueden mutar el request (headers, auth, logging) o abortarlo:
855
+
856
+ ```javascript
857
+ // Agregar token de auth a todas las peticiones
858
+ ctx.services.http.addRequestInterceptor((ctx) => {
859
+ ctx.request.headers["Authorization"] = `Bearer ${process.env.TOKEN}`;
860
+ });
861
+
862
+ // Logging de cada petición
863
+ ctx.services.http.addRequestInterceptor((ctx) => {
864
+ console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
865
+ });
866
+
867
+ // Abortar peticiones a ciertos dominios
868
+ ctx.services.http.addRequestInterceptor((ctx) => {
869
+ if (ctx.request.url.includes("internal")) {
870
+ ctx.abort("blocked by policy");
871
+ }
872
+ });
873
+ ```
874
+
875
+ Los interceptors se ejecutan en orden. Si uno aborta, se lanza un error y no se envía la petición.
876
+
877
+ ### Interceptores de response
878
+
879
+ Los interceptors se ejecutan **después** de cada respuesta. Pueden transformar el body, loguear, o hacer retry:
880
+
881
+ ```javascript
882
+ // Logging de cada respuesta
883
+ ctx.services.http.addResponseInterceptor((ctx) => {
884
+ console.log(`← ${ctx.response.status} ${ctx.request.url}`);
885
+ });
886
+
887
+ // Transformar la respuesta
888
+ ctx.services.http.addResponseInterceptor((ctx) => {
889
+ if (ctx.response.body?.data) {
890
+ ctx.response.body = ctx.response.body.data;
891
+ }
892
+ });
893
+ ```
894
+
895
+ ### Gestión de interceptors
896
+
897
+ ```javascript
898
+ // Agregar
899
+ const myInterceptor = (ctx) => { /* ... */ };
900
+ ctx.services.http.addRequestInterceptor(myInterceptor);
901
+ ctx.services.http.addResponseInterceptor(myInterceptor);
902
+
903
+ // Eliminar uno específico
904
+ ctx.services.http.removeRequestInterceptor(myInterceptor);
905
+ ctx.services.http.removeResponseInterceptor(myInterceptor);
906
+
907
+ // Limpiar todos
908
+ ctx.services.http.clearRequestInterceptors();
909
+ ctx.services.http.clearResponseInterceptors();
910
+ ```
911
+
912
+ ### Agentes HTTP nombrados
913
+
914
+ `ctx.services.http` es un **registry** que gestiona agentes HTTP. Cada agente tiene su propia configuración, interceptores y defaults aislados.
915
+
916
+ **Default agent** — directamente en `ctx.services.http`:
917
+
918
+ ```javascript
919
+ await ctx.services.http.get("/users");
920
+ await ctx.services.http.post("/users", data);
921
+ ```
922
+
923
+ **Agentes nombrados** — para APIs distintas con configuración propia:
924
+
925
+ ```javascript
926
+ import { HttpService } from "catops-cli";
927
+
928
+ // Crear y registrar un agente
929
+ ctx.services.http.createAgent(
930
+ new HttpService().configure({
931
+ baseUrl: "https://api.github.com",
932
+ defaultHeaders: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
933
+ }),
934
+ "github"
935
+ );
936
+
937
+ ctx.services.http.createAgent(
938
+ new HttpService().configure({
939
+ baseUrl: "https://internal.mycompany.com/api",
940
+ defaultHeaders: { "X-API-Key": process.env.INTERNAL_KEY }
941
+ }),
942
+ "internal"
943
+ );
944
+
945
+ // Usar por nombre — cada uno tiene interceptores y config aislados
946
+ await ctx.services.http.agent("github").get("/repos/org/repo");
947
+ await ctx.services.http.agent("internal").get("/services/status");
948
+ ```
949
+
950
+ **Gestión de agentes:**
951
+
952
+ ```javascript
953
+ // Listar todos los agentes registrados
954
+ ctx.services.http.listAgents(); // ["github", "internal"]
955
+
956
+ // Eliminar un agente
957
+ ctx.services.http.removeAgent("github");
958
+
959
+ // Reemplazar un agente existente (mismo nombre)
960
+ ctx.services.http.createAgent(new HttpService().configure({...}), "internal");
961
+ ```
962
+
963
+ **Ejemplo completo — interceptores por agente:**
964
+
965
+ ```javascript
966
+ const github = new HttpService()
967
+ .configure({
968
+ baseUrl: "https://api.github.com",
969
+ defaultHeaders: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
970
+ })
971
+ .addRequestInterceptor((ctx) => {
972
+ ctx.request.headers["Accept"] = "application/vnd.github.v3+json";
973
+ })
974
+ .addResponseInterceptor((ctx) => {
975
+ if (ctx.response.body?.data) {
976
+ ctx.response.body = ctx.response.body.data;
977
+ }
978
+ });
979
+
980
+ const internal = new HttpService()
981
+ .configure({ baseUrl: "https://internal.mycompany.com/api" })
982
+ .addRequestInterceptor(async (ctx) => {
983
+ const token = await fetchTokenFromVault();
984
+ ctx.request.headers["Authorization"] = `Bearer ${token}`;
985
+ });
986
+
987
+ ctx.services.http.createAgent(github, "github");
988
+ ctx.services.http.createAgent(internal, "internal");
989
+
990
+ // Cada agente usa sus propios interceptores
991
+ await ctx.services.http.agent("github").get("/repos/org/repo");
992
+ await ctx.services.http.agent("internal").get("/services/status");
993
+ ```
994
+
995
+ ### Opciones por llamada
996
+
997
+ ```javascript
998
+ await ctx.services.http.get("/slow-endpoint", {
999
+ timeout: 30000, // override del default
1000
+ headers: { "X-Request-Id": "123" },
1001
+ query: { includeDeleted: false }
1002
+ });
1003
+
1004
+ await ctx.services.http.post("/data", payload, {
1005
+ exec: { dryRun: true } // soporte dry-run
1006
+ });
1007
+ ```
1008
+
1009
+ ### Interceptors con async/await
1010
+
1011
+ Los interceptors soportan operaciones asíncronas (base de datos, llamadas a servicios, etc.):
1012
+
1013
+ ```javascript
1014
+ ctx.services.http.addRequestInterceptor(async (ctx) => {
1015
+ const token = await fetchTokenFromVault();
1016
+ ctx.request.headers["Authorization"] = `Bearer ${token}`;
1017
+ });
1018
+ ```
1019
+
1020
+ ## Notificaciones: clasificar errores por área de TI, personalizar el mensaje, y enviarlos
1021
+
1022
+ `ctx.notifier` tiene tres responsabilidades independientes:
1023
+
1024
+ 1. **`classify()`** — decide a qué **área de TI** pertenece un error (para elegir a qué canal mandarlo).
1025
+ 2. **`describeError()`** — decide el **mensaje** a reportar (reemplaza el stderr/stdout crudo por algo humano).
1026
+ 3. **`channel()`** / **`onSuccess()`** — a qué **senders** se manda cada área.
1027
+
1028
+ ```javascript
1029
+ const { classifiers, messages, senders } = require("catops-cli");
1030
+
1031
+ // 1. ¿A qué área de TI pertenece este error?
1032
+ ctx.notifier.classify(classifiers.byCommand({
1033
+ docker: "containers",
1034
+ kubectl: "kubernetes",
1035
+ oc: "kubernetes",
1036
+ terraform: "infra",
1037
+ ansible: "infra",
1038
+ git: "scm",
1039
+ argocd: "cd-pipeline",
1040
+ tkn: "cd-pipeline",
1041
+ az: "cloud-azure"
1042
+ }));
1043
+
1044
+ // también podés clasificar por el texto del error:
1045
+ ctx.notifier.classify(classifiers.byPattern([
1046
+ [/permission denied|unauthorized/i, "security"],
1047
+ [/timeout|ECONNREFUSED/i, "networking"],
1048
+ [/no space left|ENOSPC/i, "infra"]
1049
+ ]));
1050
+
1051
+ // 2. ¿qué mensaje se reporta? (opcional — sin esto, se usa el stderr/stdout crudo)
1052
+ ctx.notifier.describeError(messages.byRule([
1053
+ {
1054
+ command: "docker", args: "push", pattern: /500 Internal Server Error/,
1055
+ message: "Se ha reportado a infraestructura: falta de espacio en el registry"
1056
+ },
1057
+ {
1058
+ command: "kubectl", pattern: /500/,
1059
+ message: "El API server de Kubernetes devolvió 500, reintenta en unos minutos"
1060
+ },
1061
+ {
1062
+ command: "terraform", pattern: /500/,
1063
+ message: (error, ctx) => `Backend remoto de Terraform no respondió (env: ${ctx.params.env ?? "?"})`
1064
+ }
1065
+ ]));
1066
+
1067
+ // 3. ¿a dónde se manda cada área?
1068
+ ctx.notifier.channel("kubernetes", senders.webhook({ url: process.env.TEAMS_WEBHOOK }));
1069
+ ctx.notifier.channel("security", senders.http({ url: "https://security.miempresa.com/incidents" }));
1070
+ ctx.notifier.channel("*", senders.file({ path: "./catops-cli-errors.log" })); // TODO error, sin importar el área
1071
+
1072
+ // (opcional) éxito, sin clasificación de área
1073
+ ctx.notifier.onSuccess(senders.log());
1074
+ ```
1075
+
1076
+ A partir de aquí, cualquier `ctx.run(...)` o item de menú con `action` reporta automáticamente al notifier — no hay que llamarlo a mano en cada task.
1077
+
1078
+ ### Clasificadores de área (`classifiers`)
1079
+
1080
+ | Fábrica | Uso |
1081
+ |---|---|
1082
+ | `classifiers.byCommand({ docker: "containers", ... })` | Mapea el comando que falló (adjunto automáticamente por `shell.exec`) a un área. También funciona con `ServiceError`: unwrappea `cause.command` automáticamente. |
1083
+ | `classifiers.byPattern([[regex, area], ...])` | Matchea contra el `stderr`/`stdout`/mensaje del error. Unwrappea `ServiceError.cause` para extraer el texto. |
1084
+ | `classifiers.byService({ docker: "containers", ... })` | Mapea el nombre del **servicio** que falló a un área. Solo matchea `ServiceError` (generados por `ctx.wrap()`). |
1085
+
1086
+ ### Formateadores de mensaje (`messages`)
1087
+
1088
+ | Fábrica | Uso |
1089
+ |---|---|
1090
+ | `messages.byPattern([[regex, mensaje], ...])` | Mismo mensaje sin importar el comando — solo mira el texto del error (`stderr`, o `stdout` si `stderr` viene vacío). Unwrappea `ServiceError.cause`. |
1091
+ | `messages.byCommand({ docker: "mensaje fijo" })` | Mensaje fijo por comando, sin importar el detalle del error. Unwrappea `ServiceError.cause.command`. |
1092
+ | `messages.byRule([{ command?, args?, pattern?, message }, ...])` | **La opción avanzada**: combina comando + sub-comando (`args`, distingue `docker push` de `docker build`) + patrón de texto, todo en modo AND. `message` puede ser un string fijo o una función `(error, ctx) => string`. Resuelve el caso de "el mismo 500 puede venir de docker, kubectl o terraform, y cada uno necesita su propio mensaje". Unwrappea `ServiceError.cause`. |
1093
+ | `messages.byService({ docker: "mensaje fijo" })` | Mensaje fijo por nombre de servicio. Solo matchea `ServiceError` (generados por `ctx.wrap()`). |
1094
+
1095
+ > **Errores que salen por `stdout` en vez de `stderr`:** algunos comandos (p. ej. `oc login`, o errores HTTP del API server) imprimen el mensaje en `stdout` y aun así salen con exit code ≠ 0. Como `shell.exec` rechaza con el `ExecResult` completo (que conserva `stdout` y `stderr`), todos los formateadores/clasificadores de `messages`/`classifiers` prueban primero `stderr` y, si viene vacío, caen a `stdout`. No hace falta configuración extra — el mismo `describeError`/`classify` que usás hoy funciona aunque el texto vaya por stdout:
1096
+
1097
+ ```javascript
1098
+ ctx.notifier.describeError(messages.byPattern([
1099
+ [/500 Internal Server Error/, "Se ha reportado a infraestructura: falta de espacio en el registry"],
1100
+ [/unauthorized|403/i, "Credenciales inválidas contra el registry, revisa el secret"]
1101
+ ]));
1102
+ ```
1103
+
1104
+ Si ningún classifier/formatter matchea, se usa el área `"unclassified"` y el mensaje crudo del error, respectivamente — nada se rompe si no configurás nada de esto.
1105
+
1106
+ ### Senders incluidos
1107
+
1108
+ | Sender | Uso |
1109
+ |---|---|
1110
+ | `senders.log()` | Usa el logger interno (consola) |
1111
+ | `senders.file({ path })` | Agrega el evento como una línea JSON al archivo |
1112
+ | `senders.http({ url, method?, headers?, formatBody? })` | `POST` genérico del evento como JSON |
1113
+ | `senders.webhook({ url, format? })` | Como `http`, pero formatea `{ text: "❌ ..." }` por defecto — compatible con Slack/Discord y con **Microsoft Teams** vía Workflows (Power Automate), pasando un `format` que arme el payload de Adaptive Card que Teams espera |
1114
+ | `senders.websocket({ url, timeout? })` | Abre una conexión WS, manda el evento como JSON y cierra. Requiere Node ≥21 (usa el `WebSocket` global) |
1115
+
1116
+ Podés escribir tu propio sender: es cualquier función `(event) => void | Promise<void>` — recibe `{ type, taskId, area?, error?, result?, message, timestamp, service?, method?, args? }`.
1117
+
1118
+ Los campos `service`, `method` y `args` solo están presentes cuando el error viene de `ctx.wrap()` (un `ServiceError`).
1119
+
1120
+ ## Notificaciones de servicios: `ctx.wrap()`
1121
+
1122
+ `ctx.wrap(service, serviceName)` envuelve un objeto de servicio en un **Proxy** que intercepta cada llamada a método. Si el método falla, el error se envuelve automáticamente en un `ServiceError` con metadata del servicio y se despacha al `ctx.notifier` antes de re-lanzarlo.
1123
+
1124
+ ### Uso básico
1125
+
1126
+ ```typescript
1127
+ // Envolver servicios que quieras monitorear
1128
+ const docker = ctx.wrap(ctx.services.docker, "docker");
1129
+ const kubectl = ctx.wrap(ctx.services.kubectl, "kubectl");
1130
+
1131
+ // Configurar classifiers y canales (igual que siempre)
1132
+ ctx.notifier
1133
+ .classify(classifiers.byCommand({ docker: "containers", kubectl: "kubernetes" }))
1134
+ .classify(classifiers.byService({ docker: "containers", kubectl: "kubernetes" }))
1135
+ .channel("containers", senders.webhook({ url: process.env.SLACK_WEBHOOK }))
1136
+ .channel("kubernetes", senders.webhook({ url: process.env.TEAMS_WEBHOOK }));
1137
+
1138
+ // Cualquier fallo auto-notifica con contexto completo
1139
+ await docker.push("myimage:latest");
1140
+ // → taskId: 'docker.push("myimage:latest")'
1141
+ // → error: ServiceError { service: "docker", method: "push", args: ["myimage:latest"], cause: ExecResult }
1142
+ ```
1143
+
1144
+ ### Qué information llega al sender
1145
+
1146
+ Cuando `ctx.wrap()` captura un error, el `NotificationEvent` incluye:
1147
+
1148
+ | Campo | Tipo | Descripción |
1149
+ |---|---|---|
1150
+ | `taskId` | `string` | Nombre generado: `service.method(args serializados)`, ej. `docker.push("myimage:latest")` |
1151
+ | `service` | `string` | Nombre del servicio: `"docker"`, `"kubectl"`, `"http"`, etc. |
1152
+ | `method` | `string` | Método que falló: `"push"`, `"apply"`, `"request"`, etc. |
1153
+ | `args` | `unknown[]` | Argumentos originales pasados al método |
1154
+ | `error` | `ServiceError` | El error completo (`.cause` contiene el error original) |
1155
+ | `area` | `string` | Área de TI resuelta por los classifiers |
1156
+ | `message` | `string` | Mensaje resuelto por los formatters, o `ServiceError.message` |
1157
+
1158
+ ### ServiceError
1159
+
1160
+ `ServiceError` extiende `Error` y contiene:
1161
+
1162
+ ```typescript
1163
+ class ServiceError extends Error {
1164
+ readonly service: string; // "docker", "http", etc.
1165
+ readonly method: string; // "push", "request", etc.
1166
+ readonly args: unknown[]; // argumentos originales
1167
+ readonly cause: unknown; // error original (ExecResult, HttpError, Error, etc.)
1168
+ }
1169
+ ```
1170
+
1171
+ La propiedad `cause` contiene el error original — los classifiers y formatters existentes (`byCommand`, `byPattern`, `byRule`) unwrappean `ServiceError.cause` automáticamente, así que la configuración que ya tenés sigue funcionando sin cambios.
1172
+
1173
+ ### Combinación con classifiers existentes
1174
+
1175
+ `byCommand` y `byPattern` unwrappean `ServiceError.cause` automáticamente. Esto significa que un `docker.push()` que falla con un error de shell (que tiene `command: "docker"`) se clasifica correctamente vía `byCommand`, y un `http.request()` que falla con un `status: 504` se clasifica vía `byPattern`:
1176
+
1177
+ ```typescript
1178
+ ctx.notifier
1179
+ // byCommand unwrappea ServiceError.cause.command → "docker" → "containers"
1180
+ .classify(classifiers.byCommand({ docker: "containers", kubectl: "kubernetes" }))
1181
+
1182
+ // byPattern unwrappea ServiceError.cause.stderr → /permission denied/ → "security"
1183
+ .classify(classifiers.byPattern([
1184
+ [/permission denied|unauthorized/i, "security"],
1185
+ [/timeout|ECONNREFUSED/i, "networking"]
1186
+ ]))
1187
+
1188
+ // byService matchea directamente ServiceError.service → "http" → "networking"
1189
+ .classify(classifiers.byService({ http: "networking" }))
1190
+ ```
1191
+
1192
+ El orden importa: el primer classifier que matchea gana. Usá `byCommand`/`byPattern` primero (más específico) y `byService` como fallback.
1193
+
1194
+ ### Mensajes personalizados para servicios
1195
+
1196
+ ```typescript
1197
+ ctx.notifier
1198
+ // byService: mensaje fijo por nombre de servicio
1199
+ .describeError(messages.byService({
1200
+ docker: "Falló una operación de Docker, revisa el build/push del registry",
1201
+ http: "Falló una petición HTTP, revisa la conectividad"
1202
+ }))
1203
+
1204
+ // byRule unwrappea ServiceError.cause → distingue por comando + args + patrón
1205
+ .describeError(messages.byRule([
1206
+ {
1207
+ command: "docker", args: "push", pattern: /500 Internal Server Error/,
1208
+ message: "Falta espacio en el registry"
1209
+ },
1210
+ {
1211
+ command: "kubectl", pattern: /500/,
1212
+ message: "El API server de Kubernetes devolvió 500"
1213
+ }
1214
+ ]));
1215
+ ```
1216
+
1217
+ ### Propiedades no-función pasan sin proxy
1218
+
1219
+ El Proxy solo intercepta llamadas a métodos. Las propiedades que no son funciones (strings, números, objetos) pasan directamente:
1220
+
1221
+ ```typescript
1222
+ const docker = ctx.wrap(ctx.services.docker, "docker");
1223
+ docker.version; // pasa directo, sin proxy
1224
+ ```
1225
+
1226
+ ## Tests
1227
+
1228
+ ```bash
1229
+ npm test
1230
+ ```
1231
+
1232
+ `pretest` corre el build automáticamente, así los tests validan el `dist/` real que se publica (no el código fuente). Usa `node:test`, sin dependencias externas — interceptando `shell.exec` en vez de ejecutar binarios reales:
1233
+
1234
+ - `context.test.js` — Context, flags, vars, parseArgv, dryRun global
1235
+ - `shell.test.js` — retry, timeout, dryRun del motor shell.exec
1236
+ - `services.test.js` — que docker/terraform/argocd arman bien sus argumentos
1237
+ - `menu-selector.test.js` — selección automática por flag, en cascada de varios niveles
1238
+ - `hooks-integration.test.js` — ctx.run() y items de menú con onSuccess/onError
1239
+ - `notifier.test.js` — classify/channel/onSuccess/describeError/byRule
1240
+ - `senders.test.js` — file/http/webhook/log contra servidores reales en localhost
1241
+ - `http.test.js` — servicio HTTP: métodos, headers, query, interceptors, abort, timeout, dryRun, múltiples instancias aisladas
1242
+ - `kubectl.test.js`, `oc.test.js` — kubeconfig/namespace, waitForDeployment (éxito, timeout, Failed, maxRestarts)
1243
+ - `exec-options-passthrough.test.js` — retry/timeout/dryRun (`exec`) llegando a todos los servicios, incluyendo un retry real que se recupera tras 2 fallos
1244
+ - `deployment-group.test.js` — waitForDeploymentGroup (éxito total, fallo parcial, label sin matches, oc con `dc`)
1245
+ - `azdo-api.test.js` — AzureDevOpsApi contra mock server: proyectos, repos, branches, commits, PRs, builds, pipelines, work items, overrides
1246
+ - `pipeline.test.js` — Pipeline motor: stages/jobs/tasks, dependencias cross-level con paths dotted, resultados jerárquicos (stage.job.task), fluent API, registry, reset, ctx access, detección de tipo por propiedad, PipelineResultsAccessor
1247
+ - `service-notify.test.js` — ServiceError, ctx.wrap(), Notifier con service/method/args, classifiers.byService/messages.byService, unwrap de ServiceError en byCommand/byPattern/byRule, integración completa, backward compat
1248
+
1249
+ ## Siguientes pasos posibles
1250
+
1251
+ - Publicar en un registro privado (Verdaccio/Artifactory/GitHub Packages) para instalarlo con scope, p.ej. `@miorg/catops-cli`.
1252
+ - Agregar más plugins (`ansible-lint`, `trivy`, `sonar-scanner`) con el mismo patrón que `terraform.ts`/`docker.ts`.
1253
+ - CI propio (GitHub Actions/Azure Pipelines) que corra `npm test` en cada PR antes de `npm publish`.
1254
+ - `--catch=throw` (o similar) para que un item de menú fallido mate el proceso completo en vez de solo loguear y seguir — útil corriendo vía `--menu-selector` dentro de un step de Azure Pipelines.