Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions apps/cli/src/__tests__/plugin-scaffold-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join, relative } from "node:path";
import {
PLUGIN_SERVER_EXTERNALS,
RUNTIME_SLOT_BY_SPECIFIER,
SHIMMED_TYPE_PACKAGES,
} from "@bb/plugin-build";
import { scaffoldPlugin } from "@bb/templates/plugin-scaffold";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -76,20 +77,27 @@ function packageNameOf(specifier: string): string {
: (segments[0] ?? specifier);
}

async function scaffoldWithDependencies(
workDir: string,
): Promise<{ targetDir: string; dependencies: string[] }> {
async function scaffoldWithDependencies(workDir: string): Promise<{
targetDir: string;
dependencies: string[];
devDependencies: string[];
}> {
const packageName = "bb-plugin-deps";
const targetDir = join(workDir, packageName);
await scaffoldPlugin({
targetDir,
packageName,
bbVersion: "0.9.0",
});
const manifest: { dependencies?: Record<string, string> } = JSON.parse(
await readFile(join(targetDir, "package.json"), "utf8"),
);
return { targetDir, dependencies: Object.keys(manifest.dependencies ?? {}) };
const manifest: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
} = JSON.parse(await readFile(join(targetDir, "package.json"), "utf8"));
return {
targetDir,
dependencies: Object.keys(manifest.dependencies ?? {}),
devDependencies: Object.keys(manifest.devDependencies ?? {}),
};
}

