kooni-bot 0.2.13 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/kooni.js +277 -14
  2. package/package.json +1 -1
package/bin/kooni.js CHANGED
@@ -19,10 +19,10 @@ import { realpathSync } from "node:fs";
19
19
  import { join, basename } from "node:path";
20
20
  import { homedir } from "node:os";
21
21
  import { execFileSync, spawnSync } from "node:child_process";
22
- import { randomUUID } from "node:crypto";
22
+ import { randomUUID, createPublicKey, verify as edVerify } from "node:crypto";
23
23
  import { fileURLToPath } from "node:url";
24
24
 
25
- const CLI_VERSION = "0.2.13";
25
+ const CLI_VERSION = "0.2.17";
26
26
 
27
27
  const REPO = process.env.KOONI_REPO || "iamnocodeveloper/kooni-bot";
28
28
  const BRANCH = process.env.KOONI_BRANCH || "main";
@@ -37,6 +37,21 @@ const INSTALLS_FILE = join(CFG_DIR, "installs.json");
37
37
  const MARKER = ".kooni-bot.json";
38
38
  const SKILL_DIR = join(homedir(), ".claude", "skills", "kooni");
39
39
 
40
+ // Clave PÚBLICA de licencias (Ed25519). Verifica códigos KOONI-PRO-V2-… pero NO
41
+ // puede firmarlos: es segura de publicar en npm — ese es el punto de la v2.
42
+ // DEBE ser la misma que está embebida en src/license.ts del bot. La PRIVADA vive
43
+ // solo en el panel de licencias (InsForge, secret LICENSE_PRIVATE_KEY).
44
+ //
45
+ // v2 (2026-09-01): reemplaza a LICENSE_MASTER_KEY (HMAC), que viajaba en este
46
+ // mismo archivo público y permitía falsificar licencias — hallazgo S2 del PLAN.
47
+ const LICENSE_PUBLIC_KEY = process.env.KOONI_LICENSE_PUBLIC_KEY || "MCowBQYDK2VwAyEAqpP9OBrju8ebMWjQM4uYLsUV5yqWG8k8ieozT8Me8EQ=";
48
+
49
+ // Token compartido de registro/uso (X-Kooni-Token). DEBE coincidir con el secret
50
+ // REGISTER_TOKEN del panel de licencias (InsForge). Es "seguridad por oscuridad"
51
+ // (vive en el CLI público y en cada worker — ver tarea S4 en PLAN.md): el blindaje
52
+ // real lo dan la validación de formato y el rechazo de uids desconocidos.
53
+ const KOONI_REGISTER_TOKEN = process.env.KOONI_REGISTER_TOKEN || "1f8740b841b71f21eba510eb12b8e6870b5949a916cf1162";
54
+
40
55
  // ── color ─────────────────────────────────────────────────────────────────────
