@helloleo/plugins 0.1.5 → 0.1.7

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
@@ -12560,7 +12560,8 @@ function packageJson(name) {
12560
12560
  react: "^18.3.1",
12561
12561
  "react-dom": "^18.3.1",
12562
12562
  clsx: "^2.1.1",
12563
- "tailwind-merge": "^2.6.0"
12563
+ "tailwind-merge": "^2.6.0",
12564
+ "lucide-react": "^1.14.0"
12564
12565
  },
12565
12566
  devDependencies: {
12566
12567
  "@types/react": "^18.3.12",
@@ -12661,7 +12662,36 @@ function postcssConfig() {
12661
12662
  }
12662
12663
  function indexCss(primaryColor, fontFamily, borderRadius) {
12663
12664
  const fontValue = `'${fontFamily}', ui-sans-serif, system-ui, sans-serif`;
12664
- 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;
12665
12695
  @tailwind components;
12666
12696
  @tailwind utilities;
12667
12697
 
@@ -12738,6 +12768,134 @@ export function cn(...inputs: ClassValue[]) {
12738
12768
  return twMerge(clsx(inputs))
12739
12769
  }`;
12740
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
+ }
12741
12899
  function mainTsx() {
12742
12900
  return `import React from 'react'
12743
12901
  import ReactDOM from 'react-dom/client'
@@ -12750,21 +12908,112 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
12750
12908
  </React.StrictMode>,
12751
12909
  )`;
12752
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
+ }
12753
12994
  var scaffold = tool({
12754
12995
  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.",
12755
12996
  args: {
12756
12997
  name: tool.schema.string().optional().describe("Project name for package.json (default: leo-project)"),
12757
- primaryColor: tool.schema.string().optional().describe('HSL values for primary color, e.g. "221.2 83.2% 53.3%" (default: blue)'),
12998
+ 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."),
12758
12999
  fontFamily: tool.schema.string().optional().describe('Google Font family name, e.g. "Inter", "Poppins" (default: Inter)'),
12759
- borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)')
13000
+ borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)'),
13001
+ 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) and a " + "src/components/auth/SignInWithNetSuite.tsx button so OAuth is " + "wired from the start. Forbids hardcoded password gates by " + "replacing them with the real flow against the project's Worker.")
12760
13002
  },
12761
13003
  async execute(args, context) {
12762
13004
  const dir = context.directory;
12763
13005
  const name = args.name ?? "leo-project";
12764
- const primary = args.primaryColor ?? "221.2 83.2% 53.3%";
13006
+ const primary = normalizePrimaryColor(args.primaryColor ?? "221.2 83.2% 53.3%");
12765
13007
  const font = args.fontFamily ?? "Inter";
12766
13008
  const radius = args.borderRadius ?? "0.5rem";
13009
+ const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
13010
+ const hasNetsuite = integrations.includes("netsuite");
12767
13011
  await mkdir(path2.join(dir, "src", "lib"), { recursive: true });
13012
+ if (hasNetsuite) {
13013
+ await mkdir(path2.join(dir, "src", "components", "auth"), {
13014
+ recursive: true
13015
+ });
13016
+ }
12768
13017
  const files = [
12769
13018
  ["package.json", packageJson(name)],
12770
13019
  ["index.html", indexHtml(font)],
@@ -12773,21 +13022,36 @@ var scaffold = tool({
12773
13022
  ["postcss.config.js", postcssConfig()],
12774
13023
  [path2.join("src", "index.css"), indexCss(primary, font, radius)],
12775
13024
  [path2.join("src", "lib", "utils.ts"), utilsTs()],
13025
+ [path2.join("src", "lib", "proxy.ts"), proxyTs()],
12776
13026
  [path2.join("src", "main.tsx"), mainTsx()]
12777
13027
  ];
13028
+ if (hasNetsuite) {
13029
+ files.push([path2.join("src", "lib", "netsuite.ts"), netsuiteTs()], [
13030
+ path2.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
13031
+ signInButtonTsx()
13032
+ ]);
13033
+ }
12778
13034
  const created = [];
12779
13035
  for (const [filePath, content] of files) {
12780
13036
  await Bun.write(path2.join(dir, filePath), content);
12781
13037
  created.push(filePath);
12782
13038
  }
12783
- return [
13039
+ const out = [
12784
13040
  `Scaffolded ${created.length} files:`,
12785
13041
  ...created.map((f) => ` - ${f}`),
12786
13042
  "",
12787
13043
  "Next: create src/App.tsx with your application code.",
12788
- "The design system is ready -- use design tokens (bg-primary, text-foreground, etc.)",
12789
- "and create UI components in src/components/ui/."
12790
- ].join(`
13044
+ "Design system reference: read the header comment in src/index.css",
13045
+ " before adding tokens or styling primitives.",
13046
+ "Integration calls: use callIntegration() from src/lib/proxy.ts.",
13047
+ " Header comment in that file documents slugs, response unwrapping,",
13048
+ " pagination, and frontend-only vs HelloLeo Cloud routing.",
13049
+ "Update package.json with any extra packages you need after scaffolding."
13050
+ ];
13051
+ if (hasNetsuite) {
13052
+ out.push("", "NetSuite project detected:", " - Use <SignInWithNetSuite/> from src/components/auth/ as your", " login button. NEVER use a hardcoded password gate.", " - signInWithNetSuite() in src/lib/netsuite.ts redirects to the", " project's Worker at /auth/netsuite/start. The Worker must be", " deployed via the 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.");
13053
+ }
13054
+ return out.join(`
12791
13055
  `);
