Skip to content

Commit 33757e3

Browse files
Address review follow-up: pull ordering, error types, plugin spec
- pull: check plugin-managed before remote-not-found so the message reflects why we wouldn't pull regardless of remote state - config: improve duplicate function name error to identify sources (project vs plugin "<namespace>") - entity/function readers: throw ConfigInvalidError for duplicate names (instead of InvalidInputError) for semantic consistency with the rest of config loading - docs: add plugins-spec.md user-facing spec covering install / extend / author Co-authored-by: Kfir Stri <kfirstri@users.noreply.github.com>
1 parent 2575386 commit 33757e3

9 files changed

Lines changed: 347 additions & 28 deletions

File tree

docs/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Read these when working on the relevant area:
7878
- **[Adding or modifying CLI commands](commands.md)** - Factory pattern, `runCommand()`, `runTask()`, `CLIContext`, theming, `chalk` ban
7979
- **[Making API calls](api-patterns.md)** - HTTP clients, Zod snake_case-to-camelCase transforms, `ApiError.fromHttpError()`
8080
- **[Working with resources](resources.md)** - `Resource<T>` interface, adding new resources, site module, unified deploy
81-
- **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior
81+
- **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior (end-user spec: [plugins-spec.md](plugins-spec.md))
8282
- **[Error handling](error-handling.md)** - Error hierarchy, throwing patterns, error codes, `CLIExitError`, `process.exit` ban
8383
- **[Writing tests](testing.md)** - Testkit, Given/When/Then pattern, API mocks, fixtures, test overrides
8484
- **[Telemetry & error reporting](telemetry.md)** - PostHog `ErrorReporter`, what's captured, disabling