41
56
  const C = {
42
57
  cyan: (s) => `\x1b[36m${s}\x1b[0m`,
@@ -122,6 +137,9 @@ const DICT = {
122
137
  // error
123
138
  templateMissing: "no encontré el instalador en el template.",
124
139
  deployFailed: "el deploy falló. Revisa los mensajes de arriba.",
140
+ noSubdomain: "tu cuenta de Cloudflare no tiene subdominio workers.dev — lo estoy creando automáticamente…",
141
+ subdomainOk: (s) => `subdominio listo: ${s}.workers.dev`,
142
+ subdomainManual: "no pude crear el subdominio automáticamente (no encontré tu sesión de Cloudflare). Hazlo manual, 1 min:",
125
143
  unknown: "comando desconocido:",
126
144
  helpIntro: "instala tu asistente de IA en Cloudflare",
127
145
  },
@@ -189,6 +207,9 @@ const DICT = {
189
207
  qEmail: "Your email? (optional, for support)",
190
208
  templateMissing: "installer not found in template.",
191
209
  deployFailed: "deploy failed. Check the messages above.",
210
+ noSubdomain: "your Cloudflare account has no workers.dev subdomain yet — creating it automatically…",
211
+ subdomainOk: (s) => `subdomain ready: ${s}.workers.dev`,
212
+ subdomainManual: "couldn't create the subdomain automatically (I couldn't find your Cloudflare session). Do it manually, 1 min:",
192
213
  unknown: "unknown command:",
193
214
  helpIntro: "install your AI assistant on Cloudflare",
194
215
  },
@@ -651,6 +672,13 @@ function stampWrangler(dir, answers, botUid) {
651
672
  s = s.replace(/^(\s*\[vars\][^\n]*\n)/m, `$1BOT_INSTANCE_ID = "${uid}"\n`);
652
673
  }
653
674
 
675
+ // Token compartido de registro/uso (panel de licencias del dueño).
676
+ if (hasInVars("KOONI_REGISTER_TOKEN")) {
677
+ set(/KOONI_REGISTER_TOKEN\s*=\s*"[^"]*"/g, `KOONI_REGISTER_TOKEN = "${KOONI_REGISTER_TOKEN}"`);
678
+ } else {
679
+ s = s.replace(/^(\s*\[vars\][^\n]*\n)/m, `$1KOONI_REGISTER_TOKEN = "${KOONI_REGISTER_TOKEN}"\n`);
680
+ }
681
+
654
682
  if (answers.provider !== "anthropic") {
655
683
  if (hasInVars("LLM_PROVIDER")) {
656
684
  set(/LLM_PROVIDER\s*=\s*"[^"]*"/g, `LLM_PROVIDER = "${answers.provider}"`);
@@ -738,6 +766,10 @@ function writeDevVars(dir, answers, kbToken) {
738
766
  lines.push("# " + answers.secret + "=<tu-api-key> ← para wrangler dev local, pégalo aquí (o usa el panel)");
739
767
  lines.push(`DASHBOARD_PASSWORD=${answers.dashPassword || "kooni-local-password"}`);
740
768
  lines.push(`KB_REINDEX_TOKEN=${kbToken}`);
769
+ // (v2) Ya NO se escribe ninguna llave de licencias: el bot verifica con la
770
+ // clave pública que trae embebida y no necesita ningún secret para eso.
771
+ // Token compartido de registro/uso (X-Kooni-Token para el panel de licencias).
772
+ lines.push(`KOONI_REGISTER_TOKEN=${KOONI_REGISTER_TOKEN}`);
741
773
  writeFileSync(join(dir, ".dev.vars"), lines.join("\n") + "\n");
742
774
  }
743
775
 
@@ -771,6 +803,7 @@ function collectAnswers(flags) {
771
803
  reglas: String(flags.reglas || "").trim() || undefined,
772
804
  tone,
773
805
  email: String(flags.email || "").trim() || undefined,
806
+ licenseCode: String(flags.license || "").trim() || undefined,
774
807
  };
775
808
  }
776
809
 
@@ -851,9 +884,65 @@ async function onboarding(rl, answers, defaultDir) {
851
884
  ], { value: answers.tone, default: 0 });
852
885
  answers.tone = ["cercano", "formal", "divertido"][toneIdx] || "cercano";
853
886
 
887
+ // Correo del dueño: se pide SIEMPRE (registra la instalación en el panel de
888
+ // licencias y es el canal de contacto/renovación). En interactivo se exige un
889
+ // valor (con reintento); en modo agente/CI viene por --email.
890
+ if (!answers.email) {
891
+ while (interactive()) {
892
+ const em = (await ask(rl, t().qEmail, undefined)).trim();
893
+ if (em) { answers.email = em; break; }
894
+ console.log(" " + C.yellow("⚠") + " " + m("el correo es obligatorio para registrar tu instalación.", "email is required to register your install."));
895
+ }
896
+ }
897
+
898
+ // Licencia Pro: si el usuario dice que es Pro, pide el código y lo valida
899
+ // localmente (HMAC con la master key). Si es válido se guarda tras el deploy
900
+ // (settings pro_license → límites quitados). Si es inválido, se avisa y sigue gratis.
901
+ const wantsPro = interactive()
902
+ ? await confirm(rl, m("¿Tu bot será Pro (tienes una licencia)?", "Will your bot be Pro (do you have a license)?"))
903
+ : !!(flags.license || answers.licenseCode);
904
+ if (wantsPro) {
905
+ const code = answers.licenseCode || (interactive()
906
+ ? await ask(rl, m("Pega tu código KOONI-PRO-V2-…:", "Paste your KOONI-PRO-V2-… code:"), undefined)
907
+ : String(flags.license || "").trim());
908
+ if (code && verifyKooniLicense(code)) {
909
+ answers.licenseCode = code.trim();
910
+ console.log(" " + C.green("✓") + " " + m("licencia válida — se activará al terminar la instalación.", "valid license — it will be activated when the install finishes."));
911
+ } else if (code) {
912
+ console.log(" " + C.yellow("⚠") + " " + m("ese código no es válido. Puedes corregirlo luego en el panel → Licencia.", "that code isn't valid. You can fix it later in the panel → Licencia."));
913
+ }
914
+ }
915
+
854
916
  return answers;
855
917
  }
