@helloleo/plugins 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12662,7 +12662,36 @@ function postcssConfig() {
12662
12662
  }
12663
12663
  function indexCss(primaryColor, fontFamily, borderRadius) {
12664
12664
  const fontValue = `'${fontFamily}', ui-sans-serif, system-ui, sans-serif`;
12665
- return `@tailwind base;
12665
+ return `/*
12666
+ * HelloLeo Design System -- READ BEFORE STYLING.
12667
+ *
12668
+ * The CSS variables below are the single source of truth for the project's
12669
+ * design tokens. They power the no-code Design panel in the HelloLeo webapp:
12670
+ * the user edits colors / fonts / radius / shadows visually, and the panel
12671
+ * writes back to THIS file. Keeping the conventions intact is what unlocks
12672
+ * that round-trip.
12673
+ *
12674
+ * Conventions (do not break):
12675
+ * - All colors stored as raw HSL triplets (no hsl() wrapper, no #hex).
12676
+ * Tailwind classes wrap them via hsl(var(--token)). Never inline #hex
12677
+ * in components -- always use design tokens (bg-primary, text-foreground,
12678
+ * border-border, etc.).
12679
+ * - --primary / --secondary / --accent / --destructive / --success /
12680
+ * --warning each have a paired -foreground for text on that surface.
12681
+ * - --radius drives lg/md/sm via calc() in tailwind.config.js. Edit here,
12682
+ * not in component classes.
12683
+ * - --font-sans for body, --font-heading for headings. Both load from the
12684
+ * Google Fonts <link> in index.html.
12685
+ * - .dark variants mirror :root one-for-one. When adding a token, add it
12686
+ * to BOTH blocks or dark mode breaks.
12687
+ *
12688
+ * UI primitives (Button, Card, Input, Modal, Table, Badge, Alert, Spinner)
12689
+ * MUST live in src/components/ui/ and consume these tokens via Tailwind
12690
+ * classes -- never hardcoded colors. One responsibility per file, <50 lines
12691
+ * per component.
12692
+ */
12693
+
12694
+ @tailwind base;
12666
12695
  @tailwind components;
12667
12696
  @tailwind utilities;
12668
12697
 
@@ -12739,6 +12768,134 @@ export function cn(...inputs: ClassValue[]) {
12739
12768
  return twMerge(clsx(inputs))
12740
12769
  }`;
12741
12770
  }
12771
+ function proxyTs() {
12772
+ return `/*
12773
+ * HelloLeo Integration Proxy -- runtime helper for connected integrations.
12774
+ *
12775
+ * All third-party APIs (Odoo, Airtable, HubSpot, Stripe, Notion, Gladia,
12776
+ * Microsoft 365, Klaviyo, Supabase, DimoMaint, etc.) are reachable through
12777
+ * this single proxy. The proxy handles auth and CORS using credentials the
12778
+ * user already configured in Project Settings -- you NEVER pass an API key
12779
+ * from frontend code, and you NEVER call third-party hosts directly.
12780
+ *
12781
+ * Two modes:
12782
+ * A) Frontend-only project -> import callIntegration() from this file.
12783
+ * B) HelloLeo Cloud project -> route via Worker fetch(env.INTEGRATION_PROXY).
12784
+ * Use Cloud whenever the project needs backend logic, automations,
12785
+ * webhooks, scheduled jobs, or AI agents. Keep secrets server-side.
12786
+ *
12787
+ * Integration slugs:
12788
+ * Use the exact slug from the Enabled MCP list with the "pipedream-"
12789
+ * prefix removed. e.g. "pipedream-gorgias_oauth" -> "gorgias_oauth".
12790
+ * Native integrations (odoo, airtable) keep their plain name.
12791
+ *
12792
+ * Response shape (always wrapped):
12793
+ * { success: boolean, status: number, data: <provider payload> }
12794
+ *
12795
+ * Unwrap "data" by provider:
12796
+ * odoo -> data.result
12797
+ * airtable -> data.records
12798
+ * notion -> data.results
12799
+ * microsoft 365 -> data.value
12800
+ * klaviyo -> data.data
12801
+ * supabase / dimomaint -> data
12802
+ *
12803
+ * Pagination: put query params directly in the endpoint string,
12804
+ * e.g. endpoint: '/api/tickets?cursor=xyz&per_page=100'.
12805
+ * Read pagination metadata from the response to build the next call.
12806
+ * Never .filter() large lists in the frontend -- paginate server-side.
12807
+ *
12808
+ * Secrets for non-integration APIs (e.g. raw OpenAI): store them in
12809
+ * HelloLeo Cloud -> Settings -> Cloud -> Secrets, and read via env.SECRET_NAME
12810
+ * from a Worker. Never embed secrets in frontend code.
12811
+ */
12812
+
12813
+ const PROJECT_UUID = import.meta.env.VITE_PROJECT_UUID ?? '';
12814
+ export const INTEGRATION_PROXY_URL =
12815
+ \`https://api.app.helloleo.dev/api/integration-proxy/\${PROJECT_UUID}\`;
12816
+
12817
+ export interface ProxyRequest {
12818
+ integration: string;
12819
+ endpoint: string;
12820
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
12821
+ body?: unknown;
12822
+ headers?: Record<string, string>;
12823
+ }
12824
+
12825
+ export interface ProxyResponse<T = unknown> {
12826
+ success: boolean;
12827
+ status: number;
12828
+ data: T;
12829
+ }
12830
+
12831
+ export async function callIntegration<T = unknown>(
12832
+ req: ProxyRequest,
12833
+ ): Promise<ProxyResponse<T>> {
12834
+ const res = await fetch(INTEGRATION_PROXY_URL, {
12835
+ method: 'POST',
12836
+ headers: { 'Content-Type': 'application/json' },
12837
+ body: JSON.stringify({ method: 'POST', ...req }),
12838
+ });
12839
+ return res.json();
12840
+ }
12841
+
12842
+ /* Example -- Odoo search_read:
12843
+ *
12844
+ * const r = await callIntegration({
12845
+ * integration: 'odoo',
12846
+ * endpoint: '/jsonrpc',
12847
+ * body: {
12848
+ * model: 'product.template',
12849
+ * method: 'search_read',
12850
+ * args: [[['type', '=', 'consu']]],
12851
+ * kwargs: { fields: ['name', 'list_price'], limit: 50 },
12852
+ * },
12853
+ * });
12854
+ * const products = r.data?.result ?? [];
12855
+ */
12856
+ `;
12857
+ }
12858
+ function hexToHslTriplet(hex3) {
12859
+ let h = hex3.trim().replace(/^#/, "");
12860
+ if (h.length === 3) {
12861
+ h = h.split("").map((c) => c + c).join("");
12862
+ }
12863
+ if (!/^[0-9a-fA-F]{6}$/.test(h))
12864
+ return "";
12865
+ const r = Number.parseInt(h.slice(0, 2), 16) / 255;
12866
+ const g = Number.parseInt(h.slice(2, 4), 16) / 255;
12867
+ const b = Number.parseInt(h.slice(4, 6), 16) / 255;
12868
+ const max = Math.max(r, g, b);
12869
+ const min = Math.min(r, g, b);
12870
+ const l = (max + min) / 2;
12871
+ let s = 0;
12872
+ let hue = 0;
12873
+ if (max !== min) {
12874
+ const d = max - min;
12875
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
12876
+ switch (max) {
12877
+ case r:
12878
+ hue = (g - b) / d + (g < b ? 6 : 0);
12879
+ break;
12880
+ case g:
12881
+ hue = (b - r) / d + 2;
12882
+ break;
12883
+ default:
12884
+ hue = (r - g) / d + 4;
12885
+ }
12886
+ hue *= 60;
12887
+ }
12888
+ return `${Math.round(hue)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
12889
+ }
12890
+ function normalizePrimaryColor(value) {
12891
+ const trimmed = value.trim();
12892
+ if (/^(#|[0-9a-fA-F]{3}$|[0-9a-fA-F]{6}$)/.test(trimmed)) {
12893
+ const hsl = hexToHslTriplet(trimmed);
12894
+ if (hsl)
12895
+ return hsl;
12896
+ }
12897
+ return trimmed;
12898
+ }
12742
12899
  function mainTsx() {
12743
12900
  return `import React from 'react'
12744
12901
  import ReactDOM from 'react-dom/client'
@@ -12751,21 +12908,219 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
12751
12908
  </React.StrictMode>,
12752
12909
  )`;
12753
12910
  }
12911
+ function netsuiteTs() {
12912
+ return `/*
12913
+ * NetSuite OAuth client helper.
12914
+ *
12915
+ * NetSuite uses a TWO-Integration-Record setup:
12916
+ * 1. MCP record (scope: mcp) -- used by HelloLeo at build time
12917
+ * to introspect the schema. Configured in Project Settings, not here.
12918
+ * 2. Runtime record (scope: rest_webservices) -- used by THIS app to sign
12919
+ * end-users in. Credentials are stored as HelloLeo Cloud secrets on the
12920
+ * project's Worker (env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,
12921
+ * env.NETSUITE_CLIENT_SECRET). They never reach the browser.
12922
+ *
12923
+ * Flow:
12924
+ * 1. User clicks "Sign in with NetSuite".
12925
+ * 2. Browser hits the project's Worker at /auth/netsuite/start, which
12926
+ * redirects to NetSuite's OAuth authorise endpoint with PKCE.
12927
+ * 3. NetSuite redirects back to /auth/netsuite/callback on the Worker.
12928
+ * The Worker exchanges the code for an access + refresh token, stores
12929
+ * them in D1 keyed by a session id, and sets a session cookie.
12930
+ * 4. The frontend calls /api/me on the Worker to read the signed-in user.
12931
+ * 5. All NetSuite REST calls flow through the Worker so tokens stay
12932
+ * server-side.
12933
+ *
12934
+ * IMPORTANT: NEVER use a hardcoded password or sessionStorage flag as the
12935
+ * login gate -- the NetSuite OAuth flow above is the only acceptable
12936
+ * authentication for a NetSuite project.
12937
+ */
12938
+
12939
+ export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
12940
+
12941
+ export function signInWithNetSuite(returnTo: string = window.location.pathname) {
12942
+ if (!WORKER_URL) {
12943
+ throw new Error(
12944
+ 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
12945
+ );
12946
+ }
12947
+ const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
12948
+ url.searchParams.set('return_to', returnTo);
12949
+ window.location.assign(url.toString());
12950
+ }
12951
+
12952
+ export interface NetSuiteSession {
12953
+ authenticated: boolean;
12954
+ account_id?: string;
12955
+ user_id?: string;
12956
+ email?: string;
12957
+ }
12958
+
12959
+ export async function getNetSuiteSession(): Promise<NetSuiteSession> {
12960
+ if (!WORKER_URL) return { authenticated: false };
12961
+ const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
12962
+ credentials: 'include',
12963
+ });
12964
+ if (!res.ok) return { authenticated: false };
12965
+ const data = await res.json();
12966
+ return { authenticated: Boolean(data?.account_id), ...data };
12967
+ }
12968
+
12969
+ export async function signOutNetSuite(): Promise<void> {
12970
+ if (!WORKER_URL) return;
12971
+ await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
12972
+ method: 'POST',
12973
+ credentials: 'include',
12974
+ });
12975
+ window.location.reload();
12976
+ }
12977
+ `;
12978
+ }
12979
+ function signInButtonTsx() {
12980
+ return `import { signInWithNetSuite } from '../../lib/netsuite';
12981
+
12982
+ export function SignInWithNetSuite() {
12983
+ return (
12984
+ <button
12985
+ onClick={() => signInWithNetSuite()}
12986
+ className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
12987
+ >
12988
+ Sign in with NetSuite
12989
+ </button>
12990
+ );
12991
+ }
12992
+ `;
12993
+ }
12994
+ function netsuiteLoginPageTsx() {
12995
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite';
12996
+
12997
+ /**
12998
+ * Default unauthenticated landing page. Replaces any password gate.
12999
+ * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
13000
+ * only call-to-action -- it points to the Worker's /auth/netsuite/start.
13001
+ */
13002
+ export function LoginPage() {
13003
+ return (
13004
+ <div className="min-h-screen bg-background text-foreground flex">
13005
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
13006
+ <div className="text-xl font-semibold">NetSuite App</div>
13007
+ <div>
13008
+ <h1 className="text-4xl font-bold leading-tight">
13009
+ Connect your NetSuite account
13010
+ </h1>
13011
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
13012
+ Sign in with your NetSuite credentials to access your data securely.
13013
+ Tokens stay server-side on HelloLeo Cloud -- they never reach the
13014
+ browser.
13015
+ </p>
13016
+ </div>
13017
+ <div className="text-xs text-primary-foreground/60">
13018
+ Powered by HelloLeo
13019
+ </div>
13020
+ </div>
13021
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
13022
+ <div className="w-full max-w-sm space-y-6">
13023
+ <div>
13024
+ <h2 className="text-2xl font-semibold">Welcome</h2>
13025
+ <p className="mt-2 text-sm text-muted-foreground">
13026
+ Use the button below to sign in. You'll be redirected to NetSuite
13027
+ to authorise access, then back here.
13028
+ </p>
13029
+ </div>
13030
+ <SignInWithNetSuite />
13031
+ </div>
13032
+ </div>
13033
+ </div>
13034
+ );
13035
+ }
13036
+ `;
13037
+ }
13038
+ function netsuiteAuthGateTsx() {
13039
+ return `import { useEffect, useState } from 'react';
13040
+ import { LoginPage } from './LoginPage';
13041
+ import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
13042
+
13043
+ /**
13044
+ * Mandatory auth gate for NetSuite projects. Wrap your real App content with
13045
+ * <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
13046
+ * is the ONLY supported authentication for a NetSuite project (no password
13047
+ * gates, no sessionStorage flags, no shared credential).
13048
+ *
13049
+ * Pattern in src/App.tsx:
13050
+ *
13051
+ * import { AuthGate } from './components/auth/AuthGate';
13052
+ * export default function App() {
13053
+ * return (
13054
+ * <AuthGate>
13055
+ * <Dashboard /> {/* your authenticated content */}
13056
+ * </AuthGate>
13057
+ * );
13058
+ * }
13059
+ */
13060
+ export function AuthGate({ children }: { children: React.ReactNode }) {
13061
+ const [session, setSession] = useState<NetSuiteSession | null>(null);
13062
+
13063
+ useEffect(() => { getNetSuiteSession().then(setSession); }, []);
13064
+
13065
+ if (session === null) {
13066
+ return (
13067
+ <div className="min-h-screen flex items-center justify-center bg-background">
13068
+ <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
13069
+ </div>
13070
+ );
13071
+ }
13072
+
13073
+ if (!session.authenticated) return <LoginPage />;
13074
+
13075
+ return <>{children}</>;
13076
+ }
13077
+ `;
13078
+ }
13079
+ function netsuiteAppTsx() {
13080
+ return `import { AuthGate } from './components/auth/AuthGate';
13081
+
13082
+ /**
13083
+ * NetSuite project entry point. KEEP <AuthGate> as the outermost element --
13084
+ * removing it disables real OAuth and exposes NetSuite data to anonymous
13085
+ * users. Build your dashboard / pages inside the gate.
13086
+ */
13087
+ export default function App() {
13088
+ return (
13089
+ <AuthGate>
13090
+ <div className="min-h-screen bg-background text-foreground p-8">
13091
+ <h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
13092
+ <p className="text-muted-foreground mt-2">
13093
+ Replace this placeholder with your dashboard.
13094
+ </p>
13095
+ </div>
13096
+ </AuthGate>
13097
+ );
13098
+ }
13099
+ `;
13100
+ }
12754
13101
  var scaffold = tool({
12755
13102
  description: "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " + "Creates all mandatory boilerplate: package.json, index.html (with Google Fonts), " + "vite.config.js, tailwind.config.js, postcss.config.js, src/index.css (CSS variables " + "light+dark), src/lib/utils.ts (cn helper), and src/main.tsx. " + "Call this FIRST for any new project before writing App.tsx and components.",
12756
13103
  args: {
12757
13104
  name: tool.schema.string().optional().describe("Project name for package.json (default: leo-project)"),
12758
- primaryColor: tool.schema.string().optional().describe('HSL values for primary color, e.g. "221.2 83.2% 53.3%" (default: blue)'),
13105
+ primaryColor: tool.schema.string().optional().describe("Primary brand color. Prefer raw HSL triplet (no hsl() wrapper, no # prefix), " + 'e.g. "221.2 83.2% 53.3%". Hex like "#3b82f6" is also accepted and will ' + "be auto-converted. Default: blue."),
12759
13106
  fontFamily: tool.schema.string().optional().describe('Google Font family name, e.g. "Inter", "Poppins" (default: Inter)'),
12760
- borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)')
13107
+ borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)'),
13108
+ integrations: tool.schema.array(tool.schema.string()).optional().describe("Connected integration slugs for this project (live MCP list). " + 'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' + "will add src/lib/netsuite.ts (OAuth client helper), " + "src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx " + "and a src/App.tsx wrapped in <AuthGate>. OAuth is wired from " + "day one. Forbids hardcoded password gates -- the gate IS the " + "real flow against the project's Worker.")
12761
13109
  },