describe("scaffold dependency classification", () => {
Expand All @@ -104,8 +112,7 @@ describe("scaffold dependency classification", () => {
});

it("declares every bundled import as a dependency", async () => {
const { targetDir, dependencies } =
await scaffoldWithDependencies(workDir);
const { targetDir, dependencies } = await scaffoldWithDependencies(workDir);

const misdeclared: string[] = [];
for (const file of await generatedSourceFiles(targetDir)) {
Expand All @@ -125,6 +132,26 @@ describe("scaffold dependency classification", () => {
expect(misdeclared).toEqual([]);
});

/**
* The flip side of the shim (#2072): esbuild never reads a shimmed package
* from node_modules, but tsc does, so every shimmed npm package has to be
* installed for types — as a devDependency — or the documented
* `import { toast } from "sonner"` fails to typecheck in a fresh scaffold.
* Derived from the build's shim table, so adding a slot without declaring
* its types fails here.
*/
it("declares every runtime-shimmed package as a type-only devDependency", async () => {
const { dependencies, devDependencies } =
await scaffoldWithDependencies(workDir);

expect(
SHIMMED_TYPE_PACKAGES.filter((name) => !devDependencies.includes(name)),
).toEqual([]);
expect(
SHIMMED_TYPE_PACKAGES.filter((name) => dependencies.includes(name)),
).toEqual([]);
});

it("keeps host-provided packages out of dependencies", async () => {
const { dependencies } = await scaffoldWithDependencies(workDir);

Expand Down
32 changes: 25 additions & 7 deletions apps/cli/src/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,7 +1345,7 @@ export function registerPluginCommands(
plugin
.command("types [path]")
.description(
"Sync a plugin's @get-bb/plugin-sdk surface to the running bb (default: cwd): repin the npm devDependency for plugins that depend on the package, or rewrite the vendored types/ declarations for plugins that still carry them",
"Sync a plugin's @get-bb/plugin-sdk surface to the running bb (default: cwd): repin the npm devDependency and the type-only devDependencies of the packages bb shims at runtime (sonner, vaul, the portal radix families, ...) for plugins that depend on the package, or rewrite the vendored types/ declarations for plugins that still carry them",
)
.option(
"--check",
Expand All @@ -1371,6 +1371,7 @@ export function registerPluginCommands(
const pending = await setPluginSdkPin({
rootDir,
sdkVersion: PLUGIN_SDK_VERSION,
app: hasApp,
dryRun: true,
});
if (pending === null) {
Expand All @@ -1379,20 +1380,30 @@ export function registerPluginCommands(
);
return;
}
console.error(
pending.pin === null
? 'Move "@get-bb/plugin-sdk" from dependencies to devDependencies — bb provides its runtime (`bb plugin types` does it for you).'
: `Set "@get-bb/plugin-sdk" to ${PLUGIN_SDK_VERSION} in devDependencies and re-run npm install (\`bb plugin types\` does it for you).`,
);
if (pending.pin !== null || pending.movedFromDependencies) {
console.error(
pending.pin === null
? 'Move "@get-bb/plugin-sdk" from dependencies to devDependencies — bb provides its runtime (`bb plugin types` does it for you).'
: `Set "@get-bb/plugin-sdk" to ${PLUGIN_SDK_VERSION} in devDependencies and re-run npm install (\`bb plugin types\` does it for you).`,
);
}
for (const shim of pending.shimmedTypePins) {
console.error(
shim.movedFromDependencies
? `Move "${shim.name}" from dependencies to devDependencies at ${shim.to} — bb shims it at runtime and never bundles it (\`bb plugin types\` does it for you).`
: `Set "${shim.name}" to ${shim.to} in devDependencies — the version this bb shims at runtime (\`bb plugin types\` does it for you).`,
);
}
process.exit(1);
}
const changed = await setPluginSdkPin({
rootDir,
sdkVersion: PLUGIN_SDK_VERSION,
app: hasApp,
});
if (changed === null) {
console.log(
`@get-bb/plugin-sdk is already pinned to ${PLUGIN_SDK_VERSION} — this bb's SDK version.`,
`@get-bb/plugin-sdk is already pinned to ${PLUGIN_SDK_VERSION} — this bb's SDK version${hasApp ? ", and the runtime-shimmed packages are at this bb's versions" : ""}.`,
);
console.log(
"The declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.",
Expand All @@ -1411,6 +1422,13 @@ export function registerPluginCommands(
"Moved @get-bb/plugin-sdk from dependencies to devDependencies.",
);
}
for (const shim of changed.shimmedTypePins) {
// Same reasoning as the SDK: bb shims these at runtime, so they
// are declared for types only, at the versions bb itself ships.
console.log(
`${shim.name}: ${shim.from ?? "(not declared)"} → ${shim.to} in devDependencies${shim.movedFromDependencies ? " (moved from dependencies)" : ""}.`,
);
}
// The new pin has to resolve for the declarations to land, so the
// same unpublished-version warning the scaffold prints applies.
await warnIfSdkVersionUnpublished();
Expand Down
12 changes: 8 additions & 4 deletions apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,13 @@ message agents, or inspect projects, providers, and environments.
that pairs the bb mobile app with this bb (it needs the `mobileApp`
experiment: `bb settings experiment mobileApp true`): it prints the code, server URL,
connect apex, and expiry; `--json` returns `{code, serverUrl, apex,
expiresAt}`. The phone enrolls as a connect machine with its own revocable
expiresAt}`. The phone enrolls as a connect machine with its own revocable
credential (visible in the getbb.app dashboard). Settings → Remote access →
Add mobile device shows the same code as a QR. A machine-limit failure names
the dashboard so the user can revoke an unused device. Remote
access is owned by the builtin `connect` plugin: `bb plugin disable connect`
cuts it off entirely; with bb connect still enabled, `bb plugin enable
connect` restores the command. Plugins → Connect shows the current URL, QR
connect` restores the command. Plugins → Connect shows the current URL, QR
code, mobile pairing, shared ports, re-pair form, and disconnect control.
- Add remote execution machines from Settings → Machines. Its one-line
installer stores the bb connect machine credential locally and configures
Expand Down Expand Up @@ -345,7 +345,7 @@ environment pull-request show <id>`. Diff commands require an explicit target
`cursor-project` and keeps them read-only.
- Custom ACP agents live in the ACP providers plugin's `customAgents` setting,
a JSON array: `bb plugin config provider-acp set customAgents '[{"id":"amp",
"displayName":"Amp","command":"amp","args":["acp"]}]'`. The user supplies a
"displayName":"Amp","command":"amp","args":["acp"]}]'`. The user supplies a
slug `id`; bb exposes it as provider id `acp-<id>`, which is permanent.
`cursor` is reserved; `opencode`, `omp`, `grok` and `hermes-agent` are not,
so an entry with one of those ids replaces the shipped agent. The plugin
Expand Down Expand Up @@ -922,7 +922,11 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier
- `bb plugin types [path]` — sync the plugin's `@get-bb/plugin-sdk` surface
to the running bb (default: cwd). For a plugin that depends on the npm
package it rewrites the exact `devDependencies` pin to this bb's SDK
version (reporting old → new, and reminding you to `npm install`); for a
version and brings the type-only devDependencies of the packages bb shims
at runtime (sonner, vaul, the portal radix families, @pierre/diffs, clsx,
tailwind-merge, class-variance-authority) to this bb's versions — adding
any an app plugin is missing and moving one out of `dependencies`
(reporting old → new, and reminding you to `npm install`); for a
plugin that still vendors declarations it rewrites `types/*.d.ts`, creating
`types/` when absent. Run it in a cloned or older plugin: the SDK surface
grows every release. `--check` writes nothing and exits non-zero on a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ The manifest is `package.json`:
`devDependencies` makes the plugin uninstallable from git, and unbuildable
after any install that omits dev deps — including the packaged CLI's own,
which runs npm under `NODE_ENV=production`. `devDependencies` is for types
and tooling only.
and tooling only — including every package bb shims at runtime (sonner,
vaul, the portal radix families, @pierre/diffs, clsx, tailwind-merge,
class-variance-authority): the build never bundles them, but `tsc` still
resolves their declarations through node_modules, so each one you import
needs a `devDependencies` entry at the host's version (`bb plugin new`
writes all of them; `bb plugin types` repins them). Never put one in
`dependencies` — that bundles a second copy beside the host's.
- `bb.host` (optional, singular) — full-trust Node 22 ESM entry bundled into
`dist/host.js` + source map + `host.meta.json`. Its owning server entry calls
it through typed host RPC. The daemon downloads it lazily, verifies its
Expand Down Expand Up @@ -198,7 +204,10 @@ does not cover:
1. **`bb plugin types`**, run in the plugin directory (or given its path),
syncs that plugin's SDK surface to the running bb — no server needed. For a
plugin that depends on the npm package it repins the exact
`@get-bb/plugin-sdk` devDependency to this bb's SDK version (run
`@get-bb/plugin-sdk` devDependency to this bb's SDK version and brings the
runtime-shimmed packages' type-only devDependencies (sonner, vaul, the
portal radix families, ...) to the versions this bb ships — adding any an
app plugin is missing and moving one out of `dependencies` (run
`npm install` after); for an older plugin that still vendors `types/*.d.ts`
it rewrites those declarations. Either way a cloned or older plugin can be
thousands of lines behind. `--check` reports a mismatch without writing;
Expand Down Expand Up @@ -394,7 +403,12 @@ const settings = bb.settings.define({
teamKey: { type: "string", label: "Team", default: "" },
// Multi-line editor (JSON, lists); the value is still a string the plugin
// parses itself. Cannot be combined with `secret`.
agents: { type: "string", label: "Agents", experimental_multiline: true, default: "[]" },
agents: {
type: "string",
label: "Agents",
experimental_multiline: true,
default: "[]",
},
mode: {
type: "select",
label: "Mode",
Expand Down Expand Up @@ -1023,7 +1037,10 @@ bb.agents.registerTool({
// tool name and the plugin's branding glyph. Errors/interruptions keep
// that standard rendering so the failing tool remains identifiable.
presentation: {
label: { pending: "Searching bundled docs", completed: "Searched bundled docs" },
label: {
pending: "Searching bundled docs",
completed: "Searched bundled docs",
},
},
parameters: z.object({ query: z.string().min(1) }),
async execute({ query }, { threadId, projectId, signal }) {
Expand Down Expand Up @@ -2054,10 +2071,10 @@ openWorkspaceFile }` — register a leaf
through the bridge's presentation. The component receives `row` (id,
threadId, turnId, kind, toolName, status, startedAt, completedAt),
`payload` (the extension item's validated payload, or `{ arguments,
output }` for a tool call), `presentation` (the bridge's label, icon,
output }` for a tool call), `presentation` (the bridge's label, icon,
title, detail, suppress and tint for the row; null only for a tool row
persisted before bridges attached one), `thread` (`{ id,
providerId }`) and `Original`, the host's declarative base for the body —
providerId }`) and `Original`, the host's declarative base for the body —
render `<Original />` to keep it beside your own content. The row header
(label, glyph, tint, headline) stays host-rendered; a glyph of the form
`"<pluginId>/<name>"` draws the plugin's declared icon
Expand Down Expand Up @@ -2441,7 +2458,13 @@ only `definePluginApp` + the hooks):
`-tooltip`, `-navigation-menu`), `sonner`, `vaul`, `@pierre/diffs` (+
`/react`). Your vendored overlays therefore share the host's
dismissable-layer/focus/scroll-lock world — stacking against host
overlays behaves correctly.
overlays behaves correctly. "Import freely" is about the bundle: `tsc`
still needs each one's declarations in `node_modules`, so every shimmed
package is a **type-only `devDependencies` entry at the host's version**
(the scaffold declares all of them; `bb plugin types` repins them; `bb
plugin types --check` reports drift). Never list one in `dependencies` —
the build would not read it, and a git install would bundle a second
copy of a singleton.
- Also never bundled, for size rather than singleton reasons: `clsx`,
`tailwind-merge`, and `class-variance-authority`. Your app bundle uses the
host's installed copies (tailwind-merge ^3, clsx ^2, cva ^0.7), so keep
Expand Down
37 changes: 4 additions & 33 deletions packages/plugin-build/scripts/generate-runtime-export-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
import { RUNTIME_SHIM_NPM_SPECIFIERS } from "../src/runtime-shims.mjs";

// Node 20 does not expose the browser-compatible Navigator global added in
// later Node releases. Some shared browser runtimes (currently @pierre/diffs)
Expand All @@ -31,39 +32,9 @@ const appRequire = createRequire(
path.join(scriptDir, "..", "..", "..", "apps", "app", "package.json"),
);

const RUNTIME_MODULE_IDS = [
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
"react/jsx-dev-runtime",
// Portaling radix families (plugin design §5.5): shimmed so vendored
// components share the host's dismissable-layer/focus/scroll-lock world.
// Non-portal radix has no singleton semantics and bundles per plugin.
"@radix-ui/react-alert-dialog",
"@radix-ui/react-context-menu",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-hover-card",
"@radix-ui/react-menubar",
"@radix-ui/react-navigation-menu",
"@radix-ui/react-popover",
"@radix-ui/react-select",
"@radix-ui/react-tooltip",
// toast() must reach the host toaster; vaul mutates document.body styles.
"sonner",
"vaul",
// Diff rendering: FileDiff reads the host's WorkerPoolContextProvider
// (React context identity requires one module copy) and sharing keeps
// shiki's grammars out of plugin bundles.
"@pierre/diffs",
"@pierre/diffs/react",
// Host-resident libraries (RUNTIME_SLOT_BY_SPECIFIER rule 2): no singleton
// semantics, shimmed so plugin bundles stop duplicating them.
"clsx",
"tailwind-merge",
"class-variance-authority",
];
// The shimmed npm modules, from the same list `bb plugin build` shims
// (src/runtime-shims.mjs) so the manifest can never miss a slot.
const RUNTIME_MODULE_IDS = RUNTIME_SHIM_NPM_SPECIFIERS;

/**
* Workspace TypeScript modules exposed as slots. Not requireable, so their
Expand Down
Loading
Loading