856
918
 
919
+ // ── licencia Pro ─────────────────────────────────────────────────────────────
920
+ // Valida un código KOONI-PRO-V2-… localmente (mismo formato que src/license.ts
921
+ // del bot): firma Ed25519 verificada con la clave PÚBLICA. No exige que esté
922
+ // ligado a esta instalación (eso lo revalida el panel al leerlo).
923
+ // Sin red: la verificación es puramente local, igual que antes.
924
+ function verifyKooniLicense(code) {
925
+ const trimmed = String(code || "").trim();
926
+ const prefix = "KOONI-PRO-V2-";
927
+ if (!trimmed.startsWith(prefix)) return false;
928
+ const rest = trimmed.slice(prefix.length);
929
+ const dot = rest.lastIndexOf(".");
930
+ if (dot <= 0) return false;
931
+ const enc = rest.slice(0, dot);
932
+ const sig = rest.slice(dot + 1);
933
+ try {
934
+ const pub = createPublicKey({
935
+ key: Buffer.from(LICENSE_PUBLIC_KEY, "base64"),
936
+ format: "der",
937
+ type: "spki",
938
+ });
939
+ if (!edVerify(null, Buffer.from(enc, "utf8"), pub, Buffer.from(sig, "hex"))) return false;
940
+ const payload = JSON.parse(Buffer.from(enc, "base64url").toString("utf8"));
941
+ if (payload.kind === "monthly" && payload.expiry && Date.now() > payload.expiry) return false;
942
+ return true;
943
+ } catch { return false; }
944
+ }
945
+
857
946
  // ── deploy ───────────────────────────────────────────────────────────────────
858
947
  const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
859
948
 
@@ -885,6 +974,114 @@ function patchWranglerFile(dir, fn) {
885
974
  writeFileSync(wt, fn(s));
886
975
  }
887
976
 
