@helloleo/plugins 0.1.10 → 0.1.11

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
@@ -12544,263 +12544,6 @@ var RemovePlugin = async () => {
12544
12544
  // src/tools/scaffold.ts
12545
12545
  import { mkdir } from "fs/promises";
12546
12546
  import path2 from "path";
12547
-
12548
- // src/tools/scaffold-netsuite.ts
12549
- function netsuiteTs() {
12550
- return `/*
12551
- * NetSuite OAuth client helper (bearer-token-in-fragment).
12552
- *
12553
- * Two NetSuite Integration Records back this:
12554
- * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
12555
- * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
12556
- * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
12557
- * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
12558
- * the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
12559
- * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
12560
- * third-party cookies in the HelloLeo preview iframe.
12561
- *
12562
- * Flow:
12563
- * 1. User clicks "Sign in with NetSuite".
12564
- * 2. Browser navigates to the Worker /auth/netsuite/start.
12565
- * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
12566
- * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
12567
- * 5. Worker exchanges the code, mints a session id, then redirects to
12568
- * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
12569
- * 6. This module reads the fragment on load, stashes the sid in
12570
- * sessionStorage, and sends it as Authorization: Bearer on every
12571
- * Worker call.
12572
- *
12573
- * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
12574
- * authoritative auth state -- the only acceptable check is /api/me on the
12575
- * Worker. sessionStorage here is just a cache of the bearer token.
12576
- */
12577
-
12578
- export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
12579
- const TOKEN_KEY = 'nssid';
12580
-
12581
- // Capture-and-strip the #nssid=... fragment as early as possible so URLs
12582
- // shown to the user stay clean. Runs once at module load.
12583
- (function bootstrapTokenFromHash() {
12584
- if (typeof window === 'undefined') return;
12585
- const hash = window.location.hash.replace(/^#/, '');
12586
- if (!hash) return;
12587
- const params = new URLSearchParams(hash);
12588
- const sid = params.get(TOKEN_KEY);
12589
- if (sid) {
12590
- try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
12591
- params.delete(TOKEN_KEY);
12592
- const remaining = params.toString();
12593
- const newHash = remaining ? '#' + remaining : '';
12594
- window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
12595
- }
12596
- })();
12597
-
12598
- export function getSessionToken(): string | null {
12599
- try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
12600
- }
12601
-
12602
- export function signInWithNetSuite(returnTo: string = window.location.href) {
12603
- if (!WORKER_URL) {
12604
- throw new Error(
12605
- 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
12606
- );
12607
- }
12608
- const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
12609
- url.searchParams.set('return_to', returnTo);
12610
- // The app usually renders inside the HelloLeo preview iframe. NetSuite's
12611
- // OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
12612
- // Worker (which 302s to NetSuite) ends up loading a page the browser
12613
- // refuses to render -- looks like "nothing happens" to the user. Navigate
12614
- // the top frame instead so the OAuth flow runs at the top level and the
12615
- // callback redirect lands on the same top frame, which then reads the
12616
- // #nssid= fragment. Fallback to same-frame navigation if the top window
12617
- // is cross-origin and can't be reassigned.
12618
- try {
12619
- if (window.top && window.top !== window) {
12620
- window.top.location.assign(url.toString());
12621
- return;
12622
- }
12623
- } catch {
12624
- /* top window not accessible \u2014 fall through to same-frame navigation */
12625
- }
12626
- window.location.assign(url.toString());
12627
- }
12628
-
12629
- export interface NetSuiteSession {
12630
- authenticated: boolean;
12631
- account_id?: string;
12632
- user_id?: string;
12633
- email?: string;
12634
- }
12635
-
12636
- export async function getNetSuiteSession(): Promise<NetSuiteSession> {
12637
- if (!WORKER_URL) return { authenticated: false };
12638
- const sid = getSessionToken();
12639
- if (!sid) return { authenticated: false };
12640
- const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
12641
- headers: { Authorization: 'Bearer ' + sid },
12642
- });
12643
- if (!res.ok) return { authenticated: false };
12644
- const data = await res.json();
12645
- return { authenticated: Boolean(data?.account_id), ...data };
12646
- }
12647
-
12648
- export async function signOutNetSuite(): Promise<void> {
12649
- if (!WORKER_URL) return;
12650
- const sid = getSessionToken();
12651
- if (sid) {
12652
- try {
12653
- await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
12654
- method: 'POST',
12655
- headers: { Authorization: 'Bearer ' + sid },
12656
- });
12657
- } catch {}
12658
- }
12659
- try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
12660
- window.location.reload();
12661
- }
12662
-
12663
- /**
12664
- * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
12665
- * Always go through this instead of bare fetch -- it injects the bearer
12666
- * token and routes through the Worker so NetSuite tokens stay server-side.
12667
- */
12668
- export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
12669
- if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
12670
- const url = pathOrUrl.startsWith('http')
12671
- ? pathOrUrl
12672
- : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
12673
- const sid = getSessionToken();
12674
- const headers = new Headers(init.headers ?? {});
12675
- if (sid) headers.set('Authorization', 'Bearer ' + sid);
12676
- return fetch(url, { ...init, headers });
12677
- }
12678
- `;
12679
- }
12680
- function signInButtonTsx() {
12681
- return `import { signInWithNetSuite } from '../../lib/netsuite';
12682
-
12683
- export function SignInWithNetSuite() {
12684
- return (
12685
- <button
12686
- onClick={() => signInWithNetSuite()}
12687
- 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"
12688
- >
12689
- Sign in with NetSuite
12690
- </button>
12691
- );
12692
- }
12693
- `;
12694
- }
12695
- function netsuiteLoginPageTsx() {
12696
- return `import { SignInWithNetSuite } from './SignInWithNetSuite';
12697
-
12698
- /**
12699
- * Default unauthenticated landing page. Replaces any password gate.
12700
- * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
12701
- * only call-to-action -- it points to the Worker's /auth/netsuite/start.
12702
- */
12703
- export function LoginPage() {
12704
- return (
12705
- <div className="min-h-screen bg-background text-foreground flex">
12706
- <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
12707
- <div className="text-xl font-semibold">NetSuite App</div>
12708
- <div>
12709
- <h1 className="text-4xl font-bold leading-tight">
12710
- Connect your NetSuite account
12711
- </h1>
12712
- <p className="mt-4 text-primary-foreground/80 max-w-md">
12713
- Sign in with your NetSuite credentials to access your data securely.
12714
- Tokens stay server-side on HelloLeo Cloud -- they never reach the
12715
- browser.
12716
- </p>
12717
- </div>
12718
- <div className="text-xs text-primary-foreground/60">
12719
- Powered by HelloLeo
12720
- </div>
12721
- </div>
12722
- <div className="flex-1 flex items-center justify-center px-6 py-12">
12723
- <div className="w-full max-w-sm space-y-6">
12724
- <div>
12725
- <h2 className="text-2xl font-semibold">Welcome</h2>
12726
- <p className="mt-2 text-sm text-muted-foreground">
12727
- Use the button below to sign in. You'll be redirected to NetSuite
12728
- to authorise access, then back here.
12729
- </p>
12730
- </div>
12731
- <SignInWithNetSuite />
12732
- </div>
12733
- </div>
12734
- </div>
12735
- );
12736
- }
12737
- `;
12738
- }
12739
- function netsuiteAuthGateTsx() {
12740
- return `import { useEffect, useState } from 'react';
12741
- import { LoginPage } from './LoginPage';
12742
- import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
12743
-
12744
- /**
12745
- * Mandatory auth gate for NetSuite projects. Wrap your real App content with
12746
- * <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
12747
- * is the ONLY supported authentication for a NetSuite project (no password
12748
- * gates, no sessionStorage flags, no shared credential).
12749
- *
12750
- * Pattern in src/App.tsx:
12751
- *
12752
- * import { AuthGate } from './components/auth/AuthGate';
12753
- * export default function App() {
12754
- * return (
12755
- * <AuthGate>
12756
- * <Dashboard /> // your authenticated content
12757
- * </AuthGate>
12758
- * );
12759
- * }
12760
- */
12761
- export function AuthGate({ children }: { children: React.ReactNode }) {
12762
- const [session, setSession] = useState<NetSuiteSession | null>(null);
12763
-
12764
- useEffect(() => { getNetSuiteSession().then(setSession); }, []);
12765
-
12766
- if (session === null) {
12767
- return (
12768
- <div className="min-h-screen flex items-center justify-center bg-background">
12769
- <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
12770
- </div>
12771
- );
12772
- }
12773
-
12774
- if (!session.authenticated) return <LoginPage />;
12775
-
12776
- return <>{children}</>;
12777
- }
12778
- `;
12779
- }
12780
- function netsuiteAppTsx() {
12781
- return `import { AuthGate } from './components/auth/AuthGate';
12782
-
12783
- /**
12784
- * NetSuite project entry point. KEEP <AuthGate> as the outermost element --
12785
- * removing it disables real OAuth and exposes NetSuite data to anonymous
12786
- * users. Build your dashboard / pages inside the gate.
12787
- */
12788
- export default function App() {
12789
- return (
12790
- <AuthGate>
12791
- <div className="min-h-screen bg-background text-foreground p-8">
12792
- <h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
12793
- <p className="text-muted-foreground mt-2">
12794
- Replace this placeholder with your dashboard.
12795
- </p>
12796
- </div>
12797
- </AuthGate>
12798
- );
12799
- }
12800
- `;
12801
- }
12802
-
12803
- // src/tools/scaffold.ts
12804
12547
  function packageJson(name) {
12805
12548
  return JSON.stringify({
12806
12549
  name,
@@ -13165,58 +12908,41 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
13165
12908
  </React.StrictMode>,
13166
12909
  )`;
13167
12910
  }
12911
+ async function writeBaseScaffold(opts, dir) {
12912
+ const name = opts.name ?? "leo-project";
12913
+ const primary = normalizePrimaryColor(opts.primaryColor ?? "221.2 83.2% 53.3%");
12914
+ const font = opts.fontFamily ?? "Inter";
12915
+ const radius = opts.borderRadius ?? "0.5rem";
12916
+ await mkdir(path2.join(dir, "src", "lib"), { recursive: true });
12917
+ const files = [
12918
+ ["package.json", packageJson(name)],
12919
+ ["index.html", indexHtml(font)],
12920
+ ["vite.config.js", viteConfig()],
12921
+ ["tailwind.config.js", tailwindConfig()],
12922
+ ["postcss.config.js", postcssConfig()],
12923
+ [path2.join("src", "index.css"), indexCss(primary, font, radius)],
12924
+ [path2.join("src", "lib", "utils.ts"), utilsTs()],
12925
+ [path2.join("src", "lib", "proxy.ts"), proxyTs()],
12926
+ [path2.join("src", "main.tsx"), mainTsx()]
12927
+ ];
12928
+ const created = [];
12929
+ for (const [filePath, content] of files) {
12930
+ await Bun.write(path2.join(dir, filePath), content);
12931
+ created.push(filePath);
12932
+ }
12933
+ return created;
12934
+ }
13168
12935
  var scaffold = tool({
13169
- 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.",
12936
+ 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), src/lib/proxy.ts, and src/main.tsx. " + "Call this FIRST for any new project before writing App.tsx and components. " + "For NetSuite projects (NetSuite MCP in the live list), call `setup_netsuite` " + "INSTEAD -- it bundles base scaffold + OAuth wiring in one call. Do not call both.",
13170
12937
  args: {
13171
12938
  name: tool.schema.string().optional().describe("Project name for package.json (default: leo-project)"),
13172
12939
  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."),
13173
12940
  fontFamily: tool.schema.string().optional().describe('Google Font family name, e.g. "Inter", "Poppins" (default: Inter)'),
13174
- borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)'),
13175
- 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.")
12941
+ borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)')
13176
12942
  },
13177
12943
  async execute(args, context) {
13178
- const dir = context.directory;
13179
- const name = args.name ?? "leo-project";
13180
- const primary = normalizePrimaryColor(args.primaryColor ?? "221.2 83.2% 53.3%");
13181
- const font = args.fontFamily ?? "Inter";
13182
- const radius = args.borderRadius ?? "0.5rem";
13183
- const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
13184
- const hasNetsuite = integrations.includes("netsuite");
13185
- await mkdir(path2.join(dir, "src", "lib"), { recursive: true });
13186
- if (hasNetsuite) {
13187
- await mkdir(path2.join(dir, "src", "components", "auth"), {
13188
- recursive: true
13189
- });
13190
- }
13191
- const files = [
13192
- ["package.json", packageJson(name)],
13193
- ["index.html", indexHtml(font)],
13194
- ["vite.config.js", viteConfig()],
13195
- ["tailwind.config.js", tailwindConfig()],
13196
- ["postcss.config.js", postcssConfig()],
13197
- [path2.join("src", "index.css"), indexCss(primary, font, radius)],
13198
- [path2.join("src", "lib", "utils.ts"), utilsTs()],
13199
- [path2.join("src", "lib", "proxy.ts"), proxyTs()],
13200
- [path2.join("src", "main.tsx"), mainTsx()]
13201
- ];
13202
- if (hasNetsuite) {
13203
- files.push([path2.join("src", "lib", "netsuite.ts"), netsuiteTs()], [
13204
- path2.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
13205
- signInButtonTsx()
13206
- ], [
13207
- path2.join("src", "components", "auth", "LoginPage.tsx"),
13208
- netsuiteLoginPageTsx()
13209
- ], [
13210
- path2.join("src", "components", "auth", "AuthGate.tsx"),
13211
- netsuiteAuthGateTsx()
13212
- ], [path2.join("src", "App.tsx"), netsuiteAppTsx()]);
13213
- }
13214
- const created = [];
13215
- for (const [filePath, content] of files) {
13216
- await Bun.write(path2.join(dir, filePath), content);
13217
- created.push(filePath);
13218
- }
13219
- const out = [
12944
+ const created = await writeBaseScaffold(args, context.directory);
12945
+ return [
13220
12946
  `Scaffolded ${created.length} files:`,
13221
12947
  ...created.map((f) => ` - ${f}`),
13222
12948
  "",
@@ -13227,11 +12953,7 @@ var scaffold = tool({
13227
12953
  " Header comment in that file documents slugs, response unwrapping,",
13228
12954
  " pagination, and frontend-only vs HelloLeo Cloud routing.",
13229
12955
  "Update package.json with any extra packages you need after scaffolding."
13230
- ];
13231
- if (hasNetsuite) {
13232
- 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.");
13233
- }
13234
- return out.join(`
12956
+ ].join(`
13235
12957
  `);
13236
12958
  }
13237
12959
  });
