kooni-bot 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/kooni.js +185 -139
  2. package/package.json +1 -1
package/bin/kooni.js CHANGED
@@ -14,15 +14,15 @@
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import { emitKeypressEvents } from "node:readline";
16
16
  import { stdin as input, stdout as output } from "node:process";
17
- import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, statSync, cpSync, chmodSync } from "node:fs";
17
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, statSync, cpSync } from "node:fs";
18
+ import { realpathSync } from "node:fs";
18
19
  import { join, basename } from "node:path";
19
20
  import { homedir } from "node:os";
20
- import { execFileSync } from "node:child_process";
21
+ import { execFileSync, spawnSync } from "node:child_process";
21
22
  import { randomUUID } from "node:crypto";
22
23
  import { fileURLToPath } from "node:url";
23
- import { realpathSync } from "node:fs";
24
24
 
25
- const CLI_VERSION = "0.2.0";
25
+ const CLI_VERSION = "0.2.2";
26
26
 
27
27
  const REPO = process.env.KOONI_REPO || "iamnocodeveloper/kooni-bot";
28
28
  const BRANCH = process.env.KOONI_BRANCH || "main";
@@ -67,9 +67,9 @@ const DICT = {
67
67
  qBusiness: "¿Cómo se llama tu negocio?",
68
68
  qBotName: "¿Cómo se llama tu asistente?",
69
69
  qLang: "¿En qué idioma debe hablar tu bot?",
70
- qTier: "¿Qué plan quieres? (free | pro)",
71
70
  brainQ: "¿Con qué cerebro (modelo de IA) quieres que piense tu bot?",
72
71
  brainDesc: "recomendado",
72
+ qApiKey: (label) => `Pega tu API key de ${label} (no se mostrará):`,
73
73
  qBaseUrl: "URL base del gateway (ej. https://api.aisa.one/v1)",
74
74
  qWhat: "En una frase, ¿a qué se dedica?",
75
75
  qOffer: "¿Qué ofreces? (tus servicios o productos principales, con precios si quieres)",
@@ -140,9 +140,9 @@ const DICT = {
140
140
  qBusiness: "What's your business called?",
141
141
  qBotName: "What's your assistant's name?",
142
142
  qLang: "What language should your bot speak?",
143
- qTier: "Which plan do you want? (free | pro)",
144
143
  brainQ: "Which brain (AI model) should your bot think with?",
145
144
  brainDesc: "recommended",
145
+ qApiKey: (label) => `Paste your ${label} API key (hidden):`,
146
146
  qBaseUrl: "Gateway base URL (e.g. https://api.aisa.one/v1)",
147
147
  qWhat: "In one line, what does it do?",
148
148
  qOffer: "What do you offer? (main services or products, with prices if you like)",
@@ -339,32 +339,27 @@ async function ask(rl, q, val) {
339
339
  return (await rl.question("\n " + C.b(q) + "\n " + C.cyan("› "))).trim();
340
340
  }
341
341
 
342
- // Input oculto (raw mode, sin eco) para API keys / contraseñas.
343
- async function askSecret(rl, q) {
342
+ // Input oculto para API keys / contraseñas. Usa una interfaz readline DEDICADA
343
+ // (no el rl compartido) con máscara de salida para no chocar con los listeners
344
+ // del selector y soportar pegado. Devuelve "" si no hay TTY.
345
+ async function promptSecret(q) {
344
346
  if (!interactive()) return "";
345
- process.stdout.write("\n " + C.b(q) + "\n " + C.cyan("› "));
346
- return await new Promise((resolve) => {
347
- const prev = input.isRaw;
348
- input.setRawMode(true);
349
- input.resume();
350
- let buf = "";
351
- const onData = (chunk) => {
352
- const s = chunk.toString();
353
- for (const ch of s) {
354
- if (ch === "\r" || ch === "\n") {
355
- cleanup();
356
- process.stdout.write("\n");
357
- resolve(buf);
358
- return;
359
- }
360
- if (ch === "\u0003") { cleanup(); process.stdout.write("\n"); process.exit(130); }
361
- if (ch === "\u007f" || ch === "\b") { buf = buf.slice(0, -1); }
362
- else if (ch >= " " && ch !== "\u001b") { buf += ch; }
363
- }
347
+ const rl = createInterface({ input, output, terminal: true });
348
+ const answer = await new Promise((resolve) => {
349
+ // Enmascara lo que teclea el usuario: solo deja pasar el prompt y los saltos
350
+ // de línea (nunca la tecla), para que la API key quede oculta.
351
+ const orig = rl._writeToOutput;
352
+ rl._writeToOutput = (s) => {
353
+ if (s === "\r\n" || s === "\n" || s === "\r") process.stdout.write(s);
354
+ else if (typeof orig === "function" && s.includes("› ")) orig.call(rl, s);
364
355
  };
365
- const cleanup = () => { input.removeListener("data", onData); input.pause(); if (!prev) input.setRawMode(false); };
366
- input.on("data", onData);
356
+ rl.question("\n " + C.b(q) + "\n " + C.cyan("› "), (a) => {
357
+ rl.close();
358
+ resolve(a);
359
+ });
367
360
  });
361
+ process.stdout.write("\n");
362
+ return answer.trim();
368
363
  }
369
364
 
370
365
  async function confirm(rl, q) {
@@ -499,40 +494,52 @@ function resolveBotDir(arg) {
499
494
  // ── exec cross-platform ──────────────────────────────────────────────────────
500
495
  const isWin = process.platform === "win32";
501
496
 
502
- // Ejecuta `npx wrangler ...` respetando el shim .cmd en Windows.
497
+ // Ejecuta un binario de forma segura en Windows/macOS/Linux:
498
+ // - Unix/mac: `spawnSync(file, args)` directo.
499
+ // - Windows: `npx`/`pnpm` son shims `.cmd`, que NO se pueden spawnear directo
500
+ // (EINVAL). Se lanzan con `cmd.exe /c <file> <args...>` pasando los argumentos
501
+ // como lista separada (nada de `shell: true` ni concatenación → sin DEP0190).
502
+ // - `stdio` hereda por defecto (progreso en vivo); `capture` captura para parsear.
503
+ function run(file, args = [], opts = {}) {
504
+ const cwd = opts.cwd;
505
+ const input = opts.input != null ? String(opts.input) : undefined;
506
+ const stdio = opts.stdio || (opts.capture ? ["pipe", "pipe", "pipe"] : "inherit");
507
+
508
+ let cmd = file;
509
+ let argv = args;
510
+
511
+ if (isWin) {
512
+ const shim = { npx: "npx.cmd", pnpm: "pnpm.cmd" }[file] || file;
513
+ cmd = "cmd.exe";
514
+ argv = ["/d", "/c", shim, ...args];
515
+ }
516
+
517
+ const r = spawnSync(cmd, argv, { cwd, input, stdio, encoding: "utf8" });
518
+ if (r.error) throw r.error;
519
+ if (r.status !== 0) {
520
+ const err = new Error(`Command failed: ${[cmd, ...args].join(" ")}`);
521
+ err.stdout = r.stdout;
522
+ err.stderr = r.stderr;
523
+ err.status = r.status;
524
+ throw err;
525
+ }
526
+ return (r.stdout || "").toString();
527
+ }
528
+
529
+ // Ejecuta `npx wrangler ...`. `capture` captura stdout/stderr (para parsear);
530
+ // por defecto hereda la terminal (progreso visible).
503
531
  function wrangler(dir, args, opts = {}) {
504
- const npx = isWin ? "npx.cmd" : "npx";
505
- return execFileSync(npx, ["wrangler", ...args], {
506
- cwd: dir,
507
- encoding: "utf8",
508
- stdio: opts.stdio || ["ignore", "pipe", "pipe"],
509
- ...(isWin ? { shell: true } : {}),
510
- ...opts.extra,
511
- });
532
+ return run("npx", ["wrangler", ...args], { cwd: dir, ...opts });
512
533
  }
513
534
 
514
535
  // Ejecuta `pnpm ...`, habilitando corepack si no existe pnpm.
515
536
  function runPnpm(dir, args, opts = {}) {
516
- const pnpm = process.env.KOONI_PNPM || "pnpm";
517
537
  try {
518
- return execFileSync(pnpm, args, {
519
- cwd: dir,
520
- encoding: "utf8",
521
- stdio: opts.stdio || ["ignore", "pipe", "pipe"],
522
- ...(isWin ? { shell: true } : {}),
523
- ...opts.extra,
524
- });
538
+ return run("pnpm", args, { cwd: dir, ...opts });
525
539
  } catch (e) {
526
- // pnpm ausente: habilítalo vía corepack y reintenta una vez.
527
- if (/pnpm/.test(e.message || "")) {
528
- try { execFileSync("corepack", ["enable", "pnpm"], { stdio: "ignore" }); } catch {}
529
- return execFileSync(pnpm, args, {
530
- cwd: dir,
531
- encoding: "utf8",
532
- stdio: opts.stdio || ["ignore", "pipe", "pipe"],
533
- ...(isWin ? { shell: true } : {}),
534
- ...opts.extra,
535
- });
540
+ if (e && /ENOENT|not found|pnpm/.test(String(e.message || ""))) {
541
+ try { run("corepack", ["enable", "pnpm"], { stdio: "ignore" }); } catch {}
542
+ return run("pnpm", args, { cwd: dir, ...opts });
536
543
  }
537
544
  throw e;
538
545
  }
@@ -553,9 +560,10 @@ function stampWrangler(dir, answers) {
553
560
  if (!existsSync(wt)) return null;
554
561
  let s = readFileSync(wt, "utf8");
555
562
  const slug = answers.slug;
556
- const resId = slug.replace(/-/g, "_");
557
- const dbName = `kooni_${resId}_db`;
558
- const kbName = `kooni_${resId}_kb`;
563
+ // Nombres canónicos del proyecto (coinciden con `pnpm db:apply:remote`, que
564
+ // apunta a `kooni_db`, y con la instalación probada del template).
565
+ const dbName = "kooni_db";
566
+ const kbName = "kooni_kb";
559
567
  const R = REGIONS[answers.lang] || REGIONS["es-MX"];
560
568
 
561
569
  const set = (re, val) => { s = s.replace(re, val); };
@@ -567,7 +575,6 @@ function stampWrangler(dir, answers) {
567
575
  set(/BOT_TIER\s*=\s*"[^"]*"/g, `BOT_TIER = "${answers.tier}"`);
568
576
  set(/BUFFER_SECONDS\s*=\s*"[^"]*"/g, `BUFFER_SECONDS = "${answers.bufferSeconds || "15"}"`);
569
577
  set(/DASHBOARD_BASE_URL\s*=\s*"[^"]*"/g, `DASHBOARD_BASE_URL = ""`);
570
- // D1 / Vectorize namespaced por bot.
571
578
  set(/database_name\s*=\s*"[^"]*"/, `database_name = "${dbName}"`);
572
579
  set(/index_name\s*=\s*"[^"]*"/, `index_name = "${kbName}"`);
573
580
  // El id del demo no sirve: placeholder hasta que `deploy` lo reemplace.
@@ -671,54 +678,58 @@ function writeDevVars(dir, answers, kbToken) {
671
678
  writeFileSync(join(dir, ".dev.vars"), lines.join("\n") + "\n");
672
679
  }
673
680
 
674
- function collectAnswers(flags, rl) {
675
- const slug = sanitizeSlug(flags.slug || "");
676
- const brainKey = ({ claude: "claude", anthropic: "claude", chatgpt: "chatgpt", openai: "chatgpt", gpt: "chatgpt", grok: "grok", xai: "grok", gateway: "gateway" })[String(flags.cerebro || flags.brain || "claude").trim().toLowerCase()] || "claude";
677
- const tone = ({ cercano: "cercano", friendly: "cercano", formal: "formal", divertido: "divertido", playful: "divertido" })[String(flags.tono || "").trim().toLowerCase()] || "";
681
+ function collectAnswers(flags) {
682
+ const rawBrain = String(flags.cerebro || flags.brain || "").trim().toLowerCase();
683
+ const brainKey = ({ claude: "claude", anthropic: "claude", chatgpt: "chatgpt", openai: "chatgpt", gpt: "chatgpt", grok: "grok", xai: "grok", gateway: "gateway" })[rawBrain] || null;
684
+ const tone = ({ cercano: "cercano", friendly: "cercano", formal: "formal", divertido: "divertido", playful: "divertido" })[String(flags.tono || "").trim().toLowerCase()] || null;
685
+
686
+ // Lo que vino por flag se conserva; lo que NO vino queda undefined para que
687
+ // `onboarding()` lo pregunte (interactivo) o use el default (no-interactivo).
678
688
  return {
679
- slug,
680
- businessName: String(flags.negocio || flags.nombre || flags.name || "").trim(),
681
- botName: String(flags["bot-name"] || "Asistente").trim(),
682
- lang: normBotLang(flags.lang),
683
- tier: String(flags.tier || "free").trim().toLowerCase() === "pro" ? "pro" : "free",
684
- provider: BRAINS[brainKey].provider,
689
+ slug: flags.slug ? sanitizeSlug(flags.slug) : undefined,
690
+ businessName: String(flags.negocio || flags.nombre || flags.name || "").trim() || undefined,
691
+ botName: String(flags["bot-name"] || "").trim() || undefined,
692
+ lang: flags.lang ? normBotLang(flags.lang) : undefined,
693
+ tier: flags.tier ? (String(flags.tier).trim().toLowerCase() === "pro" ? "pro" : "free") : undefined,
694
+ provider: brainKey ? BRAINS[brainKey].provider : undefined,
685
695
  brainKey,
686
- baseUrl: String(flags["base-url"] || "").trim() || (brainKey === "gateway" ? "https://api.aisa.one/v1" : ""),
687
- secret: BRAINS[brainKey].secret,
688
- what: String(flags.que || "").trim(),
689
- offer: String(flags.ofrece || "").trim(),
690
- hours: String(flags.horario || "").trim(),
691
- location: String(flags.ubicacion || "").trim(),
692
- phone: String(flags.telefono || "").trim(),
693
- web: String(flags.web || flags.redes || "").trim(),
694
- pagos: String(flags.pagos || "").trim(),
695
- faq: String(flags.faq || "").trim(),
696
- reglas: String(flags.reglas || "").trim(),
696
+ baseUrl: String(flags["base-url"] || "").trim() || undefined,
697
+ secret: brainKey ? BRAINS[brainKey].secret : undefined,
698
+ apiKey: String(flags["api-key"] || "").trim() || undefined,
699
+ what: String(flags.que || "").trim() || undefined,
700
+ offer: String(flags.ofrece || "").trim() || undefined,
701
+ hours: String(flags.horario || "").trim() || undefined,
702
+ location: String(flags.ubicacion || "").trim() || undefined,
703
+ phone: String(flags.telefono || "").trim() || undefined,
704
+ web: String(flags.web || flags.redes || "").trim() || undefined,
705
+ pagos: String(flags.pagos || "").trim() || undefined,
706
+ faq: String(flags.faq || "").trim() || undefined,
707
+ reglas: String(flags.reglas || "").trim() || undefined,
697
708
  tone,
698
- email: String(flags.email || "").trim(),
709
+ email: String(flags.email || "").trim() || undefined,
699
710
  };
700
711
  }
701
712
 
702
- // Pregunta lo que falte, uno a uno. En no-interactivo sin flag, devuelve default.
703
- async function onboarding(rl, answers) {
713
+ // Pregunta lo que falte, uno a uno. En interactivo muestra menús reales; en
714
+ // no-interactivo (`--yes`/agente) usa defaults. `defaultDir` alimenta el slug.
715
+ async function onboarding(rl, answers, defaultDir) {
704
716
  console.log("\n " + C.dim(t().prep));
705
717
 
706
- answers.slug = answers.slug || sanitizeSlug(await ask(rl, t().qSlug, null) || "mi-negocio");
707
- if (!answers.businessName) answers.businessName = await ask(rl, t().qBusiness, null);
708
- if (!answers.botName || answers.botName === "Asistente") {
709
- const b = await ask(rl, t().qBotName, null);
710
- if (b) answers.botName = b;
718
+ if (!answers.slug) {
719
+ const dflt = sanitizeSlug(defaultDir || "mi-negocio");
720
+ const v = await ask(rl, t().qSlug, undefined);
721
+ answers.slug = v ? sanitizeSlug(v) : dflt;
711
722
  }
723
+ if (!answers.businessName) answers.businessName = await ask(rl, t().qBusiness, undefined) || "";
724
+ if (!answers.botName) answers.botName = await ask(rl, t().qBotName, undefined) || "Asistente";
712
725
 
713
726
  const langKeys = Object.keys(REGIONS);
714
727
  const langIdx = await select(rl, t().qLang, langKeys.map((k) => ({ key: k, label: k })), { value: answers.lang, default: 0 });
715
728
  answers.lang = langKeys[langIdx] || "es-MX";
716
729
 
717
- const tierIdx = await select(rl, t().qTier, [
718
- { key: "free", label: "free", desc: m("Starter", "Starter") },
719
- { key: "pro", label: "pro", desc: m("panel completo + tools avanzadas", "full panel + advanced tools") },
720
- ], { value: answers.tier, default: 0 });
721
- answers.tier = tierIdx === 1 ? "pro" : "free";
730
+ // El plan NO se pregunta: se instala en free y la activación de plan/clave se
731
+ // hace después desde el dashboard. Aquí solo fijamos el default.
732
+ answers.tier = answers.tier || "free";
722
733
 
723
734
  const brainKeys = ["claude", "chatgpt", "grok", "gateway"];
724
735
  const brainIdx = await select(rl, t().brainQ, brainKeys.map((k) => ({
@@ -729,35 +740,59 @@ async function onboarding(rl, answers) {
729
740
  answers.provider = BRAINS[answers.brainKey].provider;
730
741
  answers.secret = BRAINS[answers.brainKey].secret;
731
742
  if (answers.brainKey === "gateway" && !answers.baseUrl) {
732
- answers.baseUrl = await ask(rl, t().qBaseUrl, null) || "https://api.aisa.one/v1";
743
+ answers.baseUrl = await ask(rl, t().qBaseUrl, undefined) || "https://api.aisa.one/v1";
744
+ }
745
+
746
+ // La API key se pide aquí, apenas se elige el cerebro — y se conserva para el
747
+ // deploy (se guarda como secret sin mostrarla). En no-interactivo se salta.
748
+ if (!answers.apiKey) {
749
+ answers.apiKey = await promptSecret(t().qApiKey(BRAINS[answers.brainKey].label));
733
750
  }
734
751
 
735
- answers.what = answers.what || await ask(rl, t().qWhat, null);
736
- answers.offer = answers.offer || await ask(rl, t().qOffer, null);
737
- answers.hours = answers.hours || await ask(rl, t().qHours, null);
738
- answers.location = answers.location || await ask(rl, t().qLoc, null);
739
- answers.phone = answers.phone || await ask(rl, t().qPhone, null);
740
- answers.web = answers.web || await ask(rl, t().qWeb, null);
741
- answers.pagos = answers.pagos || await ask(rl, t().qPagos, null);
742
- answers.faq = answers.faq || await ask(rl, t().qFaq, null);
743
- answers.reglas = answers.reglas || await ask(rl, t().qReglas, null);
752
+ answers.what = answers.what ?? (await ask(rl, t().qWhat, undefined) || "");
753
+ answers.offer = answers.offer ?? (await ask(rl, t().qOffer, undefined) || "");
754
+ answers.hours = answers.hours ?? (await ask(rl, t().qHours, undefined) || "");
755
+ answers.location = answers.location ?? (await ask(rl, t().qLoc, undefined) || "");
756
+ answers.phone = answers.phone ?? (await ask(rl, t().qPhone, undefined) || "");
757
+ answers.web = answers.web ?? (await ask(rl, t().qWeb, undefined) || "");
758
+ answers.pagos = answers.pagos ?? (await ask(rl, t().qPagos, undefined) || "");
759
+ answers.faq = answers.faq ?? (await ask(rl, t().qFaq, undefined) || "");
760
+ answers.reglas = answers.reglas ?? (await ask(rl, t().qReglas, undefined) || "");
744
761
 
745
762
  const toneIdx = await select(rl, t().qTone, [
746
763
  { key: "cercano", label: t().toneFriendly, desc: t().tone1 },
747
764
  { key: "formal", label: t().toneFormal, desc: t().tone2 },
748
765
  { key: "divertido", label: t().tonePlayful, desc: t().tone3 },
749
- ], { value: answers.tone || "cercano", default: 0 });
766
+ ], { value: answers.tone, default: 0 });
750
767
  answers.tone = ["cercano", "formal", "divertido"][toneIdx] || "cercano";
751
768
 
752
769
  return answers;
753
770
  }
754
771
 
755
772
  // ── deploy ───────────────────────────────────────────────────────────────────
773
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
774
+
756
775
  function parseD1Id(raw) {
757
- const m = raw.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
776
+ const m = String(raw || "").match(UUID_RE);
758
777
  return m ? m[0] : "";
759
778
  }
760
779
 
780
+ // Extrae el id de una D1 por nombre desde la salida de `wrangler d1 list`. Sabe
781
+ // leer el JSON de `--json` (objeto por db) y, si no es JSON, cae a regex.
782
+ function findD1IdByName(raw, name) {
783
+ const s = String(raw || "");
784
+ try {
785
+ const parsed = JSON.parse(s);
786
+ const arr = Array.isArray(parsed) ? parsed : parsed.result;
787
+ if (Array.isArray(arr)) {
788
+ const hit = arr.find((d) => d && (d.name === name || d.database_name === name));
789
+ if (hit && hit.uuid) return hit.uuid;
790
+ }
791
+ } catch { /* no es JSON → regex */ }
792
+ const m = s.match(new RegExp(`${name}[\\s\\S]{0,200}?(${UUID_RE.source})`, "i"));
793
+ return m ? m[1] : "";
794
+ }
795
+
761
796
  function patchWranglerFile(dir, fn) {
762
797
  const wt = join(dir, "wrangler.toml");
763
798
  if (!existsSync(wt)) return;
@@ -774,38 +809,43 @@ async function deployBot(dir, { flags = {}, rl } = {}) {
774
809
 
775
810
  // login
776
811
  process.stdout.write(C.dim(" " + t().login + "\n"));
777
- wrangler(dir, ["login"], { stdio: "inherit" });
812
+ wrangler(dir, ["login"]);
778
813
  console.log(" " + C.green("✓") + " " + t().loginOk);
779
814
 
780
- // recursos
815
+ // recursos (nombres canónicos del proyecto: kooni_db / kooni_kb)
781
816
  console.log("\n " + C.dim(t().creatingResources));
782
- const dbName = (readFileSync(wt, "utf8").match(/database_name\s*=\s*"([^"]+)"/) || [])[1] || "kooni_db";
783
- const kbName = (readFileSync(wt, "utf8").match(/index_name\s*=\s*"([^"]+)"/) || [])[1] || "kooni_kb";
817
+ const dbName = "kooni_db";
818
+ const kbName = "kooni_kb";
784
819
 
785
820
  let d1Id = "";
786
821
  try {
787
- const out = wrangler(dir, ["d1", "create", dbName]);
822
+ const out = wrangler(dir, ["d1", "create", dbName], { capture: true });
788
823
  d1Id = parseD1Id(out);
789
824
  } catch {}
790
825
  if (!d1Id) {
791
826
  try {
792
- const list = wrangler(dir, ["d1", "list"]);
793
- const m = list.match(new RegExp(`${dbName}[\\s\\S]{0,160}?([0-9a-f-]{36})`));
794
- d1Id = m ? m[1] : "";
827
+ // `--json` da un JSON limpio; si falla, caemos al texto plano.
828
+ let list = "";
829
+ try { list = wrangler(dir, ["d1", "list", "--json"], { capture: true }); }
830
+ catch { list = wrangler(dir, ["d1", "list"], { capture: true }); }
831
+ d1Id = findD1IdByName(list, dbName);
795
832
  } catch {}
796
833
  }
797
- if (d1Id) {
798
- patchWranglerFile(dir, (s) => s.replace(/database_id\s*=\s*"[^"]*"/, `database_id = "${d1Id}"`));
799
- console.log(" " + C.green("✓") + " " + t().d1Ok(d1Id));
800
- } else {
801
- console.log(" " + C.yellow("⚠") + " " + t().d1Warn);
834
+ if (!d1Id) {
835
+ // Sin id real NO podemos migrar ni desplegar: el placeholder rompe wrangler.
836
+ throw new Error(m(
837
+ `no pude crear/encontrar la base D1 "${dbName}". Córrelo a mano (npx wrangler d1 create ${dbName}) y pega el id en wrangler.toml, luego reintenta kooni-bot deploy.`,
838
+ `couldn't create/find the D1 database "${dbName}". Run it manually (npx wrangler d1 create ${dbName}), paste the id into wrangler.toml, then retry kooni-bot deploy.`,
839
+ ));
802
840
  }
841
+ patchWranglerFile(dir, (s) => s.replace(/database_id\s*=\s*"[^"]*"/, `database_id = "${d1Id}"`));
842
+ console.log(" " + C.green("✓") + " " + t().d1Ok(d1Id));
803
843
 
804
- try { wrangler(dir, ["vectorize", "create", kbName, "--dimensions=1024", "--metric=cosine"]); } catch {}
844
+ try { wrangler(dir, ["vectorize", "create", kbName, "--dimensions=1024", "--metric=cosine"], { capture: true }); } catch {}
805
845
  console.log(" " + C.green("✓") + " " + t().vectorOk);
806
846
 
807
847
  try {
808
- wrangler(dir, ["r2", "bucket", "create", "kooni-bot-catalog"]);
848
+ wrangler(dir, ["r2", "bucket", "create", "kooni-bot-catalog"], { capture: true });
809
849
  console.log(" " + C.green("✓") + " " + t().r2Ok);
810
850
  } catch {
811
851
  console.log(" " + C.yellow("⚠") + " " + t().r2Warn);
@@ -818,24 +858,24 @@ async function deployBot(dir, { flags = {}, rl } = {}) {
818
858
  // secrets (sin mostrarlos)
819
859
  console.log("\n " + C.dim(t().secretsTitle));
820
860
 
821
- // API key: interactiva por defecto; en no-interactivo se delega al agente.
861
+ // API key: viene del onboarding (o de --api-key). Si no está y no-interactivo,
862
+ // se delega al agente (wrangler secret put). No se vuelve a preguntar aquí.
822
863
  let apiKey = flags["api-key"] || "";
823
- if (!apiKey && interactive()) {
824
- apiKey = await askSecret(rl, m(`Pega tu API key de ${BRAINS[flags.brainKey || "claude"].label} (no se mostrará):`, `Paste your ${BRAINS[flags.brainKey || "claude"].label} API key (hidden):`));
825
- }
826
864
  if (apiKey) {
827
- wrangler(dir, ["secret", "put", BRAINS[flags.brainKey || "claude"].secret], { stdio: ["pipe", "pipe", "pipe"], extra: { input: apiKey } });
865
+ wrangler(dir, ["secret", "put", BRAINS[flags.brainKey || "claude"].secret], { input: apiKey, capture: true });
828
866
  console.log(" " + C.green("✓") + " " + t().secretOk(BRAINS[flags.brainKey || "claude"].secret));
829
867
  } else if (!interactive()) {
830
868
  console.log(C.yellow("\n ── PARA EL AGENTE ── falta la API key de IA."));
831
869
  console.log(" " + C.cyan(`npx wrangler secret put ${BRAINS[flags.brainKey || "claude"].secret}`));
832
870
  console.log(" " + m("Pídele la llave al usuario y guárdala tú (entrada oculta).", "Ask the user for the key and save it (hidden input)."));
871
+ } else {
872
+ console.log(" " + C.yellow("⚠") + " " + m("sin API key — el bot no responderá hasta que la pongas desde el panel (Configuración → Modelo de IA).", "no API key — the bot won't reply until you set it from the panel (Settings → AI Model)."));
833
873
  }
834
874
 
835
875
  let dash = "";
836
- if (interactive()) dash = await askSecret(rl, m("Elige una contraseña para el panel /admin (usuario: admin):", "Choose a password for the /admin dashboard (user: admin):"));
876
+ if (interactive()) dash = await promptSecret(m("Elige una contraseña para el panel /admin (usuario: admin):", "Choose a password for the /admin dashboard (user: admin):"));
837
877
  if (!dash) dash = "kooni-local-password";
838
- wrangler(dir, ["secret", "put", "DASHBOARD_PASSWORD"], { stdio: ["pipe", "pipe", "pipe"], extra: { input: dash } });
878
+ wrangler(dir, ["secret", "put", "DASHBOARD_PASSWORD"], { input: dash, capture: true });
839
879
  console.log(" " + C.green("✓") + " " + t().secretOk("DASHBOARD_PASSWORD"));
840
880
 
841
881
  // KB token desde .dev.vars
@@ -843,28 +883,34 @@ async function deployBot(dir, { flags = {}, rl } = {}) {
843
883
  const dv = readFileSync(join(dir, ".dev.vars"), "utf8");
844
884
  const m = dv.match(/^KB_REINDEX_TOKEN=(.+)$/m);
845
885
  if (m && m[1]) {
846
- wrangler(dir, ["secret", "put", "KB_REINDEX_TOKEN"], { stdio: ["pipe", "pipe", "pipe"], extra: { input: m[1] } });
886
+ wrangler(dir, ["secret", "put", "KB_REINDEX_TOKEN"], { input: m[1], capture: true });
847
887
  console.log(" " + C.green("✓") + " " + t().secretOk("KB_REINDEX_TOKEN"));
848
888
  }
849
889
  } catch {}
850
890
 
851
- // dependencias + migraciones + deploy
891
+ // dependencias + migraciones + deploy (progreso visible en vivo)
852
892
  console.log("\n " + C.dim(t().installing));
893
+ 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).")));
853
894
  runPnpm(dir, ["install"]);
854
895
  console.log(" " + C.green("✓") + " " + m("dependencias listas", "dependencies ready"));
855
896
 
856
897
  console.log(" " + C.dim(t().migrations));
857
- wrangler(dir, ["d1", "execute", dbName, "--file=src/db/schema.sql", "--remote"]);
898
+ runPnpm(dir, ["db:apply:remote"]);
858
899
  console.log(" " + C.green("✓") + " " + m("migraciones aplicadas", "migrations applied"));
859
900
 
860
901
  console.log(" " + C.dim(t().deploying));
861
- let dep = wrangler(dir, ["deploy"]);
862
- let url = (dep.match(/https:\/\/[a-z0-9-]+\.workers\.dev/) || [])[0] || "";
902
+ let url = "";
903
+ try {
904
+ const dep = runPnpm(dir, ["run", "deploy"], { capture: true });
905
+ url = (dep.match(/https:\/\/[a-z0-9-]+\.workers\.dev/) || [])[0] || "";
906
+ } catch {
907
+ // el deploy-check imprime el detalle; si falló, no seguimos.
908
+ throw new Error(t().deployFailed);
909
+ }
863
910
  if (url) {
864
911
  patchWranglerFile(dir, (s) => s.replace(/DASHBOARD_BASE_URL\s*=\s*"[^"]*"/g, `DASHBOARD_BASE_URL = "${url}"`));
865
- wrangler(dir, ["deploy"]);
866
- }
867
- if (!url) {
912
+ try { runPnpm(dir, ["run", "deploy"], { capture: true }); } catch {}
913
+ } else {
868
914
  console.log(C.yellow(" ⚠ " + m("no se detectó la URL del worker — revisa la salida del deploy", "couldn't detect the worker URL — check the deploy output")));
869
915
  }
870
916
  return url;
@@ -1018,8 +1064,8 @@ async function cmdInit(flags, rest) {
1018
1064
 
1019
1065
  // config
1020
1066
  console.log("\n " + C.cyan("◇ ") + C.b(t().runInit));
1021
- const answers = collectAnswers(flags, rl);
1022
- await onboarding(rl, answers);
1067
+ const answers = collectAnswers(flags);
1068
+ await onboarding(rl, answers, basename(dir));
1023
1069
 
1024
1070
  const meta = stampWrangler(dir, answers);
1025
1071
  const kbToken = "kooni-reindex-" + randomUUID().replace(/-/g, "").slice(0, 12);
@@ -1037,7 +1083,7 @@ async function cmdInit(flags, rest) {
1037
1083
 
1038
1084
  // deploy (si no lo deshabilitan)
1039
1085
  if (!flags["no-deploy"] && !process.env.KOONI_NO_DEPLOY) {
1040
- const deployFlags = { ...flags, brainKey: answers.brainKey };
1086
+ const deployFlags = { ...flags, brainKey: answers.brainKey, "api-key": answers.apiKey || "" };
1041
1087
  const url = await deployBot(dir, { flags: deployFlags, rl });
1042
1088
  if (url) {
1043
1089
  console.log("\n " + C.green(C.b(m("🎉 BOT EN LÍNEA", "🎉 BOT LIVE"))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kooni-bot",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",