977
+ // ── workers.dev subdomain (Cloudflare error 10063) ───────────────────────────
978
+ // Cuando la cuenta aún no tiene subdominio workers.dev, `wrangler deploy` falla
979
+ // con [code: 10063]. Lo resolvemos SOLO con la API de Cloudflare reutilizando la
980
+ // sesión OAuth que wrangler ya guardó en disco (sin pedirle nada al usuario); si
981
+ // no es posible, mostramos los pasos manuales + la opción de API token.
982
+ const CF_OAUTH_CLIENT_ID = "54d11594-84e4-41aa-b438-e81b8fa78ee7"; // cliente OAuth público de wrangler
983
+
984
+ function wranglerConfigCandidates() {
985
+ const h = homedir();
986
+ const appdata = process.env.APPDATA || join(h, "AppData", "Roaming");
987
+ return [
988
+ join(appdata, "xdg.config", ".wrangler", "config", "default.toml"),
989
+ join(h, ".config", ".wrangler", "config", "default.toml"),
990
+ join(h, ".wrangler", "config", "default.toml"),
991
+ join(h, "AppData", "Local", ".wrangler", "config", "default.toml"),
992
+ ].filter((p) => existsSync(p));
993
+ }
994
+
995
+ // Access token OAuth temporal a partir de la sesión guardada de wrangler.
996
+ // IMPORTANTE: Cloudflare ROTA el refresh token en cada intercambio. Si no lo
997
+ // devolvemos al config, invalidamos la sesión de wrangler (error 9109 al
998
+ // siguiente deploy). Por eso: 1) si el access token almacenado aún no expiró,
999
+ // se usa directo (sin tocar el refresh); 2) si hay que refrescar, se persiste
1000
+ // el par nuevo de vuelta al config, igual que hace wrangler.
1001
+ async function cfOAuthAccessToken() {
1002
+ const cfgPath = wranglerConfigCandidates()[0];
1003
+ if (!cfgPath) return null;
1004
+ let raw = "";
1005
+ try { raw = readFileSync(cfgPath, "utf8"); } catch { return null; }
1006
+ const pick = (k) => {
1007
+ const line = raw.split(/\r?\n/).find((l) => l.trim().startsWith(k + " "));
1008
+ if (!line) return "";
1009
+ const m = line.match(/=\s*"([^"]*)"/);
1010
+ return m ? m[1] : "";
1011
+ };
1012
+ const storedAccess = pick("oauth_token");
1013
+ const exp = pick("expiration_time");
1014
+ // 1) Access token almacenado aún vigente → úsalo directo (cero rotación).
1015
+ if (storedAccess && exp && new Date(exp).getTime() > Date.now()) {
1016
+ try {
1017
+ const probe = await fetchRetry("https://api.cloudflare.com/client/v4/accounts", { headers: { Authorization: "Bearer " + storedAccess } }, { ms: 8000, tries: 1 });
1018
+ if (probe.ok) return storedAccess;
1019
+ } catch {}
1020
+ }
1021
+ // 2) Refrescar: intercambia el refresh token y PERSISTE el par nuevo.
1022
+ const refresh = pick("refresh_token") || pick("oauth_token");
1023
+ if (!refresh) return null;
1024
+ try {
1025
+ const res = await fetchRetry("https://dash.cloudflare.com/oauth2/token", {
1026
+ method: "POST",
1027
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1028
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh, client_id: CF_OAUTH_CLIENT_ID }),
1029
+ }, { ms: 10000, tries: 2 });
1030
+ if (!res.ok) return null;
1031
+ const j = await res.json().catch(() => ({}));
1032
+ if (!j.access_token) return null;
1033
+ try {
1034
+ const nextRefresh = j.refresh_token || refresh;
1035
+ const expIso = new Date(Date.now() + (j.expires_in || 3599) * 1000).toISOString();
1036
+ const out = raw
1037
+ .replace(/^oauth_token\s*=\s*"[^"]*"/m, `oauth_token = "${j.access_token}"`)
1038
+ .replace(/^refresh_token\s*=\s*"[^"]*"/m, `refresh_token = "${nextRefresh}"`)
1039
+ .replace(/^expiration_time\s*=\s*"[^"]*"/m, `expiration_time = "${expIso}"`);
1040
+ if (out !== raw) writeFileSync(cfgPath, out);
1041
+ } catch {}
1042
+ return j.access_token;
1043
+ } catch { return null; }
1044
+ }
1045
+
1046
+ async function cfApi(access, path, method = "GET", body) {
1047
+ const res = await fetchRetry("https://api.cloudflare.com/client/v4" + path, {
1048
+ method,
1049
+ headers: { Authorization: "Bearer " + access, "Content-Type": "application/json" },
1050
+ body: body ? JSON.stringify(body) : undefined,
1051
+ }, { ms: 15000, tries: 2 });
1052
+ const json = await res.json().catch(() => ({}));
1053
+ return { status: res.status, json };
1054
+ }
1055
+
1056
+ // Nombre candidato para el subdominio, derivado del slug del bot.
1057
+ function proposeSubdomain(slug) {
1058
+ const s = sanitizeSlug(slug || "kooni").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
1059
+ return (s.slice(0, 24).replace(/-+$/, "") || "kooni");
1060
+ }
1061
+
1062
+ async function ensureWorkersDevSubdomain(dir) {
1063
+ const slug = (readMarker(dir) || {}).slug || basename(dir);
1064
+ const access = await cfOAuthAccessToken();
1065
+ if (!access) return { ok: false, reason: "no-oauth" };
1066
+ try {
1067
+ const acc = await cfApi(access, "/accounts");
1068
+ const accounts = (acc.json && acc.json.result) || [];
1069
+ if (!accounts.length) return { ok: false, reason: "no-account" };
1070
+ const account = accounts[0];
1071
+ const sub = await cfApi(access, `/accounts/${account.id}/workers/subdomain`);
1072
+ const existing = sub.json && sub.json.result && sub.json.result.subdomain;
1073
+ if (existing) return { ok: true, subdomain: existing, accountId: account.id, accountName: account.name, via: "existing" };
1074
+ const candidate = proposeSubdomain(slug);
1075
+ const put = await cfApi(access, `/accounts/${account.id}/workers/subdomain`, "PUT", { subdomain: candidate });
1076
+ const created = put.json && put.json.result && put.json.result.subdomain;
1077
+ if (put.status < 300 && created) return { ok: true, subdomain: created, accountId: account.id, accountName: account.name, via: "created" };
1078
+ const msg = (put.json && put.json.errors && put.json.errors[0] && put.json.errors[0].message) || `HTTP ${put.status}`;
1079
+ return { ok: false, reason: "create-failed", message: msg, candidate, accountId: account.id };
1080
+ } catch (e) {
1081
+ return { ok: false, reason: "api-error", message: e.message || String(e) };
1082
+ }
1083
+ }
1084
+
888
1085
  async function deployBot(dir, { flags = {}, rl } = {}) {
889
1086
  const wt = join(dir, "wrangler.toml");
890
1087
  if (!existsSync(wt)) throw new Error(m("no encuentro wrangler.toml en " + dir, "can't find wrangler.toml in " + dir));
@@ -974,6 +1171,27 @@ async function deployBot(dir, { flags = {}, rl } = {}) {
974
1171
  }
975
1172
  } catch {}