@@ -13240,7 +12962,329 @@ var ScaffoldPlugin = async () => {
13240
12962
  tool: { scaffold }
13241
12963
  };
13242
12964
  };
12965
+ // src/tools/setup-netsuite.ts
12966
+ import { mkdir as mkdir2 } from "fs/promises";
12967
+ import path3 from "path";
12968
+ function netsuiteTs() {
12969
+ return `/*
12970
+ * NetSuite OAuth client helper (bearer-token-in-fragment).
12971
+ *
12972
+ * Two NetSuite Integration Records back this:
12973
+ * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
12974
+ * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
12975
+ * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
12976
+ * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
12977
+ * the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
12978
+ * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
12979
+ * third-party cookies in the HelloLeo preview iframe.
12980
+ *
12981
+ * Flow:
12982
+ * 1. User clicks "Sign in with NetSuite".
12983
+ * 2. Browser navigates to the Worker /auth/netsuite/start.
12984
+ * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
12985
+ * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
12986
+ * 5. Worker exchanges the code, mints a session id, then redirects to
12987
+ * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
12988
+ * 6. This module reads the fragment on load, stashes the sid in
12989
+ * sessionStorage, and sends it as Authorization: Bearer on every
12990
+ * Worker call.
12991
+ *
12992
+ * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
12993
+ * authoritative auth state -- the only acceptable check is /api/me on the
12994
+ * Worker. sessionStorage here is just a cache of the bearer token.
12995
+ */
12996
+
12997
+ export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
12998
+ const TOKEN_KEY = 'nssid';
12999
+
13000
+ // Capture-and-strip the #nssid=... fragment as early as possible so URLs
13001
+ // shown to the user stay clean. Runs once at module load.
13002
+ (function bootstrapTokenFromHash() {
13003
+ if (typeof window === 'undefined') return;
13004
+ const hash = window.location.hash.replace(/^#/, '');
13005
+ if (!hash) return;
13006
+ const params = new URLSearchParams(hash);
13007
+ const sid = params.get(TOKEN_KEY);
13008
+ if (sid) {
13009
+ try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
13010
+ params.delete(TOKEN_KEY);
13011
+ const remaining = params.toString();
13012
+ const newHash = remaining ? '#' + remaining : '';
13013
+ window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
13014
+ }
13015
+ })();
13016
+
13017
+ export function getSessionToken(): string | null {
13018
+ try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
13019
+ }
13020
+
13021
+ export function signInWithNetSuite(returnTo: string = window.location.href) {
13022
+ if (!WORKER_URL) {
13023
+ throw new Error(
13024
+ 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
13025
+ );
13026
+ }
13027
+ const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
13028
+ url.searchParams.set('return_to', returnTo);
13029
+ // The app usually renders inside the HelloLeo preview iframe. NetSuite's
13030
+ // OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
13031
+ // Worker (which 302s to NetSuite) ends up loading a page the browser
13032
+ // refuses to render -- looks like "nothing happens" to the user. Navigate
13033
+ // the top frame instead so the OAuth flow runs at the top level and the
13034
+ // callback redirect lands on the same top frame, which then reads the
13035
+ // #nssid= fragment. Fallback to same-frame navigation if the top window
13036
+ // is cross-origin and can't be reassigned.
13037
+ try {
13038
+ if (window.top && window.top !== window) {
13039
+ window.top.location.assign(url.toString());
13040
+ return;
13041
+ }
13042
+ } catch {
13043
+ /* top window not accessible \u2014 fall through to same-frame navigation */
13044
+ }
13045
+ window.location.assign(url.toString());
13046
+ }
13047
+
13048
+ export interface NetSuiteSession {
13049
+ authenticated: boolean;
13050
+ account_id?: string;
13051
+ user_id?: string;
13052
+ email?: string;
13053
+ }
13054
+
13055
+ export async function getNetSuiteSession(): Promise<NetSuiteSession> {
13056
+ if (!WORKER_URL) return { authenticated: false };
13057
+ const sid = getSessionToken();
13058
+ if (!sid) return { authenticated: false };
13059
+ const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
13060
+ headers: { Authorization: 'Bearer ' + sid },
13061
+ });
13062
+ if (!res.ok) return { authenticated: false };
13063
+ const data = await res.json();
13064
+ return { authenticated: Boolean(data?.account_id), ...data };
13065
+ }
13066
+
13067
+ export async function signOutNetSuite(): Promise<void> {
13068
+ if (!WORKER_URL) return;
13069
+ const sid = getSessionToken();
13070
+ if (sid) {
13071
+ try {
13072
+ await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
13073
+ method: 'POST',
13074
+ headers: { Authorization: 'Bearer ' + sid },
13075
+ });
13076
+ } catch {}
13077
+ }
13078
+ try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
13079
+ window.location.reload();
13080
+ }
13081
+
13082
+ /**
13083
+ * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
13084
+ * Always go through this instead of bare fetch -- it injects the bearer
13085
+ * token and routes through the Worker so NetSuite tokens stay server-side.
13086
+ */
13087
+ export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
13088
+ if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
13089
+ const url = pathOrUrl.startsWith('http')
13090
+ ? pathOrUrl
13091
+ : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
13092
+ const sid = getSessionToken();
13093
+ const headers = new Headers(init.headers ?? {});
13094
+ if (sid) headers.set('Authorization', 'Bearer ' + sid);
13095
+ return fetch(url, { ...init, headers });
13096
+ }
13097
+ `;
13098
+ }
13099
+ function signInButtonTsx() {
13100
+ return `import { signInWithNetSuite } from '../../lib/netsuite';
13101
+
13102
+ export function SignInWithNetSuite() {
13103
+ return (
13104
+ <button
13105
+ onClick={() => signInWithNetSuite()}
13106
+ 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"
13107
+ >
13108
+ Sign in with NetSuite
13109
+ </button>
13110
+ );
13111
+ }
13112
+ `;
13113
+ }
13114
+ function netsuiteLoginPageTsx() {
13115
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite';
13116
+
13117
+ /**
13118
+ * Default unauthenticated landing page. Replaces any password gate.
13119
+ * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
13120
+ * only call-to-action -- it points to the Worker's /auth/netsuite/start.
13121
+ */
13122
+ export function LoginPage() {
13123
+ return (
13124
+ <div className="min-h-screen bg-background text-foreground flex">
13125
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
13126
+ <div className="text-xl font-semibold">NetSuite App</div>
13127
+ <div>
13128
+ <h1 className="text-4xl font-bold leading-tight">
13129
+ Connect your NetSuite account
13130
+ </h1>
13131
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
13132
+ Sign in with your NetSuite credentials to access your data securely.
13133
+ Tokens stay server-side on HelloLeo Cloud -- they never reach the
13134
+ browser.
13135
+ </p>
13136
+ </div>
13137
+ <div className="text-xs text-primary-foreground/60">
13138
+ Powered by HelloLeo
13139
+ </div>
13140
+ </div>
13141
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
13142
+ <div className="w-full max-w-sm space-y-6">
13143
+ <div>
13144
+ <h2 className="text-2xl font-semibold">Welcome</h2>
13145
+ <p className="mt-2 text-sm text-muted-foreground">
13146
+ Use the button below to sign in. You'll be redirected to NetSuite
13147
+ to authorise access, then back here.
13148
+ </p>
13149
+ </div>
13150
+ <SignInWithNetSuite />
13151
+ </div>
13152
+ </div>
13153
+ </div>
13154
+ );
13155
+ }
13156
+ `;
13157
+ }
13158
+ function netsuiteAuthGateTsx() {
13159
+ return `import { useEffect, useState } from 'react';
13160
+ import { LoginPage } from './LoginPage';
13161
+ import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
13162
+
13163
+ /**
13164
+ * Mandatory auth gate for NetSuite projects. Wrap your real App content with
13165
+ * <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
13166
+ * is the ONLY supported authentication for a NetSuite project (no password
13167
+ * gates, no sessionStorage flags, no shared credential).
13168
+ *
13169
+ * Pattern in src/App.tsx:
13170
+ *
13171
+ * import { AuthGate } from './components/auth/AuthGate';
13172
+ * export default function App() {
13173
+ * return (
13174
+ * <AuthGate>
13175
+ * <Dashboard /> // your authenticated content
13176
+ * </AuthGate>
13177
+ * );
13178
+ * }
13179
+ */
13180
+ export function AuthGate({ children }: { children: React.ReactNode }) {
13181
+ const [session, setSession] = useState<NetSuiteSession | null>(null);
13182
+
13183
+ useEffect(() => { getNetSuiteSession().then(setSession); }, []);
13184
+
13185
+ if (session === null) {
13186
+ return (
13187
+ <div className="min-h-screen flex items-center justify-center bg-background">
13188
+ <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
13189
+ </div>
13190
+ );
13191
+ }
13192
+
13193
+ if (!session.authenticated) return <LoginPage />;
13194
+
13195
+ return <>{children}</>;
13196
+ }
13197
+ `;
13198
+ }
13199
+ function netsuiteAppTsx() {
13200
+ return `import { AuthGate } from './components/auth/AuthGate';
13201
+
13202
+ /**
13203
+ * NetSuite project entry point. KEEP <AuthGate> as the outermost element --
13204
+ * removing it disables real OAuth and exposes NetSuite data to anonymous
13205
+ * users. Build your dashboard / pages inside the gate.
13206
+ */
13207
+ export default function App() {
13208
+ return (
13209
+ <AuthGate>
13210
+ <div className="min-h-screen bg-background text-foreground p-8">
13211
+ <h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
13212
+ <p className="text-muted-foreground mt-2">
13213
+ Replace this placeholder with your dashboard.
13214
+ </p>
13215
+ </div>
13216
+ </AuthGate>
13217
+ );
13218
+ }
13219
+ `;
13220
+ }
13221
+ var setupNetsuite = tool({
13222
+ description: "Bootstrap a NetSuite-backed React + Vite + Tailwind project in one call. " + "Writes the full HelloLeo base scaffold AND the NetSuite OAuth wiring " + "(src/lib/netsuite.ts, src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx, " + "src/App.tsx wrapped in <AuthGate>). " + "Use this INSTEAD of `scaffold` for any project that needs NetSuite. " + "Uses default theme (Inter font, blue primary, 0.5rem radius) -- edit " + "src/index.css afterwards to customise. Real OAuth is wired from day " + "one -- never replace the gate with a password placeholder.",
13223
+ args: {},
13224
+ async execute(_args, context) {
13225
+ const dir = context.directory;
13226
+ const base = await writeBaseScaffold({}, dir);
13227
+ await mkdir2(path3.join(dir, "src", "components", "auth"), {
13228
+ recursive: true
13229
+ });
13230
+ const nsFiles = [
13231
+ [path3.join("src", "lib", "netsuite.ts"), netsuiteTs()],
13232
+ [
13233
+ path3.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
13234
+ signInButtonTsx()
13235
+ ],
13236
+ [
13237
+ path3.join("src", "components", "auth", "LoginPage.tsx"),
13238
+ netsuiteLoginPageTsx()
13239
+ ],
13240
+ [
13241
+ path3.join("src", "components", "auth", "AuthGate.tsx"),
13242
+ netsuiteAuthGateTsx()
13243
+ ],
13244
+ [path3.join("src", "App.tsx"), netsuiteAppTsx()]
13245
+ ];
13246
+ const nsCreated = [];
13247
+ for (const [filePath, content] of nsFiles) {
13248
+ await Bun.write(path3.join(dir, filePath), content);
13249
+ nsCreated.push(filePath);
13250
+ }
13251
+ const all = [...base, ...nsCreated];
13252
+ return [
13253
+ `Scaffolded ${all.length} files (base + NetSuite OAuth):`,
13254
+ ...all.map((f) => ` - ${f}`),
13255
+ "",
13256
+ "Next: build your dashboard INSIDE <AuthGate> in src/App.tsx.",
13257
+ "Design system reference: read the header comment in src/index.css",
13258
+ " before adding tokens or styling primitives.",
13259
+ "",
13260
+ "NetSuite auth -- already wired:",
13261
+ " - App.tsx wraps everything in <AuthGate>. NEVER remove the gate",
13262
+ " or build a password placeholder -- real OAuth is the only login.",
13263
+ " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated",
13264
+ " view. Customise the copy / imagery, keep <SignInWithNetSuite/>",
13265
+ " as the call-to-action.",
13266
+ " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at",
13267
+ " /auth/netsuite/start. Deploy the Worker via cloudflare-backend",
13268
+ " MCP with routes:",
13269
+ " GET /auth/netsuite/start -- redirect to NetSuite OAuth",
13270
+ " GET /auth/netsuite/callback -- code -> token exchange",
13271
+ " GET /api/me -- current session",
13272
+ " POST /auth/netsuite/logout -- clear session",
13273
+ " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,",
13274
+ " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).",
13275
+ " - VITE_WORKER_URL must be set so the frontend knows where to",
13276
+ " send users. The HelloLeo Cloud provisioner seeds this."
13277
+ ].join(`
13278
+ `);
13279
+ }
13280
+ });
13281
+ var SetupNetsuitePlugin = async () => {
13282
+ return {
13283
+ tool: { setup_netsuite: setupNetsuite }
13284
+ };
13285
+ };
13243
13286
  export {
13287
+ SetupNetsuitePlugin,
13244
13288
  ScaffoldPlugin,
13245
13289
  RemovePlugin,
13246
13290
  InspectHttpPlugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { InspectHttpPlugin } from "./tools/inspect-http.ts";
2
2
  export { RemovePlugin } from "./tools/remove.ts";
3
3
  export { ScaffoldPlugin } from "./tools/scaffold.ts";
4
+ export { SetupNetsuitePlugin } from "./tools/setup-netsuite.ts";
4
5
  // export { CreatePlanPlugin } from "./tools/create-plan.ts";
5
6
  // export { ListPlansPlugin } from "./tools/list-plans.ts";
6
7
  // export { ModifyPlanPlugin } from "./tools/modify-plan.ts";
@@ -2,13 +2,6 @@ import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { Plugin } from "@opencode-ai/plugin";
4
4
  import { tool } from "@opencode-ai/plugin";
5
- import {
6
- netsuiteAppTsx,
7
- netsuiteAuthGateTsx,
8
- netsuiteLoginPageTsx,
9
- netsuiteTs,
10
- signInButtonTsx,
11
- } from "./scaffold-netsuite";
12
5
 
13
6
  function packageJson(name: string) {
14
7
  return JSON.stringify(
@@ -394,13 +387,60 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
394
387
  )`;
395
388
  }
