@maintainer-pro/ai-bridge 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -14,7 +14,14 @@ npx --yes @maintainer-pro/ai-bridge --pair AB12-CD34 --admin-url https://your-ad
14
14
  Credentials are stored in `~/.maintainer-pro/bridge.json` (not the cwd).
15
15
 
16
16
  3. Keep the bridge running (`npx @maintainer-pro/ai-bridge`). It advertises the current folder (and any `--offer-folder` paths).
17
- 4. In admin → Bridges: pick a reported folder + client sandbox → **Setup sandbox in folder**. The bridge writes `.env`, starts `ai-server` on an assigned port, and marks the sandbox setup complete.
17
+ 4. In admin → Bridges: pick a reported folder + client sandbox → **Setup sandbox in folder**.
18
+
19
+ For **empty / new** folders the bridge:
20
+ - Writes `.env` (server + client keys)
21
+ - Scaffolds `index.html` with the chat widget (served by `ai-server`)
22
+ - Starts `ai-server` and marks the sandbox Online
23
+
24
+ For **existing** apps it injects the widget / Next env when possible. Use **Client setup** on the Bridges form for manual overrides (`empty`, `existing`, or keys-only).
18
25
 
19
26
  One bridge process can run many sandboxes (different folders / ports) for different clients.
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local bridge daemon that pairs a machine to Maintainer Pro and configures multiple client sandboxes.",
5
5
  "keywords": [
6
6
  "maintainer-pro",
package/src/daemon.mjs CHANGED
@@ -256,6 +256,401 @@ function mergeEnvFile(file, values) {
256
256
  fs.writeFileSync(file, body + "\n", "utf8");
257
257
  }
258
258
 
259
+ const IGNORE_NAMES = new Set([
260
+ ".git",
261
+ ".DS_Store",
262
+ "Thumbs.db",
263
+ "node_modules",
264
+ ".maintainer-pro",
265
+ ".maintainer-pro-bridge.json",
266
+ ".cloudflare-tunnel-url",
267
+ ]);
268
+
269
+ function isIgnorableEntry(name) {
270
+ if (IGNORE_NAMES.has(name)) return true;
271
+ if (name.startsWith(".env")) return true;
272
+ return false;
273
+ }
274
+
275
+ /**
276
+ * Detect project shape in a folder.
277
+ * @returns {"empty"|"next"|"html"|"other"}
278
+ */
279
+ function detectProjectKind(dir) {
280
+ if (!fs.existsSync(dir)) return "empty";
281
+ const names = fs.readdirSync(dir).filter((n) => !isIgnorableEntry(n));
282
+ if (names.length === 0) return "empty";
283
+
284
+ const has = (n) => names.includes(n) || fs.existsSync(path.join(dir, n));
285
+ if (
286
+ has("next.config.js") ||
287
+ has("next.config.mjs") ||
288
+ has("next.config.ts") ||
289
+ has("app") ||
290
+ has("pages")
291
+ ) {
292
+ // package.json with next is a stronger signal
293
+ try {
294
+ const pkg = JSON.parse(
295
+ fs.readFileSync(path.join(dir, "package.json"), "utf8")
296
+ );
297
+ if (pkg.dependencies?.next || pkg.devDependencies?.next) return "next";
298
+ } catch {
299
+ /* fall through */
300
+ }
301
+ if (has("next.config.js") || has("next.config.mjs") || has("next.config.ts")) {
302
+ return "next";
303
+ }
304
+ }
305
+
306
+ if (has("index.html") || has(path.join("public", "index.html"))) {
307
+ return "html";
308
+ }
309
+
310
+ // Only README / license → treat as empty scaffold target
311
+ const meaningful = names.filter(
312
+ (n) =>
313
+ !/^readme/i.test(n) &&
314
+ !/^license/i.test(n) &&
315
+ n !== "package.json"
316
+ );
317
+ if (meaningful.length === 0 && !has("package.json")) return "empty";
318
+
319
+ return "other";
320
+ }
321
+
322
+ function escapeHtml(value) {
323
+ return String(value)
324
+ .replaceAll("&", "&")
325
+ .replaceAll("<", "&lt;")
326
+ .replaceAll(">", "&gt;")
327
+ .replaceAll('"', "&quot;");
328
+ }
329
+
330
+ function emptyProjectIndexHtml(appName) {
331
+ return `<!doctype html>
332
+ <html lang="en">
333
+ <head>
334
+ <meta charset="utf-8" />
335
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
336
+ <title>${escapeHtml(appName)}</title>
337
+ <style>
338
+ body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a2330; }
339
+ p { color: #5c6b7a; }
340
+ </style>
341
+ </head>
342
+ <body>
343
+ <h1>${escapeHtml(appName)}</h1>
344
+ <p>Ask the assistant to change this page.</p>
345
+ <script src="/embed-config.js"></script>
346
+ <script src="/ai-ui.iife.js"></script>
347
+ <script>
348
+ (function () {
349
+ var cfg = window.__MAINTAINER_PRO__ || {};
350
+ AiUi.init({
351
+ apiUrl: cfg.apiUrl || "/api/chat",
352
+ title: "AI Assistant",
353
+ maintainerProUrl: cfg.maintainerProUrl || undefined,
354
+ maintainerProApiKey: cfg.maintainerProApiKey || undefined,
355
+ });
356
+ })();
357
+ </script>
358
+ </body>
359
+ </html>
360
+ `;
361
+ }
362
+
363
+ const WIDGET_MARKER = "AiUi.init";
364
+
365
+ function injectHtmlWidget(html, aiServerUrl) {
366
+ if (html.includes(WIDGET_MARKER) || html.includes("ai-ui.iife.js")) {
367
+ return { html, injected: false };
368
+ }
369
+ const snippet = `
370
+ <script src="${aiServerUrl}/embed-config.js"></script>
371
+ <script src="${aiServerUrl}/ai-ui.iife.js"></script>
372
+ <script>
373
+ (function () {
374
+ var cfg = window.__MAINTAINER_PRO__ || {};
375
+ if (!cfg.apiUrl) {
376
+ console.error("Maintainer Pro: embed-config.js missing apiUrl (is ai-server running?)");
377
+ return;
378
+ }
379
+ AiUi.init({
380
+ apiUrl: cfg.apiUrl,
381
+ title: "AI Assistant",
382
+ maintainerProUrl: cfg.maintainerProUrl || undefined,
383
+ maintainerProApiKey: cfg.maintainerProApiKey || undefined,
384
+ });
385
+ })();
386
+ </script>
387
+ `;
388
+ if (/<\/body>/i.test(html)) {
389
+ return {
390
+ html: html.replace(/<\/body>/i, `${snippet}</body>`),
391
+ injected: true,
392
+ };
393
+ }
394
+ return { html: html + snippet, injected: true };
395
+ }
396
+
397
+ function nextWidgetComponentSource() {
398
+ return `"use client";
399
+
400
+ import { useEffect } from "react";
401
+
402
+ declare global {
403
+ interface Window {
404
+ AiUi?: { init: (opts: Record<string, unknown>) => void; destroy?: () => void };
405
+ __MAINTAINER_PRO__?: {
406
+ aiServerUrl?: string;
407
+ apiUrl?: string;
408
+ maintainerProUrl?: string;
409
+ maintainerProApiKey?: string;
410
+ };
411
+ }
412
+ }
413
+
414
+ const AI_SERVER_URL = (process.env.NEXT_PUBLIC_AI_SERVER_URL || "").replace(/\\/$/, "");
415
+
416
+ export function MaintainerProWidget() {
417
+ useEffect(() => {
418
+ if (!AI_SERVER_URL) {
419
+ console.error("Set NEXT_PUBLIC_AI_SERVER_URL to the ai-server origin");
420
+ return;
421
+ }
422
+ let cancelled = false;
423
+
424
+ const start = (cfg: {
425
+ apiUrl?: string;
426
+ maintainerProUrl?: string;
427
+ maintainerProApiKey?: string;
428
+ }) => {
429
+ if (cancelled) return;
430
+ const AiUi = window.AiUi;
431
+ if (!AiUi?.init) {
432
+ setTimeout(() => start(cfg), 40);
433
+ return;
434
+ }
435
+ AiUi.init({
436
+ apiUrl: cfg.apiUrl || \`\${AI_SERVER_URL}/api/chat\`,
437
+ title: "AI Assistant",
438
+ maintainerProUrl: cfg.maintainerProUrl,
439
+ maintainerProApiKey: cfg.maintainerProApiKey,
440
+ });
441
+ };
442
+
443
+ const ensureScript = (src: string) =>
444
+ new Promise<void>((resolve, reject) => {
445
+ const existing = document.querySelector<HTMLScriptElement>(\`script[src="\${src}"]\`);
446
+ if (existing) {
447
+ if (existing.dataset.loaded === "1") resolve();
448
+ else existing.addEventListener("load", () => resolve(), { once: true });
449
+ return;
450
+ }
451
+ const script = document.createElement("script");
452
+ script.src = src;
453
+ script.async = true;
454
+ script.onload = () => {
455
+ script.dataset.loaded = "1";
456
+ resolve();
457
+ };
458
+ script.onerror = () => reject(new Error(\`Failed to load \${src}\`));
459
+ document.body.appendChild(script);
460
+ });
461
+
462
+ void (async () => {
463
+ try {
464
+ await ensureScript(\`\${AI_SERVER_URL}/embed-config.js\`);
465
+ await ensureScript(\`\${AI_SERVER_URL}/ai-ui.iife.js\`);
466
+ start(window.__MAINTAINER_PRO__ || {});
467
+ } catch (err) {
468
+ console.error(err);
469
+ }
470
+ })();
471
+
472
+ return () => {
473
+ cancelled = true;
474
+ window.AiUi?.destroy?.();
475
+ };
476
+ }, []);
477
+
478
+ return null;
479
+ }
480
+ `;
481
+ }
482
+
483
+ function tryMountNextWidget(dir) {
484
+ const candidates = [
485
+ path.join(dir, "app", "layout.tsx"),
486
+ path.join(dir, "app", "layout.jsx"),
487
+ path.join(dir, "src", "app", "layout.tsx"),
488
+ path.join(dir, "src", "app", "layout.jsx"),
489
+ ];
490
+ for (const layout of candidates) {
491
+ if (!fs.existsSync(layout)) continue;
492
+ let text = fs.readFileSync(layout, "utf8");
493
+ if (text.includes("MaintainerProWidget")) {
494
+ return { mounted: false, reason: "already mounted" };
495
+ }
496
+ const fromAppRoot =
497
+ /[/\\]app[/\\]layout\.(t|j)sx$/.test(layout) &&
498
+ !/[/\\]src[/\\]app[/\\]/.test(layout);
499
+ const imp = fromAppRoot
500
+ ? "../components/MaintainerProWidget"
501
+ : "@/components/MaintainerProWidget";
502
+
503
+ text = `import { MaintainerProWidget } from "${imp}";\n` + text;
504
+ if (/\{children\}/.test(text)) {
505
+ text = text.replace(
506
+ /\{children\}/,
507
+ "{children}\n <MaintainerProWidget />"
508
+ );
509
+ fs.writeFileSync(layout, text, "utf8");
510
+ return { mounted: true, layout };
511
+ }
512
+ }
513
+ return { mounted: false, reason: "no layout found" };
514
+ }
515
+
516
+ /**
517
+ * Configure client files for a workspace.
518
+ */
519
+ function configureClient(opts) {
520
+ const {
521
+ dir,
522
+ port,
523
+ appName,
524
+ mode, // auto | empty | existing | skip
525
+ hostAppUrl,
526
+ } = opts;
527
+ const aiOrigin = `http://localhost:${port}`;
528
+ /** @type {string[]} */
529
+ const notes = [];
530
+ /** @type {string[]} */
531
+ const filesWritten = [];
532
+
533
+ let kind = detectProjectKind(dir);
534
+ if (mode === "empty") kind = "empty";
535
+ if (mode === "existing") {
536
+ if (kind === "empty") kind = "other";
537
+ }
538
+ if (mode === "skip") {
539
+ return {
540
+ kind: "skipped",
541
+ notes: ["Client scaffolding skipped (manual)."],
542
+ filesWritten,
543
+ corsOrigin: hostAppUrl || null,
544
+ appUrl: hostAppUrl || null,
545
+ sameOrigin: false,
546
+ };
547
+ }
548
+
549
+ if (kind === "empty" || mode === "empty") {
550
+ const indexPath = path.join(dir, "index.html");
551
+ if (!fs.existsSync(indexPath)) {
552
+ fs.writeFileSync(indexPath, emptyProjectIndexHtml(appName), "utf8");
553
+ filesWritten.push("index.html");
554
+ notes.push("Created index.html (served by ai-server).");
555
+ } else {
556
+ notes.push("index.html already present.");
557
+ }
558
+ return {
559
+ kind: "empty",
560
+ notes,
561
+ filesWritten,
562
+ corsOrigin: aiOrigin,
563
+ appUrl: hostAppUrl || aiOrigin,
564
+ sameOrigin: true,
565
+ };
566
+ }
567
+
568
+ if (kind === "html") {
569
+ const candidates = [
570
+ path.join(dir, "index.html"),
571
+ path.join(dir, "public", "index.html"),
572
+ ];
573
+ for (const htmlPath of candidates) {
574
+ if (!fs.existsSync(htmlPath)) continue;
575
+ const raw = fs.readFileSync(htmlPath, "utf8");
576
+ const { html, injected } = injectHtmlWidget(raw, aiOrigin);
577
+ if (injected) {
578
+ fs.writeFileSync(htmlPath, html, "utf8");
579
+ filesWritten.push(path.relative(dir, htmlPath));
580
+ notes.push(`Injected widget into ${path.relative(dir, htmlPath)}.`);
581
+ } else {
582
+ notes.push(`Widget already present in ${path.relative(dir, htmlPath)}.`);
583
+ }
584
+ break;
585
+ }
586
+ const origin = hostAppUrl || "http://localhost:3000";
587
+ return {
588
+ kind: "html",
589
+ notes,
590
+ filesWritten,
591
+ corsOrigin: origin,
592
+ appUrl: origin,
593
+ sameOrigin: false,
594
+ };
595
+ }
596
+
597
+ if (kind === "next") {
598
+ const envLocal = path.join(dir, ".env.local");
599
+ mergeEnvFile(envLocal, {
600
+ NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
601
+ });
602
+ filesWritten.push(".env.local");
603
+ notes.push("Set NEXT_PUBLIC_AI_SERVER_URL in .env.local.");
604
+
605
+ const useSrc = fs.existsSync(path.join(dir, "src", "app"));
606
+ const compDir = useSrc
607
+ ? path.join(dir, "src", "components")
608
+ : path.join(dir, "components");
609
+ fs.mkdirSync(compDir, { recursive: true });
610
+ const widgetPath = path.join(compDir, "MaintainerProWidget.tsx");
611
+ if (!fs.existsSync(widgetPath)) {
612
+ fs.writeFileSync(widgetPath, nextWidgetComponentSource(), "utf8");
613
+ filesWritten.push(path.relative(dir, widgetPath));
614
+ notes.push("Added MaintainerProWidget.tsx.");
615
+ }
616
+ const mount = tryMountNextWidget(dir);
617
+ if (mount.mounted) {
618
+ notes.push(`Mounted widget in ${path.relative(dir, mount.layout)}.`);
619
+ } else {
620
+ notes.push(
621
+ "Add <MaintainerProWidget /> to your root layout if it is not mounted yet."
622
+ );
623
+ }
624
+ const origin = hostAppUrl || "http://localhost:3000";
625
+ return {
626
+ kind: "next",
627
+ notes,
628
+ filesWritten,
629
+ corsOrigin: origin,
630
+ appUrl: origin,
631
+ sameOrigin: false,
632
+ };
633
+ }
634
+
635
+ // other — write host env hints only
636
+ const origin = hostAppUrl || "http://localhost:3000";
637
+ mergeEnvFile(path.join(dir, ".env"), {
638
+ AI_SERVER_URL: aiOrigin,
639
+ NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
640
+ });
641
+ notes.push(
642
+ "Existing project detected. Set AI_SERVER_URL / mount the widget manually if needed."
643
+ );
644
+ return {
645
+ kind: "other",
646
+ notes,
647
+ filesWritten,
648
+ corsOrigin: origin,
649
+ appUrl: origin,
650
+ sameOrigin: false,
651
+ };
652
+ }
653
+
259
654
  function collectOfferedFolders(cfg) {
260
655
  /** @type {string[]} */
261
656
  const folders = [];
@@ -344,6 +739,13 @@ async function setupWorkspace(cfg, action) {
344
739
  action.sandboxId || action.payload?.sandboxId || ""
345
740
  );
346
741
  const port = Number(action.payload?.port) || 3100;
742
+ const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
743
+ const hostAppUrl =
744
+ typeof action.payload?.hostAppUrl === "string" &&
745
+ action.payload.hostAppUrl.trim()
746
+ ? action.payload.hostAppUrl.trim().replace(/\/$/, "")
747
+ : null;
748
+
347
749
  if (!folderPath || !sandboxId) {
348
750
  throw new Error("setup_workspace requires folderPath and sandboxId");
349
751
  }
@@ -357,13 +759,34 @@ async function setupWorkspace(cfg, action) {
357
759
  `/api/v1/bridge/machine/sandboxes/${sandboxId}/setup-config?port=${port}`
358
760
  );
359
761
 
762
+ const appName =
763
+ config.sandbox?.applicationName ||
764
+ config.sandbox?.name ||
765
+ "Maintainer Pro App";
766
+
767
+ const client = configureClient({
768
+ dir: resolved,
769
+ port,
770
+ appName,
771
+ mode: clientMode,
772
+ hostAppUrl,
773
+ });
774
+
775
+ const aiOrigin = `http://localhost:${port}`;
776
+ const corsOrigin = client.corsOrigin || aiOrigin;
777
+ const appUrl = client.appUrl || corsOrigin;
778
+
360
779
  const envPath = path.join(resolved, ".env");
361
780
  const envValues = {
362
781
  ...config.env,
363
782
  AI_CLI_WORKSPACE: ".",
364
- AI_SERVER_UI: ".",
365
- CORS_ORIGIN: process.env.CORS_ORIGIN || `http://localhost:${port}`,
366
- APP_URL: process.env.APP_URL || `http://localhost:3000`,
783
+ AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
784
+ PORT: String(port),
785
+ AI_SERVER_URL: aiOrigin,
786
+ NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
787
+ CORS_ORIGIN: corsOrigin,
788
+ APP_URL: appUrl,
789
+ AI_SERVER_PRODUCT_DESCRIPTION: appName,
367
790
  };
368
791
  mergeEnvFile(envPath, envValues);
369
792
 
@@ -375,6 +798,7 @@ async function setupWorkspace(cfg, action) {
375
798
  port,
376
799
  sandboxName: config.sandbox?.name,
377
800
  applicationName: config.sandbox?.applicationName,
801
+ clientKind: client.kind,
378
802
  };
379
803
  if (existing >= 0) cfg.workspaces[existing] = entry;
380
804
  else cfg.workspaces.push(entry);
@@ -384,16 +808,25 @@ async function setupWorkspace(cfg, action) {
384
808
 
385
809
  if (!cfg.noAiServer) {
386
810
  startAiServerForWorkspace(entry);
387
- await new Promise((r) => setTimeout(r, 1200));
811
+ await new Promise((r) => setTimeout(r, 1500));
388
812
  }
389
813
 
814
+ const aiServerUp = await probeUrl(`${aiOrigin.replace("localhost", "127.0.0.1")}/embed-config.js`);
815
+
816
+ for (const note of client.notes) log(note);
817
+
390
818
  return {
391
819
  sandboxId,
392
820
  folderPath: resolved,
393
821
  port,
394
- appUrl: envValues.APP_URL || null,
395
- origins: envValues.CORS_ORIGIN ? [envValues.CORS_ORIGIN] : [],
822
+ appUrl,
823
+ origins: [corsOrigin, aiOrigin].filter(Boolean),
396
824
  wroteEnv: true,
825
+ clientKind: client.kind,
826
+ clientFiles: client.filesWritten,
827
+ clientNotes: client.notes,
828
+ aiServerUp,
829
+ openUrl: client.sameOrigin ? aiOrigin : appUrl,
397
830
  };
398
831
  }
399
832