976
1173
 
1174
+ // Licencia Pro capturada en el onboarding: se activa después del deploy
1175
+ // escribiendo el código en settings (key pro_license), igual que el panel.
1176
+ const licenseCode = (flags.license || "") ? String(flags.license).trim() : (flags._licenseCode || "");
1177
+ if (flags._licenseCode) {
1178
+ try {
1179
+ const code = String(flags._licenseCode).trim();
1180
+ const now = Date.now();
1181
+ const sql = `INSERT INTO settings (key, value, updated_at) VALUES ('pro_license', '${code.replace(/'/g, "''")}', ${now}) ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at;`;
1182
+ const tmp = join(dir, ".kooni-license.sql");
1183
+ writeFileSync(tmp, sql);
1184
+ wrangler(dir, ["d1", "execute", dbName, "--file=" + tmp, "--remote"], { capture: true });
1185
+ rmSync(tmp, { force: true });
1186
+ console.log(" " + C.green("✓") + " " + m("licencia Pro activada (código guardado)", "Pro license activated (code saved)"));
1187
+ } catch (e) {
1188
+ console.log(" " + C.yellow("⚠") + " " + m("no pude guardar la licencia automáticamente — pégala en el panel → Licencia.", "couldn't save the license automatically — paste it in the panel → Licencia."));
1189
+ }
1190
+ }
1191
+
1192
+ // (v2) No hay secret de licencias que instalar: el worker verifica los códigos
1193
+ // con la clave PÚBLICA embebida en su propio código. Una llave menos viajando.
1194
+
977
1195
  // dependencias + migraciones + deploy (progreso visible en vivo)