docs/plugins-spec.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# Plugins — Feature Spec
2+
3+
**Keywords:** plugin spec, plugin authoring, install plugin, extend plugin entity, plugin namespace, plugin functions, base44 plugin
4+
5+
This document is the end-user spec for plugin support in the Base44 CLI. It explains the plugin model, how to **install** a plugin in an app, how to **extend** a plugin's entities, and how to **author** a new plugin.
6+
7+
For the agent-facing reference (file paths, internal flow), see [plugins.md](plugins.md).
8+
9+
## What a plugin is
10+
11+
A plugin is itself a Base44 project — same `base44/` layout, same `config.jsonc`, same entity and function files — that another Base44 project consumes as a dependency. A plugin contributes two kinds of resources to its host:
12+
13+
- **Entities** — schemas the host project sees as if it had defined them, identified by their plain name (`Customer`, not `crm.Customer`).
14+
- **Backend functions** — code modules deployed under a namespaced name (`crm__syncCustomer`).
15+
16+
Plugins do **not** contribute agents, connectors, or auth config in this version. Those are dropped silently when loading a plugin.
17+
18+
Plugin support is resolved entirely on the developer's machine when `base44` reads the project config. There is no backend record of which resources came from a plugin; the deployed app behaves exactly as if all merged resources had been written by hand.
19+
20+
## Installing a plugin in an app
21+
22+
### 1. Declare the plugin in `config.jsonc`
23+
24+
In your app's `base44/config.jsonc`, add a `plugins` array:
25+
26+
```jsonc
27+
{
28+
"name": "My App",
29+
"plugins": [
30+
{ "source": "../plugins/crm" },
31+
{ "source": "@acme/billing-plugin" },
32+
{ "source": "/absolute/path/to/plugin" }
33+
]
34+
}
35+
```
36+
37+
`source` accepts three forms:
38+
39+
| Form | Example | Resolved as |
40+
| --- | --- | --- |
41+
| Relative path | `"../plugins/crm"` | resolved from the directory of the host `config.jsonc` |
42+
| Absolute path | `"/Users/me/plugins/crm"` | used as-is |
43+
| Package name | `"@acme/billing-plugin"` | resolved with Node `createRequire()` from the host config directory, i.e. it must be installed in `node_modules` |
44+
45+
When using a package-name source, install the plugin like any npm dependency:
46+
47+
```bash
48+
npm install @acme/billing-plugin
49+
```
50+
51+
### 2. Run any command
52+
53+
That's it. The next time you run `base44 entities push`, `base44 functions deploy`, `base44 deploy`, or anything else that reads the project config, the plugin's resources are loaded and merged automatically.
54+
55+
```bash
56+
base44 deploy
57+
# → deploys local entities + plugin entities, local functions + plugin functions
58+
```
59+
60+
### 3. What you'll see
61+
62+
- `base44 entities push` includes plugin entities. The merged entity carries the plugin's name (no namespace).
63+
- `base44 functions deploy` deploys plugin functions under `<namespace>__<functionName>` (e.g. `crm__syncCustomer`).
64+
- `base44 functions pull` **skips** plugin-owned functions — it will never overwrite plugin code in your app's `functions/` directory. Pulling a plugin function by name returns a message stating it is plugin-managed.
65+
- `base44 functions list` shows the deployed names on the remote, which include any namespaced plugin functions.
66+
67+
## Extending a plugin entity
68+
69+
A host app can extend a plugin's entity by writing an entity file with the **same name** in its own `entities/` directory. This is treated as an extension, not a replacement.
70+
71+
### What you can do
72+
73+
- Add new properties.
74+
- Mark **project-added** properties as required by listing them in `required`.
75+
76+
### What you cannot do
77+
78+
- Override or replace plugin-owned properties.
79+
- Override `title`, `description`, or top-level `rls`.
80+
- Mark plugin-owned properties as required from the project.
81+
82+
Top-level RLS extension is **not** supported in this version (see [Limitations](#limitations)).
83+
84+
### Example
85+
86+
Plugin defines `Customer`:
87+
88+
```jsonc
89+
// plugins/crm/base44/entities/customer.json
90+
{
91+
"name": "Customer",
92+
"title": "Customer",
93+
"properties": {
94+
"company": { "type": "string" }
95+
},
96+
"required": ["company"]
97+
}
98+
```
99+
100+
Host extends it:
101+
102+
```jsonc
103+
// my-app/base44/entities/customer.json
104+
{
105+
"name": "Customer",
106+
"properties": {
107+
"tier": { "type": "string" }
108+
},
109+
"required": ["tier"]
110+
}
111+
```
112+
113+
Merged result deployed to Base44:
114+
115+
```jsonc
116+
{
117+
"name": "Customer",
118+
"title": "Customer",
119+
"properties": {
120+
"company": { "type": "string" },
121+
"tier": { "type": "string" }
122+
},
123+
"required": ["company", "tier"]
124+
}
125+
```
126+
127+
If you try to redefine `company` in the host entity, config loading fails with `Cannot override plugin-defined property "company"`. If you list a plugin property like `company` in the host `required` array, config loading fails with a message explaining that only project-added properties can be marked required by the host.
128+
129+
## Calling plugin functions
130+
131+
Plugin functions are deployed under their namespaced name. Reference them from your code by that name:
132+
133+
```ts
134+
// in a host project function
135+
await fetch(`${baseUrl}/functions/crm__syncCustomer`, { method: "POST" });
136+
```
137+
138+
A project function named `crm__syncCustomer` would collide with the plugin's namespaced name and is rejected at config-load time with a message identifying both sources (project vs plugin `"crm"`).
139+
140+
## Authoring a plugin
141+
142+
A plugin is just a Base44 project with a single extra field. Anyone who can write an entity or function in a normal Base44 project can author one.
143+
144+
### 1. Scaffold a plugin project
145+
146+
Use the standard `base44/` layout:
147+
148+
```
149+
my-plugin/
150+
├── package.json # required only if you publish to npm
151+
└── base44/
152+
├── config.jsonc
153+
├── entities/
154+
│ └── customer.json
155+
└── functions/
156+
└── sync-customer/
157+
├── function.jsonc
158+
└── index.ts
159+
```
160+
161+
### 2. Declare the plugin namespace in `config.jsonc`
162+
163+
```jsonc
164+
{
165+
"name": "CRM Plugin",
166+
"plugin": {
167+
"namespace": "crm"
168+
}
169+
}
170+
```
171+
172+
Namespace rules:
173+
174+
- Required for any project consumed as a plugin.
175+
- Must match `^[a-zA-Z0-9_-]+$` (letters, digits, `_`, `-`).
176+
- Must be unique across all plugins a single host project loads.
177+
- Used as the prefix for function names: `<namespace>__<functionName>`.
178+
179+
A plugin project **cannot itself declare plugins**. Config loading rejects this case explicitly — plugins of plugins are not supported in this version.
180+
181+
### 3. Write entities
182+
183+
Entities live under `base44/entities/` exactly as in a normal Base44 project. The plugin's entity name becomes the global name in any host that installs the plugin — pick names that are unlikely to collide.
184+
185+
```jsonc
186+
// base44/entities/customer.json
187+
{
188+
"name": "Customer",
189+
"title": "Customer",
190+
"properties": {
191+
"company": { "type": "string" }
192+
},
193+
"required": ["company"]
194+
}
195+
```
196+
197+
Plugin authors should treat the schema as a public API: once your plugin is in use, removing properties or changing types is a breaking change for host apps. Adding new properties is safe.
198+
199+
### 4. Write functions
200+
201+
Functions live under `base44/functions/<functionName>/` with the standard `function.jsonc` + entry file:
202+
203+
```jsonc
204+
// base44/functions/sync-customer/function.jsonc
205+
{
206+
"name": "syncCustomer",
207+
"entry": "index.ts"
208+
}
209+
```
210+
211+
```ts
212+
// base44/functions/sync-customer/index.ts
213+
Deno.serve(async () => new Response("ok"));
214+
```
215+
216+
The host app will deploy this as `crm__syncCustomer` (for namespace `crm`). Inside the function code itself the name does not matter — it's the deployed name that gets the namespace prefix.
217+
218+
### 5. (Optional) Publish to npm
219+
220+
If you want hosts to install your plugin by package name:
221+
222+
1. Add a `package.json` at the plugin root with `"name": "@acme/my-plugin"`.
223+
2. Include the `base44/` directory in the `files` field so it ships in the tarball.
224+
3. `npm publish`.
225+
226+
Hosts can then declare `{ "source": "@acme/my-plugin" }`. Local-path sources work without publishing and are the easiest way to develop and test a plugin.
227+
228+
## Validation rules
229+
230+
The CLI rejects a project config when any of the following hold:
231+
232+
| Failure | Where it triggers |
233+
| --- | --- |
234+
| Plugin source resolves to a directory with no config | `resolvePluginRoot` / `findConfigOrThrow` |
235+
| Plugin config has no `plugin.namespace` | `requirePluginNamespace` |
236+
| Two plugins declare the same namespace | `registerPluginNamespace` |
237+
| Two plugins define the same entity name | `readPlugins` |
238+
| A plugin declares its own `plugins` array | `assertPluginProjectDoesNotLoadPlugins` |
239+
| A host entity tries to override plugin metadata (`title`, `description`, `rls`) | `mergePluginEntity` |
240+
| A host entity tries to redefine a plugin-owned property | `mergePluginEntity` |
241+
| A host entity marks a non-project-added property as required | `mergePluginEntity` |
242+
| A local function name collides with a namespaced plugin function | `validateFunctionNames` |
243+
244+
All of these surface as `ConfigInvalidError` with a message that names the conflicting parties.
245+
246+
## Limitations
247+
248+
These are known gaps in the current version and may relax later:
249+
250+
- **No top-level RLS extension.** A host entity that extends a plugin entity cannot add or merge top-level `rls` rules. The plugin's RLS is preserved as-is.
251+
- **No nested plugins.** A plugin cannot itself declare `plugins`. Cycles are therefore not possible today, but if nested plugins are ever enabled, cycle protection must be added.
252+
- **No plugin spec version field.** There is no way for a host or the CLI to detect that a plugin was built against an older plugin contract.
253+
- **Entity names are global.** Plugin entities are not namespaced; two plugins cannot define the same entity name in a single host.
254+
- **Plugin agents, connectors, and auth config are dropped.** Only entities and functions are contributed.
255+
- **Plugin origin is not persisted on the backend.** The host project deploys merged resources; the backend has no record of which came from a plugin.
256+
257+
## Worked example
258+
259+
Project layout:
260+
261+
```
262+
my-app/
263+
├── package.json
264+
├── node_modules/
265+
│ └── @acme/billing-plugin/ # installed via npm
266+
│ └── base44/
267+
│ ├── config.jsonc # { plugin: { namespace: "billing" } }
268+
│ ├── entities/invoice.json # name: "Invoice"
269+
│ └── functions/create-invoice/function.jsonc # name: "createInvoice"
270+
├── plugins/
271+
│ └── crm/ # local plugin
272+
│ └── base44/
273+
│ ├── config.jsonc # { plugin: { namespace: "crm" } }
274+
│ ├── entities/customer.json # name: "Customer", props: company
275+
│ └── functions/sync-customer/function.jsonc # name: "syncCustomer"
276+
└── base44/
277+
├── config.jsonc # plugins: [crm, billing]
278+
└── entities/
279+
├── app-only.json # name: "AppOnly"
280+
└── customer.json # name: "Customer", props: tier
281+
```
282+
283+
`my-app/base44/config.jsonc`:
284+
285+
```jsonc
286+
{
287+
"name": "My App",
288+
"plugins": [
289+
{ "source": "../plugins/crm" },
290+
{ "source": "@acme/billing-plugin" }
291+
]
292+
}
293+
```
294+
295+
After `base44 deploy`:
296+
297+
- Entities deployed: `AppOnly`, `Customer` (with both `company` and `tier`), `Invoice`.
298+
- Functions deployed: `crm__syncCustomer`, `billing__createInvoice`.
299+
- `base44 functions pull` will pull project functions and skip `crm__syncCustomer` and `billing__createInvoice`.

docs/plugins.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
**Keywords:** plugin, plugins, namespace, ResourceSourceSchema, plugin source, entity extension, function namespacing, functions pull, config plugins, readProjectConfig
44

5+
For the end-user spec (installing, authoring, extending plugins with worked examples), see [plugins-spec.md](plugins-spec.md). This file is the agent-facing reference for the implementation.
6+
57
Plugins let one Base44 project consume reusable resources from another Base44-style project. In this version, plugins contribute **entities** and **backend functions** only.
68

79
Plugin support is resolved locally by `readProjectConfig()`. The backend does not store plugin ownership metadata in this version.

packages/cli/src/cli/commands/functions/pull.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ async function pullFunctionsAction(
3030
},
3131
);
3232

33+
if (name && pluginFunctionNames.has(name)) {
34+
return {
35+
outroMessage: `Function "${name}" is managed by a plugin and was not pulled into ${functionsDir}`,
36+
};
37+
}
38+
3339
const matchingRemote = name
3440
? remoteFunctions.filter((f) => f.name === name)
3541
: remoteFunctions;
@@ -40,12 +46,6 @@ async function pullFunctionsAction(
4046
};
4147
}
4248

