@helloleo/plugins 0.2.30 → 0.2.31-beta.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 (76) hide show
  1. package/dist/cli.js +2 -1
  2. package/dist/index.js +106 -45
  3. package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/bun.lock +2 -2
  4. package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/bunfig.toml +1 -1
  5. package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/package.json +1 -1
  6. package/dist/templates/tanstack-start-2026-09-29/src/lib/proxy.server.ts +204 -0
  7. package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/vite.config.ts +5 -10
  8. package/package.json +1 -1
  9. package/dist/templates/tanstack-start-2026-09-11/src/lib/proxy.ts +0 -182
  10. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/components.json +0 -0
  11. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/drizzle/meta/_journal.json +0 -0
  12. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/drizzle.config.ts +0 -0
  13. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/error-component.tsx +0 -0
  14. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/accordion.tsx +0 -0
  15. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/alert-dialog.tsx +0 -0
  16. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/alert.tsx +0 -0
  17. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/aspect-ratio.tsx +0 -0
  18. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/avatar.tsx +0 -0
  19. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/badge.tsx +0 -0
  20. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/breadcrumb.tsx +0 -0
  21. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/button.tsx +0 -0
  22. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/calendar.tsx +0 -0
  23. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/card.tsx +0 -0
  24. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/carousel.tsx +0 -0
  25. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/chart.tsx +0 -0
  26. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/checkbox.tsx +0 -0
  27. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/collapsible.tsx +0 -0
  28. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/command.tsx +0 -0
  29. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/context-menu.tsx +0 -0
  30. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/dialog.tsx +0 -0
  31. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/drawer.tsx +0 -0
  32. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/dropdown-menu.tsx +0 -0
  33. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/form.tsx +0 -0
  34. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/hover-card.tsx +0 -0
  35. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/input-otp.tsx +0 -0
  36. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/input.tsx +0 -0
  37. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/label.tsx +0 -0
  38. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/menubar.tsx +0 -0
  39. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/navigation-menu.tsx +0 -0
  40. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/pagination.tsx +0 -0
  41. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/popover.tsx +0 -0
  42. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/progress.tsx +0 -0
  43. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/radio-group.tsx +0 -0
  44. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/resizable.tsx +0 -0
  45. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/scroll-area.tsx +0 -0
  46. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/select.tsx +0 -0
  47. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/separator.tsx +0 -0
  48. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/sheet.tsx +0 -0
  49. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/sidebar.tsx +0 -0
  50. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/skeleton.tsx +0 -0
  51. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/slider.tsx +0 -0
  52. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/sonner.tsx +0 -0
  53. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/switch.tsx +0 -0
  54. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/table.tsx +0 -0
  55. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/tabs.tsx +0 -0
  56. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/textarea.tsx +0 -0
  57. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/toggle-group.tsx +0 -0
  58. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/toggle.tsx +0 -0
  59. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/components/ui/tooltip.tsx +0 -0
  60. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/db/index.ts +0 -0
  61. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/db/schema.ts +0 -0
  62. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/global.d.ts +0 -0
  63. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/hooks/use-mobile.tsx +0 -0
  64. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/lib/cache.ts +0 -0
  65. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/lib/kv.ts +0 -0
  66. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/lib/storage.ts +0 -0
  67. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/lib/utils.ts +0 -0
  68. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/routeTree.gen.ts +0 -0
  69. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/router.tsx +0 -0
  70. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/routes/README.md +0 -0
  71. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/routes/__root.tsx +0 -0
  72. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/routes/api/health.ts +0 -0
  73. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/routes/index.tsx +0 -0
  74. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/server.ts +0 -0
  75. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/src/styles.css +0 -0
  76. /package/dist/templates/{tanstack-start-2026-09-11 → tanstack-start-2026-09-29}/tsconfig.json +0 -0
package/dist/cli.js CHANGED
@@ -12367,7 +12367,7 @@ async function renderTemplate(templateId, vars, destDir) {
12367
12367
  }
12368
12368
 
12369
12369
  // src/tools/scaffold.ts
12370
- var SCAFFOLD_VERSION = "tanstack-start-2026-09-11";
12370
+ var SCAFFOLD_VERSION = "tanstack-start-2026-09-29";
12371
12371
  function resolveTemplateId() {
12372
12372
  return SCAFFOLD_VERSION;
12373
12373
  }
@@ -12427,6 +12427,7 @@ var SCAFFOLD_NOTES = [
12427
12427
  "- The database schema lives in src/db/schema.ts and is the single source of truth. Migration files under drizzle/ are generated and applied automatically. Never create, edit or delete them.",
12428
12428
  "- Never rename existing tables or columns in schema.ts. Renames cannot be auto-migrated: no migration file is generated, the database keeps the old name, and every query against the new name fails at runtime with a 500. To reshape data, add a new column or table, write to it, and migrate old values in app code (e.g. fall back to the old column on read).",
12429
12429
  "- Persistence goes through @helloleo/runtime exclusively: database via #/db (see src/db/index.ts), key-value store via #/lib/kv (see src/lib/kv.ts), file storage via #/lib/storage (see src/lib/storage.ts -- uploads/downloads flow through your API routes; presigned URLs are NOT available), env/secrets via env from @helloleo/runtime. Read those files before server-side data work. Never install another DB/storage client, never import from cloudflare:workers, all of it is server-side only.",
12430
+ "- Connected integrations (Odoo, Airtable, ...) go through src/lib/proxy.server.ts (callIntegration, uploadFile). Read its header first. It is server-only: import it from server routes under src/routes/api/, never from components. One route per operation; never write a route that forwards integration, endpoint, model, method or a raw body from the request.",
12430
12431
  "- Add routes as files in src/routes/. Do not default export route components. Routing and layout conventions are documented in src/routes/README.md, read it before adding routes.",
12431
12432
  "- shadcn/ui components are already in src/components/ui.",
12432
12433
  "- Design system: style exclusively with the token utilities, never hardcoded colors. To change the look, edit the token values in src/styles.css (light and dark blocks).",
package/dist/index.js CHANGED
@@ -14,6 +14,33 @@ var __export = (target, all) => {
14
14
  });