978
1196
  console.log("\n " + C.dim(t().installing));
979
1197
  console.log(" " + C.dim(m("esto puede tardar unos minutos la primera vez (descarga el runtime y compila dependencias).", "this may take a few minutes the first time (downloads the runtime and builds native deps).")));
@@ -985,17 +1203,49 @@ async function deployBot(dir, { flags = {}, rl } = {}) {
985
1203
  console.log(" " + C.green("✓") + " " + m("migraciones aplicadas", "migrations applied"));
986
1204
 
987
1205
  console.log(" " + C.dim(t().deploying));
988
- let url = "";
989
- try {
1206
+ const deployOnce = () => {
990
1207
  const dep = runPnpm(dir, ["run", "deploy"], { capture: true });
991
1208
  // La URL real incluye el subdominio de la cuenta: <worker>.<cuenta>.workers.dev
992
- url = (dep.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev/) || [])[0] || "";
1209
+ return (dep.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev/) || [])[0] || "";
1210
+ };
1211
+ const deployErrorText = (e) => ((e && (e.stderr || e.stdout || e.message)) || "").toString();
1212
+
1213
+ let url = "";
1214
+ try {
1215
+ url = deployOnce();
993
1216
  } catch (e) {
994
- // Muestra el detalle real del deploy en vez de tragarlo: así el usuario ve
995
- // qué falló (deploy-check, binding, auth…) y puede corregirlo.
996
- const tail = ((e && (e.stderr || e.stdout || e.message)) || "").toString().trim().split("\n").slice(-12).join("\n");
997
- if (tail) console.log(C.red(" ✗ " + tail));
998
- throw new Error(t().deployFailed);
1217
+ const errText = deployErrorText(e);
1218
+ if (/10063|workers\.dev subdomain/.test(errText)) {
1219
+ // La cuenta no tiene subdominio workers.dev: lo creamos automáticamente
1220
+ // con la sesión OAuth de wrangler y reintentamos el deploy.
1221
+ console.log(" " + C.yellow("⚠") + " " + t().noSubdomain);
1222
+ const fix = await ensureWorkersDevSubdomain(dir);
1223
+ if (fix.ok) {
1224
+ console.log(" " + C.green("✓") + " " + t().subdomainOk(fix.subdomain));
1225
+ try {
1226
+ url = deployOnce();
1227
+ } catch (e2) {
1228
+ const tail2 = deployErrorText(e2).trim().split("\n").slice(-12).join("\n");
1229
+ if (tail2) console.log(C.red(" ✗ " + tail2));
1230
+ throw new Error(t().deployFailed);
1231
+ }
1232
+ } else {
1233
+ // Fallback: instrucciones manuales + opción de API token.
1234
+ console.log(C.red(" ✗ " + t().subdomainManual));
1235
+ if (fix.message) console.log(" " + C.dim(fix.message));
1236
+ console.log(" " + C.cyan("https://dash.cloudflare.com/?to=/:account/workers-and-pages") + " " + C.dim(m("→ Workers & Pages → 'Change' junto a 'Your subdomain'", "→ Workers & Pages → 'Change' next to 'Your subdomain'")));
1237
+ console.log(" " + m("o crea un API token (Workers Scripts → Edit) y corre:", "or create an API token (Workers Scripts → Edit) and run:"));
1238
+ console.log(" " + C.cyan(`curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${fix.accountId || "<ACCOUNT_ID>"}/workers/subdomain" -H "Authorization: Bearer <TU_TOKEN>" -H "Content-Type: application/json" -d '{"subdomain":"${fix.candidate || "kooni"}"'`));
1239
+ console.log(" " + m("Luego reintenta: npx kooni-bot deploy", "Then retry: npx kooni-bot deploy"));
1240
+ throw new Error(t().deployFailed);
1241
+ }
1242
+ } else {
1243
+ // Muestra el detalle real del deploy en vez de tragarlo: así el usuario ve
1244
+ // qué falló (deploy-check, binding, auth…) y puede corregirlo.
1245
+ const tail = errText.trim().split("\n").slice(-12).join("\n");
1246
+ if (tail) console.log(C.red(" ✗ " + tail));
1247
+ throw new Error(t().deployFailed);
1248
+ }
999
1249
  }
