talo-hub 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +104 -3
- package/dist/cli.js +786 -0
- package/dist/config.js +88 -0
- package/dist/hub.js +82 -0
- package/dist/parse.js +85 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Talo Partner Hub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
| `keys list \| create [--name] [--scope read\|charge\|full] \| revoke <id>` | Keys para tus proyectos (`charge` por defecto) |
|
|
67
|
+
| `mcp [--client claude\|cursor\|desktop] [--with-key]` | Configuración del MCP para tu agente |
|
|
68
|
+
| `skills install [--global]` | Instala las skills de Talo |
|
|
69
|
+
| `use <sandbox\|production>` | Entorno activo de los próximos comandos |
|
|
70
|
+
| `logout [--production \| --all]` | Revoca la key en el hub y la borra de esta máquina |
|
|
71
|
+
|
|
72
|
+
Opciones: `--sandbox`, `--production`, `--json`, `--version`, `--hub <url>`.
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
## De sandbox a producción
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx talo-hub doctor # sandbox listo
|
|
80
|
+
npx talo-hub login --production
|
|
81
|
+
npx talo-hub keys create --production --name mi-tienda --scope charge
|
|
82
|
+
npx talo-hub webhooks add https://tu-sitio.com/api/webhooks/talo --production
|
|
83
|
+
npx talo-hub webhooks test <id> --production
|
|
84
|
+
npx talo-hub doctor --production
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Seguridad
|
|
88
|
+
|
|
89
|
+
- `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.
|
|
90
|
+
- `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.
|
|
91
|
+
- Cada key guardada recuerda el hub que la emitió y no se manda a ningún otro.
|
|
92
|
+
- `mcp` usa OAuth por defecto: tu key no queda en el historial de la terminal ni en archivos del proyecto.
|
|
93
|
+
- Tus keys solo viajan por https (o a un hub en tu propia máquina, para desarrollo).
|
|
94
|
+
|
|
95
|
+
## Links
|
|
96
|
+
|
|
97
|
+
- Docs: https://talopartnerhub.vercel.app/docs
|
|
98
|
+
- Para agentes: https://talopartnerhub.vercel.app/docs/agents · https://talopartnerhub.vercel.app/llms.txt
|
|
99
|
+
- API: https://talopartnerhub.vercel.app/docs/api · OpenAPI: https://talopartnerhub.vercel.app/openapi.json
|
|
100
|
+
- Dashboard: https://talopartnerhub.vercel.app/dashboard
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
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
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// talo-hub: Talo Pay from the terminal, through the Talo Partner Hub.
|
|
3
|
+
// Every command answers JSON with --json (human messages then go to stderr), errors included as
|
|
4
|
+
// { error: { code, message, status } }, and exits non-zero on failure, so scripts and agents can
|
|
5
|
+
// rely on it.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { configPath, environmentOfKey, loadConfig, requestedHub, resolveKey, saveConfig, DEFAULT_HUB, } from "./config.js";
|
|
13
|
+
import { Hub, HubRequestError, networkReason } from "./hub.js";
|
|
14
|
+
import { parseAmount, parseCount, parseDate, parseForwardUrl } from "./parse.js";
|
|
15
|
+
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
16
|
+
/** How to tell people to run us again: through npx when that's how they ran us. */
|
|
17
|
+
const CMD = process.env.npm_command === "exec" ? "npx talo-hub" : "talo-hub";
|
|
18
|
+
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
|
+
Keys y agentes
|
|
45
|
+
keys list | create [--name <nombre>] [--scope read|charge|full] | revoke <id> (charge por defecto)
|
|
46
|
+
mcp [--client claude|cursor|desktop] [--with-key] Configuración del MCP para tu agente
|
|
47
|
+
skills install [--global] Instala las skills de Talo en .claude/skills
|
|
48
|
+
|
|
49
|
+
Montos: 15000 o 15000.50 (en PowerShell, con separador de miles, entre comillas: "15.000,50").
|
|
50
|
+
Fechas: AAAA-MM-DD o ISO 8601; sin zona horaria, hora de Argentina.
|
|
51
|
+
Opciones: --sandbox, --production, --json, --hub <url>, --version
|
|
52
|
+
Variables: TALO_HUB_API_KEY (usa esa key en vez de la guardada), TALO_HUB_URL`;
|
|
53
|
+
/** Prints the error (as JSON with --json, with a stable code) and exits 1. */
|
|
54
|
+
function fail(message, code = "usage", extra = {}) {
|
|
55
|
+
const error = { code, message, ...extra, status: extra.status || null };
|
|
56
|
+
if (jsonMode)
|
|
57
|
+
console.log(JSON.stringify({ error }));
|
|
58
|
+
else
|
|
59
|
+
console.error(`✗ ${message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
/** A message for people: stdout normally, stderr with --json so stdout stays parseable. */
|
|
63
|
+
function say(message = "") {
|
|
64
|
+
if (jsonMode)
|
|
65
|
+
console.error(message);
|
|
66
|
+
else
|
|
67
|
+
console.log(message);
|
|
68
|
+
}
|
|
69
|
+
function emit(value, human) {
|
|
70
|
+
if (jsonMode || !human)
|
|
71
|
+
console.log(JSON.stringify(value, null, 2));
|
|
72
|
+
else
|
|
73
|
+
human();
|
|
74
|
+
}
|
|
75
|
+
const GLOBAL_OPTIONS = ["sandbox", "production", "json", "hub", "help", "version"];
|
|
76
|
+
function parseCommandLine() {
|
|
77
|
+
try {
|
|
78
|
+
return parseArgs({
|
|
79
|
+
allowPositionals: true,
|
|
80
|
+
options: {
|
|
81
|
+
sandbox: { type: "boolean" },
|
|
82
|
+
production: { type: "boolean" },
|
|
83
|
+
all: { type: "boolean" },
|
|
84
|
+
json: { type: "boolean" },
|
|
85
|
+
hub: { type: "string" },
|
|
86
|
+
amount: { type: "string" },
|
|
87
|
+
"external-id": { type: "string" },
|
|
88
|
+
motive: { type: "string" },
|
|
89
|
+
from: { type: "string" },
|
|
90
|
+
to: { type: "string" },
|
|
91
|
+
after: { type: "string" },
|
|
92
|
+
limit: { type: "string" },
|
|
93
|
+
cursor: { type: "string" },
|
|
94
|
+
payment: { type: "string" },
|
|
95
|
+
type: { type: "string" },
|
|
96
|
+
name: { type: "string" },
|
|
97
|
+
scope: { type: "string" },
|
|
98
|
+
"forward-to": { type: "string" },
|
|
99
|
+
client: { type: "string" },
|
|
100
|
+
"with-key": { type: "boolean" },
|
|
101
|
+
global: { type: "boolean" },
|
|
102
|
+
help: { type: "boolean", short: "h" },
|
|
103
|
+
version: { type: "boolean", short: "v" },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const { code, message } = error;
|
|
109
|
+
const option = message.match(/'(-[^' ]+)/)?.[1] ?? "la opción";
|
|
110
|
+
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION")
|
|
111
|
+
return fail(`No existe la opción ${option}. Mirá ${CMD} --help`, "invalid_option");
|
|
112
|
+
if (/does not take an argument/.test(message))
|
|
113
|
+
return fail(`${option} no lleva valor.`, "invalid_option");
|
|
114
|
+
if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
|
|
115
|
+
return fail(`A ${option} le falta el valor, o el valor empieza con "-": usá ${option}=valor.`, "invalid_option");
|
|
116
|
+
}
|
|
117
|
+
return fail(`${message.split("\n")[0].replace(/\.$/, "")}. Mirá ${CMD} --help`, "invalid_option");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const { values: flags, positionals } = parseCommandLine();
|
|
121
|
+
/** The options each command takes besides the global ones; anything else is a mistake to report. */
|
|
122
|
+
const COMMAND_OPTIONS = {
|
|
123
|
+
login: [],
|
|
124
|
+
logout: ["all"],
|
|
125
|
+
use: [],
|
|
126
|
+
status: [],
|
|
127
|
+
doctor: [],
|
|
128
|
+
"payments create": ["amount", "external-id", "motive"],
|
|
129
|
+
"payments get": [],
|
|
130
|
+
"payments list": ["from", "to"],
|
|
131
|
+
"payments refund": ["amount"],
|
|
132
|
+
"sandbox pay": ["amount"],
|
|
133
|
+
balance: [],
|
|
134
|
+
transactions: ["from", "to", "limit", "cursor"],
|
|
135
|
+
"events list": ["after", "limit", "payment", "type"],
|
|
136
|
+
"events listen": ["forward-to"],
|
|
137
|
+
webhooks: [],
|
|
138
|
+
keys: ["name", "scope"],
|
|
139
|
+
mcp: ["client", "with-key"],
|
|
140
|
+
skills: ["global"],
|
|
141
|
+
};
|
|
142
|
+
function checkOptions(command, sub) {
|
|
143
|
+
const key = COMMAND_OPTIONS[`${command} ${sub ?? "list"}`] ? `${command} ${sub ?? "list"}` : command;
|
|
144
|
+
const allowed = new Set([...GLOBAL_OPTIONS, ...(COMMAND_OPTIONS[key] ?? [])]);
|
|
145
|
+
const extra = Object.keys(flags).filter((option) => !allowed.has(option));
|
|
146
|
+
if (extra.length)
|
|
147
|
+
fail(`${extra.map((o) => `--${o}`).join(", ")} no aplica a "${key}". Mirá ${CMD} --help`, "invalid_option");
|
|
148
|
+
}
|
|
149
|
+
const money = new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS" });
|
|
150
|
+
// Argentine time, 24 h: toLocaleString("es-AR") prints 12-hour times without a.m./p.m. on some ICUs.
|
|
151
|
+
const AR_TIME = { timeZone: "America/Argentina/Buenos_Aires", hourCycle: "h23" };
|
|
152
|
+
const dateTime = new Intl.DateTimeFormat("es-AR", { ...AR_TIME, dateStyle: "short", timeStyle: "short" });
|
|
153
|
+
const timeOnly = new Intl.DateTimeFormat("es-AR", { ...AR_TIME, timeStyle: "medium" });
|
|
154
|
+
function chosenEnvironment(config) {
|
|
155
|
+
if (flags.production && flags.sandbox)
|
|
156
|
+
fail("Elegí --sandbox o --production, no los dos.");
|
|
157
|
+
if (flags.production)
|
|
158
|
+
return "production";
|
|
159
|
+
if (flags.sandbox)
|
|
160
|
+
return "sandbox";
|
|
161
|
+
return config.current;
|
|
162
|
+
}
|
|
163
|
+
const loginHint = (environment) => `${CMD} login${environment === "production" ? " --production" : ""}`;
|
|
164
|
+
function amountFlag(required) {
|
|
165
|
+
if (flags.amount === undefined) {
|
|
166
|
+
if (required)
|
|
167
|
+
fail("Falta --amount (en ARS).", "invalid_amount");
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return parseAmount(flags.amount) ?? fail(`--amount no es un monto válido: "${flags.amount}". Usá 15000 o 15000.50.`, "invalid_amount");
|
|
171
|
+
}
|
|
172
|
+
function limitFlag(fallback) {
|
|
173
|
+
if (flags.limit === undefined)
|
|
174
|
+
return fallback;
|
|
175
|
+
return parseCount(flags.limit, 200) ?? fail(`--limit tiene que ser un número entero de 1 a 200 (recibimos "${flags.limit}").`, "invalid_limit");
|
|
176
|
+
}
|
|
177
|
+
function dateFlag(name) {
|
|
178
|
+
const raw = flags[name];
|
|
179
|
+
if (raw === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
return parseDate(raw, name === "from" ? "start" : "end") ?? fail(`--${name}: usá AAAA-MM-DD (p. ej. 2026-10-28) o ISO 8601.`, "invalid_date");
|
|
182
|
+
}
|
|
183
|
+
/** Opens the URL without a shell, and never lets a missing opener crash the CLI. */
|
|
184
|
+
function openBrowser(url) {
|
|
185
|
+
let command;
|
|
186
|
+
let args;
|
|
187
|
+
if (process.platform === "win32")
|
|
188
|
+
[command, args] = ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
189
|
+
else if (process.platform === "darwin")
|
|
190
|
+
[command, args] = ["open", [url]];
|
|
191
|
+
else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
|
|
192
|
+
[command, args] = ["xdg-open", [url]];
|
|
193
|
+
else
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
197
|
+
child.on("error", () => undefined);
|
|
198
|
+
child.unref();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// The URL is printed anyway.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
205
|
+
/** Runs a request, retrying network errors and 5xx with backoff; other errors go up. */
|
|
206
|
+
async function resilient(task, onRetry) {
|
|
207
|
+
let wait = 2000;
|
|
208
|
+
for (;;) {
|
|
209
|
+
try {
|
|
210
|
+
return await task();
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (!(error instanceof HubRequestError) || !error.transient)
|
|
214
|
+
throw error;
|
|
215
|
+
onRetry(error, wait);
|
|
216
|
+
await sleep(wait);
|
|
217
|
+
wait = Math.min(wait * 2, 30_000);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** The hub answers in English for API clients; the CLI says the usual ones in Spanish. */
|
|
222
|
+
const HUB_MESSAGES = {
|
|
223
|
+
unauthorized: "La key no es válida o fue revocada.",
|
|
224
|
+
insufficient_scope: "Esta key no tiene permiso para eso.",
|
|
225
|
+
sandbox_only: "Eso solo funciona con una key de sandbox (tph_test_…).",
|
|
226
|
+
talo_reconnect_required: "Talo ya no acepta la autorización de esta cuenta.",
|
|
227
|
+
payment_not_found: "No hay un cobro con ese ID en esta cuenta.",
|
|
228
|
+
invalid_amount: "El monto no es válido.",
|
|
229
|
+
invalid_url: "La URL del webhook no es válida: tiene que ser https.",
|
|
230
|
+
environment_disabled: "El hub todavía no está conectado a ese entorno de Talo.",
|
|
231
|
+
talo_partner_unsupported: "Talo todavía no habilita el saldo y los movimientos para integraciones de partner: consultalos en el panel de Talo.",
|
|
232
|
+
};
|
|
233
|
+
/** A hub error in words for this CLI: transient failures read the same whatever the hub said. */
|
|
234
|
+
function hubMessage(error) {
|
|
235
|
+
if (error.code && HUB_MESSAGES[error.code])
|
|
236
|
+
return HUB_MESSAGES[error.code];
|
|
237
|
+
if (error.status === 0)
|
|
238
|
+
return error.message;
|
|
239
|
+
if (error.status >= 500)
|
|
240
|
+
return `El hub tuvo un error (HTTP ${error.status}).`;
|
|
241
|
+
return error.message;
|
|
242
|
+
}
|
|
243
|
+
function showPayment(payment) {
|
|
244
|
+
console.log(`${payment.id} ${payment.status}`);
|
|
245
|
+
if (payment.amount !== undefined)
|
|
246
|
+
console.log(` Monto: ${money.format(payment.amount)}`);
|
|
247
|
+
if (payment.cvu)
|
|
248
|
+
console.log(` CVU: ${payment.cvu}`);
|
|
249
|
+
if (payment.alias)
|
|
250
|
+
console.log(` Alias: ${payment.alias}`);
|
|
251
|
+
if (payment.paid)
|
|
252
|
+
console.log(` Pagado: ${money.format(payment.paid)}`);
|
|
253
|
+
if (payment.refunded)
|
|
254
|
+
console.log(` Reembolso: ${money.format(payment.refunded)}`);
|
|
255
|
+
if (payment.expires_at)
|
|
256
|
+
console.log(` Vence: ${dateTime.format(new Date(payment.expires_at))} (hora de Argentina)`);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Revokes a key on the hub that issued it, using the key itself. Best effort: says whether the key
|
|
260
|
+
* no longer works (revoked now, or already invalid), so the person finishes it in the dashboard
|
|
261
|
+
* only when it really is still active.
|
|
262
|
+
*/
|
|
263
|
+
async function revokeOwnKey(hubUrl, key) {
|
|
264
|
+
const hub = new Hub(hubUrl, key);
|
|
265
|
+
const prefix = `${key.slice(0, 13)}…`;
|
|
266
|
+
try {
|
|
267
|
+
const { api_keys } = await hub.get("/api/v1/api-keys");
|
|
268
|
+
const matches = api_keys.filter((k) => !k.revoked_at && key.startsWith(k.prefix.replace("…", "")));
|
|
269
|
+
if (matches.length === 1) {
|
|
270
|
+
await hub.request("DELETE", `/api/v1/api-keys/${encodeURIComponent(matches[0].id)}`);
|
|
271
|
+
return { prefix: matches[0].prefix, revoked: true, already_invalid: false };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error instanceof HubRequestError && error.status === 401)
|
|
276
|
+
return { prefix, revoked: true, already_invalid: true };
|
|
277
|
+
// Offline, or a key without permission to manage keys: it may still be active.
|
|
278
|
+
}
|
|
279
|
+
return { prefix, revoked: false, already_invalid: false };
|
|
280
|
+
}
|
|
281
|
+
function revokedText(r) {
|
|
282
|
+
if (r.already_invalid)
|
|
283
|
+
return `ya no era válida (${r.prefix})`;
|
|
284
|
+
return r.revoked ? `revocada (${r.prefix})` : `sigue activa (${r.prefix}): revocala desde el dashboard`;
|
|
285
|
+
}
|
|
286
|
+
const LOGIN_ERRORS = {
|
|
287
|
+
expired: "El login venció.",
|
|
288
|
+
already_claimed: "Este login ya se completó.",
|
|
289
|
+
not_found: "El hub no reconoce este login.",
|
|
290
|
+
environment_disabled: "El hub todavía no está conectado a ese entorno de Talo.",
|
|
291
|
+
};
|
|
292
|
+
function loginError(error) {
|
|
293
|
+
return (error.code && LOGIN_ERRORS[error.code]) ?? (error.transient ? hubMessage(error) : "El hub rechazó el login.");
|
|
294
|
+
}
|
|
295
|
+
async function login(config) {
|
|
296
|
+
if (flags.sandbox && flags.production)
|
|
297
|
+
fail("Elegí --sandbox o --production, no los dos.");
|
|
298
|
+
const environment = flags.production ? "production" : "sandbox";
|
|
299
|
+
if (process.env.TALO_HUB_API_KEY)
|
|
300
|
+
say("! TALO_HUB_API_KEY está definida: mientras exista, los comandos usan esa key y no la de este login.");
|
|
301
|
+
let hubUrl;
|
|
302
|
+
try {
|
|
303
|
+
hubUrl = requestedHub(flags.hub) ?? DEFAULT_HUB;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
fail(error.message, "invalid_hub");
|
|
307
|
+
}
|
|
308
|
+
const hub = new Hub(hubUrl);
|
|
309
|
+
let session;
|
|
310
|
+
try {
|
|
311
|
+
session = await hub.post("/api/cli/sessions", { environment });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (error instanceof HubRequestError)
|
|
315
|
+
fail(loginError(error), error.code ?? "login_failed", { status: error.status });
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
// The link is built here, not taken from the answer, so a hub can't point the browser elsewhere.
|
|
319
|
+
const loginUrl = `${hubUrl}/cli/login/${encodeURIComponent(session.id)}`;
|
|
320
|
+
say(`Conectando tu cuenta Talo ${environment === "sandbox" ? "sandbox" : "de producción"}.`);
|
|
321
|
+
say(`\n Código: ${session.user_code}\n`);
|
|
322
|
+
say(`Abrí este link (si no se abrió solo) y confirmá que ves el mismo código:\n ${loginUrl}\n`);
|
|
323
|
+
openBrowser(loginUrl);
|
|
324
|
+
const deadline = Date.now() + session.expires_in * 1000;
|
|
325
|
+
let warned = false;
|
|
326
|
+
while (Date.now() < deadline) {
|
|
327
|
+
await sleep(Math.max(session.interval, 1) * 1000);
|
|
328
|
+
let result;
|
|
329
|
+
try {
|
|
330
|
+
result = await hub.post(`/api/cli/sessions/${encodeURIComponent(session.id)}/token`, { poll_token: session.poll_token });
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (error instanceof HubRequestError && error.transient) {
|
|
334
|
+
if (!warned)
|
|
335
|
+
say(`! ${hubMessage(error)} Seguimos intentando…`);
|
|
336
|
+
warned = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (error instanceof HubRequestError)
|
|
340
|
+
fail(`${loginError(error)} Volvé a correr ${loginHint(environment)}.`, error.code ?? "login_failed", { status: error.status });
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
if (result.status === "complete" && result.api_key) {
|
|
344
|
+
const previous = config.keys[environment];
|
|
345
|
+
const previousHub = config.hubs[environment] ?? DEFAULT_HUB;
|
|
346
|
+
config.keys[environment] = result.api_key;
|
|
347
|
+
if (hubUrl === DEFAULT_HUB)
|
|
348
|
+
delete config.hubs[environment];
|
|
349
|
+
else
|
|
350
|
+
config.hubs[environment] = hubUrl;
|
|
351
|
+
// A production login never makes production the default: later commands only touch real
|
|
352
|
+
// money with --production or after `use production`.
|
|
353
|
+
if (environment === "sandbox" && !config.keys[config.current])
|
|
354
|
+
config.current = "sandbox";
|
|
355
|
+
await saveConfig(config);
|
|
356
|
+
// The key this login replaces would otherwise stay valid forever. It goes only to its own hub.
|
|
357
|
+
const replaced = previous && previous !== result.api_key ? await revokeOwnKey(previousHub, previous) : undefined;
|
|
358
|
+
const account = result.talo_user_id;
|
|
359
|
+
emit({ environment, talo_user_id: account ?? null, current: config.current, hub: hubUrl, config_path: configPath(), replaced_key: replaced ?? null }, () => {
|
|
360
|
+
console.log(`✓ Conectada la cuenta Talo${account ? ` ${account}` : ""} (${environment}).`);
|
|
361
|
+
if (!account)
|
|
362
|
+
console.log(`! No pudimos confirmar qué cuenta quedó conectada: revisala con ${CMD} status.`);
|
|
363
|
+
console.log(` Key guardada en ${configPath()}`);
|
|
364
|
+
if (replaced)
|
|
365
|
+
console.log(` Key anterior: ${revokedText(replaced)}.`);
|
|
366
|
+
console.log(` Entorno activo: ${config.current}${config.current === environment ? "" : ` (para usar esta cuenta: --${environment} o ${CMD} use ${environment})`}`);
|
|
367
|
+
console.log(environment === "sandbox" ? ` Probá: ${CMD} payments create --amount 15000` : " Ojo: en producción los cobros son con plata real.");
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
fail(`El login venció. Volvé a correr ${loginHint(environment)}.`, "expired");
|
|
373
|
+
}
|
|
374
|
+
function showEvent(event, replayed = false) {
|
|
375
|
+
const payment = event.data?.payment;
|
|
376
|
+
if (jsonMode)
|
|
377
|
+
console.log(JSON.stringify(replayed ? { ...event, replayed: true } : event));
|
|
378
|
+
else
|
|
379
|
+
console.log(`${timeOnly.format(new Date(event.created_at))} ${event.type}${payment ? ` ${payment.id} ${payment.status}` : ""}${replayed ? " (anterior)" : ""}`);
|
|
380
|
+
}
|
|
381
|
+
async function listen(hub) {
|
|
382
|
+
let forward;
|
|
383
|
+
if (flags["forward-to"]) {
|
|
384
|
+
forward =
|
|
385
|
+
parseForwardUrl(flags["forward-to"]) ??
|
|
386
|
+
fail("--forward-to tiene que ser una URL http(s) o un puerto, p. ej. http://localhost:3000/api/webhooks/talo o 3000.", "invalid_url");
|
|
387
|
+
}
|
|
388
|
+
const retry = (error, waitMs) => console.error(`! ${hubMessage(error)} Reintentando en ${Math.round(waitMs / 1000)} s…`);
|
|
389
|
+
// The last few events give context (a payment that just got paid); only new ones are relayed.
|
|
390
|
+
const recent = await resilient(() => hub.get("/api/v1/events?recent=5"), retry);
|
|
391
|
+
const shown = [...recent.events].reverse();
|
|
392
|
+
for (const event of shown)
|
|
393
|
+
showEvent(event, true);
|
|
394
|
+
let after = shown.at(-1)?.id ?? 0;
|
|
395
|
+
console.error(`Escuchando eventos${forward ? ` y reenviando a ${forward}` : ""}. Ctrl+C para salir.`);
|
|
396
|
+
if (forward)
|
|
397
|
+
console.error("Los eventos reenviados no van firmados (traen el header talo-hub-forwarded: 1): aceptalos solo en desarrollo.");
|
|
398
|
+
let hinted = false;
|
|
399
|
+
for (;;) {
|
|
400
|
+
const page = await resilient(() => hub.get(`/api/v1/events?after=${after}&limit=200`), retry);
|
|
401
|
+
for (const event of page.events) {
|
|
402
|
+
showEvent(event);
|
|
403
|
+
if (forward) {
|
|
404
|
+
try {
|
|
405
|
+
const response = await fetch(forward, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { "content-type": "application/json", "talo-hub-event": event.type, "talo-hub-forwarded": "1" },
|
|
408
|
+
body: JSON.stringify(event),
|
|
409
|
+
signal: AbortSignal.timeout(10_000),
|
|
410
|
+
});
|
|
411
|
+
console.error(` → ${forward} ${response.status}`);
|
|
412
|
+
if ((response.status === 401 || response.status === 403) && !hinted) {
|
|
413
|
+
console.error(" Tu endpoint rechazó el evento: los reenviados no traen firma. En desarrollo, aceptá los que traen talo-hub-forwarded: 1.");
|
|
414
|
+
hinted = true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
console.error(` → ${forward} falló: ${networkReason(error)}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
after = page.next_after;
|
|
423
|
+
await sleep(2000);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const SKILLS = ["talo-payments", "talo-go-live", "talo-operations"];
|
|
427
|
+
/** Downloads the skills into ./.claude/skills (or ~/.claude/skills with --global). */
|
|
428
|
+
async function installSkills(hubUrl) {
|
|
429
|
+
const root = join(flags.global ? homedir() : process.cwd(), ".claude", "skills");
|
|
430
|
+
const installed = [];
|
|
431
|
+
for (const skill of SKILLS) {
|
|
432
|
+
let text = "";
|
|
433
|
+
let status = 0;
|
|
434
|
+
try {
|
|
435
|
+
const response = await fetch(`${hubUrl}/skills/${skill}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
|
|
436
|
+
status = response.status;
|
|
437
|
+
if (response.ok)
|
|
438
|
+
text = await response.text();
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
|
|
442
|
+
}
|
|
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 });
|
|
447
|
+
await writeFile(path, text);
|
|
448
|
+
installed.push(path);
|
|
449
|
+
say(`✓ ${path}`);
|
|
450
|
+
}
|
|
451
|
+
emit({ installed }, () => console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`));
|
|
452
|
+
}
|
|
453
|
+
/** Which shell will run a printed command: PowerShell spells environment variables differently. */
|
|
454
|
+
function shellSyntax() {
|
|
455
|
+
if (process.platform !== "win32")
|
|
456
|
+
return "bash";
|
|
457
|
+
if (process.env.MSYSTEM || process.env.SHELL)
|
|
458
|
+
return "bash";
|
|
459
|
+
return "powershell";
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Prints the MCP setup. By default it is the OAuth form: the client signs the person in and the key
|
|
463
|
+
* never lands in a shell history or a project file. --with-key prints a key-based setup instead.
|
|
464
|
+
*/
|
|
465
|
+
function mcpConfig(hubUrl, key) {
|
|
466
|
+
const url = `${hubUrl}/api/mcp`;
|
|
467
|
+
const client = flags.client ?? "claude";
|
|
468
|
+
if (!["claude", "cursor", "desktop"].includes(client))
|
|
469
|
+
fail("--client tiene que ser claude, cursor o desktop.", "invalid_option");
|
|
470
|
+
const withKey = Boolean(flags["with-key"]);
|
|
471
|
+
if (client === "claude") {
|
|
472
|
+
if (!withKey) {
|
|
473
|
+
const command = `claude mcp add --transport http talo ${url}`;
|
|
474
|
+
emit({ client, oauth: true, command }, () => console.log(command));
|
|
475
|
+
say("\nDespués, en Claude Code: /mcp → talo → Authenticate. Elegís sandbox o producción y los permisos en el navegador.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// A reference to the variable, not the key, so it never lands in shell history. Each shell
|
|
479
|
+
// spells it differently, and from here we can't tell PowerShell from cmd.
|
|
480
|
+
const forShell = (variable) => `claude mcp add --transport http talo ${url} --header "Authorization: Bearer ${variable}"`;
|
|
481
|
+
const commands = shellSyntax() === "bash" ? { bash: forShell("$TALO_HUB_API_KEY") } : { powershell: forShell("$env:TALO_HUB_API_KEY"), cmd: forShell("%TALO_HUB_API_KEY%") };
|
|
482
|
+
emit({ client, oauth: false, commands, needs_env: "TALO_HUB_API_KEY" }, () => {
|
|
483
|
+
for (const [shell, command] of Object.entries(commands))
|
|
484
|
+
console.log(Object.keys(commands).length > 1 ? `# ${shell}\n${command}` : command);
|
|
485
|
+
});
|
|
486
|
+
if (!process.env.TALO_HUB_API_KEY) {
|
|
487
|
+
say(`\n! Definí antes TALO_HUB_API_KEY con una key, mejor de scope charge: ${CMD} keys create --scope charge`);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (withKey && !key)
|
|
492
|
+
fail(`No hay key para usar. Corré ${CMD} login o definí TALO_HUB_API_KEY.`, "not_logged_in");
|
|
493
|
+
let server;
|
|
494
|
+
if (client === "cursor") {
|
|
495
|
+
server = withKey ? { url, headers: { Authorization: `Bearer ${key}` } } : { url };
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
// mcp-remote runs the OAuth flow itself. With a key, the space of "Bearer <key>" lives in env:
|
|
499
|
+
// Claude Desktop on Windows splits args at spaces.
|
|
500
|
+
server = withKey
|
|
501
|
+
? { command: "npx", args: ["-y", "mcp-remote", url, "--header", "Authorization:${TALO_AUTH}"], env: { TALO_AUTH: `Bearer ${key}` } }
|
|
502
|
+
: { command: "npx", args: ["-y", "mcp-remote", url] };
|
|
503
|
+
}
|
|
504
|
+
console.log(JSON.stringify({ mcpServers: { talo: server } }, null, 2));
|
|
505
|
+
if (client === "cursor") {
|
|
506
|
+
console.error(withKey ? "\nPegalo en ~/.cursor/mcp.json (global, no en el proyecto: tiene tu key)." : "\nPegalo en .cursor/mcp.json; Cursor te pide autenticar al conectarse.");
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
console.error(withKey ? "\nPegalo en claude_desktop_config.json (tiene tu key: no lo compartas)." : "\nPegalo en claude_desktop_config.json; se abre el navegador para autorizar.");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const KEYED_COMMANDS = new Set(["status", "doctor", "payments", "sandbox", "balance", "transactions", "events", "webhooks", "keys"]);
|
|
513
|
+
const ALL_COMMANDS = new Set([...KEYED_COMMANDS, "login", "logout", "use", "mcp", "skills"]);
|
|
514
|
+
async function main() {
|
|
515
|
+
const [command, sub, arg] = positionals;
|
|
516
|
+
if (flags.version)
|
|
517
|
+
return emit({ version: VERSION }, () => console.log(VERSION));
|
|
518
|
+
if (!command || flags.help || command === "help")
|
|
519
|
+
return emit({ version: VERSION, help: HELP }, () => console.log(HELP));
|
|
520
|
+
if (!ALL_COMMANDS.has(command))
|
|
521
|
+
fail(`Comando desconocido: ${command}. Mirá ${CMD} --help`, "unknown_command");
|
|
522
|
+
checkOptions(command, sub);
|
|
523
|
+
const config = await loadConfig();
|
|
524
|
+
if (command === "login")
|
|
525
|
+
return login(config);
|
|
526
|
+
if (command === "skills") {
|
|
527
|
+
if (sub !== "install")
|
|
528
|
+
fail(`Usá: ${CMD} skills install [--global]`);
|
|
529
|
+
let hubUrl;
|
|
530
|
+
try {
|
|
531
|
+
hubUrl = requestedHub(flags.hub) ?? DEFAULT_HUB;
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
fail(error.message, "invalid_hub");
|
|
535
|
+
}
|
|
536
|
+
return installSkills(hubUrl);
|
|
537
|
+
}
|
|
538
|
+
if (command === "logout") {
|
|
539
|
+
const environments = flags.all ? ["sandbox", "production"] : [chosenEnvironment(config)];
|
|
540
|
+
const removed = [];
|
|
541
|
+
for (const environment of environments) {
|
|
542
|
+
const key = config.keys[environment];
|
|
543
|
+
if (!key)
|
|
544
|
+
continue;
|
|
545
|
+
removed.push({ environment, ...(await revokeOwnKey(config.hubs[environment] ?? DEFAULT_HUB, key)) });
|
|
546
|
+
delete config.keys[environment];
|
|
547
|
+
delete config.hubs[environment];
|
|
548
|
+
}
|
|
549
|
+
await saveConfig(config);
|
|
550
|
+
return emit({ removed }, () => {
|
|
551
|
+
if (removed.length === 0)
|
|
552
|
+
console.log("No había ninguna key guardada.");
|
|
553
|
+
for (const r of removed) {
|
|
554
|
+
console.log(`✓ Key de ${r.environment} borrada de esta máquina; en el hub, ${revokedText(r)}.`);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (command === "use") {
|
|
559
|
+
if (sub !== "sandbox" && sub !== "production")
|
|
560
|
+
fail(`Usá: ${CMD} use sandbox | production`);
|
|
561
|
+
config.current = sub;
|
|
562
|
+
await saveConfig(config);
|
|
563
|
+
if (process.env.TALO_HUB_API_KEY)
|
|
564
|
+
say("! TALO_HUB_API_KEY está definida y tiene prioridad sobre el entorno elegido.");
|
|
565
|
+
return emit({ environment: sub, logged_in: Boolean(config.keys[sub]) }, () => console.log(`✓ Entorno: ${sub}${config.keys[sub] ? "" : ` (todavía no hiciste login: ${loginHint(sub)})`}`));
|
|
566
|
+
}
|
|
567
|
+
// `sandbox pay` always means the sandbox account, whatever the current environment is.
|
|
568
|
+
if (command === "sandbox" && flags.production)
|
|
569
|
+
fail("sandbox pay solo funciona en sandbox.");
|
|
570
|
+
const environment = command === "sandbox" ? "sandbox" : chosenEnvironment(config);
|
|
571
|
+
let asked;
|
|
572
|
+
try {
|
|
573
|
+
asked = requestedHub(flags.hub);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
fail(error.message, "invalid_hub");
|
|
577
|
+
}
|
|
578
|
+
// These MCP setups need no key, only the hub's URL: OAuth, and Claude Code reading the variable.
|
|
579
|
+
if (command === "mcp" && (!flags["with-key"] || (flags.client ?? "claude") === "claude")) {
|
|
580
|
+
return mcpConfig(asked ?? config.hubs[environment] ?? DEFAULT_HUB, undefined);
|
|
581
|
+
}
|
|
582
|
+
let resolved;
|
|
583
|
+
try {
|
|
584
|
+
resolved = resolveKey(config, environment, asked);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
fail(error.message, "hub_mismatch");
|
|
588
|
+
}
|
|
589
|
+
const { key, hub: hubUrl, fromEnv } = resolved;
|
|
590
|
+
const keyEnvironment = key ? environmentOfKey(key) : undefined;
|
|
591
|
+
if (fromEnv && keyEnvironment && keyEnvironment !== environment && (flags.production || flags.sandbox || command === "sandbox")) {
|
|
592
|
+
fail(`TALO_HUB_API_KEY es una key de ${keyEnvironment}, pero pediste ${environment}. Sacá la variable o usá la key de ${environment}.`, "env_key_mismatch");
|
|
593
|
+
}
|
|
594
|
+
if (command === "mcp")
|
|
595
|
+
return mcpConfig(hubUrl, key);
|
|
596
|
+
if (!key)
|
|
597
|
+
fail(`No hay sesión en ${environment}. Corré: ${loginHint(environment)}`, "not_logged_in");
|
|
598
|
+
if (fromEnv && !jsonMode)
|
|
599
|
+
console.error("(usando TALO_HUB_API_KEY)");
|
|
600
|
+
const hub = new Hub(hubUrl, key);
|
|
601
|
+
try {
|
|
602
|
+
await run(hub, { command, sub, arg, key, keyEnvironment, fromEnv, hubUrl });
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (!(error instanceof HubRequestError))
|
|
606
|
+
throw error;
|
|
607
|
+
let message = hubMessage(error);
|
|
608
|
+
const scopes = error.code === "insufficient_scope" ? error.message.match(/scope is "(\w+)"; this needs "(\w+)"/) : null;
|
|
609
|
+
if (scopes)
|
|
610
|
+
message = `Esta key tiene scope ${scopes[1]} y esto necesita ${scopes[2]}.`;
|
|
611
|
+
if (error.status === 401)
|
|
612
|
+
message = fromEnv ? "La key de TALO_HUB_API_KEY no es válida o fue revocada." : `${message} Volvé a correr ${loginHint(environment)}.`;
|
|
613
|
+
if (error.code === "talo_reconnect_required")
|
|
614
|
+
message += ` Volvé a correr ${loginHint(environment)}.`;
|
|
615
|
+
if (error.code === "insufficient_scope" && fromEnv && config.keys[environment])
|
|
616
|
+
message += " Es la key de TALO_HUB_API_KEY: sacá la variable para usar la de tu login.";
|
|
617
|
+
if (error.code === "talo_rejected" && !jsonMode)
|
|
618
|
+
message = `Talo rechazó el pedido: ${error.message}`;
|
|
619
|
+
fail(message, error.code ?? "hub_error", {
|
|
620
|
+
status: error.status,
|
|
621
|
+
...(scopes ? { key_scope: scopes[1], required_scope: scopes[2] } : {}),
|
|
622
|
+
...(error.details ? { details: error.details } : {}),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async function run(hub, ctx) {
|
|
627
|
+
const { command, sub, arg, key, keyEnvironment, fromEnv, hubUrl } = ctx;
|
|
628
|
+
switch (command) {
|
|
629
|
+
case "status": {
|
|
630
|
+
const account = await hub.get("/api/v1/account");
|
|
631
|
+
const keys = await hub.get("/api/v1/api-keys").catch(() => null);
|
|
632
|
+
const scope = keys?.api_keys.find((k) => key.startsWith(k.prefix.replace("…", "")))?.scope;
|
|
633
|
+
return emit({ ...account, key_scope: scope ?? null, key_source: fromEnv ? "TALO_HUB_API_KEY" : "config", hub: hubUrl }, () => {
|
|
634
|
+
console.log(`Cuenta Talo ${account.talo_user_id} (${account.environment})`);
|
|
635
|
+
console.log(` Estado: ${account.account_status ?? "desconocido"}`);
|
|
636
|
+
if (scope)
|
|
637
|
+
console.log(` Permisos de la key: ${scope}`);
|
|
638
|
+
if (account.alias_prefix)
|
|
639
|
+
console.log(` Prefijo de alias: ${account.alias_prefix}`);
|
|
640
|
+
console.log(` Key: ${fromEnv ? "TALO_HUB_API_KEY" : configPath()}`);
|
|
641
|
+
console.log(` Hub: ${hubUrl}`);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
case "payments": {
|
|
645
|
+
if (sub === "create") {
|
|
646
|
+
const payment = await hub.post("/api/v1/payments", { amount: amountFlag(true), external_id: flags["external-id"], motive: flags.motive });
|
|
647
|
+
return emit(payment, () => {
|
|
648
|
+
showPayment(payment);
|
|
649
|
+
if (keyEnvironment === "sandbox")
|
|
650
|
+
console.log(`\nPara pagarlo: ${CMD} sandbox pay ${payment.id}`);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (sub === "get") {
|
|
654
|
+
if (!arg)
|
|
655
|
+
fail(`Usá: ${CMD} payments get <payment_id>`);
|
|
656
|
+
const payment = await hub.get(`/api/v1/payments/${encodeURIComponent(arg)}`);
|
|
657
|
+
return emit(payment, () => showPayment(payment));
|
|
658
|
+
}
|
|
659
|
+
if (sub === "list") {
|
|
660
|
+
const query = new URLSearchParams();
|
|
661
|
+
const from = dateFlag("from");
|
|
662
|
+
const to = dateFlag("to");
|
|
663
|
+
if (from)
|
|
664
|
+
query.set("from", from);
|
|
665
|
+
if (to)
|
|
666
|
+
query.set("to", to);
|
|
667
|
+
const result = await hub.get(`/api/v1/payments?${query}`);
|
|
668
|
+
return emit(result, () => {
|
|
669
|
+
if (result.payments.length === 0)
|
|
670
|
+
console.log("Sin cobros en el período.");
|
|
671
|
+
for (const p of result.payments)
|
|
672
|
+
console.log(`${p.status.padEnd(10)} ${money.format(p.amount ?? 0).padStart(14)} ${p.id}`);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (sub === "refund") {
|
|
676
|
+
if (!arg)
|
|
677
|
+
fail(`Usá: ${CMD} payments refund <payment_id> [--amount <ARS>]`);
|
|
678
|
+
const amount = amountFlag(false);
|
|
679
|
+
const payment = await hub.post(`/api/v1/payments/${encodeURIComponent(arg)}/refunds`, amount === undefined ? {} : { amount });
|
|
680
|
+
return emit(payment, () => console.log(`✓ Reembolso pedido (${amount === undefined ? "total" : money.format(amount)}). Talo lo procesa en unos minutos.`));
|
|
681
|
+
}
|
|
682
|
+
return fail(`Usá: ${CMD} payments create | get <id> | list | refund <id>`);
|
|
683
|
+
}
|
|
684
|
+
case "sandbox": {
|
|
685
|
+
if (sub !== "pay" || !arg)
|
|
686
|
+
return fail(`Usá: ${CMD} sandbox pay <payment_id> [--amount <ARS>]`);
|
|
687
|
+
const result = await hub.post("/api/v1/sandbox/transfers", { payment_id: arg, amount: amountFlag(false) });
|
|
688
|
+
return emit(result, () => console.log(`✓ Transferencia simulada de ${money.format(result.amount)} al CVU ${result.cvu}. Mirá el estado con: ${CMD} payments get ${arg}`));
|
|
689
|
+
}
|
|
690
|
+
case "doctor": {
|
|
691
|
+
const report = await hub.get("/api/v1/readiness");
|
|
692
|
+
if (!report.ready)
|
|
693
|
+
process.exitCode = 1;
|
|
694
|
+
return emit(report, () => {
|
|
695
|
+
console.log(`Checklist de ${report.environment === "sandbox" ? "sandbox" : "producción"}\n`);
|
|
696
|
+
for (const check of report.checks) {
|
|
697
|
+
console.log(`${check.ok ? "✓" : "✗"} ${check.title}`);
|
|
698
|
+
if (!check.ok && check.fix)
|
|
699
|
+
console.log(` → ${check.fix}`);
|
|
700
|
+
}
|
|
701
|
+
for (const tip of report.recommendations)
|
|
702
|
+
console.log(`\n! ${tip}`);
|
|
703
|
+
console.log(`\n${report.ready ? "✓" : "…"} ${report.summary}`);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
case "balance":
|
|
707
|
+
return emit(await hub.get("/api/v1/balance"));
|
|
708
|
+
case "transactions": {
|
|
709
|
+
const query = new URLSearchParams({ limit: String(limitFlag(20)) });
|
|
710
|
+
const from = dateFlag("from");
|
|
711
|
+
const to = dateFlag("to");
|
|
712
|
+
if (from)
|
|
713
|
+
query.set("from", from);
|
|
714
|
+
if (to)
|
|
715
|
+
query.set("to", to);
|
|
716
|
+
if (flags.cursor)
|
|
717
|
+
query.set("cursor", flags.cursor);
|
|
718
|
+
return emit(await hub.get(`/api/v1/transactions?${query}`));
|
|
719
|
+
}
|
|
720
|
+
case "events": {
|
|
721
|
+
if (sub === "listen")
|
|
722
|
+
return listen(hub);
|
|
723
|
+
if (sub !== undefined && sub !== "list")
|
|
724
|
+
fail(`Usá: ${CMD} events list [--after <id>] | listen [--forward-to <url>]`);
|
|
725
|
+
const query = new URLSearchParams({ limit: String(limitFlag(20)) });
|
|
726
|
+
if (flags.payment)
|
|
727
|
+
query.set("payment_id", flags.payment);
|
|
728
|
+
if (flags.type)
|
|
729
|
+
query.set("type", flags.type);
|
|
730
|
+
if (flags.after === undefined) {
|
|
731
|
+
query.set("recent", query.get("limit"));
|
|
732
|
+
return emit(await hub.get(`/api/v1/events?${query}`));
|
|
733
|
+
}
|
|
734
|
+
if (!/^\d+$/.test(flags.after) || !Number.isSafeInteger(Number(flags.after))) {
|
|
735
|
+
fail(`--after tiene que ser el número de un evento (recibimos "${flags.after}").`, "invalid_option");
|
|
736
|
+
}
|
|
737
|
+
query.set("after", flags.after);
|
|
738
|
+
return emit(await hub.get(`/api/v1/events?${query}`));
|
|
739
|
+
}
|
|
740
|
+
case "webhooks": {
|
|
741
|
+
if (sub === undefined || sub === "list")
|
|
742
|
+
return emit(await hub.get("/api/v1/webhook-endpoints"));
|
|
743
|
+
if (sub !== "add" && sub !== "remove" && sub !== "test")
|
|
744
|
+
fail(`Usá: ${CMD} webhooks list | add <url> | remove <id> | test <id>`);
|
|
745
|
+
if (!arg)
|
|
746
|
+
fail(`Usá: ${CMD} webhooks ${sub === "add" ? "add <url>" : `${sub} <id>`}`);
|
|
747
|
+
if (sub === "add") {
|
|
748
|
+
const endpoint = await hub.post("/api/v1/webhook-endpoints", { url: arg });
|
|
749
|
+
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
|
+
}
|
|
751
|
+
if (sub === "remove")
|
|
752
|
+
return emit(await hub.request("DELETE", `/api/v1/webhook-endpoints/${encodeURIComponent(arg)}`));
|
|
753
|
+
const result = await hub.post(`/api/v1/webhook-endpoints/${encodeURIComponent(arg)}/test`);
|
|
754
|
+
if (!result.delivered)
|
|
755
|
+
process.exitCode = 1;
|
|
756
|
+
return emit(result, () => console.log(result.delivered
|
|
757
|
+
? `✓ ${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.`));
|
|
759
|
+
}
|
|
760
|
+
case "keys": {
|
|
761
|
+
if (sub === undefined || sub === "list")
|
|
762
|
+
return emit(await hub.get("/api/v1/api-keys"));
|
|
763
|
+
if (sub === "create") {
|
|
764
|
+
if (flags.scope && !["read", "charge", "full"].includes(flags.scope))
|
|
765
|
+
fail("--scope tiene que ser read, charge o full.", "invalid_option");
|
|
766
|
+
const created = await hub.post("/api/v1/api-keys", {
|
|
767
|
+
name: flags.name ?? "CLI",
|
|
768
|
+
scope: flags.scope ?? "charge",
|
|
769
|
+
});
|
|
770
|
+
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
|
+
}
|
|
772
|
+
if (sub === "revoke") {
|
|
773
|
+
if (!arg)
|
|
774
|
+
fail(`Usá: ${CMD} keys revoke <id>`);
|
|
775
|
+
const result = await hub.request("DELETE", `/api/v1/api-keys/${encodeURIComponent(arg)}`);
|
|
776
|
+
return emit(result, () => console.log(`✓ Key ${arg} revocada. Si era la de este login, corré ${CMD} login de nuevo.`));
|
|
777
|
+
}
|
|
778
|
+
return fail(`Usá: ${CMD} keys list | create [--name <nombre>] [--scope read|charge|full] | revoke <id>`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
main().catch((error) => {
|
|
783
|
+
if (error instanceof HubRequestError)
|
|
784
|
+
fail(hubMessage(error), error.code ?? "hub_error", { status: error.status });
|
|
785
|
+
fail(error instanceof Error ? error.message : String(error), "internal_error");
|
|
786
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Where the CLI keeps its API keys: ~/.config/talo-hub/config.json (or %APPDATA%\talo-hub), readable
|
|
2
|
+
// only by the user. Each key remembers the hub that issued it, so it is never sent anywhere else.
|
|
3
|
+
// TALO_HUB_API_KEY and TALO_HUB_URL override them for one run (CI, agents) and are never saved.
|
|
4
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
export const DEFAULT_HUB = "https://talopartnerhub.vercel.app";
|
|
8
|
+
function configDir() {
|
|
9
|
+
if (process.platform === "win32" && process.env.APPDATA)
|
|
10
|
+
return join(process.env.APPDATA, "talo-hub");
|
|
11
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "talo-hub");
|
|
12
|
+
}
|
|
13
|
+
export const configPath = () => join(configDir(), "config.json");
|
|
14
|
+
export async function loadConfig() {
|
|
15
|
+
let stored = {};
|
|
16
|
+
try {
|
|
17
|
+
stored = JSON.parse(await readFile(configPath(), "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// No config yet.
|
|
21
|
+
}
|
|
22
|
+
const keys = { ...(stored.keys ?? {}) };
|
|
23
|
+
const hubs = {};
|
|
24
|
+
for (const environment of ["sandbox", "production"]) {
|
|
25
|
+
// Configs from before per-key hubs had one `hub` for every key.
|
|
26
|
+
const hub = stored.hubs?.[environment] ?? (typeof stored.hub === "string" ? stored.hub : undefined);
|
|
27
|
+
if (!hub || !keys[environment])
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
hubs[environment] = normalizeHub(hub);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A key saved for a hub we wouldn't talk to now (plain http…): forget it, the person logs in again.
|
|
34
|
+
delete keys[environment];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { current: stored.current === "production" ? "production" : "sandbox", keys, hubs };
|
|
38
|
+
}
|
|
39
|
+
export async function saveConfig(config) {
|
|
40
|
+
await mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
41
|
+
const hubs = Object.fromEntries(Object.entries(config.hubs).filter(([environment, hub]) => hub && config.keys[environment]));
|
|
42
|
+
const stored = { current: config.current, keys: config.keys, ...(Object.keys(hubs).length ? { hubs } : {}) };
|
|
43
|
+
await writeFile(configPath(), `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
44
|
+
await chmod(configPath(), 0o600).catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
/** Keys only travel over https, except to a hub on this machine (development). */
|
|
47
|
+
export function normalizeHub(value) {
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(value.trim());
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`La URL del hub no es válida: "${value}".`);
|
|
54
|
+
}
|
|
55
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
56
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
57
|
+
throw new Error(`El hub tiene que usar https (recibimos "${value}"). Tus keys no viajan sin cifrar.`);
|
|
58
|
+
}
|
|
59
|
+
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
60
|
+
}
|
|
61
|
+
/** A hub asked for on this run: --hub, else TALO_HUB_URL. Empty values count as unset. */
|
|
62
|
+
export function requestedHub(flag) {
|
|
63
|
+
const value = flag?.trim() || process.env.TALO_HUB_URL?.trim();
|
|
64
|
+
return value ? normalizeHub(value) : undefined;
|
|
65
|
+
}
|
|
66
|
+
export function environmentOfKey(key) {
|
|
67
|
+
if (key.startsWith("tph_test_"))
|
|
68
|
+
return "sandbox";
|
|
69
|
+
if (key.startsWith("tph_live_"))
|
|
70
|
+
return "production";
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The key and hub for a command, given the hub asked for (already normalized, see requestedHub).
|
|
75
|
+
* TALO_HUB_API_KEY wins over the stored key. A stored key is only sent to the hub that issued it:
|
|
76
|
+
* asking for another hub with it is an error.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveKey(config, environment, asked) {
|
|
79
|
+
const envKey = process.env.TALO_HUB_API_KEY?.trim();
|
|
80
|
+
if (envKey)
|
|
81
|
+
return { key: envKey, hub: asked ?? DEFAULT_HUB, fromEnv: true };
|
|
82
|
+
const key = config.keys[environment];
|
|
83
|
+
const issuer = config.hubs[environment] ?? DEFAULT_HUB;
|
|
84
|
+
if (key && asked && asked !== issuer) {
|
|
85
|
+
throw new Error(`La key de ${environment} guardada es de ${issuer}, no de ${asked}. Hacé login en ese hub o usá TALO_HUB_API_KEY.`);
|
|
86
|
+
}
|
|
87
|
+
return { key, hub: asked ?? issuer, fromEnv: false };
|
|
88
|
+
}
|
package/dist/hub.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// A small client for the hub's /api/v1.
|
|
2
|
+
const TIMEOUT_MS = 30_000;
|
|
3
|
+
export class HubRequestError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
details;
|
|
7
|
+
constructor(message, status, code,
|
|
8
|
+
/** Talo's own reason when the hub passes one on (talo_message, talo_code). */
|
|
9
|
+
details) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.details = details;
|
|
14
|
+
this.name = "HubRequestError";
|
|
15
|
+
}
|
|
16
|
+
/** Worth retrying: the network failed, or the hub is overloaded or broken for a moment. */
|
|
17
|
+
get transient() {
|
|
18
|
+
return this.status === 0 || this.status === 429 || this.status >= 500;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The root cause of a failed fetch, in words ("ECONNREFUSED", "terminated"…). */
|
|
22
|
+
export function networkReason(error) {
|
|
23
|
+
if (!(error instanceof Error))
|
|
24
|
+
return String(error);
|
|
25
|
+
const cause = error.cause;
|
|
26
|
+
return cause?.message ?? error.message;
|
|
27
|
+
}
|
|
28
|
+
function proxyHint() {
|
|
29
|
+
return process.env.HTTPS_PROXY || process.env.HTTP_PROXY ? " Si usás un proxy, corré con NODE_USE_ENV_PROXY=1 (Node 22.21+ o 24.5+)." : "";
|
|
30
|
+
}
|
|
31
|
+
export class Hub {
|
|
32
|
+
baseUrl;
|
|
33
|
+
apiKey;
|
|
34
|
+
constructor(baseUrl, apiKey) {
|
|
35
|
+
this.baseUrl = baseUrl;
|
|
36
|
+
this.apiKey = apiKey;
|
|
37
|
+
}
|
|
38
|
+
async request(method, path, body) {
|
|
39
|
+
const headers = { accept: "application/json", "talo-hub-client": "cli" };
|
|
40
|
+
if (this.apiKey)
|
|
41
|
+
headers.authorization = `Bearer ${this.apiKey}`;
|
|
42
|
+
if (body !== undefined)
|
|
43
|
+
headers["content-type"] = "application/json";
|
|
44
|
+
let response;
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(`${this.baseUrl}${path}`, {
|
|
48
|
+
method,
|
|
49
|
+
headers,
|
|
50
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
51
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
52
|
+
});
|
|
53
|
+
// Reading the body can fail too (a reset mid-response): it counts as a network error.
|
|
54
|
+
text = await response.text();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new HubRequestError(`No pudimos conectar con ${this.baseUrl} (${networkReason(error)}).${proxyHint()}`, 0, "network_error");
|
|
58
|
+
}
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
data = text ? JSON.parse(text) : undefined;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
data = text;
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok && response.status !== 202) {
|
|
67
|
+
const error = data?.error;
|
|
68
|
+
throw new HubRequestError(error?.message ?? `El hub respondió ${response.status}.`, response.status, error?.code, error?.details);
|
|
69
|
+
}
|
|
70
|
+
// Every hub answer is a JSON object: anything else is a wrong URL, a proxy or a captive portal.
|
|
71
|
+
if (typeof data !== "object" || data === null) {
|
|
72
|
+
throw new HubRequestError(`${this.baseUrl} no respondió como el hub (no es JSON). Revisá --hub o TALO_HUB_URL.`, response.status, "invalid_response");
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
get(path) {
|
|
77
|
+
return this.request("GET", path);
|
|
78
|
+
}
|
|
79
|
+
post(path, body = {}) {
|
|
80
|
+
return this.request("POST", path, body);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Parsing what people type (amounts, dates, limits, URLs), kept apart from cli.ts so it can be tested.
|
|
2
|
+
/**
|
|
3
|
+
* ARS amounts as people type them: 15000, 15000.50, 15000,50, or with thousands dots (15.000,50).
|
|
4
|
+
* Anything ambiguous is refused instead of guessed, so 15.000 means fifteen thousand and 15,000
|
|
5
|
+
* (an English thousands comma) is rejected. PowerShell rewrites an unquoted 15.000,50 to 15,50
|
|
6
|
+
* before we see it, which is why the help recommends 15000.50 or quotes.
|
|
7
|
+
*/
|
|
8
|
+
export function parseAmount(raw) {
|
|
9
|
+
const value = raw.trim();
|
|
10
|
+
let normalized;
|
|
11
|
+
if (/^\d+([.,]\d{1,2})?$/.test(value))
|
|
12
|
+
normalized = value.replace(",", ".");
|
|
13
|
+
else if (/^\d{1,3}(\.\d{3})+(,\d{1,2})?$/.test(value))
|
|
14
|
+
normalized = value.replace(/\./g, "").replace(",", ".");
|
|
15
|
+
const amount = normalized === undefined ? NaN : Number(normalized);
|
|
16
|
+
return Number.isFinite(amount) && amount > 0 ? amount : undefined;
|
|
17
|
+
}
|
|
18
|
+
const AR_OFFSET = "-03:00";
|
|
19
|
+
/** Whether the calendar day written in `value` is the day of `time` at `offset` (catches 02-31). */
|
|
20
|
+
function sameDay(value, time, offsetMinutes) {
|
|
21
|
+
return new Date(time + offsetMinutes * 60_000).toISOString().slice(0, 10) === value.slice(0, 10);
|
|
22
|
+
}
|
|
23
|
+
function offsetMinutesOf(iso) {
|
|
24
|
+
if (/Z$/i.test(iso))
|
|
25
|
+
return 0;
|
|
26
|
+
const match = iso.match(/([+-])(\d{2}):?(\d{2})$/);
|
|
27
|
+
return match ? (match[1] === "-" ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3])) : -180;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* AAAA-MM-DD as an Argentine day (its first or last instant), or an ISO 8601 date-time. A date-time
|
|
31
|
+
* without Z or an offset is taken as Argentine time too, so the result doesn't depend on the machine.
|
|
32
|
+
*/
|
|
33
|
+
export function parseDate(raw, edge) {
|
|
34
|
+
let value = raw.trim();
|
|
35
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
36
|
+
value = `${value}T${edge === "start" ? "00:00:00.000" : "23:59:59.999"}${AR_OFFSET}`;
|
|
37
|
+
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value))
|
|
38
|
+
return undefined;
|
|
39
|
+
else if (!/(Z|[+-]\d{2}:?\d{2})$/i.test(value))
|
|
40
|
+
value += AR_OFFSET;
|
|
41
|
+
const time = Date.parse(value);
|
|
42
|
+
if (Number.isNaN(time) || !sameDay(value, time, offsetMinutesOf(value)))
|
|
43
|
+
return undefined;
|
|
44
|
+
return new Date(time).toISOString();
|
|
45
|
+
}
|
|
46
|
+
/** A whole number from 1 to `max`, or undefined. */
|
|
47
|
+
export function parseCount(raw, max) {
|
|
48
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
49
|
+
return undefined;
|
|
50
|
+
const n = Number(raw);
|
|
51
|
+
return n >= 1 && n <= max ? n : undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Where `events listen` relays events: an http(s) URL, a host:port (http assumed), or a bare port
|
|
55
|
+
* meaning localhost.
|
|
56
|
+
*/
|
|
57
|
+
export function parseForwardUrl(raw) {
|
|
58
|
+
const value = raw.trim();
|
|
59
|
+
let candidate;
|
|
60
|
+
if (/^\d+$/.test(value)) {
|
|
61
|
+
const port = Number(value);
|
|
62
|
+
if (port < 1 || port > 65535)
|
|
63
|
+
return undefined;
|
|
64
|
+
candidate = `http://localhost:${port}/`;
|
|
65
|
+
}
|
|
66
|
+
else if (/^https?:\/\//i.test(value)) {
|
|
67
|
+
candidate = value;
|
|
68
|
+
}
|
|
69
|
+
else if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(value)) {
|
|
70
|
+
// Another scheme (ftp:, file:, mailto:…), with or without slashes: never guess http for it.
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
candidate = `http://${value}`;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(candidate);
|
|
78
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || !url.hostname)
|
|
79
|
+
return undefined;
|
|
80
|
+
return url.toString();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talo-hub",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"talo",
|
|
7
|
+
"talo-pay",
|
|
8
|
+
"transferencias",
|
|
9
|
+
"transferencia-bancaria",
|
|
10
|
+
"cobros",
|
|
11
|
+
"pagos",
|
|
12
|
+
"argentina",
|
|
13
|
+
"cvu",
|
|
14
|
+
"cbu",
|
|
15
|
+
"alias",
|
|
16
|
+
"ars",
|
|
17
|
+
"payments",
|
|
18
|
+
"bank-transfer",
|
|
19
|
+
"payments-argentina",
|
|
20
|
+
"webhooks",
|
|
21
|
+
"sandbox",
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"ai-agents",
|
|
25
|
+
"claude",
|
|
26
|
+
"cursor",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"homepage": "https://talopartnerhub.vercel.app/docs/cli",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/matiasbarrigadev/talo-partner-hub.git",
|
|
33
|
+
"directory": "packages/cli"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"author": "Talo Partner Hub",
|
|
37
|
+
"type": "module",
|
|
38
|
+
"bin": {
|
|
39
|
+
"talo-hub": "dist/cli.js"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.json",
|
|
51
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
|
+
"prepublishOnly": "npm test",
|
|
53
|
+
"test": "npm run build && node --test \"test/**/*.test.mjs\""
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^26.6.5",
|
|
60
|
+
"typescript": "5.9.3"
|
|
61
|
+
}
|
|
62
|
+
}
|