talo-hub 0.1.2 → 0.1.4
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.
- package/README.md +107 -104
- package/dist/cli.js +189 -47
- package/dist/probe.js +59 -0
- package/dist/secrets.js +67 -0
- package/dist/stacks.js +97 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,104 +1,107 @@
|
|
|
1
|
-
# talo-hub
|
|
2
|
-
|
|
3
|
-
**Cobrá con transferencias bancarias en Argentina, con acreditación y conciliación automáticas.** Desde la terminal, tu código o tu agente de IA, con [Talo Pay](https://talo.com.ar).
|
|
4
|
-
|
|
5
|
-
Cada cobro tiene su propio **CVU y alias**. Tu cliente transfiere desde cualquier banco o billetera, Talo lo acredita y vos sabés al instante qué pedido se pagó: sin comprobantes, sin revisar el home banking y sin conciliar a mano.
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npx talo-hub login # conectá tu cuenta Talo (sandbox, sin plata real)
|
|
9
|
-
npx talo-hub payments create --amount 15000 # → CVU y alias para que te transfieran
|
|
10
|
-
npx talo-hub sandbox pay <payment_id> # simulá la transferencia del cliente
|
|
11
|
-
npx talo-hub events listen # → payment.paid (muestra los últimos y espera los nuevos)
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Sin instalar nada: alcanza con Node 20 o más nuevo. Documentación: **https://talopartnerhub.vercel.app/docs**
|
|
15
|
-
|
|
16
|
-
## Por qué talo-hub
|
|
17
|
-
|
|
18
|
-
- **Un CVU por cobro:** cada pago se identifica solo. Se acabó el "mandame el comprobante".
|
|
19
|
-
- **Confirmación automática:** te enterás cuando la plata entra, por terminal, webhook firmado o tu agente.
|
|
20
|
-
- **Sandbox real:** simulás transferencias exactas, de menos (UNDERPAID) o de más (OVERPAID) y probás todo el flujo sin mover plata.
|
|
21
|
-
- **Hecho para agentes de IA:** servidor MCP con OAuth, skills listas y docs en Markdown. Tu agente integra, prueba y lleva a producción.
|
|
22
|
-
- **Seguro:** keys separadas para sandbox y producción, con permisos `read`, `charge` (cobra, no reembolsa) o `full`.
|
|
23
|
-
|
|
24
|
-
## Para agentes de IA
|
|
25
|
-
|
|
26
|
-
Conectá Claude Code, Cursor o Claude Desktop a tu cuenta Talo. Con OAuth, la key nunca pasa por el chat:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp
|
|
30
|
-
# después, en Claude Code: /mcp → talo → Authenticate
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
npx talo-hub mcp --client cursor # o --client desktop; --with-key para usar tu key
|
|
35
|
-
npx talo-hub skills install # skills de Talo en .claude/skills
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
El agente puede crear cobros, simular transferencias, registrar y probar su webhook, correr el checklist de producción (`check_go_live`) y leer la documentación (`read_docs`).
|
|
39
|
-
|
|
40
|
-
### Instructions for AI agents
|
|
41
|
-
|
|
42
|
-
- Purpose: accept ARS bank-transfer payments in Argentina on the user's Talo Pay account. Each payment returns a `cvu` and `alias`; status goes `PENDING` → `SUCCESS` (or `UNDERPAID` / `OVERPAID` / `EXPIRED`).
|
|
43
|
-
- Connect: MCP at `https://talopartnerhub.vercel.app/api/mcp` (OAuth), or this CLI after the user runs `npx talo-hub login`. A person must authorize Talo once per environment; everything else you can do.
|
|
44
|
-
- Keys: `tph_test_…` sandbox (test money: test freely), `tph_live_…` production (real money: confirm refunds and amount changes with the user). Scopes `read` < `charge` < `full`; give servers `charge`. Store project keys in the server's env (`TALO_HUB_API_KEY`), never in code or chat; exporting it in your own shell replaces the login key. `login --production` does not make production the default: pass `--production` or run `use production`.
|
|
45
|
-
- Machine-readable: every command takes `--json` (JSON on stdout, human messages on stderr) and exits non-zero on failure, `doctor` and `webhooks test` included. Errors with `--json`: `{"error":{"code","message","status"}}`, with stable codes such as `not_logged_in`, `invalid_amount`, `invalid_hub`, `env_key_mismatch`, `insufficient_scope` (plus `key_scope` and `required_scope`), `talo_reconnect_required`, `network_error`, `invalid_response`. `status` is the hub's HTTP status, or null when the CLI found the error itself or couldn't reach the hub. `events listen --json` prints one event per line; the last few existing events come first with `"replayed": true`. Docs as Markdown: https://talopartnerhub.vercel.app/llms.txt, `/docs/<page>.md`, OpenAPI at `/openapi.json`.
|
|
46
|
-
- Amounts: pass `--amount 15000` or `15000.50`. Don't use thousands separators unquoted: PowerShell turns `15.000,50` into `15,50`.
|
|
47
|
-
- Payments always include `paid` and `refunded` as numbers (0 when none) and `refunds: [{ id, status, type, amount, currency, created_at }]`. Filter events with `events list --payment <id>`.
|
|
48
|
-
- Test plan: `payments create --amount 15000` → `sandbox pay <id> --amount 14999` (UNDERPAID, don't fulfil) → `sandbox pay <id> --amount 1` (SUCCESS) → `webhooks add <https url>` → `webhooks test <id>` → `doctor` until it exits 0.
|
|
49
|
-
|
|
50
|
-
## Comandos
|
|
51
|
-
|
|
52
|
-
| Comando | |
|
|
53
|
-
|---|---|
|
|
54
|
-
| `login [--production]` | Conecta tu cuenta Talo desde el navegador |
|
|
55
|
-
| `status` | Cuenta, estado en Talo y permisos de la key |
|
|
56
|
-
| `doctor` | Checklist para pasar a producción, con lo que falta |
|
|
57
|
-
| `payments create --amount <ARS> [--external-id <id>] [--motive <texto>]` | Crea un cobro y muestra CVU y alias |
|
|
58
|
-
| `payments get <id>` | Estado de un cobro |
|
|
59
|
-
| `payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]` | Cobros de un período (30 días por defecto) |
|
|
60
|
-
| `payments refund <id> [--amount <ARS>]` | Reembolso total o parcial |
|
|
61
|
-
| `sandbox pay <id> [--amount <ARS>]` | Simula la transferencia del cliente |
|
|
62
|
-
| `balance`, `transactions [--from] [--to] [--limit <n>] [--cursor]` | Saldo y movimientos |
|
|
63
|
-
| `events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <n>]` | Eventos recientes, filtrables por cobro o tipo |
|
|
64
|
-
| `events listen [--forward-to <url\|puerto>]` | Recibe eventos en vivo y los reenvía a localhost sin túneles (sin firma, con `talo-hub-forwarded: 1`: solo para desarrollo) |
|
|
65
|
-
| `webhooks list \| add <url> \| remove <id> \| test <id>` | Webhooks firmados con HMAC-SHA256 |
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
npx talo-hub
|
|
83
|
-
npx talo-hub
|
|
84
|
-
npx talo-hub
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- `
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
1
|
+
# talo-hub
|
|
2
|
+
|
|
3
|
+
**Cobrá con transferencias bancarias en Argentina, con acreditación y conciliación automáticas.** Desde la terminal, tu código o tu agente de IA, con [Talo Pay](https://talo.com.ar).
|
|
4
|
+
|
|
5
|
+
Cada cobro tiene su propio **CVU y alias**. Tu cliente transfiere desde cualquier banco o billetera, Talo lo acredita y vos sabés al instante qué pedido se pagó: sin comprobantes, sin revisar el home banking y sin conciliar a mano.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx talo-hub login # conectá tu cuenta Talo (sandbox, sin plata real)
|
|
9
|
+
npx talo-hub payments create --amount 15000 # → CVU y alias para que te transfieran
|
|
10
|
+
npx talo-hub sandbox pay <payment_id> # simulá la transferencia del cliente
|
|
11
|
+
npx talo-hub events listen # → payment.paid (muestra los últimos y espera los nuevos)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Sin instalar nada: alcanza con Node 20 o más nuevo. Documentación: **https://talopartnerhub.vercel.app/docs**
|
|
15
|
+
|
|
16
|
+
## Por qué talo-hub
|
|
17
|
+
|
|
18
|
+
- **Un CVU por cobro:** cada pago se identifica solo. Se acabó el "mandame el comprobante".
|
|
19
|
+
- **Confirmación automática:** te enterás cuando la plata entra, por terminal, webhook firmado o tu agente.
|
|
20
|
+
- **Sandbox real:** simulás transferencias exactas, de menos (UNDERPAID) o de más (OVERPAID) y probás todo el flujo sin mover plata.
|
|
21
|
+
- **Hecho para agentes de IA:** servidor MCP con OAuth, skills listas y docs en Markdown. Tu agente integra, prueba y lleva a producción.
|
|
22
|
+
- **Seguro:** keys separadas para sandbox y producción, con permisos `read`, `charge` (cobra, no reembolsa) o `full`.
|
|
23
|
+
|
|
24
|
+
## Para agentes de IA
|
|
25
|
+
|
|
26
|
+
Conectá Claude Code, Cursor o Claude Desktop a tu cuenta Talo. Con OAuth, la key nunca pasa por el chat:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp
|
|
30
|
+
# después, en Claude Code: /mcp → talo → Authenticate
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx talo-hub mcp --client cursor # o --client desktop; --with-key para usar tu key
|
|
35
|
+
npx talo-hub skills install # skills de Talo + el kit de tu stack, en .claude/skills
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
El agente puede crear cobros, simular transferencias, registrar y probar su webhook, correr el checklist de producción (`check_go_live`) y leer la documentación (`read_docs`).
|
|
39
|
+
|
|
40
|
+
### Instructions for AI agents
|
|
41
|
+
|
|
42
|
+
- Purpose: accept ARS bank-transfer payments in Argentina on the user's Talo Pay account. Each payment returns a `cvu` and `alias`; status goes `PENDING` → `SUCCESS` (or `UNDERPAID` / `OVERPAID` / `EXPIRED`).
|
|
43
|
+
- Connect: MCP at `https://talopartnerhub.vercel.app/api/mcp` (OAuth), or this CLI after the user runs `npx talo-hub login`. A person must authorize Talo once per environment; everything else you can do.
|
|
44
|
+
- Keys: `tph_test_…` sandbox (test money: test freely), `tph_live_…` production (real money: confirm refunds and amount changes with the user). Scopes `read` < `charge` < `full`; give servers `charge`. Store project keys in the server's env (`TALO_HUB_API_KEY`), never in code or chat: `keys create --env-file .env.local` or `--vercel production|preview[:<branch>]` writes it there without printing it (same flags on `webhooks add` for `TALO_HUB_WEBHOOK_SECRET`); exporting it in your own shell replaces the login key. `login --production` does not make production the default: pass `--production` or run `use production`.
|
|
45
|
+
- Machine-readable: every command takes `--json` (JSON on stdout, human messages on stderr) and exits non-zero on failure, `doctor` and `webhooks test` included. Errors with `--json`: `{"error":{"code","message","status"}}`, with stable codes such as `not_logged_in`, `invalid_amount`, `invalid_hub`, `env_key_mismatch`, `insufficient_scope` (plus `key_scope` and `required_scope`), `talo_reconnect_required`, `network_error`, `invalid_response`. `status` is the hub's HTTP status, or null when the CLI found the error itself or couldn't reach the hub. `events listen --json` prints one event per line; the last few existing events come first with `"replayed": true`. Docs as Markdown: https://talopartnerhub.vercel.app/llms.txt, `/docs/<page>.md`, OpenAPI at `/openapi.json`.
|
|
46
|
+
- Amounts: pass `--amount 15000` or `15000.50`. Don't use thousands separators unquoted: PowerShell turns `15.000,50` into `15,50`.
|
|
47
|
+
- Payments always include `paid` and `refunded` as numbers (0 when none) and `refunds: [{ id, status, type, amount, currency, created_at }]`. Filter events with `events list --payment <id>`.
|
|
48
|
+
- Test plan: `payments create --amount 15000` → `sandbox pay <id> --amount 14999` (UNDERPAID, don't fulfil) → `sandbox pay <id> --amount 1` (SUCCESS) → `webhooks add <https url>` (it first probes the URL and refuses redirects, Vercel Deployment Protection or a handler that accepts unsigned events; `--skip-check` to skip) → `webhooks test <id>` (on failure `reason` says why: `redirect`, `vercel_protection`, `timeout`, `network`, `http_error`) → `doctor` until it exits 0. Best practices per stack: `skills install` (it detects the stack).
|
|
49
|
+
|
|
50
|
+
## Comandos
|
|
51
|
+
|
|
52
|
+
| Comando | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `login [--production]` | Conecta tu cuenta Talo desde el navegador |
|
|
55
|
+
| `status` | Cuenta, estado en Talo y permisos de la key |
|
|
56
|
+
| `doctor` | Checklist para pasar a producción, con lo que falta |
|
|
57
|
+
| `payments create --amount <ARS> [--external-id <id>] [--motive <texto>]` | Crea un cobro y muestra CVU y alias |
|
|
58
|
+
| `payments get <id>` | Estado de un cobro |
|
|
59
|
+
| `payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]` | Cobros de un período (30 días por defecto) |
|
|
60
|
+
| `payments refund <id> [--amount <ARS>]` | Reembolso total o parcial |
|
|
61
|
+
| `sandbox pay <id> [--amount <ARS>]` | Simula la transferencia del cliente |
|
|
62
|
+
| `balance`, `transactions [--from] [--to] [--limit <n>] [--cursor]` | Saldo y movimientos |
|
|
63
|
+
| `events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <n>]` | Eventos recientes, filtrables por cobro o tipo |
|
|
64
|
+
| `events listen [--forward-to <url\|puerto>]` | Recibe eventos en vivo y los reenvía a localhost sin túneles (sin firma, con `talo-hub-forwarded: 1`: solo para desarrollo) |
|
|
65
|
+
| `webhooks list \| add <url> \| remove <id> \| test <id>` | Webhooks firmados con HMAC-SHA256. `add` prueba antes la URL (redirecciones, Deployment Protection de Vercel) |
|
|
66
|
+
| `webhooks add <url> --vercel <production\|preview[:rama]>` / `--env-file <archivo>` | Guarda el secreto como `TALO_HUB_WEBHOOK_SECRET` en Vercel o en tu .env, sin mostrarlo |
|
|
67
|
+
| `keys list \| create [--name] [--scope read\|charge\|full] \| revoke <id>` | Keys para tus proyectos (`charge` por defecto) |
|
|
68
|
+
| `keys create … --vercel <production\|preview[:rama]>` / `--env-file <archivo>` | Guarda la key como `TALO_HUB_API_KEY` en Vercel (tipo Secret) o en tu .env, sin mostrarla |
|
|
69
|
+
| `mcp [--client claude\|cursor\|desktop] [--with-key]` | Configuración del MCP para tu agente |
|
|
70
|
+
| `skills install [--stack <kit>] [--global]` | Instala las skills de Talo y el kit de mejores prácticas de tu stack (Next.js + Vercel, Node, Python, Laravel, edge) |
|
|
71
|
+
| `skills list` | Kits por stack y cuál corresponde a tu proyecto |
|
|
72
|
+
| `use <sandbox\|production>` | Entorno activo de los próximos comandos |
|
|
73
|
+
| `logout [--production \| --all]` | Revoca la key en el hub y la borra de esta máquina |
|
|
74
|
+
|
|
75
|
+
Opciones: `--sandbox`, `--production`, `--json`, `--version`, `--hub <url>`.
|
|
76
|
+
|
|
77
|
+
Montos: `15000` o `15000.50`. También entiende `15000,50` y `15.000,50`, pero en PowerShell escribilos entre comillas (`"15.000,50"`): sin comillas, PowerShell los convierte en otro número antes de que lleguen al CLI. Fechas: `AAAA-MM-DD` o ISO 8601, en hora de Argentina.
|
|
78
|
+
|
|
79
|
+
## De sandbox a producción
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx talo-hub doctor # sandbox listo
|
|
83
|
+
npx talo-hub login --production
|
|
84
|
+
npx talo-hub keys create --production --name mi-tienda --scope charge --vercel production # o --env-file .env
|
|
85
|
+
npx talo-hub webhooks add https://tu-sitio.com/api/webhooks/talo --production --vercel production
|
|
86
|
+
npx talo-hub webhooks test <id> --production
|
|
87
|
+
npx talo-hub doctor --production
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Seguridad
|
|
91
|
+
|
|
92
|
+
- `login` muestra un código para confirmar en el navegador. La key queda en `~/.config/talo-hub/config.json` (en Windows, `%APPDATA%\talo-hub`), legible solo por tu usuario.
|
|
93
|
+
- `TALO_HUB_API_KEY` usa una key sin login, para CI y agentes. Si no coincide con `--sandbox` o `--production`, el CLI frena en vez de operar en el entorno equivocado.
|
|
94
|
+
- Cada key guardada recuerda el hub que la emitió y no se manda a ningún otro.
|
|
95
|
+
- `mcp` usa OAuth por defecto: tu key no queda en el historial de la terminal ni en archivos del proyecto.
|
|
96
|
+
- Tus keys solo viajan por https (o a un hub en tu propia máquina, para desarrollo).
|
|
97
|
+
|
|
98
|
+
## Links
|
|
99
|
+
|
|
100
|
+
- Docs: https://talopartnerhub.vercel.app/docs
|
|
101
|
+
- Para agentes: https://talopartnerhub.vercel.app/docs/agents · https://talopartnerhub.vercel.app/llms.txt
|
|
102
|
+
- API: https://talopartnerhub.vercel.app/docs/api · OpenAPI: https://talopartnerhub.vercel.app/openapi.json
|
|
103
|
+
- Dashboard: https://talopartnerhub.vercel.app/dashboard
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
talo-hub es parte de Talo Partner Hub, una integración de un partner de Talo Pay; no es un producto oficial de Talo. Los cobros los procesa Talo en tu cuenta.
|
package/dist/cli.js
CHANGED
|
@@ -12,43 +12,52 @@ import { parseArgs } from "node:util";
|
|
|
12
12
|
import { configPath, environmentOfKey, loadConfig, requestedHub, resolveKey, saveConfig, DEFAULT_HUB, } from "./config.js";
|
|
13
13
|
import { Hub, HubRequestError, networkReason } from "./hub.js";
|
|
14
14
|
import { parseAmount, parseCount, parseDate, parseForwardUrl } from "./parse.js";
|
|
15
|
+
import { probeWebhookUrl } from "./probe.js";
|
|
16
|
+
import { addToVercel, describeTarget, gitIgnores, parseVercelTarget, writeEnvFile } from "./secrets.js";
|
|
17
|
+
import { detectStacks, pickStacks, readSkillsIndex } from "./stacks.js";
|
|
15
18
|
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
16
19
|
/** How to tell people to run us again: through npx when that's how they ran us. */
|
|
17
20
|
const CMD = process.env.npm_command === "exec" ? "npx talo-hub" : "talo-hub";
|
|
18
21
|
const jsonMode = process.argv.some((arg) => /^--json(=|$)/.test(arg));
|
|
19
|
-
const HELP = `talo-hub ${VERSION}: transferencias bancarias automáticas en Argentina con Talo Pay.
|
|
20
|
-
|
|
21
|
-
Cuenta
|
|
22
|
-
login [--production] Conecta tu cuenta Talo (sandbox por defecto)
|
|
23
|
-
logout [--production | --all] Revoca y borra la key guardada
|
|
24
|
-
use <sandbox|production> Elige el entorno de los próximos comandos
|
|
25
|
-
status Cuenta conectada, estado en Talo y permisos de la key
|
|
26
|
-
doctor Checklist para pasar a producción
|
|
27
|
-
|
|
28
|
-
Cobros
|
|
29
|
-
payments create --amount <ARS> [--external-id <id>] [--motive <texto>]
|
|
30
|
-
payments get <payment_id>
|
|
31
|
-
payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]
|
|
32
|
-
payments refund <payment_id> [--amount <ARS>] Sin --amount, reembolso total
|
|
33
|
-
sandbox pay <payment_id> [--amount <ARS>] Simula la transferencia (sandbox)
|
|
34
|
-
balance
|
|
35
|
-
transactions [--from …] [--to …] [--limit <1-200>] [--cursor <next_cursor>]
|
|
36
|
-
|
|
37
|
-
Eventos y webhooks
|
|
38
|
-
events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <1-200>]
|
|
39
|
-
Sin --after, los más recientes
|
|
40
|
-
events listen [--forward-to <url|puerto>] Muestra los últimos y escucha los nuevos
|
|
41
|
-
(los reenviados no van firmados: solo para desarrollo)
|
|
42
|
-
webhooks list | add <url> | remove <id> | test <id>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
22
|
+
const HELP = `talo-hub ${VERSION}: transferencias bancarias automáticas en Argentina con Talo Pay.
|
|
23
|
+
|
|
24
|
+
Cuenta
|
|
25
|
+
login [--production] Conecta tu cuenta Talo (sandbox por defecto)
|
|
26
|
+
logout [--production | --all] Revoca y borra la key guardada
|
|
27
|
+
use <sandbox|production> Elige el entorno de los próximos comandos
|
|
28
|
+
status Cuenta conectada, estado en Talo y permisos de la key
|
|
29
|
+
doctor Checklist para pasar a producción
|
|
30
|
+
|
|
31
|
+
Cobros
|
|
32
|
+
payments create --amount <ARS> [--external-id <id>] [--motive <texto>]
|
|
33
|
+
payments get <payment_id>
|
|
34
|
+
payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]
|
|
35
|
+
payments refund <payment_id> [--amount <ARS>] Sin --amount, reembolso total
|
|
36
|
+
sandbox pay <payment_id> [--amount <ARS>] Simula la transferencia (sandbox)
|
|
37
|
+
balance
|
|
38
|
+
transactions [--from …] [--to …] [--limit <1-200>] [--cursor <next_cursor>]
|
|
39
|
+
|
|
40
|
+
Eventos y webhooks
|
|
41
|
+
events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <1-200>]
|
|
42
|
+
Sin --after, los más recientes
|
|
43
|
+
events listen [--forward-to <url|puerto>] Muestra los últimos y escucha los nuevos
|
|
44
|
+
(los reenviados no van firmados: solo para desarrollo)
|
|
45
|
+
webhooks list | add <url> | remove <id> | test <id>
|
|
46
|
+
webhooks add <url> [--vercel <entorno>] [--env-file <archivo>] [--skip-check]
|
|
47
|
+
Antes de registrar prueba la URL (redirecciones, Vercel)
|
|
48
|
+
|
|
49
|
+
Keys y agentes
|
|
50
|
+
keys list | create [--name <nombre>] [--scope read|charge|full] | revoke <id> (charge por defecto)
|
|
51
|
+
keys create … --vercel <production|preview[:rama]> | --env-file <archivo>
|
|
52
|
+
Guarda la key en Vercel o en tu .env sin mostrarla
|
|
53
|
+
mcp [--client claude|cursor|desktop] [--with-key] Configuración del MCP para tu agente
|
|
54
|
+
skills install [--stack <kit>] [--global] Instala las skills de Talo en .claude/skills, con el kit
|
|
55
|
+
de tu stack (lo detecta; --stack all, --stack none)
|
|
56
|
+
skills list Kits por stack y cuál corresponde a este proyecto
|
|
57
|
+
|
|
58
|
+
Montos: 15000 o 15000.50 (en PowerShell, con separador de miles, entre comillas: "15.000,50").
|
|
59
|
+
Fechas: AAAA-MM-DD o ISO 8601; sin zona horaria, hora de Argentina.
|
|
60
|
+
Opciones: --sandbox, --production, --json, --hub <url>, --version
|
|
52
61
|
Variables: TALO_HUB_API_KEY (usa esa key en vez de la guardada), TALO_HUB_URL`;
|
|
53
62
|
/** Prints the error (as JSON with --json, with a stable code) and exits 1. */
|
|
54
63
|
function fail(message, code = "usage", extra = {}) {
|
|
@@ -99,6 +108,10 @@ function parseCommandLine() {
|
|
|
99
108
|
client: { type: "string" },
|
|
100
109
|
"with-key": { type: "boolean" },
|
|
101
110
|
global: { type: "boolean" },
|
|
111
|
+
stack: { type: "string" },
|
|
112
|
+
vercel: { type: "string" },
|
|
113
|
+
"env-file": { type: "string" },
|
|
114
|
+
"skip-check": { type: "boolean" },
|
|
102
115
|
help: { type: "boolean", short: "h" },
|
|
103
116
|
version: { type: "boolean", short: "v" },
|
|
104
117
|
},
|
|
@@ -135,9 +148,12 @@ const COMMAND_OPTIONS = {
|
|
|
135
148
|
"events list": ["after", "limit", "payment", "type"],
|
|
136
149
|
"events listen": ["forward-to"],
|
|
137
150
|
webhooks: [],
|
|
151
|
+
"webhooks add": ["vercel", "env-file", "skip-check"],
|
|
138
152
|
keys: ["name", "scope"],
|
|
153
|
+
"keys create": ["name", "scope", "vercel", "env-file"],
|
|
139
154
|
mcp: ["client", "with-key"],
|
|
140
|
-
skills: ["global"],
|
|
155
|
+
"skills install": ["global", "stack"],
|
|
156
|
+
"skills list": [],
|
|
141
157
|
};
|
|
142
158
|
function checkOptions(command, sub) {
|
|
143
159
|
const key = COMMAND_OPTIONS[`${command} ${sub ?? "list"}`] ? `${command} ${sub ?? "list"}` : command;
|
|
@@ -423,16 +439,47 @@ async function listen(hub) {
|
|
|
423
439
|
await sleep(2000);
|
|
424
440
|
}
|
|
425
441
|
}
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
/** The hub's list of skills: the core ones, and the stack kits with how to recognize each stack. */
|
|
443
|
+
async function fetchSkillsIndex(hubUrl) {
|
|
444
|
+
let status = 0;
|
|
445
|
+
let body;
|
|
446
|
+
try {
|
|
447
|
+
const response = await fetch(`${hubUrl}/skills/index.json`, { signal: AbortSignal.timeout(30_000) });
|
|
448
|
+
status = response.status;
|
|
449
|
+
if (response.ok)
|
|
450
|
+
body = await response.json().catch(() => undefined);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
|
|
454
|
+
}
|
|
455
|
+
const skills = readSkillsIndex(body);
|
|
456
|
+
if (!skills?.length)
|
|
457
|
+
fail(`No pudimos leer la lista de skills del hub (HTTP ${status}).`, "download_failed");
|
|
458
|
+
return skills;
|
|
459
|
+
}
|
|
460
|
+
const kitIds = (kits) => kits.map((kit) => kit.stack).join(", ");
|
|
461
|
+
/**
|
|
462
|
+
* Downloads the core skills and the stack kits into ./.claude/skills (or ~/.claude/skills with
|
|
463
|
+
* --global): the kits that match this project, the ones --stack names, or all of them for --global.
|
|
464
|
+
*/
|
|
428
465
|
async function installSkills(hubUrl) {
|
|
466
|
+
const index = await fetchSkillsIndex(hubUrl);
|
|
467
|
+
const kits = index.filter((skill) => skill.kind === "stack");
|
|
468
|
+
const detected = flags.global ? [] : await detectStacks(process.cwd(), kits);
|
|
469
|
+
let chosen = flags.global ? kits : detected;
|
|
470
|
+
if (flags.stack !== undefined) {
|
|
471
|
+
const picked = pickStacks(kits, flags.stack);
|
|
472
|
+
if (picked.unknown.length)
|
|
473
|
+
fail(`No hay kit para ${picked.unknown.join(", ")}. Kits: ${kitIds(kits)} (o all, none).`, "unknown_stack");
|
|
474
|
+
chosen = picked.kits;
|
|
475
|
+
}
|
|
429
476
|
const root = join(flags.global ? homedir() : process.cwd(), ".claude", "skills");
|
|
430
477
|
const installed = [];
|
|
431
|
-
for (const skill of
|
|
478
|
+
for (const skill of [...index.filter((entry) => entry.kind === "core"), ...chosen]) {
|
|
432
479
|
let text = "";
|
|
433
480
|
let status = 0;
|
|
434
481
|
try {
|
|
435
|
-
const response = await fetch(`${hubUrl}/skills/${skill}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
|
|
482
|
+
const response = await fetch(`${hubUrl}/skills/${skill.slug}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
|
|
436
483
|
status = response.status;
|
|
437
484
|
if (response.ok)
|
|
438
485
|
text = await response.text();
|
|
@@ -440,15 +487,75 @@ async function installSkills(hubUrl) {
|
|
|
440
487
|
catch (error) {
|
|
441
488
|
fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
|
|
442
489
|
}
|
|
443
|
-
if (!text.startsWith("---") || !text.includes(`name: ${skill}`))
|
|
444
|
-
fail(`No pudimos descargar la skill ${skill} (HTTP ${status}).`, "download_failed");
|
|
445
|
-
const path = join(root, skill, "SKILL.md");
|
|
446
|
-
await mkdir(join(root, skill), { recursive: true });
|
|
490
|
+
if (!text.startsWith("---") || !text.includes(`name: ${skill.slug}`))
|
|
491
|
+
fail(`No pudimos descargar la skill ${skill.slug} (HTTP ${status}).`, "download_failed");
|
|
492
|
+
const path = join(root, skill.slug, "SKILL.md");
|
|
493
|
+
await mkdir(join(root, skill.slug), { recursive: true });
|
|
447
494
|
await writeFile(path, text);
|
|
448
495
|
installed.push(path);
|
|
449
496
|
say(`✓ ${path}`);
|
|
450
497
|
}
|
|
451
|
-
emit({ installed
|
|
498
|
+
emit({ installed, stacks: chosen.map((kit) => kit.stack), detected: detected.map((kit) => kit.stack) }, () => {
|
|
499
|
+
if (chosen.length)
|
|
500
|
+
console.log(`Kits por stack: ${chosen.map((kit) => kit.label).join(", ")}.`);
|
|
501
|
+
else if (flags.stack === undefined)
|
|
502
|
+
console.log(`No reconocí el stack de este proyecto. Elegí un kit con ${CMD} skills install --stack <${kitIds(kits)}>.`);
|
|
503
|
+
console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/** The stack kits, marking the ones that match this project. */
|
|
507
|
+
async function listSkills(hubUrl) {
|
|
508
|
+
const index = await fetchSkillsIndex(hubUrl);
|
|
509
|
+
const kits = index.filter((skill) => skill.kind === "stack");
|
|
510
|
+
const detected = new Set((await detectStacks(process.cwd(), kits)).map((kit) => kit.slug));
|
|
511
|
+
emit({ skills: index.map((skill) => ({ ...skill, ...(skill.kind === "stack" ? { detected: detected.has(skill.slug) } : {}) })) }, () => {
|
|
512
|
+
console.log(`Skills base: ${index.filter((skill) => skill.kind === "core").map((skill) => skill.slug).join(", ")}`);
|
|
513
|
+
console.log("\nKits por stack (--stack <kit>):");
|
|
514
|
+
for (const kit of kits)
|
|
515
|
+
console.log(` ${kit.stack.padEnd(16)}${kit.label}${detected.has(kit.slug) ? " ← este proyecto" : ""}`);
|
|
516
|
+
console.log(`\nInstalar: ${CMD} skills install. Otro stack: ${DEFAULT_HUB}/docs/stacks.md`);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Where --vercel and --env-file ask a new secret to go, checked before anything is created. */
|
|
520
|
+
function secretDestinations() {
|
|
521
|
+
let vercel;
|
|
522
|
+
if (flags.vercel !== undefined) {
|
|
523
|
+
try {
|
|
524
|
+
vercel = parseVercelTarget(flags.vercel);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
fail(error.message, "invalid_option");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (flags["env-file"] !== undefined && !flags["env-file"].trim())
|
|
531
|
+
fail("--env-file necesita la ruta del archivo, por ejemplo .env.local.", "invalid_option");
|
|
532
|
+
return { vercel, envFile: flags["env-file"] };
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Writes the secret to each destination. Returns where it went, or Vercel's or the file system's
|
|
536
|
+
* error (the caller then undoes what it created, so no secret is left that nobody has).
|
|
537
|
+
*/
|
|
538
|
+
async function storeSecret(name, value, to) {
|
|
539
|
+
const saved = [];
|
|
540
|
+
if (to.envFile) {
|
|
541
|
+
try {
|
|
542
|
+
await writeEnvFile(to.envFile, name, value);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
return { saved, error: `No pudimos escribir ${to.envFile}: ${error.message}` };
|
|
546
|
+
}
|
|
547
|
+
saved.push(to.envFile);
|
|
548
|
+
if (gitIgnores(to.envFile) === false)
|
|
549
|
+
say(`! git no ignora ${to.envFile}: agregá .env* a .gitignore antes de commitear.`);
|
|
550
|
+
}
|
|
551
|
+
if (to.vercel) {
|
|
552
|
+
say(`… Guardando ${name} en Vercel (${to.vercel.environment}${to.vercel.branch ? `, rama ${to.vercel.branch}` : ""})`);
|
|
553
|
+
const error = addToVercel(name, value, to.vercel);
|
|
554
|
+
if (error)
|
|
555
|
+
return { saved, error: `Vercel no la guardó:\n${error}\n¿Corriste vercel link en este proyecto?` };
|
|
556
|
+
saved.push(describeTarget(to.vercel));
|
|
557
|
+
}
|
|
558
|
+
return { saved };
|
|
452
559
|
}
|
|
453
560
|
/** Which shell will run a printed command: PowerShell spells environment variables differently. */
|
|
454
561
|
function shellSyntax() {
|
|
@@ -524,8 +631,8 @@ async function main() {
|
|
|
524
631
|
if (command === "login")
|
|
525
632
|
return login(config);
|
|
526
633
|
if (command === "skills") {
|
|
527
|
-
if (sub !== "install")
|
|
528
|
-
fail(`Usá: ${CMD} skills install [--global]`);
|
|
634
|
+
if (sub !== undefined && sub !== "install" && sub !== "list")
|
|
635
|
+
fail(`Usá: ${CMD} skills install [--stack <kit>] [--global] | skills list`);
|
|
529
636
|
let hubUrl;
|
|
530
637
|
try {
|
|
531
638
|
hubUrl = requestedHub(flags.hub) ?? DEFAULT_HUB;
|
|
@@ -533,7 +640,7 @@ async function main() {
|
|
|
533
640
|
catch (error) {
|
|
534
641
|
fail(error.message, "invalid_hub");
|
|
535
642
|
}
|
|
536
|
-
return installSkills(hubUrl);
|
|
643
|
+
return sub === "install" ? installSkills(hubUrl) : listSkills(hubUrl);
|
|
537
644
|
}
|
|
538
645
|
if (command === "logout") {
|
|
539
646
|
const environments = flags.all ? ["sandbox", "production"] : [chosenEnvironment(config)];
|
|
@@ -745,7 +852,25 @@ async function run(hub, ctx) {
|
|
|
745
852
|
if (!arg)
|
|
746
853
|
fail(`Usá: ${CMD} webhooks ${sub === "add" ? "add <url>" : `${sub} <id>`}`);
|
|
747
854
|
if (sub === "add") {
|
|
855
|
+
const destinations = secretDestinations();
|
|
856
|
+
if (!flags["skip-check"]) {
|
|
857
|
+
const probe = await probeWebhookUrl(arg);
|
|
858
|
+
const shown = arg.replace(/(x-vercel-protection-bypass=)[^&#]+/, "$1redacted");
|
|
859
|
+
if (probe.verdict !== "ok")
|
|
860
|
+
fail(`${shown}: ${probe.message}\nSi ya lo resolviste, repetí con --skip-check.`, `webhook_url_${probe.verdict}`, { status: probe.status ?? null });
|
|
861
|
+
say(`✓ ${probe.message}`);
|
|
862
|
+
}
|
|
748
863
|
const endpoint = await hub.post("/api/v1/webhook-endpoints", { url: arg });
|
|
864
|
+
if (destinations.vercel || destinations.envFile) {
|
|
865
|
+
const stored = await storeSecret("TALO_HUB_WEBHOOK_SECRET", endpoint.secret, destinations);
|
|
866
|
+
if (stored.error) {
|
|
867
|
+
await hub.request("DELETE", `/api/v1/webhook-endpoints/${encodeURIComponent(endpoint.id)}`).catch(() => undefined);
|
|
868
|
+
fail(`${stored.error}\nBorramos el webhook que acabábamos de registrar.`, "secret_not_saved");
|
|
869
|
+
}
|
|
870
|
+
const { secret: _secret, ...shown } = endpoint;
|
|
871
|
+
return emit({ ...shown, secret_saved_to: stored.saved }, () => console.log(`✓ Webhook ${endpoint.id}: secreto guardado como TALO_HUB_WEBHOOK_SECRET en ${stored.saved.join(" y ")} (no se muestra).` +
|
|
872
|
+
(destinations.vercel ? `\n Redesplegá para que lo tome, y probalo con ${CMD} webhooks test ${endpoint.id}.` : "")));
|
|
873
|
+
}
|
|
749
874
|
return emit(endpoint, () => console.log(`✓ Webhook ${endpoint.id}\n Secreto para verificar la firma (guardalo en el .env de tu servidor como TALO_HUB_WEBHOOK_SECRET; no se vuelve a mostrar):\n ${endpoint.secret}`));
|
|
750
875
|
}
|
|
751
876
|
if (sub === "remove")
|
|
@@ -755,7 +880,7 @@ async function run(hub, ctx) {
|
|
|
755
880
|
process.exitCode = 1;
|
|
756
881
|
return emit(result, () => console.log(result.delivered
|
|
757
882
|
? `✓ ${result.url} recibió el evento de prueba (HTTP ${result.status_code}).`
|
|
758
|
-
: `✗ ${result.url} no lo aceptó: ${result.error ?? `HTTP ${result.status_code}`}. Revisá que verifique la firma y responda 2xx
|
|
883
|
+
: `✗ ${result.url} no lo aceptó: ${result.error ?? `HTTP ${result.status_code}`}.${result.reason && result.reason !== "http_error" ? "" : " Revisá que verifique la firma y responda 2xx."}`));
|
|
759
884
|
}
|
|
760
885
|
case "keys": {
|
|
761
886
|
if (sub === undefined || sub === "list")
|
|
@@ -763,10 +888,27 @@ async function run(hub, ctx) {
|
|
|
763
888
|
if (sub === "create") {
|
|
764
889
|
if (flags.scope && !["read", "charge", "full"].includes(flags.scope))
|
|
765
890
|
fail("--scope tiene que ser read, charge o full.", "invalid_option");
|
|
891
|
+
const destinations = secretDestinations();
|
|
766
892
|
const created = await hub.post("/api/v1/api-keys", {
|
|
767
893
|
name: flags.name ?? "CLI",
|
|
768
894
|
scope: flags.scope ?? "charge",
|
|
769
895
|
});
|
|
896
|
+
if (destinations.vercel || destinations.envFile) {
|
|
897
|
+
const revoke = () => hub.request("DELETE", `/api/v1/api-keys/${encodeURIComponent(created.id)}`).catch(() => undefined);
|
|
898
|
+
if (created.environment === "production" && destinations.vercel && destinations.vercel.environment !== "production") {
|
|
899
|
+
await revoke();
|
|
900
|
+
fail("Una key de producción (plata real) va solo en el entorno production de Vercel. La revocamos; para preview creá una de sandbox.", "live_key_outside_production");
|
|
901
|
+
}
|
|
902
|
+
const stored = await storeSecret("TALO_HUB_API_KEY", created.api_key, destinations);
|
|
903
|
+
if (stored.error) {
|
|
904
|
+
await revoke();
|
|
905
|
+
fail(`${stored.error}\nRevocamos la key que acabábamos de crear.`, "secret_not_saved");
|
|
906
|
+
}
|
|
907
|
+
const { api_key, ...shown } = created;
|
|
908
|
+
const prefix = `${api_key.slice(0, 13)}…`;
|
|
909
|
+
return emit({ ...shown, prefix, saved_to: stored.saved }, () => console.log(`✓ Key ${prefix} (${created.environment} · scope ${created.scope}) guardada como TALO_HUB_API_KEY en ${stored.saved.join(" y ")}. No se muestra.` +
|
|
910
|
+
(destinations.vercel ? "\n Redesplegá para que la tome." : "")));
|
|
911
|
+
}
|
|
770
912
|
return emit(created, () => console.log(`✓ ${created.api_key}\n ${created.environment} · scope ${created.scope}. Guardala ahora en el .env o los secretos de tu servidor (TALO_HUB_API_KEY): no se vuelve a mostrar.\n En esta terminal, exportarla reemplaza a la key de tu login.`));
|
|
771
913
|
}
|
|
772
914
|
if (sub === "revoke") {
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Before registering a webhook URL, one unsigned POST tells whether the hub's signed deliveries
|
|
2
|
+
// would reach the handler: the hub doesn't follow redirects and sends no login, and a correct
|
|
3
|
+
// handler rejects an unsigned body with 400.
|
|
4
|
+
const VERCEL_LOGIN = /^https:\/\/vercel\.com\/sso-api/;
|
|
5
|
+
export async function probeWebhookUrl(url, fetchImpl = fetch) {
|
|
6
|
+
let response;
|
|
7
|
+
try {
|
|
8
|
+
response = await fetchImpl(url, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json", "user-agent": "TaloPartnerHub-Webhooks/1" },
|
|
11
|
+
body: "{}",
|
|
12
|
+
redirect: "manual",
|
|
13
|
+
signal: AbortSignal.timeout(10_000),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const reason = error.cause?.code ?? error.name;
|
|
18
|
+
return { verdict: "unreachable", message: `No pudimos llegar a la URL (${reason}). ¿Está desplegada y es pública?` };
|
|
19
|
+
}
|
|
20
|
+
const status = response.status;
|
|
21
|
+
const location = response.headers.get("location") ?? "";
|
|
22
|
+
const body = status === 401 || status === 403 ? (await response.text().catch(() => "")).slice(0, 2000) : "";
|
|
23
|
+
const vercelLogin = VERCEL_LOGIN.test(location) || /_vercel_sso_nonce=/.test(response.headers.get("set-cookie") ?? "") || /Protected deployment|Protected by Vercel/.test(body);
|
|
24
|
+
if (vercelLogin) {
|
|
25
|
+
return {
|
|
26
|
+
verdict: "vercel_protection",
|
|
27
|
+
status,
|
|
28
|
+
message: "La Deployment Protection de Vercel la bloquea antes de tu código (las URLs *.vercel.app de cada deploy y de cada rama están protegidas). Usá el dominio de producción, o abrí esa URL a propósito (Deployment Protection Exception o ?x-vercel-protection-bypass=<secreto>).",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (status >= 300 && status < 400) {
|
|
32
|
+
return { verdict: "redirect", status, message: `Redirige${location ? ` a ${location}` : ""}. El hub no sigue redirecciones: registrá la URL final (sin barra al final, el dominio principal).` };
|
|
33
|
+
}
|
|
34
|
+
if (response.headers.get("x-vercel-mitigated")) {
|
|
35
|
+
return { verdict: "vercel_firewall", status, message: "El Firewall de Vercel desafió el pedido. Agregá una regla Bypass para POST a esta ruta antes de los managed rulesets." };
|
|
36
|
+
}
|
|
37
|
+
if (status === 419) {
|
|
38
|
+
return { verdict: "csrf", status, message: "Responde 419: la ruta está bajo el CSRF de Laravel. Ponela en routes/api.php o excluila en bootstrap/app.php." };
|
|
39
|
+
}
|
|
40
|
+
if (status === 401 || status === 403) {
|
|
41
|
+
return {
|
|
42
|
+
verdict: "auth",
|
|
43
|
+
status,
|
|
44
|
+
message: `Responde ${status} antes de verificar la firma: algo delante de la ruta pide login o un token CSRF (middleware, proxy de auth, CsrfViewMiddleware de Django). Excluí la ruta del webhook.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (status === 404 || status === 405) {
|
|
48
|
+
return { verdict: "not_found", status, message: `Responde ${status}: no hay una ruta POST ahí. Revisá la URL o desplegá el handler primero.` };
|
|
49
|
+
}
|
|
50
|
+
if (status === 429) {
|
|
51
|
+
return { verdict: "vercel_firewall", status, message: "Responde 429: un rate limit o el firewall frena el pedido. Excluí la ruta del webhook." };
|
|
52
|
+
}
|
|
53
|
+
if (status >= 200 && status < 300) {
|
|
54
|
+
return { verdict: "unsigned_accepted", status, message: `Aceptó un evento sin firma (HTTP ${status}). El handler tiene que verificar talo-hub-signature y responder 400 si no es válida.` };
|
|
55
|
+
}
|
|
56
|
+
// 400 is the expected answer; a 5xx usually means TALO_HUB_WEBHOOK_SECRET isn't set yet, which is
|
|
57
|
+
// normal before registering (the secret comes from this command).
|
|
58
|
+
return { verdict: "ok", status, message: status === 400 ? "Rechaza eventos sin firma (HTTP 400), como corresponde." : `Respondió HTTP ${status}.` };
|
|
59
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Puts a new secret (a project key or a webhook secret) where the project reads it, without
|
|
2
|
+
// printing it: into a dotenv file, or into a Vercel environment with `vercel env add`, the value
|
|
3
|
+
// going through stdin. Piping into `vercel env add` from a shell can store an empty value (it reads
|
|
4
|
+
// stdin for only 500 ms) or a BOM (Windows PowerShell), so we write the pipe ourselves.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,249}$/;
|
|
8
|
+
/** `production`, `preview` or `preview:<git branch>`. */
|
|
9
|
+
export function parseVercelTarget(value) {
|
|
10
|
+
const [environment, branch, ...rest] = value.trim().split(":");
|
|
11
|
+
if (environment === "development") {
|
|
12
|
+
throw new Error("Para desarrollo usá --env-file .env.local: Vercel no deja bajar los valores secretos a tu máquina.");
|
|
13
|
+
}
|
|
14
|
+
if ((environment !== "production" && environment !== "preview") || rest.length) {
|
|
15
|
+
throw new Error(`--vercel tiene que ser production, preview o preview:<rama> (recibimos "${value}").`);
|
|
16
|
+
}
|
|
17
|
+
if (branch !== undefined && (environment !== "preview" || !BRANCH.test(branch))) {
|
|
18
|
+
throw new Error(`La rama va solo con preview y sin espacios ni comillas: --vercel preview:<rama> (recibimos "${value}").`);
|
|
19
|
+
}
|
|
20
|
+
return branch ? { environment, branch } : { environment };
|
|
21
|
+
}
|
|
22
|
+
export const describeTarget = (target) => `vercel:${target.environment}${target.branch ? `:${target.branch}` : ""}`;
|
|
23
|
+
function run(command, args, input) {
|
|
24
|
+
// Through the shell so `vercel` (vercel.cmd on Windows) resolves; every argument is validated
|
|
25
|
+
// above and the secret only travels on stdin.
|
|
26
|
+
return spawnSync([command, ...args].join(" "), { input, encoding: "utf8", shell: true, timeout: 180_000 });
|
|
27
|
+
}
|
|
28
|
+
/** The Vercel CLI on the PATH, or npx's (TALO_HUB_VERCEL_CLI overrides both, for tests). */
|
|
29
|
+
function vercelCommand() {
|
|
30
|
+
if (process.env.TALO_HUB_VERCEL_CLI)
|
|
31
|
+
return process.env.TALO_HUB_VERCEL_CLI;
|
|
32
|
+
return run("vercel", ["--version"]).status === 0 ? "vercel" : "npx --yes vercel";
|
|
33
|
+
}
|
|
34
|
+
/** Stores `value` as a Secret env var of the linked Vercel project. Returns Vercel's error when it fails. */
|
|
35
|
+
export function addToVercel(name, value, target) {
|
|
36
|
+
const args = ["env", "add", name, target.environment, ...(target.branch ? [target.branch] : []), "--sensitive", "--force", "--yes"];
|
|
37
|
+
const result = run(vercelCommand(), args, value);
|
|
38
|
+
if (result.status === 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.replaceAll(value, "…").trim();
|
|
41
|
+
return output.split("\n").filter(Boolean).slice(-4).join("\n") || (result.error?.message ?? `vercel terminó con código ${result.status}`);
|
|
42
|
+
}
|
|
43
|
+
/** Sets NAME=value in a dotenv file (replacing the line if there is one), readable only by the user. */
|
|
44
|
+
export async function writeEnvFile(path, name, value) {
|
|
45
|
+
let text = "";
|
|
46
|
+
try {
|
|
47
|
+
text = await readFile(path, "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "ENOENT")
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
const line = `${name}=${value}`;
|
|
54
|
+
const existing = new RegExp(`^(export\\s+)?${name}=.*$`, "m");
|
|
55
|
+
const next = existing.test(text) ? text.replace(existing, () => line) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`;
|
|
56
|
+
await writeFile(path, next, { mode: 0o600 });
|
|
57
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
/** Whether git ignores the file: false means it could end up committed; undefined, not a git repo. */
|
|
60
|
+
export function gitIgnores(path) {
|
|
61
|
+
const result = spawnSync("git", ["check-ignore", "-q", path], { encoding: "utf8" });
|
|
62
|
+
if (result.status === 0)
|
|
63
|
+
return true;
|
|
64
|
+
if (result.status === 1)
|
|
65
|
+
return false;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
package/dist/stacks.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Which stack kits fit a project. The hub's /skills/index.json says how to recognize each stack
|
|
2
|
+
// (dependency names, or files at the project root); we look for them in the current folder.
|
|
3
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
6
|
+
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
|
|
7
|
+
/** Keeps the index entries we can trust: the slug becomes a folder name, so it must be plain. */
|
|
8
|
+
export function readSkillsIndex(body) {
|
|
9
|
+
const skills = body?.skills;
|
|
10
|
+
if (!Array.isArray(skills))
|
|
11
|
+
return undefined;
|
|
12
|
+
return skills.flatMap((raw) => {
|
|
13
|
+
const entry = raw;
|
|
14
|
+
if (typeof entry.slug !== "string" || !SLUG.test(entry.slug) || (entry.kind !== "core" && entry.kind !== "stack"))
|
|
15
|
+
return [];
|
|
16
|
+
if (entry.kind === "core")
|
|
17
|
+
return [{ slug: entry.slug, kind: "core" }];
|
|
18
|
+
if (typeof entry.stack !== "string" || !SLUG.test(entry.stack))
|
|
19
|
+
return [];
|
|
20
|
+
const detect = (entry.detect ?? {});
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
slug: entry.slug,
|
|
24
|
+
kind: "stack",
|
|
25
|
+
stack: entry.stack,
|
|
26
|
+
label: typeof entry.label === "string" ? entry.label : entry.stack,
|
|
27
|
+
detect: { npm: strings(detect.npm), composer: strings(detect.composer), python: strings(detect.python), files: strings(detect.files) },
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function text(path) {
|
|
33
|
+
try {
|
|
34
|
+
return await readFile(path, "utf8");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function exists(path) {
|
|
41
|
+
try {
|
|
42
|
+
await access(path);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function dependencyNames(source, sections) {
|
|
50
|
+
if (!source)
|
|
51
|
+
return new Set();
|
|
52
|
+
try {
|
|
53
|
+
const manifest = JSON.parse(source);
|
|
54
|
+
return new Set(sections.flatMap((section) => Object.keys(manifest[section] ?? {})));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return new Set();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** A Python requirement as written in requirements.txt (`fastapi==…`), pyproject.toml (`"fastapi>=…"`, `fastapi = "^…"`) or a Pipfile. */
|
|
61
|
+
function mentionsPythonPackage(sources, name) {
|
|
62
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
return new RegExp(`(^|[\\s"'\\[,])${escaped}(?=[\\s"'<>=!~\\[;,]|$)`, "im").test(sources);
|
|
64
|
+
}
|
|
65
|
+
/** The stack kits whose dependencies or files appear at the root of `dir`. */
|
|
66
|
+
export async function detectStacks(dir, kits) {
|
|
67
|
+
const npm = dependencyNames(await text(join(dir, "package.json")), ["dependencies", "devDependencies"]);
|
|
68
|
+
const composer = dependencyNames(await text(join(dir, "composer.json")), ["require", "require-dev"]);
|
|
69
|
+
const entries = await readdir(dir).catch(() => []);
|
|
70
|
+
const pythonFiles = entries.filter((name) => /^requirements.*\.txt$/i.test(name) || name === "pyproject.toml" || name === "Pipfile");
|
|
71
|
+
const python = (await Promise.all(pythonFiles.map((name) => text(join(dir, name))))).join("\n");
|
|
72
|
+
const matched = [];
|
|
73
|
+
for (const kit of kits) {
|
|
74
|
+
const detect = kit.detect ?? {};
|
|
75
|
+
const found = (detect.npm ?? []).some((name) => npm.has(name)) ||
|
|
76
|
+
(detect.composer ?? []).some((name) => composer.has(name)) ||
|
|
77
|
+
(detect.python ?? []).some((name) => mentionsPythonPackage(python, name)) ||
|
|
78
|
+
(await Promise.all((detect.files ?? []).map((file) => exists(join(dir, file))))).some(Boolean);
|
|
79
|
+
if (found)
|
|
80
|
+
matched.push(kit);
|
|
81
|
+
}
|
|
82
|
+
return matched;
|
|
83
|
+
}
|
|
84
|
+
/** Resolves `--stack nextjs-vercel,python` (ids or full slugs), `all` or `none`. */
|
|
85
|
+
export function pickStacks(kits, asked) {
|
|
86
|
+
const wanted = asked
|
|
87
|
+
.split(",")
|
|
88
|
+
.map((item) => item.trim().toLowerCase())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
if (wanted.includes("none"))
|
|
91
|
+
return { kits: [], unknown: [] };
|
|
92
|
+
if (wanted.includes("all"))
|
|
93
|
+
return { kits, unknown: [] };
|
|
94
|
+
const chosen = kits.filter((kit) => wanted.includes(kit.stack) || wanted.includes(kit.slug));
|
|
95
|
+
const unknown = wanted.filter((item) => !kits.some((kit) => kit.stack === item || kit.slug === item));
|
|
96
|
+
return { kits: chosen, unknown };
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talo-hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Transferencias bancarias automáticas en Argentina con Talo Pay: un CVU por cobro, acreditación y conciliación automáticas, webhooks firmados, sandbox y MCP para agentes de IA (Claude, Cursor).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"talo",
|