@vendoai/vendo 0.4.2 → 0.4.3

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/cli/init.js CHANGED
@@ -15,7 +15,7 @@ import { ENV_KEY_VARS, resolveDevCredential, describeDevCredential } from "../de
15
15
  import { detectFramework, detectVendoWiring } from "./framework.js";
16
16
  import { resolveScaffoldAuth } from "./init-auth.js";
17
17
  import { ensureProviderDeps } from "./provider-deps.js";
18
- import { expressServerSource, registrySource, routeSource, serverActionsModuleSource, VENDO_ENV_EXAMPLE, wiringServerActions, } from "./init-scaffolds.js";
18
+ import { expressServerSource, registrySource, routeSource, serverActionsModuleSource, VENDO_ENV_EXAMPLE, vendoRootWrapperSource, wiringServerActions, } from "./init-scaffolds.js";
19
19
  import { createPrettyOutput, plainSelect, usePrettyOutput } from "./pretty.js";
20
20
  import { contrastingText } from "./theme/color.js";
21
21
  import { applyThemeDraft, extractTheme as extractThemeSlots, validateSlotValue, } from "./theme/extract-theme.js";
@@ -24,17 +24,24 @@ import { cloudProjectProps, consoleOutput, envLocalValueSync, errorClass, exists
24
24
  * `vendo init` (install-dx v1, re-derived 2026-07-18): one command, zero
25
25
  * questions on the happy path, no ceremony.
26
26
  *
27
- * scan → wire (the two-file surface — empty vendo/registry.tsx + the
28
- * catch-all handler wired to it; a detected auth preset gets one
29
- * consent-style confirm in interactive runs, --yes/non-interactive accept
30
- * it silently plus package.json hooks; never edits user-authored code)
27
+ * scan → wire (the surface files — empty vendo/registry.tsx, the client
28
+ * mount vendo/vendo-root.tsx, the catch-all handler wired to the registry,
29
+ * and the ONE bounded layout edit that mounts <VendoRoot> + <VendoOverlay />
30
+ * so a by-the-book install is actually visible (0.4.1 E2E cert B3); a
31
+ * detected auth preset gets one consent-style confirm in interactive runs,
32
+ * --yes/non-interactive accept it silently — plus package.json hooks)
31
33
  * → key (env stated, else the cloud starter offer) → done summary (files
32
- * changed, the VendoRoot line to paste, next steps).
34
+ * changed, any remaining paste, next steps).
33
35
  *
