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
136 changes: 111 additions & 25 deletions apps/cli/src/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@ import type {
} from "@bb/server-contract";
import { installedPluginSchema } from "@bb/server-contract";
import { BbHttpError } from "@bb/sdk";
import {
parseDataDirEnvValue,
resolveProdDataDir,
} from "@bb/config/runtime";
import { scaffoldPlugin } from "@bb/templates/plugin-scaffold";
import { parseDataDirEnvValue, resolveProdDataDir } from "@bb/config/runtime";
import { scaffoldPlugin, syncPluginTypes } from "@bb/templates/plugin-scaffold";
import { action } from "../action.js";
import { cliFetch, createCliBbSdk } from "../client.js";
import {
Expand Down Expand Up @@ -119,6 +116,52 @@ const pluginManifestSchema = z.object({
});
const secretSettingValueSchema = z.object({ set: z.boolean().optional() });

/**
* Read a plugin directory's manifest. Returns null when the directory has no
* readable `package.json`, so callers can print their own guidance.
*/
async function readPluginManifest(
rootDir: string,
): Promise<z.infer<typeof pluginManifestSchema> | null> {
try {
const raw: unknown = JSON.parse(
await readFile(join(rootDir, "package.json"), "utf8"),
);
return pluginManifestSchema.parse(raw);
} catch {
return null;
}
}

/**
* Refresh `types/*.d.ts` against this CLI's bundled SDK declarations and
* report each file that actually changed.
*
* `bb plugin build` and `bb plugin dev` call this so an author never
* typechecks against declarations older than the bb they run. A failure here
* is reported and swallowed: a read-only or otherwise unwritable `types/`
* must not fail a build.
*/
async function refreshPluginTypes(
rootDir: string,
hasApp: boolean,
): Promise<void> {
let files: Awaited<ReturnType<typeof syncPluginTypes>>;
try {
files = await syncPluginTypes({ rootDir, app: hasApp });
} catch (error) {
console.warn(
`Could not refresh types/ — ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
const written = files.filter((file) => file.outcome === "written");
if (written.length === 0) return;
console.log(
`Refreshed SDK declarations: ${written.map((file) => file.path).join(", ")}`,
);
}

type PluginSettingDescriptor = z.infer<typeof pluginSettingDescriptorSchema>;
type PluginSettingsResult = z.infer<typeof pluginSettingsResultSchema>;

Expand Down Expand Up @@ -735,6 +778,52 @@ export function registerPluginCommands(
}),
);

plugin
.command("types [path]")
.description(
"Write this bb's @bb/plugin-sdk declarations into the plugin's types/ directory (default: cwd); the authoritative, readable API surface for editors, tsc, and agents",
)
.option("--check", "Report whether types/ is current; write nothing")
.action(
action(async (path: string | undefined, opts: { check?: boolean }) => {
const rootDir = resolve(process.cwd(), path ?? ".");
const manifest = await readPluginManifest(rootDir);
if (!manifest) {
console.error(
`No readable package.json in ${rootDir} — run from a plugin directory or pass its path.`,
);
process.exit(1);
}
if (typeof manifest.bb?.server !== "string") {
console.error(
`${rootDir} is not a bb plugin — package.json has no "bb.server" entry.`,
);
process.exit(1);
}
const hasApp = typeof manifest.bb.app === "string";
const files = await syncPluginTypes({
rootDir,
app: hasApp,
check: opts.check ?? false,
});
for (const file of files) {
console.log(`${file.path} ${file.outcome}`);
}
if (opts.check) {
if (files.some((file) => file.outcome === "stale")) {
console.error(
"Declarations are out of date — run `bb plugin types` to refresh them.",
);
process.exit(1);
}
return;
}
console.log(
"These declarations are the full plugin API — read them for exact signatures.",
);
}),
);

plugin
.command("build [path]")
.description(
Expand All @@ -744,22 +833,21 @@ export function registerPluginCommands(
action(async (path: string | undefined) => {
const rootDir = resolve(process.cwd(), path ?? ".");
const bbVersion = resolveBbCliVersion();
// buildPluginServer errors legibly on a missing/invalid bb.server —
// every plugin has one, so a headless plugin succeeds with just the
// backend bundle (prebuilt distribution, design §6).
// Read the manifest before building: buildPluginServer errors legibly
// on a missing/invalid bb.server, so a null manifest here is only the
// unreachable case where that read also fails.
const manifest = await readPluginManifest(rootDir);
const hasApp = typeof manifest?.bb?.app === "string";
// Keep the local declarations tracking the bb doing the build, so a
// plugin scaffolded against an older SDK never typechecks green
// against an API this bb no longer has. Gate on bb.server so a
// directory this command is about to reject is never written to first.
if (typeof manifest?.bb?.server === "string") {
await refreshPluginTypes(rootDir, hasApp);
}
const toolchain = await cliBuildToolchain();
const server = await buildPluginServer(rootDir, bbVersion, toolchain);
const files = [server.jsPath, server.mapPath, server.metaPath];
let hasApp = false;
try {
const raw: unknown = JSON.parse(
await readFile(join(rootDir, "package.json"), "utf8"),
);
const pkg = pluginManifestSchema.parse(raw);
hasApp = typeof pkg.bb?.app === "string";
} catch {
// Unreachable in practice: buildPluginServer already read it.
}
if (hasApp) {
const app = await buildPluginApp(rootDir, bbVersion, toolchain);
files.push(app.jsPath, app.cssPath, app.metaPath);
Expand All @@ -778,13 +866,8 @@ export function registerPluginCommands(
.action(
action(async (path: string | undefined) => {
const rootDir = resolve(process.cwd(), path ?? ".");
let manifest: z.infer<typeof pluginManifestSchema>;
try {
const raw: unknown = JSON.parse(
await readFile(join(rootDir, "package.json"), "utf8"),
);
manifest = pluginManifestSchema.parse(raw);
} catch {
const manifest = await readPluginManifest(rootDir);
if (!manifest) {
console.error(
`No readable package.json in ${rootDir} — run from a plugin directory or pass its path.`,
);
Expand All @@ -797,6 +880,9 @@ export function registerPluginCommands(
process.exit(1);
}
const hasApp = typeof manifest.bb.app === "string";
// Refresh before the watcher starts, so writing types/ cannot feed
// the loop its own change event.
await refreshPluginTypes(rootDir, hasApp);
// The dev loop drives an *installed* plugin; match this directory
// against the server's installed rows (realpath tolerates symlinked
// checkouts).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,12 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier
`server.meta.json` stamped with SDK/identity metadata; preferred by
git/npm installs over source) and, when `bb.app` is declared, `app.js` +
`app.css` + `app.meta.json`. Neither needs the server.
- `bb plugin types [path]` — rewrite the plugin's `types/*.d.ts` from the
running bb's `@bb/plugin-sdk` declarations, creating `types/` when absent.
Run it in a cloned or older plugin: the scaffold seeds those files once and
the SDK surface grows every release. `--check` reports staleness and exits
non-zero without writing (for CI). `bb plugin build` and `bb plugin dev`
refresh them automatically. Needs no server.
- `bb plugin dev [path]` — watch loop for an installed plugin (default:
cwd): on every change it rebuilds the frontend bundle (when `bb.app` is
declared) and reloads the plugin; open app pages pick the new UI up live.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,7 @@ The manifest is `package.json`:
`custom-instructions`, `inline-vis`, `secrets`) cannot be
installed from a non-`builtin:` source — use `builtin:<name>` instead.

The scaffold ships the full API as bundled type declarations in `types/`
(`bb-plugin-sdk.d.ts`, plus `bb-plugin-sdk-app.d.ts` for `--app`); its
`tsconfig.json` maps `@bb/plugin-sdk` to them, so `npm install && npx tsc
--noEmit` typechecks anywhere — no bb checkout required. Those `.d.ts` files
are the authoritative, exhaustive surface: read them (or the source at
<https://github.com/get-bb/bb>, cloned) when you need an exact signature or
a symbol this skill doesn't cover. Backend API imports normally stay type-only;
Backend API imports normally stay type-only;
the root runtime exports are `defineRpcContract`, supplied by BB for shared
schema contracts, and the numeric `PLUGIN_CLI_OUTPUT_MAX_BYTES` ceiling:
`import { defineRpcContract, type BbPluginApi } from
Expand All @@ -134,6 +128,27 @@ On-disk state per plugin: `<dataDir>/plugins/<id>/data.db` (its SQLite),
rotated at 5MB). Settings edits never auto-reload — `bb plugin reload <id>`
after configuring.

## Looking up the exact API

This skill is a guide, not the contract. For an exact signature or a symbol it
does not cover:

1. **`bb plugin types`**, run in the plugin directory (or given its path),
rewrites that plugin's `types/*.d.ts` from the running bb — no server
needed. The scaffold seeds them once, so a cloned or older plugin can be
thousands of lines behind. `--check` reports staleness without writing;
`bb plugin build` and `bb plugin dev` refresh them too.
2. **Read `types/bb-plugin-sdk.d.ts`** (`-app.d.ts` for frontend symbols) —
the authoritative surface, ~13,000 lines of readable declarations with doc
comments, and what the scaffold `tsconfig.json` maps `@bb/plugin-sdk` to.
3. **`git clone --depth 1 https://github.com/get-bb/bb`** for host behavior or
a reference implementation: `packages/plugin-sdk/src/`,
`apps/server/src/services/plugins/`, `plugins/`.

Never answer an API question from a built bundle — `dist/*.js` and the bb app's
own JavaScript are minified. If you are grepping minified JavaScript, go back
to step 1.

## Distributing a plugin

Users can install third-party plugins directly from a local path, npm package,
Expand Down Expand Up @@ -1602,3 +1617,6 @@ Remaining reference examples in `examples/plugins/`:
`defineRpcContract` plus `PLUGIN_CLI_OUTPUT_MAX_BYTES`; validator imports are
plugin dependencies. The
scaffold tsconfig typechecks both `server.ts` and `app.tsx`.
- `types/*.d.ts` is a per-plugin copy, not a live view of the SDK: run
`bb plugin types` before trusting it, and never fall back to a minified
`dist/` bundle — see "Looking up the exact API".
2 changes: 1 addition & 1 deletion packages/templates/src/generated/templates.generated.ts

Large diffs are not rendered by default.

Loading
Loading