43-
if (name && pluginFunctionNames.has(name)) {
44-
return {
45-
outroMessage: `Function "${name}" is managed by a plugin and was not pulled into ${functionsDir}`,
46-
};
47-
}
48-
4949
const skippedPluginOwned = matchingRemote.filter((fn) =>
5050
pluginFunctionNames.has(fn.name),
5151
);

packages/cli/src/core/project/config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ import { readJsonFile } from "@/core/utils/fs.js";
3232

3333
type ProjectResources = Omit<ProjectData, "project">;
3434

35+
function describeFunctionSource(fn: BackendFunction): string {
36+
return fn.source.type === "plugin"
37+
? `plugin "${fn.source.namespace}"`
38+
: "project";
39+
}
40+
3541
async function findConfigInDir(dir: string): Promise<string | null> {
3642
const files = await globby(PROJECT_CONFIG_PATTERNS, {
3743
cwd: dir,
@@ -293,7 +299,7 @@ class ProjectConfigReader {
293299
const existingFunction = functionsByName.get(fn.name);
294300
if (existingFunction) {
295301
throw new ConfigInvalidError(
296-
`Duplicate function name "${fn.name}" after loading project plugins.`,
302+
`Duplicate function name "${fn.name}" after loading project plugins (${describeFunctionSource(existingFunction)} and ${describeFunctionSource(fn)}).`,
297303
configPath,
298304
{
299305
hints: [

packages/cli/src/core/resources/entity/config.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { globby } from "globby";
22
import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js";
3-
import { InvalidInputError, SchemaValidationError } from "@/core/errors.js";
3+
import { ConfigInvalidError, SchemaValidationError } from "@/core/errors.js";
44
import type { Entity } from "@/core/resources/entity/schema.js";
55
import { EntitySchema } from "@/core/resources/entity/schema.js";
66
import { pathExists, readJsonFile } from "@/core/utils/fs.js";
@@ -37,13 +37,17 @@ export async function readAllEntities(entitiesDir: string): Promise<Entity[]> {
3737
const names = new Set<string>();
3838
for (const entity of entities) {
3939
if (names.has(entity.name)) {
40-
throw new InvalidInputError(`Duplicate entity name "${entity.name}"`, {
41-
hints: [
42-
{
43-
message: `Remove duplicate entities with name "${entity.name}" - only one entity per name is allowed`,
44-
},
45-
],
46-
});
40+
throw new ConfigInvalidError(
41+
`Duplicate entity name "${entity.name}" in ${entitiesDir}`,
42+
entitiesDir,
43+
{
44+
hints: [
45+
{
46+
message: `Remove duplicate entities with name "${entity.name}" - only one entity per name is allowed`,
47+
},
48+
],
49+
},
50+
);
4751
}
4852
names.add(entity.name);
4953
}

0 commit comments

Comments
 (0)