12762
13110
  async execute(args, context) {
12763
13111
  const dir = context.directory;
12764
13112
  const name = args.name ?? "leo-project";
12765
- const primary = args.primaryColor ?? "221.2 83.2% 53.3%";
13113
+ const primary = normalizePrimaryColor(args.primaryColor ?? "221.2 83.2% 53.3%");
12766
13114
  const font = args.fontFamily ?? "Inter";
12767
13115
  const radius = args.borderRadius ?? "0.5rem";
13116
+ const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
13117
+ const hasNetsuite = integrations.includes("netsuite");
12768
13118
  await mkdir(path2.join(dir, "src", "lib"), { recursive: true });
13119
+ if (hasNetsuite) {
13120
+ await mkdir(path2.join(dir, "src", "components", "auth"), {
13121
+ recursive: true
13122
+ });
13123
+ }
12769
13124
  const files = [
12770
13125
  ["package.json", packageJson(name)],
12771
13126
  ["index.html", indexHtml(font)],
@@ -12774,22 +13129,42 @@ var scaffold = tool({
12774
13129
  ["postcss.config.js", postcssConfig()],
12775
13130
  [path2.join("src", "index.css"), indexCss(primary, font, radius)],
12776
13131
  [path2.join("src", "lib", "utils.ts"), utilsTs()],
13132
+ [path2.join("src", "lib", "proxy.ts"), proxyTs()],
12777
13133
  [path2.join("src", "main.tsx"), mainTsx()]
12778
13134
  ];
13135
+ if (hasNetsuite) {
13136
+ files.push([path2.join("src", "lib", "netsuite.ts"), netsuiteTs()], [
13137
+ path2.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
13138
+ signInButtonTsx()
13139
+ ], [
13140
+ path2.join("src", "components", "auth", "LoginPage.tsx"),
13141
+ netsuiteLoginPageTsx()
13142
+ ], [
13143
+ path2.join("src", "components", "auth", "AuthGate.tsx"),
13144
+ netsuiteAuthGateTsx()
13145
+ ], [path2.join("src", "App.tsx"), netsuiteAppTsx()]);
13146
+ }
12779
13147
  const created = [];
12780
13148
  for (const [filePath, content] of files) {
12781
13149
  await Bun.write(path2.join(dir, filePath), content);
12782
13150
  created.push(filePath);
12783
13151
  }
12784
- return [
13152
+ const out = [
12785
13153
  `Scaffolded ${created.length} files:`,
12786
13154
  ...created.map((f) => ` - ${f}`),
12787
13155
  "",
12788
13156
  "Next: create src/App.tsx with your application code.",
12789
- "The design system is ready -- use design tokens (bg-primary, text-foreground, etc.)",
12790
- "and create UI components in src/components/ui/.",
12791
- "Make sure to update package.json with all packages you need after scaffolding."
12792
- ].join(`
13157
+ "Design system reference: read the header comment in src/index.css",
13158
+ " before adding tokens or styling primitives.",
13159
+ "Integration calls: use callIntegration() from src/lib/proxy.ts.",
13160
+ " Header comment in that file documents slugs, response unwrapping,",
13161
+ " pagination, and frontend-only vs HelloLeo Cloud routing.",
13162
+ "Update package.json with any extra packages you need after scaffolding."
13163
+ ];
13164
+ if (hasNetsuite) {
13165
+ out.push("", "NetSuite project -- auth is already wired:", " - App.tsx wraps everything in <AuthGate>. Build your dashboard", " INSIDE the gate. NEVER remove the gate or build a password", " placeholder -- real OAuth is the only allowed login.", " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated", " view. Customise the copy / imagery, keep <SignInWithNetSuite/>", " as the call-to-action.", " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at", " /auth/netsuite/start. Deploy the Worker via cloudflare-backend", " MCP with routes:", " GET /auth/netsuite/start -- redirect to NetSuite OAuth", " GET /auth/netsuite/callback -- code -> token exchange", " GET /api/me -- current session", " POST /auth/netsuite/logout -- clear session", " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,", " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).", " - VITE_WORKER_URL must be set in the project env so the frontend", " knows where to send users. The HelloLeo Cloud provisioner", " seeds this automatically.");
13166
+ }
13167
+ return out.join(`
12793
13168
  `);
12794
13169
  }
12795
13170
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -134,7 +134,36 @@ function indexCss(
134
134
  borderRadius: string,
135
135
  ) {
136
136
  const fontValue = `'${fontFamily}', ui-sans-serif, system-ui, sans-serif`;
137
- return `@tailwind base;
137
+ return `/*
138
+ * HelloLeo Design System -- READ BEFORE STYLING.
139
+ *
140
+ * The CSS variables below are the single source of truth for the project's
141
+ * design tokens. They power the no-code Design panel in the HelloLeo webapp:
142
+ * the user edits colors / fonts / radius / shadows visually, and the panel
143
+ * writes back to THIS file. Keeping the conventions intact is what unlocks
144
+ * that round-trip.
145
+ *
146
+ * Conventions (do not break):
147
+ * - All colors stored as raw HSL triplets (no hsl() wrapper, no #hex).
148
+ * Tailwind classes wrap them via hsl(var(--token)). Never inline #hex
149
+ * in components -- always use design tokens (bg-primary, text-foreground,
150
+ * border-border, etc.).
151
+ * - --primary / --secondary / --accent / --destructive / --success /
152
+ * --warning each have a paired -foreground for text on that surface.
153
+ * - --radius drives lg/md/sm via calc() in tailwind.config.js. Edit here,
154
+ * not in component classes.
155
+ * - --font-sans for body, --font-heading for headings. Both load from the
156
+ * Google Fonts <link> in index.html.
157
+ * - .dark variants mirror :root one-for-one. When adding a token, add it
158
+ * to BOTH blocks or dark mode breaks.
159
+ *
160
+ * UI primitives (Button, Card, Input, Modal, Table, Badge, Alert, Spinner)
161
+ * MUST live in src/components/ui/ and consume these tokens via Tailwind
162
+ * classes -- never hardcoded colors. One responsibility per file, <50 lines
163
+ * per component.
164
+ */
165
+
166
+ @tailwind base;
138
167
  @tailwind components;
139
168
  @tailwind utilities;
140
169
 
@@ -213,6 +242,138 @@ export function cn(...inputs: ClassValue[]) {
213
242
  }`;
214
243
  }
215
244
 
245
+ function proxyTs() {
246
+ return `/*
247
+ * HelloLeo Integration Proxy -- runtime helper for connected integrations.
248
+ *
249
+ * All third-party APIs (Odoo, Airtable, HubSpot, Stripe, Notion, Gladia,
250
+ * Microsoft 365, Klaviyo, Supabase, DimoMaint, etc.) are reachable through
251
+ * this single proxy. The proxy handles auth and CORS using credentials the
252
+ * user already configured in Project Settings -- you NEVER pass an API key
253
+ * from frontend code, and you NEVER call third-party hosts directly.
254
+ *
255
+ * Two modes:
256
+ * A) Frontend-only project -> import callIntegration() from this file.
257
+ * B) HelloLeo Cloud project -> route via Worker fetch(env.INTEGRATION_PROXY).
258
+ * Use Cloud whenever the project needs backend logic, automations,
259
+ * webhooks, scheduled jobs, or AI agents. Keep secrets server-side.
260
+ *
261
+ * Integration slugs:
262
+ * Use the exact slug from the Enabled MCP list with the "pipedream-"
263
+ * prefix removed. e.g. "pipedream-gorgias_oauth" -> "gorgias_oauth".
264
+ * Native integrations (odoo, airtable) keep their plain name.
265
+ *
266
+ * Response shape (always wrapped):
267
+ * { success: boolean, status: number, data: <provider payload> }
268
+ *
269
+ * Unwrap "data" by provider:
270
+ * odoo -> data.result
271
+ * airtable -> data.records
272
+ * notion -> data.results
273
+ * microsoft 365 -> data.value
274
+ * klaviyo -> data.data
275
+ * supabase / dimomaint -> data
276
+ *
277
+ * Pagination: put query params directly in the endpoint string,
278
+ * e.g. endpoint: '/api/tickets?cursor=xyz&per_page=100'.
279
+ * Read pagination metadata from the response to build the next call.
280
+ * Never .filter() large lists in the frontend -- paginate server-side.
281
+ *
282
+ * Secrets for non-integration APIs (e.g. raw OpenAI): store them in
283
+ * HelloLeo Cloud -> Settings -> Cloud -> Secrets, and read via env.SECRET_NAME
284
+ * from a Worker. Never embed secrets in frontend code.
285
+ */
286
+
287
+ const PROJECT_UUID = import.meta.env.VITE_PROJECT_UUID ?? '';
288
+ export const INTEGRATION_PROXY_URL =
289
+ \`https://api.app.helloleo.dev/api/integration-proxy/\${PROJECT_UUID}\`;
290
+
291
+ export interface ProxyRequest {
292
+ integration: string;
293
+ endpoint: string;
294
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
295
+ body?: unknown;
296
+ headers?: Record<string, string>;
297
+ }
298
+
299
+ export interface ProxyResponse<T = unknown> {
300
+ success: boolean;
301
+ status: number;
302
+ data: T;
303
+ }
304
+
305
+ export async function callIntegration<T = unknown>(
306
+ req: ProxyRequest,
307
+ ): Promise<ProxyResponse<T>> {
308
+ const res = await fetch(INTEGRATION_PROXY_URL, {
309
+ method: 'POST',
310
+ headers: { 'Content-Type': 'application/json' },
311
+ body: JSON.stringify({ method: 'POST', ...req }),
312
+ });
313
+ return res.json();
314
+ }
315
+
316
+ /* Example -- Odoo search_read:
317
+ *
318
+ * const r = await callIntegration({
319
+ * integration: 'odoo',
320
+ * endpoint: '/jsonrpc',
321
+ * body: {
322
+ * model: 'product.template',
323
+ * method: 'search_read',
324
+ * args: [[['type', '=', 'consu']]],
325
+ * kwargs: { fields: ['name', 'list_price'], limit: 50 },
326
+ * },
327
+ * });
328
+ * const products = r.data?.result ?? [];
329
+ */
330
+ `;
331
+ }
332
+
333
+ function hexToHslTriplet(hex: string): string {
334
+ let h = hex.trim().replace(/^#/, "");
335
+ if (h.length === 3) {
336
+ h = h
337
+ .split("")
338
+ .map((c) => c + c)
339
+ .join("");
340
+ }
341
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return "";
342
+ const r = Number.parseInt(h.slice(0, 2), 16) / 255;
343
+ const g = Number.parseInt(h.slice(2, 4), 16) / 255;
344
+ const b = Number.parseInt(h.slice(4, 6), 16) / 255;
345
+ const max = Math.max(r, g, b);
346
+ const min = Math.min(r, g, b);
347
+ const l = (max + min) / 2;
348
+ let s = 0;
349
+ let hue = 0;
350
+ if (max !== min) {
351
+ const d = max - min;
352
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
353
+ switch (max) {
354
+ case r:
355
+ hue = (g - b) / d + (g < b ? 6 : 0);
356
+ break;
357
+ case g:
358
+ hue = (b - r) / d + 2;
359
+ break;
360
+ default:
361
+ hue = (r - g) / d + 4;
362
+ }
363
+ hue *= 60;
364
+ }
365
+ return `${Math.round(hue)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
366
+ }
367
+
368
+ function normalizePrimaryColor(value: string): string {
369
+ const trimmed = value.trim();
370
+ if (/^(#|[0-9a-fA-F]{3}$|[0-9a-fA-F]{6}$)/.test(trimmed)) {
371
+ const hsl = hexToHslTriplet(trimmed);
372
+ if (hsl) return hsl;
373
+ }
374
+ return trimmed;
375
+ }
376
+
216
377
  function mainTsx() {
217
378
  return `import React from 'react'
218
379
  import ReactDOM from 'react-dom/client'
@@ -226,6 +387,201 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
226
387
  )`;
227
388
  }
228
389
 
390
+ function netsuiteTs() {
391
+ return `/*
392
+ * NetSuite OAuth client helper.
393
+ *
394
+ * NetSuite uses a TWO-Integration-Record setup:
395
+ * 1. MCP record (scope: mcp) -- used by HelloLeo at build time
396
+ * to introspect the schema. Configured in Project Settings, not here.
397
+ * 2. Runtime record (scope: rest_webservices) -- used by THIS app to sign
398
+ * end-users in. Credentials are stored as HelloLeo Cloud secrets on the
399
+ * project's Worker (env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,
400
+ * env.NETSUITE_CLIENT_SECRET). They never reach the browser.
401
+ *
402
+ * Flow:
403
+ * 1. User clicks "Sign in with NetSuite".
404
+ * 2. Browser hits the project's Worker at /auth/netsuite/start, which
405
+ * redirects to NetSuite's OAuth authorise endpoint with PKCE.
406
+ * 3. NetSuite redirects back to /auth/netsuite/callback on the Worker.
407
+ * The Worker exchanges the code for an access + refresh token, stores
408
+ * them in D1 keyed by a session id, and sets a session cookie.
409
+ * 4. The frontend calls /api/me on the Worker to read the signed-in user.
410
+ * 5. All NetSuite REST calls flow through the Worker so tokens stay
411
+ * server-side.
412
+ *
413
+ * IMPORTANT: NEVER use a hardcoded password or sessionStorage flag as the
414
+ * login gate -- the NetSuite OAuth flow above is the only acceptable
415
+ * authentication for a NetSuite project.
416
+ */
417
+
418
+ export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
419
+
420
+ export function signInWithNetSuite(returnTo: string = window.location.pathname) {
421
+ if (!WORKER_URL) {
422
+ throw new Error(
423
+ 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
424
+ );
425
+ }
426
+ const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
427
+ url.searchParams.set('return_to', returnTo);
428
+ window.location.assign(url.toString());
429
+ }
430
+
431
+ export interface NetSuiteSession {
432
+ authenticated: boolean;
433
+ account_id?: string;
434
+ user_id?: string;
435
+ email?: string;
436
+ }
437
+
438
+ export async function getNetSuiteSession(): Promise<NetSuiteSession> {
439
+ if (!WORKER_URL) return { authenticated: false };
440
+ const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
441
+ credentials: 'include',
442
+ });
443
+ if (!res.ok) return { authenticated: false };
444
+ const data = await res.json();
445
+ return { authenticated: Boolean(data?.account_id), ...data };
446
+ }
447
+
448
+ export async function signOutNetSuite(): Promise<void> {
449
+ if (!WORKER_URL) return;
450
+ await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
451
+ method: 'POST',
452
+ credentials: 'include',
453
+ });
454
+ window.location.reload();
455
+ }
456
+ `;
457
+ }
458
+
459
+ function signInButtonTsx() {
460
+ return `import { signInWithNetSuite } from '../../lib/netsuite';
461
+
462
+ export function SignInWithNetSuite() {
463
+ return (
464
+ <button
465
+ onClick={() => signInWithNetSuite()}
466
+ className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
467
+ >
468
+ Sign in with NetSuite
469
+ </button>
470
+ );
471
+ }
472
+ `;
473
+ }
474
+
475
+ function netsuiteLoginPageTsx() {
476
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite';
477
+
478
+ /**
479
+ * Default unauthenticated landing page. Replaces any password gate.
480
+ * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
481
+ * only call-to-action -- it points to the Worker's /auth/netsuite/start.
482
+ */
483
+ export function LoginPage() {
484
+ return (
485
+ <div className="min-h-screen bg-background text-foreground flex">
486
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
487
+ <div className="text-xl font-semibold">NetSuite App</div>
488
+ <div>
489
+ <h1 className="text-4xl font-bold leading-tight">
490
+ Connect your NetSuite account
491
+ </h1>
492
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
493
+ Sign in with your NetSuite credentials to access your data securely.
494
+ Tokens stay server-side on HelloLeo Cloud -- they never reach the
495
+ browser.
496
+ </p>
497
+ </div>
498
+ <div className="text-xs text-primary-foreground/60">
499
+ Powered by HelloLeo
500
+ </div>
501
+ </div>
502
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
503
+ <div className="w-full max-w-sm space-y-6">
504
+ <div>
505
+ <h2 className="text-2xl font-semibold">Welcome</h2>
506
+ <p className="mt-2 text-sm text-muted-foreground">
507
+ Use the button below to sign in. You'll be redirected to NetSuite
508
+ to authorise access, then back here.
509
+ </p>
510
+ </div>
511
+ <SignInWithNetSuite />
512
+ </div>
513
+ </div>
514
+ </div>
515
+ );
516
+ }
517
+ `;
518
+ }
519
+
520
+ function netsuiteAuthGateTsx() {
521
+ return `import { useEffect, useState } from 'react';
522
+ import { LoginPage } from './LoginPage';
523
+ import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
524
+
525
+ /**
526
+ * Mandatory auth gate for NetSuite projects. Wrap your real App content with
527
+ * <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
528
+ * is the ONLY supported authentication for a NetSuite project (no password
529
+ * gates, no sessionStorage flags, no shared credential).
530
+ *
531
+ * Pattern in src/App.tsx:
532
+ *
533
+ * import { AuthGate } from './components/auth/AuthGate';
534
+ * export default function App() {
535
+ * return (
536
+ * <AuthGate>
537
+ * <Dashboard /> {/* your authenticated content */}
538
+ * </AuthGate>
539
+ * );
540
+ * }
541
+ */
542
+ export function AuthGate({ children }: { children: React.ReactNode }) {
543
+ const [session, setSession] = useState<NetSuiteSession | null>(null);
544
+
545
+ useEffect(() => { getNetSuiteSession().then(setSession); }, []);
546
+
547
+ if (session === null) {
548
+ return (
549
+ <div className="min-h-screen flex items-center justify-center bg-background">
550
+ <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
551
+ </div>
552
+ );
553
+ }
554
+
555
+ if (!session.authenticated) return <LoginPage />;
556
+
557
+ return <>{children}</>;
558
+ }
559
+ `;
560
+ }
561
+
562
+ function netsuiteAppTsx() {
563
+ return `import { AuthGate } from './components/auth/AuthGate';
564
+
565
+ /**
566
+ * NetSuite project entry point. KEEP <AuthGate> as the outermost element --
567
+ * removing it disables real OAuth and exposes NetSuite data to anonymous
568
+ * users. Build your dashboard / pages inside the gate.
569
+ */
570
+ export default function App() {
571
+ return (
572
+ <AuthGate>
573
+ <div className="min-h-screen bg-background text-foreground p-8">
574
+ <h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
575
+ <p className="text-muted-foreground mt-2">
576
+ Replace this placeholder with your dashboard.
577
+ </p>
578
+ </div>
579
+ </AuthGate>
580
+ );
581
+ }
582
+ `;
583
+ }
584
+
229
585
  export const scaffold = tool({
230
586
  description:
231
587
  "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " +
@@ -242,7 +598,9 @@ export const scaffold = tool({
242
598
  .string()
243
599
  .optional()
244
600
  .describe(
245
- 'HSL values for primary color, e.g. "221.2 83.2% 53.3%" (default: blue)',
601
+ "Primary brand color. Prefer raw HSL triplet (no hsl() wrapper, no # prefix), " +
602
+ 'e.g. "221.2 83.2% 53.3%". Hex like "#3b82f6" is also accepted and will ' +
603
+ "be auto-converted. Default: blue.",
246
604
  ),
247
605
  fontFamily: tool.schema
248
606
  .string()
@@ -256,15 +614,36 @@ export const scaffold = tool({
256
614
  .describe(
257
615
  'Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)',
258
616
  ),
617
+ integrations: tool.schema
618
+ .array(tool.schema.string())
619
+ .optional()
620
+ .describe(
621
+ "Connected integration slugs for this project (live MCP list). " +
622
+ 'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' +
623
+ "will add src/lib/netsuite.ts (OAuth client helper), " +
624
+ "src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx " +
625
+ "and a src/App.tsx wrapped in <AuthGate>. OAuth is wired from " +
626
+ "day one. Forbids hardcoded password gates -- the gate IS the " +
627
+ "real flow against the project's Worker.",
628
+ ),
259
629
  },
260
630
  async execute(args, context) {
261
631
  const dir = context.directory;
262
632
  const name = args.name ?? "leo-project";
263
- const primary = args.primaryColor ?? "221.2 83.2% 53.3%";
633
+ const primary = normalizePrimaryColor(
634
+ args.primaryColor ?? "221.2 83.2% 53.3%",
635
+ );
264
636
  const font = args.fontFamily ?? "Inter";
265
637
  const radius = args.borderRadius ?? "0.5rem";
638
+ const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
639
+ const hasNetsuite = integrations.includes("netsuite");
266
640
 
267
641
  await mkdir(path.join(dir, "src", "lib"), { recursive: true });
642
+ if (hasNetsuite) {
643
+ await mkdir(path.join(dir, "src", "components", "auth"), {
644
+ recursive: true,
645
+ });
646
+ }
268
647
 
269
648
  const files: [string, string][] = [
270
649
  ["package.json", packageJson(name)],
@@ -274,24 +653,74 @@ export const scaffold = tool({
274
653
  ["postcss.config.js", postcssConfig()],
275
654
  [path.join("src", "index.css"), indexCss(primary, font, radius)],
276
655
  [path.join("src", "lib", "utils.ts"), utilsTs()],
656
+ [path.join("src", "lib", "proxy.ts"), proxyTs()],
277
657
  [path.join("src", "main.tsx"), mainTsx()],
278
658
  ];
279
659
 
660
+ if (hasNetsuite) {
661
+ files.push(
662
+ [path.join("src", "lib", "netsuite.ts"), netsuiteTs()],
663
+ [
664
+ path.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
665
+ signInButtonTsx(),
666
+ ],
667
+ [
668
+ path.join("src", "components", "auth", "LoginPage.tsx"),
669
+ netsuiteLoginPageTsx(),
670
+ ],
671
+ [
672
+ path.join("src", "components", "auth", "AuthGate.tsx"),
673
+ netsuiteAuthGateTsx(),
674
+ ],
675
+ [path.join("src", "App.tsx"), netsuiteAppTsx()],
676
+ );
677
+ }
678
+
280
679
  const created: string[] = [];
281
680
  for (const [filePath, content] of files) {
282
681
  await Bun.write(path.join(dir, filePath), content);
283
682
  created.push(filePath);
284
683
  }
285
684
 
286
- return [
685
+ const out: string[] = [
287
686
  `Scaffolded ${created.length} files:`,
288
687
  ...created.map((f) => ` - ${f}`),
289
688
  "",
290
689
  "Next: create src/App.tsx with your application code.",
291
- "The design system is ready -- use design tokens (bg-primary, text-foreground, etc.)",
292
- "and create UI components in src/components/ui/.",
293
- "Make sure to update package.json with all packages you need after scaffolding.",
294
- ].join("\n");
690
+ "Design system reference: read the header comment in src/index.css",
691
+ " before adding tokens or styling primitives.",
692
+ "Integration calls: use callIntegration() from src/lib/proxy.ts.",
693
+ " Header comment in that file documents slugs, response unwrapping,",
694
+ " pagination, and frontend-only vs HelloLeo Cloud routing.",
695
+ "Update package.json with any extra packages you need after scaffolding.",
696
+ ];
697
+
698
+ if (hasNetsuite) {
699
+ out.push(
700
+ "",
701
+ "NetSuite project -- auth is already wired:",
702
+ " - App.tsx wraps everything in <AuthGate>. Build your dashboard",
703
+ " INSIDE the gate. NEVER remove the gate or build a password",
704
+ " placeholder -- real OAuth is the only allowed login.",
705
+ " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated",
706
+ " view. Customise the copy / imagery, keep <SignInWithNetSuite/>",
707
+ " as the call-to-action.",
708
+ " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at",
709
+ " /auth/netsuite/start. Deploy the Worker via cloudflare-backend",
710
+ " MCP with routes:",
711
+ " GET /auth/netsuite/start -- redirect to NetSuite OAuth",
712
+ " GET /auth/netsuite/callback -- code -> token exchange",
713
+ " GET /api/me -- current session",
714
+ " POST /auth/netsuite/logout -- clear session",
715
+ " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,",
716
+ " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).",
717
+ " - VITE_WORKER_URL must be set in the project env so the frontend",
718
+ " knows where to send users. The HelloLeo Cloud provisioner",
719
+ " seeds this automatically.",
720
+ );
721
+ }
722
+
723
+ return out.join("\n");
295
724
  },
296
725
  });
297
726