396
389
 
390
+ export interface BaseScaffoldOptions {
391
+ name?: string;
392
+ primaryColor?: string;
393
+ fontFamily?: string;
394
+ borderRadius?: string;
395
+ }
396
+
397
+ /**
398
+ * Write the 9 base scaffold files (no integrations). Shared between
399
+ * `scaffold` (frontend-only) and `setup_netsuite` (base + OAuth files).
400
+ * Returns the list of relative file paths written.
401
+ */
402
+ export async function writeBaseScaffold(
403
+ opts: BaseScaffoldOptions,
404
+ dir: string,
405
+ ): Promise<string[]> {
406
+ const name = opts.name ?? "leo-project";
407
+ const primary = normalizePrimaryColor(
408
+ opts.primaryColor ?? "221.2 83.2% 53.3%",
409
+ );
410
+ const font = opts.fontFamily ?? "Inter";
411
+ const radius = opts.borderRadius ?? "0.5rem";
412
+
413
+ await mkdir(path.join(dir, "src", "lib"), { recursive: true });
414
+
415
+ const files: [string, string][] = [
416
+ ["package.json", packageJson(name)],
417
+ ["index.html", indexHtml(font)],
418
+ ["vite.config.js", viteConfig()],
419
+ ["tailwind.config.js", tailwindConfig()],
420
+ ["postcss.config.js", postcssConfig()],
421
+ [path.join("src", "index.css"), indexCss(primary, font, radius)],
422
+ [path.join("src", "lib", "utils.ts"), utilsTs()],
423
+ [path.join("src", "lib", "proxy.ts"), proxyTs()],
424
+ [path.join("src", "main.tsx"), mainTsx()],
425
+ ];
426
+
427
+ const created: string[] = [];
428
+ for (const [filePath, content] of files) {
429
+ await Bun.write(path.join(dir, filePath), content);
430
+ created.push(filePath);
431
+ }
432
+ return created;
433
+ }
434
+
397
435
  export const scaffold = tool({
398
436
  description:
399
437
  "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " +
400
438
  "Creates all mandatory boilerplate: package.json, index.html (with Google Fonts), " +
401
439
  "vite.config.js, tailwind.config.js, postcss.config.js, src/index.css (CSS variables " +
402
- "light+dark), src/lib/utils.ts (cn helper), and src/main.tsx. " +
403
- "Call this FIRST for any new project before writing App.tsx and components.",
440
+ "light+dark), src/lib/utils.ts (cn helper), src/lib/proxy.ts, and src/main.tsx. " +
441
+ "Call this FIRST for any new project before writing App.tsx and components. " +
442
+ "For NetSuite projects (NetSuite MCP in the live list), call `setup_netsuite` " +
443
+ "INSTEAD -- it bundles base scaffold + OAuth wiring in one call. Do not call both.",
404
444
  args: {
405
445
  name: tool.schema
406
446
  .string()
@@ -426,75 +466,10 @@ export const scaffold = tool({
426
466
  .describe(
427
467
  'Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)',
428
468
  ),
429
- integrations: tool.schema
430
- .array(tool.schema.string())
431
- .optional()
432
- .describe(
433
- "Connected integration slugs for this project (live MCP list). " +
434
- 'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' +
435
- "will add src/lib/netsuite.ts (OAuth client helper), " +
436
- "src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx " +
437
- "and a src/App.tsx wrapped in <AuthGate>. OAuth is wired from " +
438
- "day one. Forbids hardcoded password gates -- the gate IS the " +
439
- "real flow against the project's Worker.",
440
- ),
441
469
  },
442
470
  async execute(args, context) {
443
- const dir = context.directory;
444
- const name = args.name ?? "leo-project";
445
- const primary = normalizePrimaryColor(
446
- args.primaryColor ?? "221.2 83.2% 53.3%",
447
- );
448
- const font = args.fontFamily ?? "Inter";
449
- const radius = args.borderRadius ?? "0.5rem";
450
- const integrations = (args.integrations ?? []).map((s) => s.toLowerCase());
451
- const hasNetsuite = integrations.includes("netsuite");
452
-
453
- await mkdir(path.join(dir, "src", "lib"), { recursive: true });
454
- if (hasNetsuite) {
455
- await mkdir(path.join(dir, "src", "components", "auth"), {
456
- recursive: true,
457
- });
458
- }
459
-
460
- const files: [string, string][] = [
461
- ["package.json", packageJson(name)],
462
- ["index.html", indexHtml(font)],
463
- ["vite.config.js", viteConfig()],
464
- ["tailwind.config.js", tailwindConfig()],
465
- ["postcss.config.js", postcssConfig()],
466
- [path.join("src", "index.css"), indexCss(primary, font, radius)],
467
- [path.join("src", "lib", "utils.ts"), utilsTs()],
468
- [path.join("src", "lib", "proxy.ts"), proxyTs()],
469
- [path.join("src", "main.tsx"), mainTsx()],
470
- ];
471
-
472
- if (hasNetsuite) {
473
- files.push(
474
- [path.join("src", "lib", "netsuite.ts"), netsuiteTs()],
475
- [
476
- path.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
477
- signInButtonTsx(),
478
- ],
479
- [
480
- path.join("src", "components", "auth", "LoginPage.tsx"),
481
- netsuiteLoginPageTsx(),
482
- ],
483
- [
484
- path.join("src", "components", "auth", "AuthGate.tsx"),
485
- netsuiteAuthGateTsx(),
486
- ],
487
- [path.join("src", "App.tsx"), netsuiteAppTsx()],
488
- );
489
- }
490
-
491
- const created: string[] = [];
492
- for (const [filePath, content] of files) {
493
- await Bun.write(path.join(dir, filePath), content);
494
- created.push(filePath);
495
- }
496
-
497
- const out: string[] = [
471
+ const created = await writeBaseScaffold(args, context.directory);
472
+ return [
498
473
  `Scaffolded ${created.length} files:`,
499
474
  ...created.map((f) => ` - ${f}`),
500
475
  "",
@@ -505,34 +480,7 @@ export const scaffold = tool({
505
480
  " Header comment in that file documents slugs, response unwrapping,",
506
481
  " pagination, and frontend-only vs HelloLeo Cloud routing.",
507
482
  "Update package.json with any extra packages you need after scaffolding.",
508
- ];
509
-
510
- if (hasNetsuite) {
511
- out.push(
512
- "",
513
- "NetSuite project -- auth is already wired:",
514
- " - App.tsx wraps everything in <AuthGate>. Build your dashboard",
515
- " INSIDE the gate. NEVER remove the gate or build a password",
516
- " placeholder -- real OAuth is the only allowed login.",
517
- " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated",
518
- " view. Customise the copy / imagery, keep <SignInWithNetSuite/>",
519
- " as the call-to-action.",
520
- " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at",
521
- " /auth/netsuite/start. Deploy the Worker via cloudflare-backend",
522
- " MCP with routes:",
523
- " GET /auth/netsuite/start -- redirect to NetSuite OAuth",
524
- " GET /auth/netsuite/callback -- code -> token exchange",
525
- " GET /api/me -- current session",
526
- " POST /auth/netsuite/logout -- clear session",
527
- " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,",
528
- " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).",
529
- " - VITE_WORKER_URL must be set in the project env so the frontend",
530
- " knows where to send users. The HelloLeo Cloud provisioner",
531
- " seeds this automatically.",
532
- );
533
- }
534
-
535
- return out.join("\n");
483
+ ].join("\n");
536
484
  },
537
485
  });
538
486
 
@@ -1,15 +1,22 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Plugin } from "@opencode-ai/plugin";
4
+ import { tool } from "@opencode-ai/plugin";
5
+ import { writeBaseScaffold } from "./scaffold.ts";
6
+
1
7
  /**
2
- * NetSuite-specific scaffold templates.
8
+ * One-shot bootstrap for a NetSuite project. Writes the base HelloLeo
9
+ * scaffold (via writeBaseScaffold) plus the NetSuite OAuth wiring
10
+ * (AuthGate, LoginPage, sign-in button, netsuite client, auth-gated
11
+ * App.tsx). All NetSuite-specific templates live in this file -- one
12
+ * place to read or change anything NetSuite-related.
3
13
  *
4
- * Extracted from scaffold.ts so the OAuth bits (client helper, sign-in
5
- * button, login page, auth gate, gated App.tsx entry point) live in one
6
- * file. Imported and emitted by scaffold.ts when the caller passes
7
- * `integrations: ["netsuite"]`. The string contents are byte-for-byte
8
- * identical to the previous inline versions — splitting is purely
9
- * organisational and does NOT change scaffold output.
14
+ * Use INSTEAD of `scaffold` when the project needs NetSuite. The tool
15
+ * name carries the intent so the LLM can't accidentally ship a project
16
+ * without the auth files.
10
17
  */
11
18
 
12
- export function netsuiteTs(): string {
19
+ function netsuiteTs(): string {
13
20
  return `/*
14
21
  * NetSuite OAuth client helper (bearer-token-in-fragment).
15
22
  *
@@ -141,7 +148,7 @@ export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Pr
141
148
  `;
142
149
  }
143
150
 
144
- export function signInButtonTsx(): string {
151
+ function signInButtonTsx(): string {
145
152
  return `import { signInWithNetSuite } from '../../lib/netsuite';
146
153
 
147
154
  export function SignInWithNetSuite() {
@@ -157,7 +164,7 @@ export function SignInWithNetSuite() {
157
164
  `;
158
165
  }
159
166
 
160
- export function netsuiteLoginPageTsx(): string {
167
+ function netsuiteLoginPageTsx(): string {
161
168
  return `import { SignInWithNetSuite } from './SignInWithNetSuite';
162
169
 
163
170
  /**
@@ -202,7 +209,7 @@ export function LoginPage() {
202
209
  `;
203
210
  }
204
211
 
205
- export function netsuiteAuthGateTsx(): string {
212
+ function netsuiteAuthGateTsx(): string {
206
213
  return `import { useEffect, useState } from 'react';
207
214
  import { LoginPage } from './LoginPage';
208
215
  import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
@@ -244,7 +251,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
244
251
  `;
245
252
  }
246
253
 
247
- export function netsuiteAppTsx(): string {
254
+ function netsuiteAppTsx(): string {
248
255
  return `import { AuthGate } from './components/auth/AuthGate';
249
256
 
250
257
  /**
@@ -266,3 +273,81 @@ export default function App() {
266
273
  }
267
274
  `;
268
275
  }
276
+
277
+ export const setupNetsuite = tool({
278
+ description:
279
+ "Bootstrap a NetSuite-backed React + Vite + Tailwind project in one call. " +
280
+ "Writes the full HelloLeo base scaffold AND the NetSuite OAuth wiring " +
281
+ "(src/lib/netsuite.ts, src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx, " +
282
+ "src/App.tsx wrapped in <AuthGate>). " +
283
+ "Use this INSTEAD of `scaffold` for any project that needs NetSuite. " +
284
+ "Uses default theme (Inter font, blue primary, 0.5rem radius) -- edit " +
285
+ "src/index.css afterwards to customise. Real OAuth is wired from day " +
286
+ "one -- never replace the gate with a password placeholder.",
287
+ args: {},
288
+ async execute(_args, context) {
289
+ const dir = context.directory;
290
+ const base = await writeBaseScaffold({}, dir);
291
+
292
+ await mkdir(path.join(dir, "src", "components", "auth"), {
293
+ recursive: true,
294
+ });
295
+
296
+ const nsFiles: [string, string][] = [
297
+ [path.join("src", "lib", "netsuite.ts"), netsuiteTs()],
298
+ [
299
+ path.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
300
+ signInButtonTsx(),
301
+ ],
302
+ [
303
+ path.join("src", "components", "auth", "LoginPage.tsx"),
304
+ netsuiteLoginPageTsx(),
305
+ ],
306
+ [
307
+ path.join("src", "components", "auth", "AuthGate.tsx"),
308
+ netsuiteAuthGateTsx(),
309
+ ],
310
+ [path.join("src", "App.tsx"), netsuiteAppTsx()],
311
+ ];
312
+
313
+ const nsCreated: string[] = [];
314
+ for (const [filePath, content] of nsFiles) {
315
+ await Bun.write(path.join(dir, filePath), content);
316
+ nsCreated.push(filePath);
317
+ }
318
+
319
+ const all = [...base, ...nsCreated];
320
+ return [
321
+ `Scaffolded ${all.length} files (base + NetSuite OAuth):`,
322
+ ...all.map((f) => ` - ${f}`),
323
+ "",
324
+ "Next: build your dashboard INSIDE <AuthGate> in src/App.tsx.",
325
+ "Design system reference: read the header comment in src/index.css",
326
+ " before adding tokens or styling primitives.",
327
+ "",
328
+ "NetSuite auth -- already wired:",
329
+ " - App.tsx wraps everything in <AuthGate>. NEVER remove the gate",
330
+ " or build a password placeholder -- real OAuth is the only login.",
331
+ " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated",
332
+ " view. Customise the copy / imagery, keep <SignInWithNetSuite/>",
333
+ " as the call-to-action.",
334
+ " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at",
335
+ " /auth/netsuite/start. Deploy the Worker via cloudflare-backend",
336
+ " MCP with routes:",
337
+ " GET /auth/netsuite/start -- redirect to NetSuite OAuth",
338
+ " GET /auth/netsuite/callback -- code -> token exchange",
339
+ " GET /api/me -- current session",
340
+ " POST /auth/netsuite/logout -- clear session",
341
+ " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,",
342
+ " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).",
343
+ " - VITE_WORKER_URL must be set so the frontend knows where to",
344
+ " send users. The HelloLeo Cloud provisioner seeds this.",
345
+ ].join("\n");
346
+ },
347
+ });
348
+
349
+ export const SetupNetsuitePlugin: Plugin = async () => {
350
+ return {
351
+ tool: { setup_netsuite: setupNetsuite },
352
+ };
353
+ };