@helloleo/plugins 0.1.6 → 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
@@ -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,112 @@ 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
+ }
12754
12994
  var scaffold = tool({
12755
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.",
12756
12996
  args: {
12757
12997
  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)'),
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."),
12759
12999
  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)')
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.")
12761
13002
  },
12762
13003
  async execute(args, context) {
12763
13004
  const dir = context.directory;
12764
13005
  const name = args.name ?? "leo-project";
12765
- const primary = args.primaryColor ?? "221.2 83.2% 53.3%";
13006
+ const primary = normalizePrimaryColor(args.primaryColor ?? "221.2 83.2% 53.3%");
12766
13007
  const font = args.fontFamily ?? "Inter";
12767
13008
  const radius = args.borderRadius ?? "0.5rem";
13009
+ const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
13010
+ const hasNetsuite = integrations.includes("netsuite");
12768
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
+ }
12769
13017
  const files = [
12770
13018
  ["package.json", packageJson(name)],
12771
13019
  ["index.html", indexHtml(font)],
@@ -12774,22 +13022,36 @@ var scaffold = tool({
12774
13022
  ["postcss.config.js", postcssConfig()],
12775
13023
  [path2.join("src", "index.css"), indexCss(primary, font, radius)],
12776
13024
  [path2.join("src", "lib", "utils.ts"), utilsTs()],
13025
+ [path2.join("src", "lib", "proxy.ts"), proxyTs()],
12777
13026
  [path2.join("src", "main.tsx"), mainTsx()]
12778
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
+ }
12779
13034
  const created = [];
12780
13035
  for (const [filePath, content] of files) {
12781
13036
  await Bun.write(path2.join(dir, filePath), content);
12782
13037
  created.push(filePath);
12783
13038
  }
12784
- return [
13039
+ const out = [
12785
13040
  `Scaffolded ${created.length} files:`,
12786
13041
  ...created.map((f) => ` - ${f}`),
12787
13042
  "",
12788
13043
  "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(`
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(`
12793
13055
  `);
12794
13056
  }
12795
13057
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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,91 @@ 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
+
229
475
  export const scaffold = tool({
230
476
  description:
231
477
  "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " +
@@ -242,7 +488,9 @@ export const scaffold = tool({
242
488
  .string()
243
489
  .optional()
244
490
  .describe(
245
- '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.",
246
494
  ),
247
495
  fontFamily: tool.schema
248
496
  .string()
@@ -256,15 +504,35 @@ export const scaffold = tool({
256
504
  .describe(
257
505
  'Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)',
258
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
+ ),
259
518
  },
260
519
  async execute(args, context) {
261
520
  const dir = context.directory;
262
521
  const name = args.name ?? "leo-project";
263
- const primary = args.primaryColor ?? "221.2 83.2% 53.3%";
522
+ const primary = normalizePrimaryColor(
523
+ args.primaryColor ?? "221.2 83.2% 53.3%",
524
+ );
264
525
  const font = args.fontFamily ?? "Inter";
265
526
  const radius = args.borderRadius ?? "0.5rem";
527
+ const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
528
+ const hasNetsuite = integrations.includes("netsuite");
266
529
 
267
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
+ }
268
536
 
269
537
  const files: [string, string][] = [
270
538
  ["package.json", packageJson(name)],
@@ -274,24 +542,61 @@ export const scaffold = tool({
274
542
  ["postcss.config.js", postcssConfig()],
275
543
  [path.join("src", "index.css"), indexCss(primary, font, radius)],
276
544
  [path.join("src", "lib", "utils.ts"), utilsTs()],
545
+ [path.join("src", "lib", "proxy.ts"), proxyTs()],
277
546
  [path.join("src", "main.tsx"), mainTsx()],
278
547
  ];
279
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
+
280
559
  const created: string[] = [];
281
560
  for (const [filePath, content] of files) {
282
561
  await Bun.write(path.join(dir, filePath), content);
283
562
  created.push(filePath);
284
563
  }
285
564
 
286
- return [
565
+ const out: string[] = [
287
566
  `Scaffolded ${created.length} files:`,
288
567
  ...created.map((f) => ` - ${f}`),
289
568
  "",
290
569
  "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");
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");
295
600
  },
296
601
  });
297
602