talo-hub 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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] Instala las skills de Talo en .claude/skills
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;
@@ -423,16 +439,47 @@ async function listen(hub) {
423
439
  await sleep(2000);
424
440
  }
425
441
  }
426
- const SKILLS = ["talo-payments", "talo-go-live", "talo-operations"];
427
- /** Downloads the skills into ./.claude/skills (or ~/.claude/skills with --global). */
442
+ /** The hub's list of skills: the core ones, and the stack kits with how to recognize each stack. */
443
+ async function fetchSkillsIndex(hubUrl) {
444
+ let status = 0;
445
+ let body;
446
+ try {
447
+ const response = await fetch(`${hubUrl}/skills/index.json`, { signal: AbortSignal.timeout(30_000) });
448
+ status = response.status;
449
+ if (response.ok)
450
+ body = await response.json().catch(() => undefined);
451
+ }
452
+ catch (error) {
453
+ fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
454
+ }
455
+ const skills = readSkillsIndex(body);
456
+ if (!skills?.length)
457
+ fail(`No pudimos leer la lista de skills del hub (HTTP ${status}).`, "download_failed");
458
+ return skills;
459
+ }
460
+ const kitIds = (kits) => kits.map((kit) => kit.stack).join(", ");
461
+ /**
462
+ * Downloads the core skills and the stack kits into ./.claude/skills (or ~/.claude/skills with
463
+ * --global): the kits that match this project, the ones --stack names, or all of them for --global.
464
+ */
428
465
  async function installSkills(hubUrl) {
466
+ const index = await fetchSkillsIndex(hubUrl);
467
+ const kits = index.filter((skill) => skill.kind === "stack");
468
+ const detected = flags.global ? [] : await detectStacks(process.cwd(), kits);
469
+ let chosen = flags.global ? kits : detected;
470
+ if (flags.stack !== undefined) {
471
+ const picked = pickStacks(kits, flags.stack);
472
+ if (picked.unknown.length)
473
+ fail(`No hay kit para ${picked.unknown.join(", ")}. Kits: ${kitIds(kits)} (o all, none).`, "unknown_stack");
474
+ chosen = picked.kits;
475
+ }
429
476
  const root = join(flags.global ? homedir() : process.cwd(), ".claude", "skills");
430
477
  const installed = [];
431
- for (const skill of SKILLS) {
478
+ for (const skill of [...index.filter((entry) => entry.kind === "core"), ...chosen]) {
432
479
  let text = "";
433
480
  let status = 0;
434
481
  try {
435
- const response = await fetch(`${hubUrl}/skills/${skill}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
482
+ const response = await fetch(`${hubUrl}/skills/${skill.slug}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
436
483
  status = response.status;
437
484
  if (response.ok)
438
485
  text = await response.text();
@@ -440,15 +487,75 @@ async function installSkills(hubUrl) {
440
487
  catch (error) {
441
488
  fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
442
489
  }
443
- if (!text.startsWith("---") || !text.includes(`name: ${skill}`))
444
- fail(`No pudimos descargar la skill ${skill} (HTTP ${status}).`, "download_failed");
445
- const path = join(root, skill, "SKILL.md");
446
- await mkdir(join(root, skill), { recursive: true });
490
+ if (!text.startsWith("---") || !text.includes(`name: ${skill.slug}`))
491
+ fail(`No pudimos descargar la skill ${skill.slug} (HTTP ${status}).`, "download_failed");
492
+ const path = join(root, skill.slug, "SKILL.md");
493
+ await mkdir(join(root, skill.slug), { recursive: true });
447
494
  await writeFile(path, text);
448
495
  installed.push(path);
449
496
  say(`✓ ${path}`);
450
497
  }
451
- emit({ installed }, () => console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`));
498
+ emit({ installed, stacks: chosen.map((kit) => kit.stack), detected: detected.map((kit) => kit.stack) }, () => {
499
+ if (chosen.length)
500
+ console.log(`Kits por stack: ${chosen.map((kit) => kit.label).join(", ")}.`);
501
+ else if (flags.stack === undefined)
502
+ console.log(`No reconocí el stack de este proyecto. Elegí un kit con ${CMD} skills install --stack <${kitIds(kits)}>.`);
503
+ console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`);
504
+ });
505
+ }
506
+ /** The stack kits, marking the ones that match this project. */
507
+ async function listSkills(hubUrl) {
508
+ const index = await fetchSkillsIndex(hubUrl);
509
+ const kits = index.filter((skill) => skill.kind === "stack");
510
+ const detected = new Set((await detectStacks(process.cwd(), kits)).map((kit) => kit.slug));
511
+ emit({ skills: index.map((skill) => ({ ...skill, ...(skill.kind === "stack" ? { detected: detected.has(skill.slug) } : {}) })) }, () => {
512
+ console.log(`Skills base: ${index.filter((skill) => skill.kind === "core").map((skill) => skill.slug).join(", ")}`);
513
+ console.log("\nKits por stack (--stack <kit>):");
514
+ for (const kit of kits)
515
+ console.log(` ${kit.stack.padEnd(16)}${kit.label}${detected.has(kit.slug) ? " ← este proyecto" : ""}`);
516
+ console.log(`\nInstalar: ${CMD} skills install. Otro stack: ${DEFAULT_HUB}/docs/stacks.md`);
517
+ });
518
+ }
519
+ /** Where --vercel and --env-file ask a new secret to go, checked before anything is created. */
520
+ function secretDestinations() {
521
+ let vercel;
522
+ if (flags.vercel !== undefined) {
523
+ try {
524
+ vercel = parseVercelTarget(flags.vercel);
525
+ }
526
+ catch (error) {
527
+ fail(error.message, "invalid_option");
528
+ }
529
+ }
530
+ if (flags["env-file"] !== undefined && !flags["env-file"].trim())
531
+ fail("--env-file necesita la ruta del archivo, por ejemplo .env.local.", "invalid_option");
532
+ return { vercel, envFile: flags["env-file"] };
533
+ }
534
+ /**
535
+ * Writes the secret to each destination. Returns where it went, or Vercel's or the file system's
536
+ * error (the caller then undoes what it created, so no secret is left that nobody has).
537
+ */
538
+ async function storeSecret(name, value, to) {
539
+ const saved = [];
540
+ if (to.envFile) {
541
+ try {
542
+ await writeEnvFile(to.envFile, name, value);
543
+ }
544
+ catch (error) {
545
+ return { saved, error: `No pudimos escribir ${to.envFile}: ${error.message}` };
546
+ }
547
+ saved.push(to.envFile);
548
+ if (gitIgnores(to.envFile) === false)
549
+ say(`! git no ignora ${to.envFile}: agregá .env* a .gitignore antes de commitear.`);
550
+ }
551
+ if (to.vercel) {
552
+ say(`… Guardando ${name} en Vercel (${to.vercel.environment}${to.vercel.branch ? `, rama ${to.vercel.branch}` : ""})`);
553
+ const error = addToVercel(name, value, to.vercel);
554
+ if (error)
555
+ return { saved, error: `Vercel no la guardó:\n${error}\n¿Corriste vercel link en este proyecto?` };
556
+ saved.push(describeTarget(to.vercel));
557
+ }
558
+ return { saved };
452
559
  }
453
560
  /** Which shell will run a printed command: PowerShell spells environment variables differently. */
454
561
  function shellSyntax() {
@@ -524,8 +631,8 @@ async function main() {
524
631
  if (command === "login")
525
632
  return login(config);
526
633
  if (command === "skills") {
527
- if (sub !== "install")
528
- fail(`Usá: ${CMD} skills install [--global]`);
634
+ if (sub !== undefined && sub !== "install" && sub !== "list")
635
+ fail(`Usá: ${CMD} skills install [--stack <kit>] [--global] | skills list`);
529
636
  let hubUrl;
530
637
  try {
531
638
  hubUrl = requestedHub(flags.hub) ?? DEFAULT_HUB;
@@ -533,7 +640,7 @@ async function main() {
533
640
  catch (error) {
534
641
  fail(error.message, "invalid_hub");
535
642
  }
536
- return installSkills(hubUrl);
643
+ return sub === "install" ? installSkills(hubUrl) : listSkills(hubUrl);
537
644
  }
538
645
  if (command === "logout") {
539
646
  const environments = flags.all ? ["sandbox", "production"] : [chosenEnvironment(config)];
@@ -745,7 +852,25 @@ async function run(hub, ctx) {
745
852
  if (!arg)
746
853
  fail(`Usá: ${CMD} webhooks ${sub === "add" ? "add <url>" : `${sub} <id>`}`);
747
854
  if (sub === "add") {
855
+ const destinations = secretDestinations();
856
+ if (!flags["skip-check"]) {
857
+ const probe = await probeWebhookUrl(arg);
858
+ const shown = arg.replace(/(x-vercel-protection-bypass=)[^&#]+/, "$1redacted");
859
+ if (probe.verdict !== "ok")
860
+ fail(`${shown}: ${probe.message}\nSi ya lo resolviste, repetí con --skip-check.`, `webhook_url_${probe.verdict}`, { status: probe.status ?? null });
861
+ say(`✓ ${probe.message}`);
862
+ }
748
863
  const endpoint = await hub.post("/api/v1/webhook-endpoints", { url: arg });
864
+ if (destinations.vercel || destinations.envFile) {
865
+ const stored = await storeSecret("TALO_HUB_WEBHOOK_SECRET", endpoint.secret, destinations);
866
+ if (stored.error) {
867
+ await hub.request("DELETE", `/api/v1/webhook-endpoints/${encodeURIComponent(endpoint.id)}`).catch(() => undefined);
868
+ fail(`${stored.error}\nBorramos el webhook que acabábamos de registrar.`, "secret_not_saved");
869
+ }
870
+ const { secret: _secret, ...shown } = endpoint;
871
+ return emit({ ...shown, secret_saved_to: stored.saved }, () => console.log(`✓ Webhook ${endpoint.id}: secreto guardado como TALO_HUB_WEBHOOK_SECRET en ${stored.saved.join(" y ")} (no se muestra).` +
872
+ (destinations.vercel ? `\n Redesplegá para que lo tome, y probalo con ${CMD} webhooks test ${endpoint.id}.` : "")));
873
+ }
749
874
  return emit(endpoint, () => console.log(`✓ Webhook ${endpoint.id}\n Secreto para verificar la firma (guardalo en el .env de tu servidor como TALO_HUB_WEBHOOK_SECRET; no se vuelve a mostrar):\n ${endpoint.secret}`));
750
875
  }
751
876
  if (sub === "remove")
@@ -755,7 +880,7 @@ async function run(hub, ctx) {
755
880
  process.exitCode = 1;
756
881
  return emit(result, () => console.log(result.delivered
757
882
  ? `✓ ${result.url} recibió el evento de prueba (HTTP ${result.status_code}).`
758
- : `✗ ${result.url} no lo aceptó: ${result.error ?? `HTTP ${result.status_code}`}. Revisá que verifique la firma y responda 2xx.`));
883
+ : `✗ ${result.url} no lo aceptó: ${result.error ?? `HTTP ${result.status_code}`}.${result.reason && result.reason !== "http_error" ? "" : " Revisá que verifique la firma y responda 2xx."}`));
759
884
  }
760
885
  case "keys": {
761
886
  if (sub === undefined || sub === "list")
@@ -763,10 +888,27 @@ async function run(hub, ctx) {
763
888
  if (sub === "create") {
764
889
  if (flags.scope && !["read", "charge", "full"].includes(flags.scope))
765
890
  fail("--scope tiene que ser read, charge o full.", "invalid_option");
891
+ const destinations = secretDestinations();
766
892
  const created = await hub.post("/api/v1/api-keys", {
767
893
  name: flags.name ?? "CLI",
768
894
  scope: flags.scope ?? "charge",
769
895
  });
896
+ if (destinations.vercel || destinations.envFile) {
897
+ const revoke = () => hub.request("DELETE", `/api/v1/api-keys/${encodeURIComponent(created.id)}`).catch(() => undefined);
898
+ if (created.environment === "production" && destinations.vercel && destinations.vercel.environment !== "production") {
899
+ await revoke();
900
+ fail("Una key de producción (plata real) va solo en el entorno production de Vercel. La revocamos; para preview creá una de sandbox.", "live_key_outside_production");
901
+ }
902
+ const stored = await storeSecret("TALO_HUB_API_KEY", created.api_key, destinations);
903
+ if (stored.error) {
904
+ await revoke();
905
+ fail(`${stored.error}\nRevocamos la key que acabábamos de crear.`, "secret_not_saved");
906
+ }
907
+ const { api_key, ...shown } = created;
908
+ const prefix = `${api_key.slice(0, 13)}…`;
909
+ return emit({ ...shown, prefix, saved_to: stored.saved }, () => console.log(`✓ Key ${prefix} (${created.environment} · scope ${created.scope}) guardada como TALO_HUB_API_KEY en ${stored.saved.join(" y ")}. No se muestra.` +
910
+ (destinations.vercel ? "\n Redesplegá para que la tome." : "")));
911
+ }
770
912
  return emit(created, () => console.log(`✓ ${created.api_key}\n ${created.environment} · scope ${created.scope}. Guardala ahora en el .env o los secretos de tu servidor (TALO_HUB_API_KEY): no se vuelve a mostrar.\n En esta terminal, exportarla reemplaza a la key de tu login.`));
771
913
  }
772
914
  if (sub === "revoke") {
package/dist/probe.js ADDED
@@ -0,0 +1,59 @@
1
+ // Before registering a webhook URL, one unsigned POST tells whether the hub's signed deliveries
2
+ // would reach the handler: the hub doesn't follow redirects and sends no login, and a correct
3
+ // handler rejects an unsigned body with 400.
4
+ const VERCEL_LOGIN = /^https:\/\/vercel\.com\/sso-api/;
5
+ export async function probeWebhookUrl(url, fetchImpl = fetch) {
6
+ let response;
7
+ try {
8
+ response = await fetchImpl(url, {
9
+ method: "POST",
10
+ headers: { "content-type": "application/json", "user-agent": "TaloPartnerHub-Webhooks/1" },
11
+ body: "{}",
12
+ redirect: "manual",
13
+ signal: AbortSignal.timeout(10_000),
14
+ });
15
+ }
16
+ catch (error) {
17
+ const reason = error.cause?.code ?? error.name;
18
+ return { verdict: "unreachable", message: `No pudimos llegar a la URL (${reason}). ¿Está desplegada y es pública?` };
19
+ }
20
+ const status = response.status;
21
+ const location = response.headers.get("location") ?? "";
22
+ const body = status === 401 || status === 403 ? (await response.text().catch(() => "")).slice(0, 2000) : "";
23
+ const vercelLogin = VERCEL_LOGIN.test(location) || /_vercel_sso_nonce=/.test(response.headers.get("set-cookie") ?? "") || /Protected deployment|Protected by Vercel/.test(body);
24
+ if (vercelLogin) {
25
+ return {
26
+ verdict: "vercel_protection",
27
+ status,
28
+ message: "La Deployment Protection de Vercel la bloquea antes de tu código (las URLs *.vercel.app de cada deploy y de cada rama están protegidas). Usá el dominio de producción, o abrí esa URL a propósito (Deployment Protection Exception o ?x-vercel-protection-bypass=<secreto>).",
29
+ };
30
+ }
31
+ if (status >= 300 && status < 400) {
32
+ return { verdict: "redirect", status, message: `Redirige${location ? ` a ${location}` : ""}. El hub no sigue redirecciones: registrá la URL final (sin barra al final, el dominio principal).` };
33
+ }
34
+ if (response.headers.get("x-vercel-mitigated")) {
35
+ return { verdict: "vercel_firewall", status, message: "El Firewall de Vercel desafió el pedido. Agregá una regla Bypass para POST a esta ruta antes de los managed rulesets." };
36
+ }
37
+ if (status === 419) {
38
+ return { verdict: "csrf", status, message: "Responde 419: la ruta está bajo el CSRF de Laravel. Ponela en routes/api.php o excluila en bootstrap/app.php." };
39
+ }
40
+ if (status === 401 || status === 403) {
41
+ return {
42
+ verdict: "auth",
43
+ status,
44
+ message: `Responde ${status} antes de verificar la firma: algo delante de la ruta pide login o un token CSRF (middleware, proxy de auth, CsrfViewMiddleware de Django). Excluí la ruta del webhook.`,
45
+ };
46
+ }
47
+ if (status === 404 || status === 405) {
48
+ return { verdict: "not_found", status, message: `Responde ${status}: no hay una ruta POST ahí. Revisá la URL o desplegá el handler primero.` };
49
+ }
50
+ if (status === 429) {
51
+ return { verdict: "vercel_firewall", status, message: "Responde 429: un rate limit o el firewall frena el pedido. Excluí la ruta del webhook." };
52
+ }
53
+ if (status >= 200 && status < 300) {
54
+ return { verdict: "unsigned_accepted", status, message: `Aceptó un evento sin firma (HTTP ${status}). El handler tiene que verificar talo-hub-signature y responder 400 si no es válida.` };
55
+ }
56
+ // 400 is the expected answer; a 5xx usually means TALO_HUB_WEBHOOK_SECRET isn't set yet, which is
57
+ // normal before registering (the secret comes from this command).
58
+ return { verdict: "ok", status, message: status === 400 ? "Rechaza eventos sin firma (HTTP 400), como corresponde." : `Respondió HTTP ${status}.` };
59
+ }
@@ -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",
3
+ "version": "0.1.4",
4
4
  "description": "Transferencias bancarias automáticas en Argentina con Talo Pay: un CVU por cobro, acreditación y conciliación automáticas, webhooks firmados, sandbox y MCP para agentes de IA (Claude, Cursor).",
5
5
  "keywords": [
6
6
  "talo",