1000
1250
  if (url) {
1001
1251
  patchWranglerFile(dir, (s) => s.replace(/DASHBOARD_BASE_URL\s*=\s*"[^"]*"/g, `DASHBOARD_BASE_URL = "${url}"`));
@@ -1016,7 +1266,7 @@ async function checkin(dir, answers, version) {
1016
1266
  const slug = answers.slug || marker.slug || basename(dir);
1017
1267
  await fetchTimeout(CHECKIN_URL, {
1018
1268
  method: "POST",
1019
- headers: { "Content-Type": "application/json" },
1269
+ headers: { "Content-Type": "application/json", "X-Kooni-Token": KOONI_REGISTER_TOKEN },
1020
1270
  body: JSON.stringify({
1021
1271
  email: answers.email || undefined,
1022
1272
  slug,
@@ -1084,6 +1334,11 @@ Si dudas: **member/ es sagrado, src/ se actualiza.**
1084
1334
  3. Abrir el panel en \`https://<worker>.workers.dev/admin\` (usuario \`admin\` + \`DASHBOARD_PASSWORD\`).
1085
1335
  4. Conectar canales DESPUÉS del primer deploy (Telegram primero, ~5 min) desde \`/admin/conexiones\`.
1086
1336
 
1337
+ ## Subdominio workers.dev (error 10063)
1338
+ - Si el deploy falla con \`[code: 10063]\` ("You need a workers.dev subdomain"), el CLI **lo crea solo** usando la sesión OAuth de wrangler y reintenta el deploy automáticamente — no hay que hacer nada manual.
1339
+ - Si el CLI no pudo (sin sesión de Cloudflare), el usuario hace: dashboard → Workers & Pages → **Change** junto a "Your subdomain" → guardar. O crea un API token con permiso **Workers Scripts → Edit** en dash.cloudflare.com/profile/api-tokens y corre el curl que el propio CLI imprime.
1340
+ - El subdominio es ÚNICO por cuenta de Cloudflare (no por bot); se crea una sola vez.
1341
+
1087
1342
  ## Secrets y vars (referencia rápida)
1088
1343
  - Secrets: \`ANTHROPIC_API_KEY\` / \`OPENAI_API_KEY\` / \`XAI_API_KEY\` (cerebro), \`DASHBOARD_PASSWORD\` (panel), \`KB_REINDEX_TOKEN\` (reindex).
1089
1344
  - Canales: \`TELEGRAM_BOT_TOKEN\`, \`MANYCHAT_API_KEY\`, \`TWILIO_ACCOUNT_SID\`+\`TWILIO_AUTH_TOKEN\`+\`TWILIO_WA_FROM\`, \`META_PAGE_ACCESS_TOKEN\`+\`META_VERIFY_TOKEN\`+\`META_APP_SECRET\`, \`ZERNIO_API_KEY\`.
@@ -1202,9 +1457,13 @@ async function cmdInit(flags, rest) {
1202
1457
  kbName: meta && meta.kbName,
1203
1458
  });
1204
1459
 
1460
+ // Check-in al panel de licencias ANTES del deploy: así TODAS las instalaciones
1461
+ // (gratis o pagas) quedan registradas aunque el deploy falle (ej. error 10063).
1462
+ await checkin(dir, answers, version);
1463
+
1205
1464
  // deploy (si no lo deshabilitan)
1206
1465
  if (!flags["no-deploy"] && !process.env.KOONI_NO_DEPLOY) {
1207
- const deployFlags = { ...flags, brainKey: answers.brainKey, "api-key": answers.apiKey || "" };
1466
+ const deployFlags = { ...flags, brainKey: answers.brainKey, "api-key": answers.apiKey || "", _licenseCode: answers.licenseCode || "" };
1208
1467
  const url = await deployBot(dir, { flags: deployFlags, rl });
1209
1468
  if (url) {
1210
1469
  console.log("\n " + C.green(C.b(m("🎉 BOT EN LÍNEA", "🎉 BOT LIVE"))));
@@ -1252,6 +1511,8 @@ async function cmdDeploy(flags, rest) {
1252
1511
  } catch {}
1253
1512
  flags.brainKey = brainKey;
1254
1513
  await deployBot(dir, { flags, rl });
1514
+ // Re-registrar tras un deploy exitoso (URL real del worker + tier/provider).
1515
+ try { await checkin(dir, {}, readPkgVersion(dir) || "0.0.0"); } catch {}
1255
1516
  } catch (e) {
1256
1517
  console.log("\n " + C.red("✗ " + (e.message || e)) + "\n");
1257
1518
  process.exit(1);
@@ -1448,11 +1709,13 @@ ${C.cyan("kooni-bot")} — ${t().helpIntro}
1448
1709
  ${C.cyan("npx kooni-bot doctor [dir]")} ${m("diagnóstico del bot instalado", "diagnose the installed bot")}
1449
1710
  ${C.cyan("npx kooni-bot version")} ${m("versión del CLI", "CLI version")}
1450
1711
 
1712
+ ${C.dim(" Subdominio workers.dev: si tu cuenta no lo tiene, el deploy lo crea solo y reintenta.")}
1713
+
1451
1714
  ${C.dim(" Flags de init (modo no-interactivo, para agentes):")}
1452
1715
  ${C.dim(" --yes --slug <slug> --negocio <nombre> --bot-name <nombre> --lang es-MX|es-ES|en|pt-BR")}
1453
1716
  ${C.dim(" --tier free|pro --cerebro claude|chatgpt|grok|gateway --base-url <url>")}
1454
1717
  ${C.dim(" --que --ofrece --horario --ubicacion --telefono --web --pagos --faq --reglas --tono")}
1455
- ${C.dim(" --no-deploy --no-agent-skill --email <correo>")}
1718
+ ${C.dim(" --no-deploy --no-agent-skill --email <correo> --license <codigo-KOONI-PRO-V2>")}
1456
1719
  `);
1457
1720
  }
1458
1721
 
@@ -1482,4 +1745,4 @@ if (IS_MAIN) {
1482
1745
  });
1483
1746
  }
1484
1747
 
1485
- export { parseFlags, renderMemberConfig, stampWrangler, sanitizeSlug, normBotLang, collectAnswers };
1748
+ export { parseFlags, renderMemberConfig, stampWrangler, sanitizeSlug, normBotLang, collectAnswers, cfOAuthAccessToken, ensureWorkersDevSubdomain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kooni-bot",
3
- "version": "0.2.13",
3
+ "version": "0.3.0",
4
4
  "description": "Kooni — instala tu asistente de IA multicanal (WhatsApp, Instagram, Messenger, Telegram) en TU Cloudflare, en un comando.",
5
5
  "license": "MIT",
6
6
  "type": "module",