talo-hub 0.1.3 → 0.1.5
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 +10 -7
- package/dist/cli.js +158 -15
- 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
|
@@ -32,7 +32,7 @@ claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp
|
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
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
|
|
35
|
+
npx talo-hub skills install # skills de Talo + el kit de tu stack, en .claude/skills
|
|
36
36
|
```
|
|
37
37
|
|
|
38
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`).
|
|
@@ -41,11 +41,11 @@ El agente puede crear cobros, simular transferencias, registrar y probar su webh
|
|
|
41
41
|
|
|
42
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
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`.
|
|
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
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
46
|
- Amounts: pass `--amount 15000` or `15000.50`. Don't use thousands separators unquoted: PowerShell turns `15.000,50` into `15,50`.
|
|
47
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.
|
|
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
49
|
|
|
50
50
|
## Comandos
|
|
51
51
|
|
|
@@ -62,10 +62,13 @@ El agente puede crear cobros, simular transferencias, registrar y probar su webh
|
|
|
62
62
|
| `balance`, `transactions [--from] [--to] [--limit <n>] [--cursor]` | Saldo y movimientos |
|
|
63
63
|
| `events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <n>]` | Eventos recientes, filtrables por cobro o tipo |
|
|
64
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 |
|
|
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 |
|
|
66
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 |
|
|
67
69
|
| `mcp [--client claude\|cursor\|desktop] [--with-key]` | Configuración del MCP para tu agente |
|
|
68
|
-
| `skills install [--global]` | Instala las skills de Talo |
|
|
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 |
|
|
69
72
|
| `use <sandbox\|production>` | Entorno activo de los próximos comandos |
|
|
70
73
|
| `logout [--production \| --all]` | Revoca la key en el hub y la borra de esta máquina |
|
|
71
74
|
|
|
@@ -78,8 +81,8 @@ Montos: `15000` o `15000.50`. También entiende `15000,50` y `15.000,50`, pero e
|
|
|
78
81
|
```bash
|
|
79
82
|
npx talo-hub doctor # sandbox listo
|
|
80
83
|
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
|
|
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
|
|
83
86
|
npx talo-hub webhooks test <id> --production
|
|
84
87
|
npx talo-hub doctor --production
|
|
85
88
|
```
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,9 @@ 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";
|
|
@@ -40,11 +43,17 @@ Eventos y webhooks
|
|
|
40
43
|
events listen [--forward-to <url|puerto>] Muestra los últimos y escucha los nuevos
|
|
41
44
|
(los reenviados no van firmados: solo para desarrollo)
|
|
42
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)
|
|
43
48
|
|
|
44
49
|
Keys y agentes
|
|
45
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
|
|
46
53
|
mcp [--client claude|cursor|desktop] [--with-key] Configuración del MCP para tu agente
|
|
47
|
-
skills install [--global]
|
|
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
|
|
48
57
|
|
|
49
58
|
Montos: 15000 o 15000.50 (en PowerShell, con separador de miles, entre comillas: "15.000,50").
|
|
50
59
|
Fechas: AAAA-MM-DD o ISO 8601; sin zona horaria, hora de Argentina.
|
|
@@ -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;
|
|
@@ -229,6 +245,7 @@ const HUB_MESSAGES = {
|
|
|
229
245
|
invalid_url: "La URL del webhook no es válida: tiene que ser https.",
|
|
230
246
|
environment_disabled: "El hub todavía no está conectado a ese entorno de Talo.",
|
|
231
247
|
talo_partner_unsupported: "Talo todavía no habilita el saldo y los movimientos para integraciones de partner: consultalos en el panel de Talo.",
|
|
248
|
+
talo_unavailable: "Talo no respondió bien o a tiempo. Puede que lo haya hecho igual: revisá el cobro (payments get) antes de reintentar.",
|
|
232
249
|
};
|
|
233
250
|
/** A hub error in words for this CLI: transient failures read the same whatever the hub said. */
|
|
234
251
|
function hubMessage(error) {
|
|
@@ -423,16 +440,47 @@ async function listen(hub) {
|
|
|
423
440
|
await sleep(2000);
|
|
424
441
|
}
|
|
425
442
|
}
|
|
426
|
-
|
|
427
|
-
|
|
443
|
+
/** The hub's list of skills: the core ones, and the stack kits with how to recognize each stack. */
|
|
444
|
+
async function fetchSkillsIndex(hubUrl) {
|
|
445
|
+
let status = 0;
|
|
446
|
+
let body;
|
|
447
|
+
try {
|
|
448
|
+
const response = await fetch(`${hubUrl}/skills/index.json`, { signal: AbortSignal.timeout(30_000) });
|
|
449
|
+
status = response.status;
|
|
450
|
+
if (response.ok)
|
|
451
|
+
body = await response.json().catch(() => undefined);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
|
|
455
|
+
}
|
|
456
|
+
const skills = readSkillsIndex(body);
|
|
457
|
+
if (!skills?.length)
|
|
458
|
+
fail(`No pudimos leer la lista de skills del hub (HTTP ${status}).`, "download_failed");
|
|
459
|
+
return skills;
|
|
460
|
+
}
|
|
461
|
+
const kitIds = (kits) => kits.map((kit) => kit.stack).join(", ");
|
|
462
|
+
/**
|
|
463
|
+
* Downloads the core skills and the stack kits into ./.claude/skills (or ~/.claude/skills with
|
|
464
|
+
* --global): the kits that match this project, the ones --stack names, or all of them for --global.
|
|
465
|
+
*/
|
|
428
466
|
async function installSkills(hubUrl) {
|
|
467
|
+
const index = await fetchSkillsIndex(hubUrl);
|
|
468
|
+
const kits = index.filter((skill) => skill.kind === "stack");
|
|
469
|
+
const detected = flags.global ? [] : await detectStacks(process.cwd(), kits);
|
|
470
|
+
let chosen = flags.global ? kits : detected;
|
|
471
|
+
if (flags.stack !== undefined) {
|
|
472
|
+
const picked = pickStacks(kits, flags.stack);
|
|
473
|
+
if (picked.unknown.length)
|
|
474
|
+
fail(`No hay kit para ${picked.unknown.join(", ")}. Kits: ${kitIds(kits)} (o all, none).`, "unknown_stack");
|
|
475
|
+
chosen = picked.kits;
|
|
476
|
+
}
|
|
429
477
|
const root = join(flags.global ? homedir() : process.cwd(), ".claude", "skills");
|
|
430
478
|
const installed = [];
|
|
431
|
-
for (const skill of
|
|
479
|
+
for (const skill of [...index.filter((entry) => entry.kind === "core"), ...chosen]) {
|
|
432
480
|
let text = "";
|
|
433
481
|
let status = 0;
|
|
434
482
|
try {
|
|
435
|
-
const response = await fetch(`${hubUrl}/skills/${skill}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
|
|
483
|
+
const response = await fetch(`${hubUrl}/skills/${skill.slug}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
|
|
436
484
|
status = response.status;
|
|
437
485
|
if (response.ok)
|
|
438
486
|
text = await response.text();
|
|
@@ -440,15 +488,75 @@ async function installSkills(hubUrl) {
|
|
|
440
488
|
catch (error) {
|
|
441
489
|
fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
|
|
442
490
|
}
|
|
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 });
|
|
491
|
+
if (!text.startsWith("---") || !text.includes(`name: ${skill.slug}`))
|
|
492
|
+
fail(`No pudimos descargar la skill ${skill.slug} (HTTP ${status}).`, "download_failed");
|
|
493
|
+
const path = join(root, skill.slug, "SKILL.md");
|
|
494
|
+
await mkdir(join(root, skill.slug), { recursive: true });
|
|
447
495
|
await writeFile(path, text);
|
|
448
496
|
installed.push(path);
|
|
449
497
|
say(`✓ ${path}`);
|
|
450
498
|
}
|
|
451
|
-
emit({ installed
|
|
499
|
+
emit({ installed, stacks: chosen.map((kit) => kit.stack), detected: detected.map((kit) => kit.stack) }, () => {
|
|
500
|
+
if (chosen.length)
|
|
501
|
+
console.log(`Kits por stack: ${chosen.map((kit) => kit.label).join(", ")}.`);
|
|
502
|
+
else if (flags.stack === undefined)
|
|
503
|
+
console.log(`No reconocí el stack de este proyecto. Elegí un kit con ${CMD} skills install --stack <${kitIds(kits)}>.`);
|
|
504
|
+
console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/** The stack kits, marking the ones that match this project. */
|
|
508
|
+
async function listSkills(hubUrl) {
|
|
509
|
+
const index = await fetchSkillsIndex(hubUrl);
|
|
510
|
+
const kits = index.filter((skill) => skill.kind === "stack");
|
|
511
|
+
const detected = new Set((await detectStacks(process.cwd(), kits)).map((kit) => kit.slug));
|
|
512
|
+
emit({ skills: index.map((skill) => ({ ...skill, ...(skill.kind === "stack" ? { detected: detected.has(skill.slug) } : {}) })) }, () => {
|
|
513
|
+
console.log(`Skills base: ${index.filter((skill) => skill.kind === "core").map((skill) => skill.slug).join(", ")}`);
|
|
514
|
+
console.log("\nKits por stack (--stack <kit>):");
|
|
515
|
+
for (const kit of kits)
|
|
516
|
+
console.log(` ${kit.stack.padEnd(16)}${kit.label}${detected.has(kit.slug) ? " ← este proyecto" : ""}`);
|
|
517
|
+
console.log(`\nInstalar: ${CMD} skills install. Otro stack: ${DEFAULT_HUB}/docs/stacks.md`);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/** Where --vercel and --env-file ask a new secret to go, checked before anything is created. */
|
|
521
|
+
function secretDestinations() {
|
|
522
|
+
let vercel;
|
|
523
|
+
if (flags.vercel !== undefined) {
|
|
524
|
+
try {
|
|
525
|
+
vercel = parseVercelTarget(flags.vercel);
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
fail(error.message, "invalid_option");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (flags["env-file"] !== undefined && !flags["env-file"].trim())
|
|
532
|
+
fail("--env-file necesita la ruta del archivo, por ejemplo .env.local.", "invalid_option");
|
|
533
|
+
return { vercel, envFile: flags["env-file"] };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Writes the secret to each destination. Returns where it went, or Vercel's or the file system's
|
|
537
|
+
* error (the caller then undoes what it created, so no secret is left that nobody has).
|
|
538
|
+
*/
|
|
539
|
+
async function storeSecret(name, value, to) {
|
|
540
|
+
const saved = [];
|
|
541
|
+
if (to.envFile) {
|
|
542
|
+
try {
|
|
543
|
+
await writeEnvFile(to.envFile, name, value);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
return { saved, error: `No pudimos escribir ${to.envFile}: ${error.message}` };
|
|
547
|
+
}
|
|
548
|
+
saved.push(to.envFile);
|
|
549
|
+
if (gitIgnores(to.envFile) === false)
|
|
550
|
+
say(`! git no ignora ${to.envFile}: agregá .env* a .gitignore antes de commitear.`);
|
|
551
|
+
}
|
|
552
|
+
if (to.vercel) {
|
|
553
|
+
say(`… Guardando ${name} en Vercel (${to.vercel.environment}${to.vercel.branch ? `, rama ${to.vercel.branch}` : ""})`);
|
|
554
|
+
const error = addToVercel(name, value, to.vercel);
|
|
555
|
+
if (error)
|
|
556
|
+
return { saved, error: `Vercel no la guardó:\n${error}\n¿Corriste vercel link en este proyecto?` };
|
|
557
|
+
saved.push(describeTarget(to.vercel));
|
|
558
|
+
}
|
|
559
|
+
return { saved };
|
|
452
560
|
}
|
|
453
561
|
/** Which shell will run a printed command: PowerShell spells environment variables differently. */
|
|
454
562
|
function shellSyntax() {
|
|
@@ -524,8 +632,8 @@ async function main() {
|
|
|
524
632
|
if (command === "login")
|
|
525
633
|
return login(config);
|
|
526
634
|
if (command === "skills") {
|
|
527
|
-
if (sub !== "install")
|
|
528
|
-
fail(`Usá: ${CMD} skills install [--global]`);
|
|
635
|
+
if (sub !== undefined && sub !== "install" && sub !== "list")
|
|
636
|
+
fail(`Usá: ${CMD} skills install [--stack <kit>] [--global] | skills list`);
|
|
529
637
|
let hubUrl;
|
|
530
638
|
try {
|
|
531
639
|
hubUrl = requestedHub(flags.hub) ?? DEFAULT_HUB;
|
|
@@ -533,7 +641,7 @@ async function main() {
|
|
|
533
641
|
catch (error) {
|
|
534
642
|
fail(error.message, "invalid_hub");
|
|
535
643
|
}
|
|
536
|
-
return installSkills(hubUrl);
|
|
644
|
+
return sub === "install" ? installSkills(hubUrl) : listSkills(hubUrl);
|
|
537
645
|
}
|
|
538
646
|
if (command === "logout") {
|
|
539
647
|
const environments = flags.all ? ["sandbox", "production"] : [chosenEnvironment(config)];
|
|
@@ -745,7 +853,25 @@ async function run(hub, ctx) {
|
|
|
745
853
|
if (!arg)
|
|
746
854
|
fail(`Usá: ${CMD} webhooks ${sub === "add" ? "add <url>" : `${sub} <id>`}`);
|
|
747
855
|
if (sub === "add") {
|
|
856
|
+
const destinations = secretDestinations();
|
|
857
|
+
if (!flags["skip-check"]) {
|
|
858
|
+
const probe = await probeWebhookUrl(arg);
|
|
859
|
+
const shown = arg.replace(/(x-vercel-protection-bypass=)[^&#]+/, "$1redacted");
|
|
860
|
+
if (probe.verdict !== "ok")
|
|
861
|
+
fail(`${shown}: ${probe.message}\nSi ya lo resolviste, repetí con --skip-check.`, `webhook_url_${probe.verdict}`, { status: probe.status ?? null });
|
|
862
|
+
say(`✓ ${probe.message}`);
|
|
863
|
+
}
|
|
748
864
|
const endpoint = await hub.post("/api/v1/webhook-endpoints", { url: arg });
|
|
865
|
+
if (destinations.vercel || destinations.envFile) {
|
|
866
|
+
const stored = await storeSecret("TALO_HUB_WEBHOOK_SECRET", endpoint.secret, destinations);
|
|
867
|
+
if (stored.error) {
|
|
868
|
+
await hub.request("DELETE", `/api/v1/webhook-endpoints/${encodeURIComponent(endpoint.id)}`).catch(() => undefined);
|
|
869
|
+
fail(`${stored.error}\nBorramos el webhook que acabábamos de registrar.`, "secret_not_saved");
|
|
870
|
+
}
|
|
871
|
+
const { secret: _secret, ...shown } = endpoint;
|
|
872
|
+
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).` +
|
|
873
|
+
(destinations.vercel ? `\n Redesplegá para que lo tome, y probalo con ${CMD} webhooks test ${endpoint.id}.` : "")));
|
|
874
|
+
}
|
|
749
875
|
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
876
|
}
|
|
751
877
|
if (sub === "remove")
|
|
@@ -755,7 +881,7 @@ async function run(hub, ctx) {
|
|
|
755
881
|
process.exitCode = 1;
|
|
756
882
|
return emit(result, () => console.log(result.delivered
|
|
757
883
|
? `✓ ${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
|
|
884
|
+
: `✗ ${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
885
|
}
|
|
760
886
|
case "keys": {
|
|
761
887
|
if (sub === undefined || sub === "list")
|
|
@@ -763,10 +889,27 @@ async function run(hub, ctx) {
|
|
|
763
889
|
if (sub === "create") {
|
|
764
890
|
if (flags.scope && !["read", "charge", "full"].includes(flags.scope))
|
|
765
891
|
fail("--scope tiene que ser read, charge o full.", "invalid_option");
|
|
892
|
+
const destinations = secretDestinations();
|
|
766
893
|
const created = await hub.post("/api/v1/api-keys", {
|
|
767
894
|
name: flags.name ?? "CLI",
|
|
768
895
|
scope: flags.scope ?? "charge",
|
|
769
896
|
});
|
|
897
|
+
if (destinations.vercel || destinations.envFile) {
|
|
898
|
+
const revoke = () => hub.request("DELETE", `/api/v1/api-keys/${encodeURIComponent(created.id)}`).catch(() => undefined);
|
|
899
|
+
if (created.environment === "production" && destinations.vercel && destinations.vercel.environment !== "production") {
|
|
900
|
+
await revoke();
|
|
901
|
+
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");
|
|
902
|
+
}
|
|
903
|
+
const stored = await storeSecret("TALO_HUB_API_KEY", created.api_key, destinations);
|
|
904
|
+
if (stored.error) {
|
|
905
|
+
await revoke();
|
|
906
|
+
fail(`${stored.error}\nRevocamos la key que acabábamos de crear.`, "secret_not_saved");
|
|
907
|
+
}
|
|
908
|
+
const { api_key, ...shown } = created;
|
|
909
|
+
const prefix = `${api_key.slice(0, 13)}…`;
|
|
910
|
+
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.` +
|
|
911
|
+
(destinations.vercel ? "\n Redesplegá para que la tome." : "")));
|
|
912
|
+
}
|
|
770
913
|
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
914
|
}
|
|
772
915
|
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.5",
|
|
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",
|