34
- * Removed by design: the interview, per-diff y/N approvals, the layout
35
- * codemod, the lib/ai.ts scaffold (createVendo's `model` is optional now),
36
- * remix offers, the encryption-key step, the refine offer, and the finale
37
- * ceremony (doctor owns verification and the live turn).
36
+ * The layout edit is the one place init touches user-authored code: it is
37
+ * idempotent (skipped when <VendoRoot> or <VendoOverlay> is already mounted),
38
+ * bounded (wrap the single JSX `{children}` + one import line), and degrades
39
+ * to the printed paste when the layout can't be edited safely.
40
+ *
41
+ * Removed by design: the interview, per-diff y/N approvals, the lib/ai.ts
42
+ * scaffold (createVendo's `model` is optional now), remix offers, the
43
+ * encryption-key step, the refine offer, and the finale ceremony (doctor owns
44
+ * verification and the live turn).
38
45
  */
39
46
  const DEFAULT_RADIUS = { small: "4px", large: "12px" };
40
47
  const BRIEF_PLACEHOLDER = `${BRIEF_TEMPLATE}\n`;
@@ -309,27 +316,49 @@ async function setupSkillSource() {
309
316
  return null;
310
317
  }
311
318
  }
312
- /** The one line init never writes: the user pastes the VendoRoot wrap. When
313
- the shared registry exists (scaffolded or host-authored) the wrap carries
314
- `components={registry}` the client half of the one-file/two-consumers
315
- pattern; it stays ONE pasted line plus its imports. */
316
- async function vendoRootPasteLines(root, framework, withRegistry) {
317
- if (framework === "express") {
319
+ /** The remaining manual wiring lines, derived from how far the auto-wire got.
320
+ A layout wired this run (or one that already mounts a surface) leaves
321
+ nothing to paste; an uneditable layout gets the wrapper paste (the wrapper
322
+ owns the registry/theme imports pasting them into a Server Component
323
+ layout is the RSC-serialization crash the wrapper exists to avoid); a
324
+ <VendoRoot>-without-surface host gets the one overlay line; Express keeps
325
+ its printed wiring lines. */
326
+ async function manualWiringLines(root, layout, withRegistry) {
327
+ if (layout.kind === "express") {
318
328
  const wrap = withRegistry
319
- ? `<VendoRoot components={registry} theme={theme}>…</VendoRoot>`
320
- : `<VendoRoot theme={theme}>…</VendoRoot>`;
329
+ ? `<VendoRoot components={registry} theme={theme}>…<VendoOverlay /></VendoRoot>`
330
+ : `<VendoRoot theme={theme}>…<VendoOverlay /></VendoRoot>`;
321
331
  return [
322
332
  `app.use("/api/vendo", mountVendo()); // in your server`,
323
- `${wrap} // around your client root (see vendo/server for the imports)`,
333
+ `${wrap} // around your client root (see vendo/server for the imports; <VendoOverlay /> is the visible launcher + panel)`,
334
+ ];
335
+ }
336
+ if (layout.kind === "wired" || layout.kind === "already")
337
+ return [];
338
+ if (layout.kind === "overlay-missing") {
339
+ return [
340
+ `In ${layout.layoutPath}: nothing renders yet — <VendoRoot> is a context provider. Mount the visible surface inside it:`,
341
+ ` import { VendoOverlay } from "@vendoai/vendo/react";`,
342
+ ` … then add: <VendoOverlay /> (the launcher pill + panel users open)`,
324
343
  ];
325
344
  }
326
345
  const app = await appDirectory(root);
346
+ const layoutRel = relative(root, join(app, "layout.tsx"));
347
+ if (withRegistry) {
348
+ const wrapperSpecifier = relative(app, join(dirname(app), "vendo", "vendo-root")).split(sep).join("/");
349
+ return [
350
+ `In ${layoutRel}:`,
351
+ ` import { VendoRoot } from ${JSON.stringify(wrapperSpecifier)};`,
352
+ ` … then wrap: <VendoRoot>{children}</VendoRoot>`,
353
+ ` (${join("vendo", "vendo-root.tsx")} mounts <VendoOverlay />, the visible launcher + panel)`,
354
+ ];
355
+ }
356
+ // No registry consumer (a hand-wired route that ignores it): the direct
357
+ // provider + overlay paste — theme.json is serializable, so it may cross
358
+ // the Server Component boundary; the registry may not.
327
359
  const specifier = await themeImportSpecifier(root, app);
328
- const layout = relative(root, join(app, "layout.tsx"));
329
- const registrySpecifier = relative(app, join(dirname(app), "vendo", "registry")).split(sep).join("/");
330
360
  const importLines = [
331
- `import { VendoRoot } from "@vendoai/vendo/react";`,
332
- ...(withRegistry ? [`import { registry } from ${JSON.stringify(registrySpecifier)};`] : []),
361
+ `import { VendoOverlay, VendoRoot } from "@vendoai/vendo/react";`,
333
362
  ...(specifier === null
334
363
  ? []
335
364
  : [
@@ -337,12 +366,8 @@ async function vendoRootPasteLines(root, framework, withRegistry) {
337
366
  `import type { VendoTheme } from "@vendoai/vendo";`,
338
367
  ]),
339
368
  ];
340
- const props = [
341
- ...(withRegistry ? ["components={registry}"] : []),
342
- ...(specifier === null ? [] : ["theme={theme as VendoTheme}"]),
343
- ];
344
- const wrap = `<VendoRoot${props.length === 0 ? "" : ` ${props.join(" ")}`}>{children}</VendoRoot>`;
345
- return [`In ${layout}:`, ...importLines.map((line) => ` ${line}`), ` … then wrap: ${wrap}`];
369
+ const wrap = `<VendoRoot${specifier === null ? "" : " theme={theme as VendoTheme}"}>{children}<VendoOverlay /></VendoRoot>`;
370
+ return [`In ${layoutRel}:`, ...importLines.map((line) => ` ${line}`), ` … then wrap: ${wrap}`];
346
371
  }
347
372
  /** The repo-specific agent tail (agent-install-dx): a non-interactive
348
373
  scaffold run is agent-driven, so the run ends with plain deterministic
@@ -374,11 +399,17 @@ async function agentTailLines(args) {
374
399
  if (args.framework === "express") {
375
400
  // No exact entry file exists to name on Express — point at the printed
376
401
  // wiring lines instead of guessing a path.
377
- lines.push("edit your server and client entries — paste the mountVendo() and <VendoRoot> lines above");
402
+ lines.push("edit your server and client entries — paste the mountVendo() and <VendoRoot>/<VendoOverlay /> lines above (without a mounted surface, users see nothing)");
378
403
  }
379
- else {
404
+ else if (args.layout.kind === "wired") {
405
+ lines.push(`surface: ${args.layout.layoutPath} wired this run — <VendoRoot> wraps the app and mounts <VendoOverlay /> (the visible launcher + panel) via ${join("vendo", "vendo-root.tsx")}; review the diff`);
406
+ }
407
+ else if (args.layout.kind === "overlay-missing") {
408
+ lines.push(`edit ${args.layout.layoutPath} — add <VendoOverlay /> inside your <VendoRoot> (see the lines above; <VendoRoot> alone renders NOTHING visible)`);
409
+ }
410
+ else if (args.layout.kind === "manual") {
380
411
  const layout = relative(args.root, join(await appDirectory(args.root), "layout.tsx"));
381
- lines.push(`edit ${layout} — wrap the app in the <VendoRoot> lines above`);
412
+ lines.push(`edit ${layout} — wrap the app in the <VendoRoot> lines above (it mounts <VendoOverlay />, the visible surface; without it users see nothing)`);
382
413
  }
383
414
  if (await readOptional(join(args.root, ".vendo", "brief.md")) === BRIEF_PLACEHOLDER) {
384
415
  lines.push(`edit ${join(".vendo", "brief.md")} — replace the placeholder with what this product does and for whom`);
@@ -420,6 +451,48 @@ export function starViaGh(spawnStar, timeoutMs = 5_000) {
420
451
  child.on("exit", (code) => settle(code === 0));
421
452
  });
422
453
  }
454
+ /** Where the wrapped `{children}` insert lands: the SINGLE JSX-rendered
455
+ `{children}` (between a `>` and a `<`). Requiring the JSX position keeps
456
+ the layout's own `({ children })` parameter destructure out of the count. */
457
+ const LAYOUT_CHILDREN = /(>)(\s*\{\s*children\s*\}\s*)(<)/g;
458
+ /** End index for the wrapper import: after the last top-level import
459
+ statement (lazy up to its module-specifier string literal, so multi-line
460
+ destructures work), else after a leading directive ("use client"), else 0. */
461
+ function importInsertionIndex(source) {
462
+ const heads = [...source.matchAll(/^import\b/gm)];
463
+ if (heads.length === 0) {
464
+ const directive = /^(?:\s*(?:'[^']*'|"[^"]*");?[^\S\n]*\n)+/.exec(source);
465
+ return directive === null ? 0 : directive[0].length;
466
+ }
467
+ const last = heads[heads.length - 1].index;
468
+ const statement = /import\s[\s\S]*?["'][^"']*["'][^\S\n]*;?/.exec(source.slice(last));
469
+ const end = last + (statement === null ? 0 : statement[0].length);
470
+ const newline = source.indexOf("\n", end);
471
+ return newline === -1 ? source.length : newline + 1;
472
+ }
473
+ /**
474
+ * The one bounded edit init makes to user-authored code (visible-surface fix,
475
+ * 0.4.1 E2E cert B3): mount the generated client wrapper around the layout's
476
+ * `{children}` and import it. Null — leave the file untouched, fall back to
477
+ * the printed paste — whenever a Vendo mount already exists (idempotence) or
478
+ * the file doesn't have exactly one JSX `{children}` to wrap (ambiguity).
479
+ * Exported for direct unit tests.
480
+ */
481
+ export function wireNextLayout(source, wrapperSpecifier) {
482
+ if (source.includes("VendoRoot") || source.includes("VendoOverlay"))
483
+ return null;
484
+ const matches = [...source.matchAll(LAYOUT_CHILDREN)];
485
+ if (matches.length !== 1)
486
+ return null;
487
+ const match = matches[0];
488
+ const open = match.index + match[1].length;
489
+ const close = match.index + match[0].length - match[3].length;
490
+ let next = `${source.slice(0, open)}<VendoRoot>${source.slice(open, close)}</VendoRoot>${source.slice(close)}`;
491
+ const at = importInsertionIndex(next);
492
+ const importLine = `import { VendoRoot } from ${JSON.stringify(wrapperSpecifier)};\n`;
493
+ next = `${next.slice(0, at)}${importLine}${next.slice(at)}`;
494
+ return next;
495
+ }
423
496
  async function buildPlan(options, confirmAuth, selectAuth) {
424
497
  const root = resolve(options.targetDir);
425
498
  const framework = options.framework ?? await detectFramework(root);
@@ -429,7 +502,9 @@ async function buildPlan(options, confirmAuth, selectAuth) {
429
502
  let compositionPath = null;
430
503
  let registryPath = null;
431
504
  let withRegistry = false;
505
+ let layout = { kind: "manual" };
432
506
  if (framework === "express") {
507
+ layout = { kind: "express" };
433
508
  const wiring = await detectVendoWiring(root);
434
509
  if (!wiring.server || !wiring.client) {
435
510
  const typescript = await exists(join(root, "tsconfig.json"));
@@ -522,6 +597,45 @@ async function buildPlan(options, confirmAuth, selectAuth) {
522
597
  changes.push({ absolute: route, path, before: routeBefore, after: wiredRoute, diff: diff(path, routeBefore, wiredRoute) });
523
598
  }
524
599
  }
600
+ // Visible surface (0.4.1 E2E cert B3): the client mount wrapper next to
601
+ // the registry — the "use client" boundary that owns the registry + theme
602
+ // imports (passing the registry from the Server Component layout into the
603
+ // client provider fails RSC serialization) and mounts <VendoOverlay /> —
604
+ // plus the ONE bounded layout edit that wraps `{children}` in it. Both
605
+ // idempotent: an existing wrapper is never clobbered, a layout already
606
+ // mounting <VendoRoot> or <VendoOverlay> is never re-edited, and an
607
+ // uneditable layout degrades to the printed paste.
608
+ const wrapperFile = join(dirname(app), "vendo", "vendo-root.tsx");
609
+ const wrapperBefore = await readOptional(wrapperFile);
610
+ const layoutFile = join(app, "layout.tsx");
611
+ const layoutBefore = await readOptional(layoutFile);
612
+ // The generated wrapper doesn't count as a host mount: its overlay is
613
+ // only real once a layout mounts the wrapper itself.
614
+ const mounts = await detectVendoWiring(root, { exclude: [wrapperFile] });
615
+ if (mounts.client || mounts.surface) {
616
+ // A mounted <VendoRoot> next to an existing wrapper IS the surface —
617
+ // the wrapper renders <VendoOverlay /> (the re-run after an auto-wire).
618
+ layout = mounts.surface || wrapperBefore !== null
619
+ ? { kind: "already" }
620
+ : { kind: "overlay-missing", layoutPath: relative(root, layoutFile) };
621
+ }
622
+ else if (withRegistry) {
623
+ // The wrapper consumes ./registry, so it exists only alongside one —
624
+ // a hand-wired host that ignores the registry keeps the manual paste.
625
+ if (wrapperBefore === null) {
626
+ const path = relative(root, wrapperFile);
627
+ const themeSpecifier = await themeImportSpecifier(root, dirname(wrapperFile));
628
+ const wrapperAfter = vendoRootWrapperSource({ themeSpecifier });
629
+ changes.push({ absolute: wrapperFile, path, before: null, after: wrapperAfter, diff: diff(path, null, wrapperAfter) });
630
+ }
631
+ const wrapperSpecifier = relative(app, join(dirname(app), "vendo", "vendo-root")).split(sep).join("/");
632
+ const layoutAfter = layoutBefore === null ? null : wireNextLayout(layoutBefore, wrapperSpecifier);
633
+ if (layoutAfter !== null) {
634
+ const path = relative(root, layoutFile);
635
+ changes.push({ absolute: layoutFile, path, before: layoutBefore, after: layoutAfter, diff: diff(path, layoutBefore, layoutAfter) });
636
+ layout = { kind: "wired", layoutPath: path };
637
+ }
638
+ }
525
639
  }
526
640
  const packageJson = join(root, "package.json");
527
641
  const packageBefore = await readOptional(packageJson);
@@ -561,7 +675,7 @@ async function buildPlan(options, confirmAuth, selectAuth) {
561
675
  ".vendo/theme.json",
562
676
  ".vendo/data/.gitignore",
563
677
  ];
564
- const manualSteps = await vendoRootPasteLines(root, framework, withRegistry);
678
+ const manualSteps = await manualWiringLines(root, layout, withRegistry);
565
679
  return {
566
680
  changes,
567
681
  manualSteps,
@@ -569,6 +683,7 @@ async function buildPlan(options, confirmAuth, selectAuth) {
569
683
  authWired,
570
684
  compositionPath,
571
685
  registryPath,
686
+ layout,
572
687
  plan: {
573
688
  framework,
574
689
  root,
@@ -671,7 +786,7 @@ export async function runInit(options) {
671
786
  ? undefined
672
787
  : (options.selectAuth ?? (pretty === null ? plainSelect : pretty.select));
673
788
  const detectStarted = Date.now();
674
- const { plan, changes, manualSteps, authAdvice, authWired, compositionPath, registryPath } = await buildPlan(options, confirmAuth, selectAuth);
789
+ const { plan, changes, manualSteps, authAdvice, authWired, compositionPath, registryPath, layout } = await buildPlan(options, confirmAuth, selectAuth);
675
790
  const detectMs = Date.now() - detectStarted;
676
791
  let telemetry = telemetryFor(options, output, root);
677
792
  await telemetry.track("init_started", { framework: plan.framework });
@@ -950,10 +1065,16 @@ export async function runInit(options) {
950
1065
  if (aiVersion !== null && Number.parseInt(aiVersion, 10) >= 7) {
951
1066
  output.error(`warning: installed ai@${aiVersion} is unsupported — Vendo supports ai@6; downgrade (npm install ai@^6 @ai-sdk/anthropic@^3 @ai-sdk/react@^3) or track github.com/runvendo/vendo/issues/478`);
952
1067
  }
953
- // Done — the one paste that is the user's, then their own dev server.
954
- output.log("\nLast steps are yours:");
955
- for (const line of manualSteps)
956
- output.log(` ${line}`);
1068
+ // Done — whatever paste remains (none when the layout auto-wire landed),
1069
+ // then their own dev server.
1070
+ if (layout.kind === "wired") {
1071
+ output.log(`\nMounted <VendoRoot> + <VendoOverlay /> (the visible launcher + panel) in ${layout.layoutPath} — review the diff before committing.`);
1072
+ }
1073
+ if (manualSteps.length > 0) {
1074
+ output.log("\nLast steps are yours:");
1075
+ for (const line of manualSteps)
1076
+ output.log(` ${line}`);
1077
+ }
957
1078
  output.log("\nThen start your dev server — the agent is live in your app.");
958
1079
  output.log("Verify everything: `npx vendo doctor` (it can start the server and run a live turn).");
959
1080
  // Agent tail (agent-install-dx): the --yes-or-non-TTY path is agent-driven
@@ -962,7 +1083,7 @@ export async function runInit(options) {
962
1083
  // never reaches here (its read-only JSON plan returned above).
963
1084
  if (options.yes === true || !interactive) {
964
1085
  output.log("\nAgent tail:");
965
- const tail = await agentTailLines({ root, framework: plan.framework, registryPath, compositionPath, authWired, cloudKeyMissing: credential.rung === "none" });
1086
+ const tail = await agentTailLines({ root, framework: plan.framework, registryPath, compositionPath, authWired, layout, cloudKeyMissing: credential.rung === "none" });
966
1087
  for (const line of tail)
967
1088
  output.log(` ${line}`);
968
1089
  }