15
15
  };
16
16
 
17
+ // src/hooks/image-expiry.ts
18
+ var KEEP_TURNS = 3;
19
+ var EXPIRED_IMAGE_NOTE = "[A screenshot the user attached here was removed from context because the conversation has moved on. Do not refer to it. If you still need it, ask the user to share it again.]";
20
+ function isUserTurn(m) {
21
+ return m.info.role === "user" && m.parts.some((p) => p.type === "text" && !p.synthetic && !p.ignored);
22
+ }
23
+ var ImageExpiryPlugin = async () => ({
24
+ "experimental.chat.messages.transform": async (_input, output) => {
25
+ let laterTurns = 0;
26
+ for (let i = output.messages.length - 1;i >= 0; i--) {
27
+ const m = output.messages[i];
28
+ const age = laterTurns;
29
+ if (isUserTurn(m))
30
+ laterTurns++;
31
+ if (m.info.role !== "user" || age < KEEP_TURNS)
32
+ continue;
33
+ m.parts = m.parts.map((p) => p.type === "file" && p.mime.startsWith("image/") ? {
34
+ id: p.id,
35
+ sessionID: p.sessionID,
36
+ messageID: p.messageID,
37
+ type: "text",
38
+ text: EXPIRED_IMAGE_NOTE,
39
+ synthetic: true
40
+ } : p);
41
+ }
42
+ }
43
+ });
17
44
  // src/hooks/syntax-check.ts
18
45
  import { readFileSync, statSync } from "fs";
19
46
  import { relative } from "path";
@@ -116,6 +143,9 @@ var SyntaxCheckPlugin = async ({ worktree, directory }) => {
116
143
  }
117
144
  };
118
145
  };
146
+ // src/tools/inspect-http.ts
147
+ import path from "path";
148
+
119
149
  // node_modules/zod/v4/classic/external.js
120
150
  var exports_external = {};
