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