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
25 changes: 25 additions & 0 deletions apps/cli/src/__tests__/plugin-new.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveNewPluginTarget } from "../commands/plugin.js";

describe("resolveNewPluginTarget", () => {
it.each([
["hello", "bb-plugin-hello", "bb-plugin-hello"],
["bb-plugin-hello", "bb-plugin-hello", "bb-plugin-hello"],
["@acme/bb-plugin-hello", "@acme/bb-plugin-hello", "bb-plugin-hello"],
])("resolves %s", (name, expectedPackageName, expectedDirectoryName) => {
expect(resolveNewPluginTarget(name)).toEqual({
packageName: expectedPackageName,
directoryName: expectedDirectoryName,
});
});

it.each([
"Hello",
"bb-plugin-",
"@acme/hello",
"@acme/bb-plugin-Hello",
"@acme/team/bb-plugin-hello",
])("rejects %s", (name) => {
expect(resolveNewPluginTarget(name)).toBeNull();
});
});
42 changes: 33 additions & 9 deletions apps/cli/src/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createInterface } from "node:readline/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { Command } from "commander";
import { z } from "zod";
import { derivePluginId } from "@bb/domain";
import type {
InstalledPlugin as PluginEntry,
PluginApplyUpdateResult,
Expand All @@ -32,6 +33,30 @@ import { resolveBbCliVersion } from "../version.js";
import { outputJson, type JsonOutputOptions } from "./helpers.js";
import { renderBorderlessTable } from "../table.js";

export interface NewPluginTarget {
packageName: string;
directoryName: string;
}

export function resolveNewPluginTarget(name: string): NewPluginTarget | null {
const packageName = name.startsWith("@")
? name
: name.startsWith("bb-plugin-")
? name
: `bb-plugin-${name}`;
if (
!/^(?:@[a-z0-9][a-z0-9-]*\/)?bb-plugin-[a-z0-9][a-z0-9-]*$/.test(
packageName,
)
) {
return null;
}
return {
packageName,
directoryName: `bb-plugin-${derivePluginId(packageName)}`,
};
}

/**
* Where `bb plugin build`/`dev` cache the pinned esbuild/Tailwind set.
*
Expand Down Expand Up @@ -722,31 +747,30 @@ export function registerPluginCommands(
plugin
.command("new <name>")
.description(
"Scaffold a new plugin in ./bb-plugin-<name> (no server required)",
"Scaffold a plugin in ./bb-plugin-<name>; accepts @scope/bb-plugin-<name>",
)
.option(
"--app",
"Also scaffold a frontend entry (app.tsx, built by `bb plugin build`)",
)
.action(
action(async (name: string, opts: { app?: boolean }) => {
const packageName = name.startsWith("bb-plugin-")
? name
: `bb-plugin-${name}`;
if (!/^bb-plugin-[a-z0-9][a-z0-9-]*$/.test(packageName)) {
const target = resolveNewPluginTarget(name);
if (target === null) {
console.error(
`Invalid plugin name "${name}" — use lowercase letters, digits, and dashes.`,
`Invalid plugin name "${name}" — use name, bb-plugin-name, or @scope/bb-plugin-name.`,
);
process.exit(1);
}
const targetDir = resolve(process.cwd(), packageName);
const { directoryName, packageName } = target;
const targetDir = resolve(process.cwd(), directoryName);
await scaffoldPlugin({
targetDir,
packageName,
bbVersion: resolveBbCliVersion(),
app: opts.app ?? false,
});
console.log(`Created ${packageName}/`);
console.log(`Created ${directoryName}/ (${packageName}).`);
// App scaffolds vendor components whose npm deps must be installed
// before `bb plugin build` bundles them. Best-effort: authors need
// npm anyway (design §5.5); a failure here just surfaces the manual
Expand All @@ -770,7 +794,7 @@ export function registerPluginCommands(
}
}
console.log("Next steps:");
console.log(` cd ${packageName}`);
console.log(` cd ${directoryName}`);
if (opts.app && !installed) {
console.log(" npm install");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,13 @@ The manifest is `package.json`:
`builtWith: { bbVersion, pluginSdkVersion }`. Managed installs reject
artifacts whose `pluginId`/`pluginVersion` disagree with the package
manifest, or whose SDK major does not match the host.
- The plugin id is the package name minus the `bb-plugin-` prefix
(`bb-plugin-hello` → `hello`); it namespaces routes, storage, settings,
and CLI commands. Ids reserved by builtins (`automations`, `connect`,
`custom-instructions`, `inline-vis`, `secrets`) cannot be
installed from a non-`builtin:` source — use `builtin:<name>` instead.
- Default to `bb-plugin-hello` for the package name. Scoped names such as
`@acme/bb-plugin-hello` are also supported. The plugin id is the final
package-name component minus the `bb-plugin-` prefix, so both forms use
`hello`; it namespaces routes, storage, settings, and CLI commands. Builtin
ids such as
`automations`, `connect`, `custom-instructions`, `inline-vis`, and `secrets`
cannot use a non-`builtin:` source — use `builtin:<name>` instead.

Backend API imports normally stay type-only;
the root runtime exports are `defineRpcContract`, supplied by BB for shared
Expand Down
12 changes: 12 additions & 0 deletions packages/domain/test/plugin-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";

import { derivePluginId } from "../src/plugin-id.js";

describe("derivePluginId", () => {
it.each([
["bb-plugin-hello", "hello"],
["@acme/bb-plugin-hello", "hello"],
])("derives %s as %s", (packageName, expectedId) => {
expect(derivePluginId(packageName)).toBe(expectedId);
});
});
2 changes: 1 addition & 1 deletion packages/templates/src/generated/templates.generated.ts

Large diffs are not rendered by default.

15 changes: 5 additions & 10 deletions packages/templates/src/plugin-scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
writeFile,
} from "node:fs/promises";
import { dirname, isAbsolute, join, relative } from "node:path";
import { PLUGIN_SDK_VERSION } from "@bb/domain";
import { derivePluginId, PLUGIN_SDK_VERSION } from "@bb/domain";
import {
PLUGIN_SDK_APP_DTS,
PLUGIN_SDK_DTS,
Expand Down Expand Up @@ -190,13 +190,8 @@ async function writeDeclarationAtomically(
}
}

/** "bb-plugin-hello" → "hello" (mirrors the server's id derivation). */
function pluginIdOf(packageName: string): string {
return packageName.replace(/^bb-plugin-/, "");
}

function pluginNameOf(packageName: string): string {
return pluginIdOf(packageName)
return derivePluginId(packageName)
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
Expand Down Expand Up @@ -253,7 +248,7 @@ function componentsJsonSource(bbVersion: string): string {
}

function serverEntrySource(packageName: string): string {
const id = pluginIdOf(packageName);
const id = derivePluginId(packageName);
return `// ${packageName} — a BB plugin backend entry.
//
// The default export is a factory that receives the plugin API. BB supplies
Expand Down Expand Up @@ -319,7 +314,7 @@ export default async function plugin(bb: BbPluginApi) {
}

function appEntrySource(packageName: string): string {
const id = pluginIdOf(packageName);
const id = derivePluginId(packageName);
return `// ${packageName} — a BB plugin frontend entry.
//
// Compiled by \`bb plugin build\` into dist/app.js + dist/app.css. React and
Expand Down Expand Up @@ -444,7 +439,7 @@ Describe when to use this skill and the steps to follow.
}

function readmeSource(packageName: string, app: boolean): string {
const id = pluginIdOf(packageName);
const id = derivePluginId(packageName);
const componentsSection = app
? `
## UI components
Expand Down
6 changes: 4 additions & 2 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,10 @@ least `icon` or `logo.light`, `bb.server`
into agent threads unless filtered by `bb.agents.configure`; default
`skills/`), `engines.bb` (supported bb range),
and optional `engines.bbPluginSdk` (supported plugin SDK range; scaffold
writes `"^0.4.1"` for SDK 0.4.1). The plugin id is the package name minus
`bb-plugin-`.
writes `"^0.4.1"` for SDK 0.4.1). Use `bb-plugin-hello` for the package name by
default. Scoped names such as `@acme/bb-plugin-hello` are also supported. The

@SawyerHood SawyerHood Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The scoped-name statement does not match the author process.

bb plugin new @acme/bb-plugin-hello rejects this value. The scaffold also uses a duplicate parser that keeps the scope in generated IDs. Please reuse derivePluginId and add a scoped scaffold test. You can instead limit this text to managed installs.

plugin id is the final package-name component minus `bb-plugin-`, so both forms
use `hello`.

Plugins can contribute palettes with `bb.themes`: an array of
`{ id, name, description?, css }`, where `css` is a plugin-relative `.css`
Expand Down
23 changes: 23 additions & 0 deletions packages/templates/test/plugin-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,27 @@ describe("scaffoldPlugin bundled types", () => {
"https://raw.githubusercontent.com/get-bb/bb/desktop-v0.9.0/packages/plugin-registry/r/{name}.json",
);
});

it("uses the canonical id in a scoped package scaffold", async () => {
const targetDir = join(workDir, "bb-plugin-scoped");
await scaffoldPlugin({
targetDir,
packageName: "@acme/bb-plugin-scoped",
bbVersion: "0.9.0",
});

const pkg = JSON.parse(
await readFile(join(targetDir, "package.json"), "utf8"),
);
expect(pkg.name).toBe("@acme/bb-plugin-scoped");
expect(pkg.bb.name).toBe("Scoped");

const readme = await readFile(join(targetDir, "README.md"), "utf8");
expect(readme).toContain("bb plugin reload scoped");
expect(readme).toContain("bb plugin config scoped");

const server = await readFile(join(targetDir, "server.ts"), "utf8");
expect(server).toContain("bb plugin config scoped");
expect(server).not.toContain("bb plugin config @acme/");
});
});
Loading