@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 +384 -9
- package/package.json +1 -1
- package/src/tools/scaffold.ts +437 -8
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
"
|
|
12790
|
-
"
|
|
12791
|
-
"
|
|
12792
|
-
|
|
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
package/src/tools/scaffold.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
"
|
|
292
|
-
"
|
|
293
|
-
"
|
|
294
|
-
|
|
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
|
|