12792
13056
  }
12793
13057
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -21,6 +21,7 @@ function packageJson(name: string) {
21
21
  "react-dom": "^18.3.1",
22
22
  clsx: "^2.1.1",
23
23
  "tailwind-merge": "^2.6.0",
24
+ "lucide-react": "^1.14.0",
24
25
  },
25
26
  devDependencies: {
26
27
  "@types/react": "^18.3.12",
@@ -133,7 +134,36 @@ function indexCss(
133
134
  borderRadius: string,
134
135
  ) {
135
136
  const fontValue = `'${fontFamily}', ui-sans-serif, system-ui, sans-serif`;
136
- 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;
137
167
  @tailwind components;
138
168
  @tailwind utilities;
139
169
 
@@ -212,6 +242,138 @@ export function cn(...inputs: ClassValue[]) {
212
242
  }`;
213
243
  }
214
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
+
215
377
  function mainTsx() {
216
378
  return `import React from 'react'
217
379
  import ReactDOM from 'react-dom/client'
@@ -225,6 +387,91 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
225
387
  )`;
226
388
  }
227
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
+
228
475
  export const scaffold = tool({
229
476
  description:
230
477
  "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " +
@@ -241,7 +488,9 @@ export const scaffold = tool({
241
488
  .string()
242
489
  .optional()
243
490
  .describe(
244
- 'HSL values for primary color, e.g. "221.2 83.2% 53.3%" (default: blue)',
491
+ "Primary brand color. Prefer raw HSL triplet (no hsl() wrapper, no # prefix), " +
492
+ 'e.g. "221.2 83.2% 53.3%". Hex like "#3b82f6" is also accepted and will ' +
493
+ "be auto-converted. Default: blue.",
245
494
  ),
246
495
  fontFamily: tool.schema
247
496
  .string()
@@ -255,15 +504,35 @@ export const scaffold = tool({
255
504
  .describe(
256
505
  'Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)',
257
506
  ),
507
+ integrations: tool.schema
508
+ .array(tool.schema.string())
509
+ .optional()
510
+ .describe(
511
+ "Connected integration slugs for this project (live MCP list). " +
512
+ 'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' +
513
+ "will add src/lib/netsuite.ts (OAuth client helper) and a " +
514
+ "src/components/auth/SignInWithNetSuite.tsx button so OAuth is " +
515
+ "wired from the start. Forbids hardcoded password gates by " +
516
+ "replacing them with the real flow against the project's Worker.",
517
+ ),
258
518
  },
259
519
  async execute(args, context) {
260
520
  const dir = context.directory;
261
521
  const name = args.name ?? "leo-project";
262
- const primary = args.primaryColor ?? "221.2 83.2% 53.3%";
522
+ const primary = normalizePrimaryColor(
523
+ args.primaryColor ?? "221.2 83.2% 53.3%",
524
+ );
263
525
  const font = args.fontFamily ?? "Inter";
264
526
  const radius = args.borderRadius ?? "0.5rem";
527
+ const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
528
+ const hasNetsuite = integrations.includes("netsuite");
265
529
 
266
530
  await mkdir(path.join(dir, "src", "lib"), { recursive: true });
531
+ if (hasNetsuite) {
532
+ await mkdir(path.join(dir, "src", "components", "auth"), {
533
+ recursive: true,
534
+ });
535
+ }
267
536
 
268
537
  const files: [string, string][] = [
269
538
  ["package.json", packageJson(name)],
@@ -273,23 +542,61 @@ export const scaffold = tool({
273
542
  ["postcss.config.js", postcssConfig()],
274
543
  [path.join("src", "index.css"), indexCss(primary, font, radius)],
275
544
  [path.join("src", "lib", "utils.ts"), utilsTs()],
545
+ [path.join("src", "lib", "proxy.ts"), proxyTs()],
276
546
  [path.join("src", "main.tsx"), mainTsx()],
277
547
  ];
278
548
 
549
+ if (hasNetsuite) {
550
+ files.push(
551
+ [path.join("src", "lib", "netsuite.ts"), netsuiteTs()],
552
+ [
553
+ path.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
554
+ signInButtonTsx(),
555
+ ],
556
+ );
557
+ }
558
+
279
559
  const created: string[] = [];
280
560
  for (const [filePath, content] of files) {
281
561
  await Bun.write(path.join(dir, filePath), content);
282
562
  created.push(filePath);
283
563
  }
284
564
 
285
- return [
565
+ const out: string[] = [
286
566
  `Scaffolded ${created.length} files:`,
287
567
  ...created.map((f) => ` - ${f}`),
288
568
  "",
289
569
  "Next: create src/App.tsx with your application code.",
290
- "The design system is ready -- use design tokens (bg-primary, text-foreground, etc.)",
291
- "and create UI components in src/components/ui/.",
292
- ].join("\n");
570
+ "Design system reference: read the header comment in src/index.css",
571
+ " before adding tokens or styling primitives.",
572
+ "Integration calls: use callIntegration() from src/lib/proxy.ts.",
573
+ " Header comment in that file documents slugs, response unwrapping,",
574
+ " pagination, and frontend-only vs HelloLeo Cloud routing.",
575
+ "Update package.json with any extra packages you need after scaffolding.",
576
+ ];
577
+
578
+ if (hasNetsuite) {
579
+ out.push(
580
+ "",
581
+ "NetSuite project detected:",
582
+ " - Use <SignInWithNetSuite/> from src/components/auth/ as your",
583
+ " login button. NEVER use a hardcoded password gate.",
584
+ " - signInWithNetSuite() in src/lib/netsuite.ts redirects to the",
585
+ " project's Worker at /auth/netsuite/start. The Worker must be",
586
+ " deployed via the cloudflare-backend MCP with routes:",
587
+ " GET /auth/netsuite/start -- redirect to NetSuite OAuth",
588
+ " GET /auth/netsuite/callback -- code -> token exchange",
589
+ " GET /api/me -- current session",
590
+ " POST /auth/netsuite/logout -- clear session",
591
+ " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,",
592
+ " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).",
593
+ " - VITE_WORKER_URL must be set in the project env so the frontend",
594
+ " knows where to send users. The HelloLeo Cloud provisioner",
595
+ " seeds this automatically.",
596
+ );
597
+ }
598
+
599
+ return out.join("\n");
293
600
  },
294
601
  });
295
602