121
151
  __export(exports_external, {
@@ -12490,6 +12520,11 @@ var inspectHttp = tool({
12490
12520
  Origin: "https://app.helloleo.dev",
12491
12521
  ...args.headers
12492
12522
  };
12523
+ if (!hasHeader(requestHeaders, "authorization") && isOwnProxyUrl(url, path.basename(context.directory))) {
12524
+ const key = await readWorkspaceEnv(context.directory, "HELLOLEO_API_KEY");
12525
+ if (key)
12526
+ requestHeaders.Authorization = `Bearer ${key}`;
12527
+ }
12493
12528
  if (METHODS_AUTO_JSON.has(args.method) && args.body !== undefined && !hasHeader(requestHeaders, "content-type")) {
12494
12529
  requestHeaders["Content-Type"] = "application/json";
12495
12530
  }
@@ -12568,6 +12603,30 @@ function isPrivateHost(hostname) {
12568
12603
  const base = findHostBase(hostname);
12569
12604
  return base !== null && hosts[base].private;
12570
12605
  }
12606
+ function isOwnProxyUrl(url, projectUuid) {
12607
+ if (!projectUuid || !isPrivateHost(url.hostname))
12608
+ return false;
12609
+ const pathname = url.pathname.replace(/\/+$/, "");
12610
+ if (pathname === `/api/integration-proxy/${projectUuid}`)
12611
+ return true;
12612
+ const host = url.hostname.toLowerCase();
12613
+ const isProxyHost = host === "proxy.helloleo.dev" || host.endsWith(".proxy.helloleo.dev");
12614
+ return isProxyHost && pathname === `/${projectUuid}`;
12615
+ }
12616
+ async function readWorkspaceEnv(dir, key) {
12617
+ try {
12618
+ const text = await Bun.file(path.join(dir, ".env")).text();
12619
+ for (const line of text.split(`
12620
+ `)) {
12621
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
12622
+ if (match?.[1] === key) {
12623
+ const value = match[2].trim().replace(/^["']|["']$/g, "");
12624
+ return value || null;
12625
+ }
12626
+ }
12627
+ } catch {}
12628
+ return null;
12629
+ }
12571
12630
  function hasHeader(headers, name) {
12572
12631
  const target = name.toLowerCase();
12573
12632
  for (const key of Object.keys(headers)) {
@@ -12625,23 +12684,23 @@ var InspectHttpPlugin = async () => {
12625
12684
  };
12626
12685
  // src/tools/remove.ts
12627
12686
  import { unlink } from "fs/promises";
12628
- import path from "path";
12687
+ import path2 from "path";
12629
12688
  var remove = tool({
12630
12689
  description: "Remove a file from the project. The file path must be relative to the project root. " + "This tool will ask for permission before deleting. " + "Cannot delete files outside the project boundary.",
12631
12690
  args: {
12632
12691
  filePath: tool.schema.string().describe("Relative path to the file to remove (e.g. src/App.tsx)")
12633
12692
  },
12634
12693
  async execute(args, context) {
12635
- const resolved = path.resolve(context.directory, args.filePath);
12636
- const normalized = path.normalize(resolved);
12637
- if (!normalized.startsWith(context.directory + path.sep) && normalized !== context.directory) {
12694
+ const resolved = path2.resolve(context.directory, args.filePath);
12695
+ const normalized = path2.normalize(resolved);
12696
+ if (!normalized.startsWith(context.directory + path2.sep) && normalized !== context.directory) {
12638
12697
  throw new Error(`Path "${args.filePath}" resolves outside the project boundary. Deletion blocked.`);
12639
12698
  }
12640
12699
  const file = Bun.file(resolved);
12641
12700
  if (!await file.exists()) {
12642
12701
  throw new Error(`File not found: ${args.filePath}`);
12643
12702
  }
12644
- const relative = path.relative(context.directory, resolved);
12703
+ const relative = path2.relative(context.directory, resolved);
12645
12704
  await context.ask({
12646
12705
  permission: "remove",
12647
12706
  patterns: [resolved],
@@ -12659,15 +12718,15 @@ var RemovePlugin = async () => {
12659
12718
  };
12660
12719
  // src/tools/scaffold.ts
12661
12720
  import { mkdir } from "fs/promises";
12662
- import path3 from "path";
12721
+ import path4 from "path";
12663
12722
  // src/template-render.ts
12664
- import path2 from "path";
12723
+ import path3 from "path";
12665
12724
  var {Glob } = globalThis.Bun;
12666
- var TEMPLATES_ROOT = path2.join(import.meta.dir, "templates");
12725
+ var TEMPLATES_ROOT = path3.join(import.meta.dir, "templates");
12667
12726
  var TOKEN_RE = /\{\{(LEO_[A-Z0-9_]+)\}\}/g;
12668
12727
  var SKIP = /(^|\/)(node_modules|dist|dist-ssr|\.tanstack|\.output|bun\.lockb|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)(\/|$)/;
12669
12728
  async function renderTemplate(templateId, vars, destDir) {
12670
- const root = path2.join(TEMPLATES_ROOT, templateId);
12729
+ const root = path3.join(TEMPLATES_ROOT, templateId);
12671
12730
  const glob = new Glob("**/*");
12672
12731
  const created = [];
12673
12732
  for await (const rel of glob.scan({
@@ -12677,21 +12736,21 @@ async function renderTemplate(templateId, vars, destDir) {
12677
12736
  })) {
12678
12737
  if (SKIP.test(rel))
12679
12738
  continue;
12680
- const raw = await Bun.file(path2.join(root, rel)).text();
12739
+ const raw = await Bun.file(path3.join(root, rel)).text();
12681
12740
  const content = raw.replace(TOKEN_RE, (_m, key) => vars[key] ?? "");
12682
- await Bun.write(path2.join(destDir, rel), content);
12741
+ await Bun.write(path3.join(destDir, rel), content);
12683
12742
  created.push(rel);
12684
12743
  }
12685
12744
  return created.sort();
12686
12745
  }
12687
12746
 
12688
12747
  // src/tools/scaffold.ts
12689
- var SCAFFOLD_VERSION = "tanstack-start-2026-09-11";
12748
+ var SCAFFOLD_VERSION = "tanstack-start-2026-09-29";
12690
12749
  function resolveTemplateId() {
12691
12750
  return SCAFFOLD_VERSION;
12692
12751
  }
12693
12752
  function projectUuidFromDir(dir) {
12694
- return path3.basename(dir);
12753
+ return path4.basename(dir);
12695
12754
  }
12696
12755
  function integrationProxyBase() {
12697
12756
  return (process.env.PLUGINS_INTEGRATION_PROXY_URL || "https://api.app.helloleo.dev").replace(/\/+$/, "");
@@ -12713,10 +12772,10 @@ class AlreadyScaffoldedError extends Error {
12713
12772
  }
12714
12773
  }
12715
12774
  async function existingScaffold(dir) {
12716
- if (!await Bun.file(path3.join(dir, "package.json")).exists())
12775
+ if (!await Bun.file(path4.join(dir, "package.json")).exists())
12717
12776
  return null;
12718
12777
  try {
12719
- const meta = await Bun.file(path3.join(dir, ".leo", "project.json")).json();
12778
+ const meta = await Bun.file(path4.join(dir, ".leo", "project.json")).json();
12720
12779
  return typeof meta?.scaffold === "string" ? meta.scaffold : "legacy";
12721
12780
  } catch {
12722
12781
  return "legacy";
@@ -12735,9 +12794,9 @@ async function writeBaseScaffold({ name, description }, dir) {
12735
12794
  LEO_PROJECT_UUID: projectUuid,
12736
12795
  LEO_INTEGRATION_PROXY_URL: `${integrationProxyBase()}/api/integration-proxy/${projectUuid}`
12737
12796
  }, dir);
12738
- await mkdir(path3.join(dir, ".leo"), { recursive: true });
12739
- const leoPath = path3.join(".leo", "project.json");
12740
- await Bun.write(path3.join(dir, leoPath), JSON.stringify({ $schema: 1, scaffold: templateId }, null, 2));
12797
+ await mkdir(path4.join(dir, ".leo"), { recursive: true });
12798
+ const leoPath = path4.join(".leo", "project.json");
12799
+ await Bun.write(path4.join(dir, leoPath), JSON.stringify({ $schema: 1, scaffold: templateId }, null, 2));
12741
12800
  return [...created, leoPath];
12742
12801
  }
12743
12802
  var SCAFFOLD_NOTES = [
@@ -12746,6 +12805,7 @@ var SCAFFOLD_NOTES = [
12746
12805
  "- The database schema lives in src/db/schema.ts and is the single source of truth. Migration files under drizzle/ are generated and applied automatically. Never create, edit or delete them.",
12747
12806
  "- Never rename existing tables or columns in schema.ts. Renames cannot be auto-migrated: no migration file is generated, the database keeps the old name, and every query against the new name fails at runtime with a 500. To reshape data, add a new column or table, write to it, and migrate old values in app code (e.g. fall back to the old column on read).",
12748
12807
  "- Persistence goes through @helloleo/runtime exclusively: database via #/db (see src/db/index.ts), key-value store via #/lib/kv (see src/lib/kv.ts), file storage via #/lib/storage (see src/lib/storage.ts -- uploads/downloads flow through your API routes; presigned URLs are NOT available), env/secrets via env from @helloleo/runtime. Read those files before server-side data work. Never install another DB/storage client, never import from cloudflare:workers, all of it is server-side only.",
12808
+ "- Connected integrations (Odoo, Airtable, ...) go through src/lib/proxy.server.ts (callIntegration, uploadFile). Read its header first. It is server-only: import it from server routes under src/routes/api/, never from components. One route per operation; never write a route that forwards integration, endpoint, model, method or a raw body from the request.",
12749
12809
  "- Add routes as files in src/routes/. Do not default export route components. Routing and layout conventions are documented in src/routes/README.md, read it before adding routes.",
12750
12810
  "- shadcn/ui components are already in src/components/ui.",
12751
12811
  "- Design system: style exclusively with the token utilities, never hardcoded colors. To change the look, edit the token values in src/styles.css (light and dark blocks).",
@@ -12800,10 +12860,10 @@ var ScaffoldPlugin = async () => {
12800
12860
  };
12801
12861
  // src/tools/setup-netsuite.ts
12802
12862
  import { mkdir as mkdir2, readFile } from "fs/promises";
12803
- import path4 from "path";
12863
+ import path5 from "path";
12804
12864
  async function readScaffoldId(dir) {
12805
12865
  try {
12806
- const meta = await Bun.file(path4.join(dir, ".leo", "project.json")).json();
12866
+ const meta = await Bun.file(path5.join(dir, ".leo", "project.json")).json();
12807
12867
  return typeof meta?.scaffold === "string" ? meta.scaffold : null;
12808
12868
  } catch {
12809
12869
  return null;
@@ -12814,7 +12874,7 @@ async function isStartScaffold(dir) {
12814
12874
  if (scaffoldId)
12815
12875
  return scaffoldId.startsWith("tanstack-start");
12816
12876
  try {
12817
- const pkg = await Bun.file(path4.join(dir, "package.json")).json();
12877
+ const pkg = await Bun.file(path5.join(dir, "package.json")).json();
12818
12878
  return Boolean(pkg?.dependencies?.["@tanstack/react-start"] ?? pkg?.devDependencies?.["@tanstack/react-start"]);
12819
12879
  } catch {
12820
12880
  return false;
@@ -13975,18 +14035,18 @@ function Gate({ children }: { children: ReactNode }) {
13975
14035
  `;
13976
14036
  }
13977
14037
  async function executeStart(dir) {
13978
- await mkdir2(path4.join(dir, "src", "lib"), { recursive: true });
13979
- await mkdir2(path4.join(dir, "src", "components", "auth"), {
14038
+ await mkdir2(path5.join(dir, "src", "lib"), { recursive: true });
14039
+ await mkdir2(path5.join(dir, "src", "components", "auth"), {
13980
14040
  recursive: true
13981
14041
  });
13982
- await mkdir2(path4.join(dir, "src", "routes", "auth", "netsuite"), {
14042
+ await mkdir2(path5.join(dir, "src", "routes", "auth", "netsuite"), {
13983
14043
  recursive: true
13984
14044
  });
13985
- await mkdir2(path4.join(dir, "src", "routes", "api"), { recursive: true });
14045
+ await mkdir2(path5.join(dir, "src", "routes", "api"), { recursive: true });
13986
14046
  const schemaImports = `import { sql } from 'drizzle-orm'
13987
14047
  import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
13988
14048
  `;
13989
- const schemaPath = path4.join(dir, "src", "db", "schema.ts");
14049
+ const schemaPath = path5.join(dir, "src", "db", "schema.ts");
13990
14050
  let schemaAppended = false;
13991
14051
  try {
13992
14052
  const current = await readFile(schemaPath, "utf8");
@@ -14000,41 +14060,41 @@ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
14000
14060
  schemaAppended = true;
14001
14061
  }
14002
14062
  const files = [
14003
- [path4.join("src", "lib", "netsuite-server.ts"), netsuiteServerTs()],
14063
+ [path5.join("src", "lib", "netsuite-server.ts"), netsuiteServerTs()],
14004
14064
  [
14005
- path4.join("src", "routes", "auth", "netsuite", "start.ts"),
14065
+ path5.join("src", "routes", "auth", "netsuite", "start.ts"),
14006
14066
  startRouteTs()
14007
14067
  ],
14008
14068
  [
14009
- path4.join("src", "routes", "auth", "netsuite", "callback.ts"),
14069
+ path5.join("src", "routes", "auth", "netsuite", "callback.ts"),
14010
14070
  callbackRouteTs()
14011
14071
  ],
14012
14072
  [
14013
- path4.join("src", "routes", "auth", "netsuite", "logout.ts"),
14073
+ path5.join("src", "routes", "auth", "netsuite", "logout.ts"),
14014
14074
  logoutRouteTs()
14015
14075
  ],
14016
14076
  [
14017
- path4.join("src", "routes", "auth", "netsuite", "config.ts"),
14077
+ path5.join("src", "routes", "auth", "netsuite", "config.ts"),
14018
14078
  configRouteTs()
14019
14079
  ],
14020
- [path4.join("src", "routes", "api", "me.ts"), meRouteTs()],
14021
- [path4.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
14022
- [path4.join("src", "lib", "auth.tsx"), startAuthTsx()],
14080
+ [path5.join("src", "routes", "api", "me.ts"), meRouteTs()],
14081
+ [path5.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
14082
+ [path5.join("src", "lib", "auth.tsx"), startAuthTsx()],
14023
14083
  [
14024
- path4.join("src", "components", "auth", "AuthLayout.tsx"),
14084
+ path5.join("src", "components", "auth", "AuthLayout.tsx"),
14025
14085
  startAuthLayoutTsx()
14026
14086
  ],
14027
14087
  [
14028
- path4.join("src", "components", "auth", "LoginPage.tsx"),
14088
+ path5.join("src", "components", "auth", "LoginPage.tsx"),
14029
14089
  startLoginPageTsx()
14030
14090
  ],
14031
14091
  [
14032
- path4.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
14092
+ path5.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
14033
14093
  startSignInButtonTsx()
14034
14094
  ]
14035
14095
  ];
14036
14096
  for (const [filePath, content] of files) {
14037
- await Bun.write(path4.join(dir, filePath), content);
14097
+ await Bun.write(path5.join(dir, filePath), content);
14038
14098
  }
14039
14099
  return [
14040
14100
  `Wrote ${files.length} NetSuite files${schemaAppended ? " + appended src/db/schema.ts" : ""}:`,
@@ -14083,27 +14143,27 @@ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
14083
14143
  `);
14084
14144
  }
14085
14145
  async function executeSpa(dir) {
14086
- await mkdir2(path4.join(dir, "src", "components", "auth"), {
14146
+ await mkdir2(path5.join(dir, "src", "components", "auth"), {
14087
14147
  recursive: true
14088
14148
  });
14089
14149
  const nsFiles = [
14090
- [path4.join("src", "lib", "netsuite.ts"), spaNetsuiteTs()],
14091
- [path4.join("src", "lib", "auth.tsx"), spaAuthTsx()],
14150
+ [path5.join("src", "lib", "netsuite.ts"), spaNetsuiteTs()],
14151
+ [path5.join("src", "lib", "auth.tsx"), spaAuthTsx()],
14092
14152
  [
14093
- path4.join("src", "components", "auth", "AuthLayout.tsx"),
14153
+ path5.join("src", "components", "auth", "AuthLayout.tsx"),
14094
14154
  spaAuthLayoutTsx()
14095
14155
  ],
14096
14156
  [
14097
- path4.join("src", "components", "auth", "LoginPage.tsx"),
14157
+ path5.join("src", "components", "auth", "LoginPage.tsx"),
14098
14158
  spaLoginPageTsx()
14099
14159
  ],
14100
14160
  [
14101
- path4.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
14161
+ path5.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
14102
14162
  spaSignInButtonTsx()
14103
14163
  ]
14104
14164
  ];
14105
14165
  for (const [filePath, content] of nsFiles) {
14106
- await Bun.write(path4.join(dir, filePath), content);
14166
+ await Bun.write(path5.join(dir, filePath), content);
14107
14167
  }
14108
14168
  return [
14109
14169
  `Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
@@ -14148,6 +14208,7 @@ var SetupNetsuitePlugin = async () => {
14148
14208
  };
14149
14209
  };
14150
14210
  export {
14211
+ ImageExpiryPlugin,
14151
14212
  InspectHttpPlugin,
14152
14213
  RemovePlugin,
14153
14214
  ScaffoldPlugin,
@@ -62,7 +62,7 @@
62
62
  "devDependencies": {
63
63
  "@cloudflare/vite-plugin": "1.54.4",
64
64
  "@cloudflare/workers-types": "5.20260908.1",
65
- "@helloleo/vite-config": "0.1.6",
65
+ "@helloleo/vite-config": "0.1.7",
66
66
  "@libsql/client": "0.17.4",
67
67
  "@tailwindcss/typography": "0.5.20",
68
68
  "@tanstack/router-cli": "1.167.34",
@@ -206,7 +206,7 @@
206
206
 
207
207
  "@helloleo/runtime": ["@helloleo/runtime@0.1.6", "", { "dependencies": { "aws4fetch": "^1.0.20" }, "peerDependencies": { "@libsql/client": "*", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@libsql/client"] }, "sha512-oH5v5cZ0xMB8/IOg3VsJ2OpoG9Bod6D8UXjR2IGjFKAwx3vj62phit+3daJ9Wwpn3EuX4+BHs84swLfsOW3reg=="],
208
208
 
209
- "@helloleo/vite-config": ["@helloleo/vite-config@0.1.6", "", { "peerDependencies": { "@cloudflare/vite-plugin": "*", "@tailwindcss/vite": "*", "@tanstack/react-start": "*", "@tanstack/router-plugin": "*", "@vitejs/plugin-react": "*", "vite": "*" } }, "sha512-W/B7vXXo/4qTRvisDv379fkVyoaiKu2JJ+T7+iA45PPJfct1i78pZQTomEy36V2u8beS3F7ZImYogePjfCUqSw=="],
209
+ "@helloleo/vite-config": ["@helloleo/vite-config@0.1.7", "", { "peerDependencies": { "@cloudflare/vite-plugin": "*", "@tailwindcss/vite": "*", "@tanstack/react-start": "*", "@tanstack/router-plugin": "*", "@vitejs/plugin-react": "*", "vite": "*" } }, "sha512-couH35PuS5qN11tYvEJBl/ccdoP0Zec3KaUwc5vMGeihNd82k2bRqIAZGEGqAF2YJPjGr74mK/0vglCGDIT8Aw=="],
210
210
 
211
211
  "@hookform/resolvers": ["@hookform/resolvers@5.9.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^1.2.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^3.10.3", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0 || ^18.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=6.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@sinclair/typebox", "@standard-schema/spec", "@typeschema/main", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "valibot", "vest", "yup", "zod"] }, "sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w=="],
212
212
 
@@ -8,4 +8,4 @@
8
8
  # install the runtime it was just scaffolded against.
9
9
  [install]
10
10
  minimumReleaseAge = 259200
11
- minimumReleaseAgeExcludes = ["@helloleo/runtime", "@helloleo/plugins"]
11
+ minimumReleaseAgeExcludes = ["@helloleo/runtime", "@helloleo/vite-config", "@helloleo/plugins"]
@@ -72,7 +72,7 @@
72
72
  "devDependencies": {
73
73
  "@cloudflare/vite-plugin": "1.54.4",
74
74
  "@cloudflare/workers-types": "5.20260908.1",
75
- "@helloleo/vite-config": "0.1.6",
75
+ "@helloleo/vite-config": "0.1.7",
76
76
  "@libsql/client": "0.17.4",
77
77
  "@tailwindcss/typography": "0.5.20",
78
78
  "drizzle-kit": "0.31.10",
@@ -0,0 +1,204 @@
1
+ import '@tanstack/react-start/server-only'
2
+ import { env } from '@helloleo/runtime'
3
+
4
+ /*
5
+ * HelloLeo Integration Proxy: server-side helper for connected integrations.
6
+ *
7
+ * All third-party APIs (Odoo, Airtable, HubSpot, Stripe, Notion, Gladia,
8
+ * Microsoft 365, Klaviyo, Supabase, DimoMaint, etc.) are reachable through
9
+ * this single proxy. The proxy holds the credentials the user configured in
10
+ * Project Settings -- you NEVER pass an integration's API key yourself, and you
11
+ * NEVER call third-party hosts directly.
12
+ *
13
+ * SERVER-ONLY. Every call carries this project's HELLOLEO_API_KEY, which only
14
+ * exists on the server. Importing this file from browser code fails the build
15
+ * (the import above, plus the .server.ts name). Call it from a server route
16
+ * under src/routes/api/ and fetch that route from the page with react-query:
17
+ *
18
+ * // src/routes/api/products.ts
19
+ * import { createFileRoute } from '@tanstack/react-router'
20
+ * import { callIntegration } from '#/lib/proxy.server'
21
+ *
22
+ * export const Route = createFileRoute('/api/products')({
23
+ * server: {
24
+ * handlers: {
25
+ * GET: async () => {
26
+ * const r = await callIntegration({
27
+ * integration: 'odoo',
28
+ * endpoint: '/jsonrpc',
29
+ * body: {
30
+ * model: 'product.template',
31
+ * method: 'search_read',
32
+ * args: [[['type', '=', 'consu']]],
33
+ * kwargs: { fields: ['name', 'list_price'], limit: 50 },
34
+ * },
35
+ * })
36
+ * if (!r.success) return Response.json({ error: r.data }, { status: 502 })
37
+ * return Response.json(r.data?.result ?? [])
38
+ * },
39
+ * },
40
+ * },
41
+ * })
42
+ *
43
+ * One route per operation the app needs. NEVER write a route that forwards
44
+ * `integration`, `endpoint`, `model`, `method` or a raw body from the request
45
+ * to callIntegration: that hands every visitor the user's full integration
46
+ * access, with this project's key attached.
47
+ *
48
+ * Integration slugs:
49
+ * Use the exact slug from the Enabled MCP list with the "pipedream-"
50
+ * prefix removed. e.g. "pipedream-gorgias_oauth" -> "gorgias_oauth".
51
+ * Native integrations (odoo, airtable) keep their plain name.
52
+ *
53
+ * Response shape (always wrapped):
54
+ * { success: boolean, status: number, data: <provider payload>, docs: string }
55
+ *
56
+ * - `status` is the UPSTREAM status, not the proxy's.
57
+ * - `success` is false whenever that status is >= 400.
58
+ * - `data` is null when the upstream returned no body.
59
+ * - `docs` links the proxy's own reference.
60
+ *
61
+ * Unwrap "data" by provider:
62
+ * odoo -> data.result
63
+ * airtable -> data.records
64
+ * notion -> data.results
65
+ * microsoft 365 -> data.value
66
+ * klaviyo -> data.data
67
+ * supabase / dimomaint -> data
68
+ *
69
+ * A 401 with code "api_key_missing" or "api_key_invalid" comes from the
70
+ * proxy itself: the call ran without this project's key. Its `llm` field
71
+ * says what to change.
72
+ *
73
+ * Pagination: put query params directly in the endpoint string,
74
+ * e.g. endpoint: '/api/tickets?cursor=xyz&per_page=100'.
75
+ * Read pagination metadata from the response to build the next call.
76
+ * Never .filter() large lists in the page -- paginate in the route.
77
+ *
78
+ * Sending files:
79
+ * Most APIs take a file as base64 inside the normal JSON body. Odoo is the
80
+ * common case: ir.attachment.create with the bytes in `datas` and BOTH
81
+ * `res_model` and `res_id` set, or the file is stored but appears on no
82
+ * record. Nothing special is needed for those.
83
+ *
84
+ * Some upload endpoints accept only multipart/form-data and answer JSON with
85
+ * HTTP 415. For those set bodyEncoding: 'multipart' (see uploadFile below).
86
+ * Never set a Content-Type header for multipart, the proxy lets fetch
87
+ * generate the boundary. Not accepted for odoo, which has no multipart API.
88
+ *
89
+ * Either way the browser POSTs the file to one of the app's own routes as
90
+ * FormData; the route reads it with `await request.formData()` and encodes
91
+ * it with fileToBase64 below.
92
+ *
93
+ * Secrets for non-integration APIs (e.g. raw OpenAI): store them in the
94
+ * project's Variables and read them via `env` from @helloleo/runtime, in
95
+ * server code only.
96
+ */
97
+
98
+ export const INTEGRATION_PROXY_URL = '{{LEO_INTEGRATION_PROXY_URL}}'
99
+
100
+ // This project's key, issued by HelloLeo per target and injected into the
101
+ // server env: the preview holds one value, the published app another. The
102
+ // proxy reads which one it got to pick the integration connection, so the
103
+ // published app talks to production and the preview to development with no
104
+ // flag to set. Not in @helloleo/runtime's RuntimeEnv, hence the local shape.
105
+ const platformEnv = env as unknown as { HELLOLEO_API_KEY?: string }
106
+
107
+ function proxyHeaders(): Record<string, string> {
108
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
109
+ const key = platformEnv.HELLOLEO_API_KEY
110
+ if (key) headers.Authorization = `Bearer ${key}`
111
+ return headers
112
+ }
113
+
114
+ export interface MultipartFile {
115
+ /** Form field name the upstream expects, e.g. 'content' or 'file'. */
116
+ name: string
117
+ filename: string
118
+ contentType?: string
119
+ /** File bytes, base64-encoded (no data: prefix). */
120
+ dataBase64: string
121
+ }
122
+
123
+ export interface MultipartBody {
124
+ fields?: Record<string, string>
125
+ files?: MultipartFile[]
126
+ }
127
+
128
+ export interface ProxyRequest {
129
+ integration: string
130
+ endpoint: string
131
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
132
+ body?: unknown
133
+ headers?: Record<string, string>
134
+ /** Omit for JSON. 'multipart' when the upstream upload endpoint rejects JSON with 415. */
135
+ bodyEncoding?: 'json' | 'multipart'
136
+ }
137
+
138
+ export interface ProxyResponse<T = unknown> {
139
+ success: boolean
140
+ /** The UPSTREAM status, not the proxy's. */
141
+ status: number
142
+ /** Null when the upstream returned no body. */
143
+ data: T | null
144
+ /** The proxy's own reference, on every response. */
145
+ docs: string
146
+ }
147
+
148
+ export async function callIntegration<T = unknown>(
149
+ req: ProxyRequest,
150
+ ): Promise<ProxyResponse<T>> {
151
+ if (req.integration.toLowerCase() === 'netsuite') {
152
+ throw new Error(
153
+ "NetSuite is not proxied -- call it from this app's own /auth/netsuite + " +
154
+ "/api server routes with the signed-in user's session, not callIntegration().",
155
+ )
156
+ }
157
+ const res = await fetch(INTEGRATION_PROXY_URL, {
158
+ method: 'POST',
159
+ headers: proxyHeaders(),
160
+ body: JSON.stringify({ method: 'POST', ...req }),
161
+ })
162
+ return res.json()
163
+ }
164
+
165
+ /**
166
+ * Upload a file to an integration whose endpoint requires multipart/form-data
167
+ * (Zoho WorkDrive and similar). For Odoo, do NOT use this -- create an
168
+ * ir.attachment with the bytes base64-encoded in `datas` instead.
169
+ */
170
+ export async function uploadFile<T = unknown>(
171
+ req: Omit<ProxyRequest, 'body' | 'bodyEncoding'> & { body: MultipartBody },
172
+ ): Promise<ProxyResponse<T>> {
173
+ return callIntegration<T>({ method: 'POST', ...req, bodyEncoding: 'multipart' })
174
+ }
175
+
176
+ export async function fileToBase64(file: Blob): Promise<string> {
177
+ const bytes = new Uint8Array(await file.arrayBuffer())
178
+ let binary = ''
179
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i])
180
+ return btoa(binary)
181
+ }
182
+
183
+ /* Example -- multipart upload (Zoho WorkDrive), inside a POST route handler:
184
+ *
185
+ * POST: async ({ request }) => {
186
+ * const form = await request.formData()
187
+ * const file = form.get('file') as File
188
+ * const r = await uploadFile({
189
+ * integration: 'zoho_workdrive',
190
+ * endpoint: '/api/v1/upload',
191
+ * body: {
192
+ * fields: { parent_id: FOLDER_ID, 'override-name-exist': 'true' },
193
+ * files: [{
194
+ * name: 'content',
195
+ * filename: file.name,
196
+ * contentType: file.type,
197
+ * dataBase64: await fileToBase64(file),
198
+ * }],
199
+ * },
200
+ * })
201
+ * if (!r.success) return Response.json({ error: r.data }, { status: 502 })
202
+ * return Response.json(r.data)
203
+ * }
204
+ */
@@ -4,14 +4,9 @@ import { defineConfig } from '@helloleo/vite-config'
4
4
  // aliases `cloudflare:workers` -> @helloleo/runtime/cf-shim (libsql .dev.db for
5
5
  // D1+KV, local disk for R2) and runs plain Vite SSR; at build it adds
6
6
  // @cloudflare/vite-plugin to emit a workerd Worker bound to wrangler.jsonc. It
7
- // also binds host:true/port:5173 itself (>=0.1.2).
7
+ // also binds host:true/port:5173 itself (>=0.1.2) and blocks *.server.* files
8
+ // from the client bundle (>=0.1.7).
8
9
  //
9
- // The one thing the wrapper doesn't set: HMR over the HelloLeo preview proxy,
10
- // which terminates HTTPS on :443 — so point the HMR client at wss://…:443.
11
- export default defineConfig({
12
- vite: {
13
- server: {
14
- hmr: { clientPort: 443, protocol: 'wss' },
15
- },
16
- },
17
- })
10
+ // No HMR override: with no clientPort set, the Vite client connects back to the
11
+ // page's own origin, which is wss://…:443 behind the preview proxy.
12
+ export default defineConfig()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.2.30",
3
+ "version": "0.2.31-beta.0",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,182 +0,0 @@
1
- /*
2
- * HelloLeo Integration Proxy: runtime helper for connected integrations.
3
- *
4
- * All third-party APIs (Odoo, Airtable, HubSpot, Stripe, Notion, Gladia,
5
- * Microsoft 365, Klaviyo, Supabase, DimoMaint, etc.) are reachable through
6
- * this single proxy. The proxy handles auth and CORS using credentials the
7
- * user already configured in Project Settings -- you NEVER pass an API key
8
- * from frontend code, and you NEVER call third-party hosts directly.
9
- *
10
- * Two modes:
11
- * A) Frontend-only project -> import callIntegration() from this file.
12
- * B) HelloLeo Cloud project -> route via Worker fetch(env.INTEGRATION_PROXY).
13
- * Use Cloud whenever the project needs backend logic, automations,
14
- * webhooks, scheduled jobs, or AI agents. Keep secrets server-side.
15
- *
16
- * Integration slugs:
17
- * Use the exact slug from the Enabled MCP list with the "pipedream-"
18
- * prefix removed. e.g. "pipedream-gorgias_oauth" -> "gorgias_oauth".
19
- * Native integrations (odoo, airtable) keep their plain name.
20
- *
21
- * Response shape (always wrapped):
22
- * { success: boolean, status: number, data: <provider payload>, docs: string }
23
- *
24
- * - `status` is the UPSTREAM status, not the proxy's.
25
- * - `success` is false whenever that status is >= 400.
26
- * - `data` is null when the upstream returned no body.
27
- * - `docs` links the proxy's own reference.
28
- *
29
- * Unwrap "data" by provider:
30
- * odoo -> data.result
31
- * airtable -> data.records
32
- * notion -> data.results
33
- * microsoft 365 -> data.value
34
- * klaviyo -> data.data
35
- * supabase / dimomaint -> data
36
- *
37
- * Pagination: put query params directly in the endpoint string,
38
- * e.g. endpoint: '/api/tickets?cursor=xyz&per_page=100'.
39
- * Read pagination metadata from the response to build the next call.
40
- * Never .filter() large lists in the frontend -- paginate server-side.
41
- *
42
- * Sending files:
43
- * Most APIs take a file as base64 inside the normal JSON body. Odoo is the
44
- * common case: ir.attachment.create with the bytes in `datas` and BOTH
45
- * `res_model` and `res_id` set, or the file is stored but appears on no
46
- * record. Nothing special is needed for those.
47
- *
48
- * Some upload endpoints accept only multipart/form-data and answer JSON with
49
- * HTTP 415. For those set bodyEncoding: 'multipart' (see uploadFile below).
50
- * Never set a Content-Type header for multipart, the proxy lets fetch
51
- * generate the boundary. Not accepted for odoo, which has no multipart API.
52
- *
53
- * Secrets for non-integration APIs (e.g. raw OpenAI): store them in
54
- * HelloLeo Cloud -> Settings -> Cloud -> Secrets, and read via env.SECRET_NAME
55
- * from a Worker. Never embed secrets in frontend code.
56
- */
57
-
58
- export const INTEGRATION_PROXY_URL = '{{LEO_INTEGRATION_PROXY_URL}}';
59
-
60
- // Multi-environment integrations: the published HelloLeo Cloud Worker has
61
- // HELLOLEO_ENV=production injected at publish. Forwarding `?env=production`
62
- // makes the proxy resolve credentials from the project's PRODUCTION integration
63
- // connection (e.g. your production Odoo) instead of the development one, so a
64
- // published app talks to production while the preview keeps talking to staging.
65
- //
66
- // Reads the flag from the server runtime (Cloudflare Worker / Node) when
67
- // available; in the browser it's absent and we send no param — the proxy then
68
- // derives the environment from the request origin, so client-side calls from
69
- // the published app still resolve to production. Server-side calls (SSR / Cloud
70
- // Worker) carry no browser origin, which is exactly why the explicit param
71
- // matters there.
72
- function proxyUrl(): string {
73
- // Read via globalThis only (no bare `process` reference) so this compiles in
74
- // any scaffolded project regardless of whether node types are present.
75
- const g = globalThis as {
76
- HELLOLEO_ENV?: string;
77
- process?: { env?: Record<string, string | undefined> };
78
- };
79
- const env = g.HELLOLEO_ENV ?? g.process?.env?.HELLOLEO_ENV;
80
- return env === 'production' ? `${INTEGRATION_PROXY_URL}?env=production` : INTEGRATION_PROXY_URL;
81
- }
82
-
83
- export interface MultipartFile {
84
- /** Form field name the upstream expects, e.g. 'content' or 'file'. */
85
- name: string;
86
- filename: string;
87
- contentType?: string;
88
- /** File bytes, base64-encoded (no data: prefix). */
89
- dataBase64: string;
90
- }
91
-
92
- export interface MultipartBody {
93
- fields?: Record<string, string>;
94
- files?: MultipartFile[];
95
- }
96
-
97
- export interface ProxyRequest {
98
- integration: string;
99
- endpoint: string;
100
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
101
- body?: unknown;
102
- headers?: Record<string, string>;
103
- /** Omit for JSON. 'multipart' when the upstream upload endpoint rejects JSON with 415. */
104
- bodyEncoding?: 'json' | 'multipart';
105
- }
106
-
107
- export interface ProxyResponse<T = unknown> {
108
- success: boolean;
109
- /** The UPSTREAM status, not the proxy's. */
110
- status: number;
111
- /** Null when the upstream returned no body. */
112
- data: T | null;
113
- /** The proxy's own reference, on every response. */
114
- docs: string;
115
- }
116
-
117
- export async function callIntegration<T = unknown>(
118
- req: ProxyRequest,
119
- ): Promise<ProxyResponse<T>> {
120
- if (req.integration.toLowerCase() === 'netsuite') {
121
- throw new Error(
122
- 'NetSuite is not proxied -- call it from this app\'s own /auth/netsuite + ' +
123
- '/api server routes with the signed-in user\'s session, not callIntegration().',
124
- );
125
- }
126
- const res = await fetch(proxyUrl(), {
127
- method: 'POST',
128
- headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify({ method: 'POST', ...req }),
130
- });
131
- return res.json();
132
- }
133
-
134
- /**
135
- * Upload a file to an integration whose endpoint requires multipart/form-data
136
- * (Zoho WorkDrive and similar). For Odoo, do NOT use this -- create an
137
- * ir.attachment with the bytes base64-encoded in `datas` instead.
138
- */
139
- export async function uploadFile<T = unknown>(
140
- req: Omit<ProxyRequest, 'body' | 'bodyEncoding'> & { body: MultipartBody },
141
- ): Promise<ProxyResponse<T>> {
142
- return callIntegration<T>({ method: 'POST', ...req, bodyEncoding: 'multipart' });
143
- }
144
-
145
- export async function fileToBase64(file: Blob): Promise<string> {
146
- const bytes = new Uint8Array(await file.arrayBuffer());
147
- let binary = '';
148
- for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
149
- return btoa(binary);
150
- }
151
-
152
- /* Example -- Odoo search_read:
153
- *
154
- * const r = await callIntegration({
155
- * integration: 'odoo',
156
- * endpoint: '/jsonrpc',
157
- * body: {
158
- * model: 'product.template',
159
- * method: 'search_read',
160
- * args: [[['type', '=', 'consu']]],
161
- * kwargs: { fields: ['name', 'list_price'], limit: 50 },
162
- * },
163
- * });
164
- * const products = r.data?.result ?? [];
165
- *
166
- * Example -- multipart upload (Zoho WorkDrive):
167
- *
168
- * const r = await uploadFile({
169
- * integration: 'zoho_workdrive',
170
- * endpoint: '/api/v1/upload',
171
- * body: {
172
- * fields: { parent_id: folderId, 'override-name-exist': 'true' },
173
- * files: [{
174
- * name: 'content',
175
- * filename: file.name,
176
- * contentType: file.type,
177
- * dataBase64: await fileToBase64(file),
178
- * }],
179
- * },
180
- * });
181
- * if (!r.success) throw new Error(String(r.data));
182
- */