@sparkerp/plugin-sdk 0.1.0
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/LICENSE +21 -0
- package/README.md +61 -0
- package/bundle/blocks.json +12229 -0
- package/bundle/catalog.json +32475 -0
- package/bundle/docs/applications/hcm/employee-profile.md +144 -0
- package/bundle/docs/concepts/artifact-types.md +80 -0
- package/bundle/docs/concepts/glossary.md +124 -0
- package/bundle/docs/concepts/overview.md +88 -0
- package/bundle/docs/concepts/page-data-flow.md +84 -0
- package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
- package/bundle/docs/concepts/sdk-modes.md +71 -0
- package/bundle/docs/docs.json +109 -0
- package/bundle/docs/guides/add-a-cadence-job.md +134 -0
- package/bundle/docs/guides/add-a-compliance-job.md +139 -0
- package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
- package/bundle/docs/guides/add-a-custom-block.md +225 -0
- package/bundle/docs/guides/add-a-data-provider.md +216 -0
- package/bundle/docs/guides/add-a-kpi.md +154 -0
- package/bundle/docs/guides/add-a-reminder-job.md +181 -0
- package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
- package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
- package/bundle/docs/guides/add-an-entity.md +170 -0
- package/bundle/docs/guides/add-business-rules.md +147 -0
- package/bundle/docs/guides/add-i18n.md +121 -0
- package/bundle/docs/guides/add-menus.md +149 -0
- package/bundle/docs/guides/build-a-code-plugin.md +141 -0
- package/bundle/docs/guides/build-a-page.md +213 -0
- package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
- package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
- package/bundle/docs/guides/create-a-plugin.md +172 -0
- package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
- package/bundle/docs/guides/index.md +53 -0
- package/bundle/docs/guides/publish-and-upgrade.md +152 -0
- package/bundle/docs/guides/responsive-plugin.md +117 -0
- package/bundle/docs/guides/seed-data-on-install.md +127 -0
- package/bundle/docs/guides/set-up-the-sdk.md +120 -0
- package/bundle/docs/guides/use-the-mcp-server.md +104 -0
- package/bundle/docs/guides/validate-and-test.md +119 -0
- package/bundle/docs/guides/wire-a-pages-data.md +153 -0
- package/bundle/docs/quickstart/index.md +118 -0
- package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
- package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
- package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
- package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
- package/bundle/docs/recipes/index.md +40 -0
- package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
- package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
- package/bundle/docs/recipes/master-detail-page.md +84 -0
- package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
- package/bundle/docs/recipes/per-row-document-generator.md +65 -0
- package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
- package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
- package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
- package/bundle/docs/reference/action.md +18 -0
- package/bundle/docs/reference/block-definition.md +35 -0
- package/bundle/docs/reference/block-instance.md +33 -0
- package/bundle/docs/reference/composite-pack-manifest.md +33 -0
- package/bundle/docs/reference/connector-definition.md +36 -0
- package/bundle/docs/reference/data-service-definition.md +22 -0
- package/bundle/docs/reference/data-view-definition.md +26 -0
- package/bundle/docs/reference/entity-aggregation-config.md +38 -0
- package/bundle/docs/reference/entity-cadence-config.md +33 -0
- package/bundle/docs/reference/entity-compliance-config.md +37 -0
- package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
- package/bundle/docs/reference/entity-definition.md +18 -0
- package/bundle/docs/reference/entity-document-generator-config.md +35 -0
- package/bundle/docs/reference/entity-rule-definition.md +29 -0
- package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
- package/bundle/docs/reference/form.md +35 -0
- package/bundle/docs/reference/index.md +37 -0
- package/bundle/docs/reference/menu.md +26 -0
- package/bundle/docs/reference/mobile-nav.md +22 -0
- package/bundle/docs/reference/page-template.md +29 -0
- package/bundle/docs/reference/page.md +44 -0
- package/bundle/docs/reference/plugin-manifest.md +54 -0
- package/bundle/docs/reference/plugin-seed-data.md +21 -0
- package/bundle/docs/reference/print-template-definition.md +22 -0
- package/bundle/docs/reference/provider-definition.md +22 -0
- package/bundle/docs/reference/report-definition.md +22 -0
- package/bundle/docs/reference/rule.md +30 -0
- package/bundle/docs/reference/workflow-definition.md +28 -0
- package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
- package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
- package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
- package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
- package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
- package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
- package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
- package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
- package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
- package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
- package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
- package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
- package/bundle/docs/tutorial/index.md +72 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
- package/bundle/examples/example-patterns.mjs +94 -0
- package/bundle/manifest.json +14 -0
- package/bundle/schemas/action.schema.json +102 -0
- package/bundle/schemas/block-definition.schema.json +222 -0
- package/bundle/schemas/block-instance.schema.json +139 -0
- package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
- package/bundle/schemas/connector-definition.schema.json +29 -0
- package/bundle/schemas/data-service-definition.schema.json +64 -0
- package/bundle/schemas/data-view-definition.schema.json +148 -0
- package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
- package/bundle/schemas/entity-cadence-config.schema.json +27 -0
- package/bundle/schemas/entity-compliance-config.schema.json +31 -0
- package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
- package/bundle/schemas/entity-definition.schema.json +126 -0
- package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
- package/bundle/schemas/entity-rule-definition.schema.json +75 -0
- package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
- package/bundle/schemas/form.schema.json +281 -0
- package/bundle/schemas/menu.schema.json +106 -0
- package/bundle/schemas/mobile-nav.schema.json +53 -0
- package/bundle/schemas/page-template.schema.json +46 -0
- package/bundle/schemas/page.schema.json +292 -0
- package/bundle/schemas/plugin-manifest.schema.json +139 -0
- package/bundle/schemas/plugin-seed-data.schema.json +24 -0
- package/bundle/schemas/print-template-definition.schema.json +90 -0
- package/bundle/schemas/provider-definition.schema.json +27 -0
- package/bundle/schemas/report-definition.schema.json +96 -0
- package/bundle/schemas/rule.schema.json +82 -0
- package/bundle/schemas/workflow-definition.schema.json +42 -0
- package/bundle/validators/block-engine.mjs +23222 -0
- package/bundle/validators/page-engine.mjs +21645 -0
- package/erp-cli/authoring-root.mjs +344 -0
- package/erp-cli/erp.mjs +4065 -0
- package/erp-cli/example-patterns.mjs +94 -0
- package/erp-mcp-server/index.mjs +818 -0
- package/package.json +27 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a tenant extension service (L5)
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a tenant extension service (L5)
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
Every guide before this one builds something that runs **inside** the shared
|
|
11
|
+
ERP — a plugin's Java code loads into the same JVM as every other tenant's.
|
|
12
|
+
An L5 extension is different: it is **your own separate process**, running
|
|
13
|
+
in your own container, that the ERP core calls over plain HTTP whenever your
|
|
14
|
+
tenant has one installed and enabled for a given extension point. The core
|
|
15
|
+
never loads your code, never shares your JVM, and keeps working exactly as
|
|
16
|
+
before if your service is down, slow, or was never installed at all.
|
|
17
|
+
|
|
18
|
+
Use this when a plugin isn't enough — you need to run code the platform
|
|
19
|
+
can't vet and load in-process (a proprietary calculation, a call to your own
|
|
20
|
+
internal systems, dependencies that would never be approved into the shared
|
|
21
|
+
runtime).
|
|
22
|
+
|
|
23
|
+
| Artifact | What it is | Where it runs |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| A regular plugin (every other guide) | Java/TS code, installed into the shared ERP | In-process, shared JVM |
|
|
26
|
+
| An **L5 extension service** (this guide) | Your own Spring Boot app | Your own process/container |
|
|
27
|
+
|
|
28
|
+
## The complete example
|
|
29
|
+
|
|
30
|
+
Copy the real, runnable skeleton at
|
|
31
|
+
[`extensions/archetype/`](../../extensions/archetype) — don't hand-write one.
|
|
32
|
+
The fastest way:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
erp extension create-service acme-payroll
|
|
36
|
+
cd extensions/acme-payroll-extension-service
|
|
37
|
+
mvn spring-boot:run
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This scaffolds a small Spring Boot app with exactly one endpoint:
|
|
41
|
+
|
|
42
|
+
```java
|
|
43
|
+
@RestController
|
|
44
|
+
@RequestMapping("/extension-api")
|
|
45
|
+
public class ExtensionApiController {
|
|
46
|
+
|
|
47
|
+
@PostMapping("/{extensionPointCode}")
|
|
48
|
+
public ResponseEntity<Map<String, Object>> invoke(
|
|
49
|
+
@PathVariable String extensionPointCode,
|
|
50
|
+
@RequestHeader(value = "Authorization", required = false) String authorization,
|
|
51
|
+
@RequestBody(required = false) Map<String, Object> request) {
|
|
52
|
+
if (!serviceAuthTokenVerifier.verify(authorization)) {
|
|
53
|
+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "invalid token"));
|
|
54
|
+
}
|
|
55
|
+
// request is a FLAT map: your payload fields + tenantId/applicationId/
|
|
56
|
+
// extensionId/actor/correlationId merged in — see the file's own doc comment.
|
|
57
|
+
Map<String, Object> response = new LinkedHashMap<>();
|
|
58
|
+
// ... your logic here, keyed on extensionPointCode ...
|
|
59
|
+
return ResponseEntity.ok(response); // fields to merge into the caller's record
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## The wire contract, exactly
|
|
65
|
+
|
|
66
|
+
The ERP core's `ExtensionRouter` calls your service like this — match it
|
|
67
|
+
exactly, there is no other shape:
|
|
68
|
+
|
|
69
|
+
- **Request**: `POST {your-endpoint}/extension-api/{extensionPointCode}` — a
|
|
70
|
+
FLAT JSON body (your business fields, plus `tenantId`/`applicationId`/
|
|
71
|
+
`extensionId`/`actor`/`correlationId` always present and always trustworthy
|
|
72
|
+
— never read a tenant id from anywhere else). Headers carry
|
|
73
|
+
`Authorization: Bearer <token>` (see Security below), `X-Tenant-Id`,
|
|
74
|
+
`X-Correlation-Id`.
|
|
75
|
+
- **Response**: any 2xx with a FLAT JSON body = "extended" — return only the
|
|
76
|
+
field(s) you want merged into the record. `{}` is a valid, successful
|
|
77
|
+
"no changes" response.
|
|
78
|
+
- **Timeout**: 2 seconds. The core wraps every call in a circuit breaker —
|
|
79
|
+
a slow or broken service degrades to "not extended," it never breaks the
|
|
80
|
+
tenant's request.
|
|
81
|
+
- Today the ERP fires exactly ONE extension point:
|
|
82
|
+
`EntityRecordBeforeCreate` — before a new Entity Engine record is
|
|
83
|
+
inserted, for ANY tenant-designed entity in ANY application.
|
|
84
|
+
|
|
85
|
+
## Security
|
|
86
|
+
|
|
87
|
+
Every call from the core carries a short-lived (30s) HMAC-SHA256 signed
|
|
88
|
+
bearer token. Set the SAME secret on both sides:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# ERP core (env on the erp-suite/engine-api process)
|
|
92
|
+
ERP_EXTENSION_SERVICE_AUTH_SECRET=<your real shared secret>
|
|
93
|
+
|
|
94
|
+
# your extension service
|
|
95
|
+
ERP_EXTENSION_SERVICE_AUTH_SECRET=<the SAME shared secret>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Never ship the `dev-only-...` default to production. This is a shared
|
|
99
|
+
secret, not certificate-based mTLS — see the archetype's own README
|
|
100
|
+
"Security model" section for exactly what's real vs. deliberately deferred.
|
|
101
|
+
|
|
102
|
+
## Ground yourself first
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
erp extension create-service --help
|
|
106
|
+
erp platform describe RemoteExtensionPoint
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Register and enable it
|
|
110
|
+
|
|
111
|
+
Your service does nothing until it's installed AND enabled for a tenant —
|
|
112
|
+
until then, the core never calls it at all.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
erp tenant plugin install <tenantId> acme-payroll --runtime service \
|
|
116
|
+
--endpoint https://<your-service-host>:8480 --application HCM
|
|
117
|
+
erp tenant plugin enable <tenantId> acme-payroll --application HCM
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## How to verify it worked
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
curl -s -X POST localhost:8480/extension-api/EntityRecordBeforeCreate \
|
|
124
|
+
-H 'content-type: application/json' \
|
|
125
|
+
-H 'authorization: Bearer <token minted with your shared secret>' \
|
|
126
|
+
-d '{"tenantId":42,"applicationId":"HCM","extensionId":"EntityRecordBeforeCreate","actor":"system","correlationId":"demo","employeeId":100234}'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Then create a record in that tenant's app that goes through the Entity
|
|
134
|
+
Engine's create path — your service's logs should show the call arriving.
|
|
135
|
+
|
|
136
|
+
## Common mistakes
|
|
137
|
+
|
|
138
|
+
| Symptom | Cause | Fix |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| Service never gets called | Binding not `ENABLED`, or you're on the wrong `applicationId` | `erp tenant plugin list <tenantId>` to check status |
|
|
141
|
+
| `401` on every call | Shared secret mismatch | Set the exact same `ERP_EXTENSION_SERVICE_AUTH_SECRET` on both sides |
|
|
142
|
+
| Core logs "security violation" | Your response's `tenantId` field doesn't match the request | Don't echo `tenantId` back at all unless it's unchanged |
|
|
143
|
+
| Change never takes effect for up to ~45s | Binding resolution is cached | Expected — bounded staleness, not a bug |
|
|
144
|
+
| Deployed extension can't reach anything else in the cluster | `NetworkPolicy` default-denies egress | Correct by design — add one narrow rule if you truly need an external call |
|
|
145
|
+
|
|
146
|
+
## What to read next
|
|
147
|
+
|
|
148
|
+
- [`extensions/archetype/README.md`](../../extensions/archetype/README.md) — full security model, Docker/Kubernetes deployment
|
|
149
|
+
- [Publish and upgrade a plugin](./publish-and-upgrade.md) — for the regular (in-process) plugin path this guide is the alternative to
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Create a plugin from scratch
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Create a plugin from scratch
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
A **plugin** is a folder of JSON metadata that the ERP's generic engines
|
|
11
|
+
execute — no Java required. You declare what you want — a
|
|
12
|
+
table here, a screen there, an approval there — and the platform does the rest.
|
|
13
|
+
This guide gets you an empty, valid, buildable plugin.
|
|
14
|
+
|
|
15
|
+
## The complete sequence
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
erp plugin create office-equipment --name "Office Equipment" --type business-app
|
|
19
|
+
erp menu create office-equipment office-equipment-menu \
|
|
20
|
+
--display-name "Equipment" --route /office-equipment/equipment-register
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That produces:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
office-equipment/
|
|
27
|
+
├── README.md
|
|
28
|
+
└── spk-assembly/
|
|
29
|
+
├── plugin.json <- the manifest
|
|
30
|
+
└── metadata/
|
|
31
|
+
├── entities/ <- one *.json per table
|
|
32
|
+
├── page/ <- one *.json per screen
|
|
33
|
+
├── provider/ <- REST data providers for grids
|
|
34
|
+
├── data_service/ <- parameterized read queries
|
|
35
|
+
├── data_view/ <- SQL joins over physical tables
|
|
36
|
+
├── rules/ <- lifecycle rules
|
|
37
|
+
├── workflow/ <- approval processes
|
|
38
|
+
├── seed-data/ <- rows to upsert on install
|
|
39
|
+
├── menu/ <- navigation
|
|
40
|
+
└── i18n/ <- en.json etc.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`spk-assembly/` is the build root. The `.spk` file `erp plugin build` produces
|
|
44
|
+
is just a zip of this tree.
|
|
45
|
+
|
|
46
|
+
## The manifest (`spk-assembly/plugin.json`)
|
|
47
|
+
|
|
48
|
+
Here is a complete, real manifest — the one the [tutorial module](../tutorial/example-plugin/spk-assembly/plugin.json) ships:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"id": "office-equipment",
|
|
53
|
+
"name": "Office Equipment",
|
|
54
|
+
"version": "1.0.3",
|
|
55
|
+
"type": "business-application",
|
|
56
|
+
"schemaName": "erp_core",
|
|
57
|
+
"vendor": "ACME Corp",
|
|
58
|
+
"license": "Proprietary",
|
|
59
|
+
"licenseTier": "free",
|
|
60
|
+
"category": "custom",
|
|
61
|
+
"erpVersion": "*",
|
|
62
|
+
"minErpVersion": "1.0",
|
|
63
|
+
"maxErpVersion": "99.0",
|
|
64
|
+
"mainClass": null,
|
|
65
|
+
"dependencies": [],
|
|
66
|
+
"optionalDependencies": [],
|
|
67
|
+
"owner": "business",
|
|
68
|
+
"editable": false,
|
|
69
|
+
"extendable": true,
|
|
70
|
+
"capabilitiesProvided": [],
|
|
71
|
+
"capabilitiesRequired": [],
|
|
72
|
+
"featureFlags": [],
|
|
73
|
+
"configSchemaJson": null,
|
|
74
|
+
"roles": [
|
|
75
|
+
{
|
|
76
|
+
"name": "Office Equipment Manager",
|
|
77
|
+
"code": "OFFICE_EQUIPMENT_MANAGER",
|
|
78
|
+
"description": "Full control of the Office Equipment module.",
|
|
79
|
+
"permissionKeys": [
|
|
80
|
+
"OeqEquipment.View", "OeqEquipment.Create", "OeqEquipment.Edit",
|
|
81
|
+
"OeqCheckout.View", "OeqCheckout.Create", "OeqCheckout.Submit", "OeqCheckout.Approve"
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"runtimeModes": ["embedded"],
|
|
86
|
+
"serviceDeployment": null
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Line by line
|
|
91
|
+
|
|
92
|
+
- **`id`** — globally unique, lowercase kebab-case. It's baked into every route,
|
|
93
|
+
i18n key, and page id. **Choose it once.** Renaming later means rewriting every
|
|
94
|
+
file. (It is also the PF4J plugin id, and the platform cannot load two plugins
|
|
95
|
+
with the same id even across versions — see
|
|
96
|
+
[Publish and upgrade](./publish-and-upgrade.md).)
|
|
97
|
+
- **`version`** — semver. **Bump it before every publish** or the publish is a
|
|
98
|
+
silent no-op (see [known gotchas](../troubleshooting/known-gotchas.md)).
|
|
99
|
+
- **`type`** — `business-application` for a normal feature plugin.
|
|
100
|
+
- **`schemaName`** — the Postgres schema your entity tables route to. The
|
|
101
|
+
scaffold default is `erp_core`. See the caveat below.
|
|
102
|
+
- **`mainClass`** — leave it `null` (as the scaffold writes it) for a pure-JSON
|
|
103
|
+
plugin. Only set it if you ship your own Java extensions. See "Java: only if
|
|
104
|
+
you need it" below.
|
|
105
|
+
- **`roles`** — the roles this plugin creates on install. `code` is stable and
|
|
106
|
+
referenced by workflows; `name` must be unique across all installed plugins.
|
|
107
|
+
- **`dependencies`** — plugin ids that must already be installed. Leave `[]` for
|
|
108
|
+
a standalone plugin.
|
|
109
|
+
- **`runtimeModes: ["embedded"]`** — the plugin runs inside the ERP process. The
|
|
110
|
+
alternative, `"service"`, is for plugins deployed as their own container and is
|
|
111
|
+
out of scope here.
|
|
112
|
+
|
|
113
|
+
## Java: only if you need it
|
|
114
|
+
|
|
115
|
+
A pure-JSON plugin ships **no Java at all**. Leave `"mainClass": null` and omit
|
|
116
|
+
`spk-assembly/lib/`. On install the platform loads it through a built-in generic
|
|
117
|
+
no-op PF4J entry point; start/stop/enable/disable/upgrade, migrations and data
|
|
118
|
+
seeding all run exactly as for a plugin with its own `mainClass`. This is the
|
|
119
|
+
default and the recommended shape.
|
|
120
|
+
|
|
121
|
+
You only add Java when a feature genuinely needs behaviour the Entity / Query /
|
|
122
|
+
Rule / Workflow / Job engines can't express. Then write a `Plugin` subclass,
|
|
123
|
+
compile it against `pf4j` + `slf4j-api`, drop the jar in `spk-assembly/lib/`,
|
|
124
|
+
and set `mainClass` to its fully-qualified name — `erp plugin build` zips
|
|
125
|
+
whatever is in `lib/` into the `.spk`.
|
|
126
|
+
|
|
127
|
+
## The `schemaName` caveat
|
|
128
|
+
|
|
129
|
+
Entity tables get created in the schema named by `plugin.json`'s `schemaName`.
|
|
130
|
+
The scaffold default `erp_core` works for CRUD and for the pages in this guide,
|
|
131
|
+
**but** some generic write paths (notably the status-date-sweep job) assume a
|
|
132
|
+
tenant-scoped table and can fail against a table that isn't laid out the way an
|
|
133
|
+
app-schema table is. If your plugin owns entities and uses scheduled jobs,
|
|
134
|
+
prefer a dedicated schema name and confirm your entities behave against it with
|
|
135
|
+
`erp plugin test` and a live sweep before you rely on it. The tutorial uses
|
|
136
|
+
`erp_core` and documents where this bites.
|
|
137
|
+
|
|
138
|
+
## How to verify it worked
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
erp plugin validate office-equipment/spk-assembly
|
|
142
|
+
erp plugin test office-equipment/spk-assembly
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A fresh scaffold with no pages yet prints:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
no page JSON found under .../metadata/page — nothing to validate
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
Plugin Tests
|
|
153
|
+
────────────────────────────────────────
|
|
154
|
+
✓ plugin.json:valid-json
|
|
155
|
+
1 passed, 0 failed, 0 semantic warning(s), 3 skipped
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
That's a healthy empty plugin. Now add an [entity](./add-an-entity.md).
|
|
159
|
+
|
|
160
|
+
## Common mistakes
|
|
161
|
+
|
|
162
|
+
| Symptom | Cause | Fix |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| `erp menu create` route becomes `C:/Program Files/Git/...` | Git Bash rewrites the leading `/` | run it from PowerShell, or `MSYS_NO_PATHCONV=1` |
|
|
165
|
+
| you renamed `id` after building pages | id is in every page/i18n/menu key | pick the id once; if you must rename, sed the whole tree |
|
|
166
|
+
| publish rejected: role name already exists | another installed plugin has a role with the same `name` | make your role `name` distinctive |
|
|
167
|
+
|
|
168
|
+
## What to read next
|
|
169
|
+
|
|
170
|
+
- [Add an entity](./add-an-entity.md)
|
|
171
|
+
- [Build a page](./build-a-page.md)
|
|
172
|
+
- [Validate and test a plugin](./validate-and-test.md)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Expose a plugin operation as an AI tool
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Expose a plugin operation as an AI tool
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
This is different from [Use the MCP server with an AI agent](./use-the-mcp-server.md)
|
|
11
|
+
— that guide is about *your coding agent* (Claude Code, Cursor) getting
|
|
12
|
+
grounded while it writes your plugin. **This guide is about the ERP
|
|
13
|
+
product's own embedded AI assistant** calling *your plugin's* real
|
|
14
|
+
operations for an end user, at runtime, once your plugin is installed.
|
|
15
|
+
|
|
16
|
+
Declare which of your plugin's REST endpoints are safe for the AI to call,
|
|
17
|
+
under what permission, with what risk level. The platform reads this at
|
|
18
|
+
install time and registers real, callable tools — no separate admin step,
|
|
19
|
+
no retraining, no hand-written tool-registry entry.
|
|
20
|
+
|
|
21
|
+
| | Your plugin's REST APIs | Your declared AI tools |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| Who calls it | Any authorized caller | The platform's AI assistant, on a user's behalf |
|
|
24
|
+
| Default | Every endpoint you write | **None** — opt-in only |
|
|
25
|
+
| Where it's declared | Your controller code | `metadata/tool/*.json` files |
|
|
26
|
+
|
|
27
|
+
**Never expose every endpoint automatically.** An internal cache-clear
|
|
28
|
+
endpoint or an admin-only bulk-delete has no business being AI-callable just
|
|
29
|
+
because it exists — only declare the operations you've deliberately decided
|
|
30
|
+
are safe for an AI to invoke on a user's behalf.
|
|
31
|
+
|
|
32
|
+
## The file
|
|
33
|
+
|
|
34
|
+
One JSON file per tool, under your plugin's `metadata/tool/` directory —
|
|
35
|
+
same convention as `metadata/page/`, `metadata/form/`, `metadata/provider/`.
|
|
36
|
+
Real, shipped example — `hello-plugin`'s own
|
|
37
|
+
`metadata/tool/hello.greeting.get.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"toolCode": "hello.greeting.get",
|
|
42
|
+
"name": "Get Hello Greeting",
|
|
43
|
+
"description": "Returns hello-plugin's demo greeting for the current tenant.",
|
|
44
|
+
"inputSchema": { "type": "object", "properties": {} },
|
|
45
|
+
"handlerType": "REST",
|
|
46
|
+
"handlerConfig": {
|
|
47
|
+
"path": "/api/v1/plugins/hello-plugin/greeting",
|
|
48
|
+
"method": "GET"
|
|
49
|
+
},
|
|
50
|
+
"requiredPermission": "HELLO_PLUGIN_DEMO",
|
|
51
|
+
"applicationCode": "hello-plugin",
|
|
52
|
+
"riskLevel": "LOW",
|
|
53
|
+
"strictPermissionCheck": false
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`handlerConfig` also accepts `pathParams`/`queryParams` (input keys
|
|
58
|
+
substituted into the path/query string) and `bodyFromInput: true` (the whole
|
|
59
|
+
input map sent as a POST body) — see `hcm-ai-intelligence`'s own 18 tool
|
|
60
|
+
files for real examples of each, including a real **cross-plugin** one
|
|
61
|
+
(`employee.search` calling `hcm-employee`'s own endpoint, not
|
|
62
|
+
`hcm-ai-intelligence`'s own namespace — a deliberately supported pattern for
|
|
63
|
+
an AI-assistant module that fronts several other plugins).
|
|
64
|
+
|
|
65
|
+
## The namespace rule
|
|
66
|
+
|
|
67
|
+
`handlerConfig.path` **must** be under `/api/v1/plugins/**` (any plugin's own
|
|
68
|
+
namespace) or `/api/v1/entities/**` (the Entity Engine data API). A tool
|
|
69
|
+
pointed at an internal/admin/authoring route is rejected at install — before
|
|
70
|
+
your plugin touches anything else.
|
|
71
|
+
|
|
72
|
+
## Ownership
|
|
73
|
+
|
|
74
|
+
Re-installing/upgrading your own plugin re-registers your own tools
|
|
75
|
+
idempotently. Declaring a `toolCode` another plugin already owns — or one
|
|
76
|
+
that was created manually outside any plugin — fails install with a clear
|
|
77
|
+
error; tool codes aren't a shared free-for-all namespace.
|
|
78
|
+
|
|
79
|
+
Disabling or uninstalling your plugin deactivates its tools (not a hard
|
|
80
|
+
delete) — re-enabling reactivates them.
|
|
81
|
+
|
|
82
|
+
## Ground yourself first
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
erp_get_schema {"name":"plugin-manifest"}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Look at `hcm-ai-intelligence`'s real `metadata/tool/*.json` files for the
|
|
89
|
+
fullest working example of `pathParams`/`queryParams`/`bodyFromInput` in
|
|
90
|
+
practice, and for `riskLevel: "HIGH"` + `strictPermissionCheck: true` pairing
|
|
91
|
+
on a genuinely mutating operation.
|
|
92
|
+
|
|
93
|
+
## How to verify it worked
|
|
94
|
+
|
|
95
|
+
Install your plugin, then check the discovery endpoint lists your tool:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
curl localhost:8080/api/v1/agents/metadata/tools \
|
|
99
|
+
-H "X-Tenant-Id: <id>" -H "X-Actor: <actor>" | grep your.tool.code
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Common mistakes
|
|
103
|
+
|
|
104
|
+
| Symptom | Cause | Fix |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| Install fails: "not under an allowed namespace" | `handlerConfig.path` points outside `/api/v1/plugins/**` or `/api/v1/entities/**` | An AI tool can only call a real plugin business endpoint or the Entity Engine, never an internal/admin route |
|
|
107
|
+
| Install fails: tool already exists | `toolCode` collides with a manually-created tool or another plugin's tool | Pick a more specific, namespaced `toolCode` (e.g. `<your-plugin>.<noun>.<verb>`) |
|
|
108
|
+
| Tool still callable after uninstall | You're checking a cached list | Tools are deactivated, not deleted — re-query the discovery endpoint |
|
|
109
|
+
| AI never calls your tool | `requiredPermission` the calling user lacks, or `riskLevel`/policy gating it out | Check the user's actual permission grant first |
|
|
110
|
+
|
|
111
|
+
## What to read next
|
|
112
|
+
|
|
113
|
+
- [Use the MCP server with an AI agent](./use-the-mcp-server.md) — the *other* MCP concept: grounding your coding agent while you build, not this guide's runtime AI-tool-calling
|
|
114
|
+
- [Create a plugin from scratch](./create-a-plugin.md)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guides
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Guides
|
|
7
|
+
|
|
8
|
+
Task-focused how-tos. Each page answers one question and follows the same rhythm:
|
|
9
|
+
what you're building → a complete, real, copy-pasteable example → a line-by-line
|
|
10
|
+
walkthrough → how to verify it worked → common mistakes → what to read next.
|
|
11
|
+
Every code sample is a real file under
|
|
12
|
+
[`tutorial/example-plugin/`](../tutorial/index.md) or
|
|
13
|
+
[`examples/`](../examples/), kept honest by
|
|
14
|
+
`node developer-docs/examples/test-examples.mjs`.
|
|
15
|
+
|
|
16
|
+
## Getting started
|
|
17
|
+
|
|
18
|
+
- [Set up the SDK and connect to an environment](./set-up-the-sdk.md)
|
|
19
|
+
- [Create a plugin from scratch](./create-a-plugin.md)
|
|
20
|
+
- [Use the MCP server with an AI agent](./use-the-mcp-server.md)
|
|
21
|
+
|
|
22
|
+
## Data
|
|
23
|
+
|
|
24
|
+
- [Add an entity](./add-an-entity.md)
|
|
25
|
+
- [Add a data provider, data view, or data service](./add-a-data-provider.md)
|
|
26
|
+
- [Seed config and reference data on install](./seed-data-on-install.md)
|
|
27
|
+
|
|
28
|
+
## Screens
|
|
29
|
+
|
|
30
|
+
- [Build a page](./build-a-page.md)
|
|
31
|
+
- [Wire a page's data](./wire-a-pages-data.md)
|
|
32
|
+
- [Add a create / edit form](./add-a-create-edit-form.md)
|
|
33
|
+
- [Add a KPI or aggregation](./add-a-kpi.md)
|
|
34
|
+
- [Add menus](./add-menus.md)
|
|
35
|
+
- [Add translations (i18n)](./add-i18n.md)
|
|
36
|
+
- [Make a plugin work on desktop, tablet, and mobile](./responsive-plugin.md)
|
|
37
|
+
|
|
38
|
+
## Logic
|
|
39
|
+
|
|
40
|
+
- [Add business rules and expressions](./add-business-rules.md)
|
|
41
|
+
- [Add an approval workflow](./add-an-approval-workflow.md)
|
|
42
|
+
|
|
43
|
+
## Scheduled jobs (no Java)
|
|
44
|
+
|
|
45
|
+
- [Add a scheduled reminder job](./add-a-reminder-job.md)
|
|
46
|
+
- [Add a cross-row aggregation job](./add-an-aggregation-job.md)
|
|
47
|
+
- [Add a cadence-generator job](./add-a-cadence-job.md)
|
|
48
|
+
- [Add a compliance-sweep job](./add-a-compliance-job.md)
|
|
49
|
+
|
|
50
|
+
## Ship it
|
|
51
|
+
|
|
52
|
+
- [Validate and test a plugin](./validate-and-test.md)
|
|
53
|
+
- [Publish and upgrade a plugin](./publish-and-upgrade.md)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Publish and upgrade a plugin
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Publish and upgrade a plugin
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
Getting the `.spk` onto your ERP and, later, shipping a new version.
|
|
11
|
+
|
|
12
|
+
## The complete sequence
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# 1. build the .spk (a zip of spk-assembly/)
|
|
16
|
+
erp plugin build spk-assembly -o office-equipment-1.0.0.spk
|
|
17
|
+
|
|
18
|
+
# 2. publish it to the current env, for a tenant
|
|
19
|
+
erp plugin publish office-equipment-1.0.0.spk --tenant 2
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Output on success:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
packaged 33 files -> office-equipment-1.0.0.spk (231986 bytes)
|
|
26
|
+
sha256: cef54cdf...
|
|
27
|
+
erp plugin publish: office-equipment-1.0.0.spk -> https://erp.example.com (env "dev", tenant 2)
|
|
28
|
+
POST .../api/v1/authoring/plugins/upload ...
|
|
29
|
+
installed:
|
|
30
|
+
{"pluginId":"office-equipment","version":"1.0.0","state":"installed","pf4jState":"STARTED", ...}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`"state":"installed"` and `"pf4jState":"STARTED"` mean it's live. The server
|
|
34
|
+
recomputes the checksum itself — it never trusts the client digest.
|
|
35
|
+
|
|
36
|
+
## What happens on install
|
|
37
|
+
|
|
38
|
+
1. checksum verified server-side;
|
|
39
|
+
2. the manifest is read (`id` and `version` are required; `mainClass` is
|
|
40
|
+
optional — a pure-JSON plugin leaves it `null`);
|
|
41
|
+
3. PF4J loads the plugin — `lib/*.jar` if present, otherwise a built-in generic
|
|
42
|
+
no-op entry point for a pure-JSON plugin;
|
|
43
|
+
4. entities are created / altered (idempotent-additive);
|
|
44
|
+
5. providers, data views, data services, pages, menus, rules, workflows,
|
|
45
|
+
i18n and roles are installed;
|
|
46
|
+
6. seed data is upserted;
|
|
47
|
+
7. `AFTER_CREATE` register rules fire → scheduled jobs register.
|
|
48
|
+
|
|
49
|
+
If any step fails the whole install rolls back, including the PF4J load.
|
|
50
|
+
|
|
51
|
+
## Version discipline — the rules that will bite you
|
|
52
|
+
|
|
53
|
+
### 1. Bump the version before every publish
|
|
54
|
+
|
|
55
|
+
`plugin.json` `version` must increase. **A re-publish of a version that's
|
|
56
|
+
already installed is a silent no-op** — your fix does not ship. Bump it even if
|
|
57
|
+
you're certain the last publish already contained the change.
|
|
58
|
+
|
|
59
|
+
### 2. A version is immutable once stored
|
|
60
|
+
|
|
61
|
+
Once the server has stored `office-equipment/1.0.0/office-equipment-1.0.0.spk`,
|
|
62
|
+
re-uploading **different bytes** under `1.0.0` is rejected:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Refusing to overwrite immutable artifact at "system/plugins/.../1.0.0/..." with different content
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A version that genuinely **shipped** (reached `pf4jState: STARTED` on some
|
|
69
|
+
tenant) stays immutable forever — re-publishing different bytes under it is
|
|
70
|
+
rejected, go to the next version.
|
|
71
|
+
|
|
72
|
+
**But a *failed or partial* install is not a real release.** As of the SDK
|
|
73
|
+
publish-hardening pass, if a `1.0.0` publish never reached `STARTED` (it failed
|
|
74
|
+
later in the pipeline, or it's a metadata-only plugin that isn't PF4J-loaded),
|
|
75
|
+
re-publishing `1.0.0` **replaces** the stored `.spk` and retries — no version
|
|
76
|
+
bump needed just to fix a broken publish. `erp plugin publish` of an
|
|
77
|
+
already-installed id automatically takes the upgrade path, so this "edit a page,
|
|
78
|
+
re-publish the same version" loop works directly for JSON-only plugins.
|
|
79
|
+
|
|
80
|
+
### 3. The platform cannot load two versions of the same plugin id at once
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
There is an already loaded plugin ... with the same id (office-equipment) ...
|
|
84
|
+
Simultaneous loading of plugins with the same PluginId is not currently supported.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The publish/upgrade path now **reliably stops and unloads the currently-loaded
|
|
88
|
+
same-id plugin before loading the new bundle** (stop → unload → GC hint → load →
|
|
89
|
+
start), so a normal in-place re-publish never hits this. If an earlier crash or a
|
|
90
|
+
transient DB error during uninstall left the old code *wedged* — `state:
|
|
91
|
+
uninstalled` in the install row but `pf4jState: STARTED` in the process — recover
|
|
92
|
+
without a backend restart:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
erp plugin force-unload office-equipment # clears the stuck PF4J load
|
|
96
|
+
erp plugin publish office-equipment-1.0.1.spk --tenant 2
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`erp plugin publish --force` does the force-unload automatically before
|
|
100
|
+
retrying, and `spark publish` / `erp plugin publish` also auto-retry once with a
|
|
101
|
+
force-unload if they see the "already loaded" error.
|
|
102
|
+
|
|
103
|
+
> These behaviours were all hit while building the tutorial module. Treat
|
|
104
|
+
> version numbers as cheap and monotonic; use `force-unload` rather than a
|
|
105
|
+
> restart when a load gets stuck.
|
|
106
|
+
|
|
107
|
+
## Roles and names must be unique across installed plugins
|
|
108
|
+
|
|
109
|
+
A `plugin.json` role whose `name` **or** `code` collides with a role from
|
|
110
|
+
another installed plugin fails the install:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
A role named "Equipment Manager" already exists
|
|
114
|
+
Role "..." (code "...") already exists owned by "..."
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Prefix your role names and codes with something plugin-specific.
|
|
118
|
+
|
|
119
|
+
## Upgrading
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# edit files, bump plugin.json version to 1.0.1, rebuild the lib jar if Java changed
|
|
123
|
+
erp plugin build spk-assembly -o office-equipment-1.0.1.spk
|
|
124
|
+
erp plugin publish office-equipment-1.0.1.spk --tenant 2
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Entities gain new columns; pages, rules, seed data are re-installed (upserted).
|
|
128
|
+
Nothing is dropped — a removed field's column stays, a removed seed row stays.
|
|
129
|
+
|
|
130
|
+
## How to verify it worked
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
erp plugin list
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Find your plugin: `"version": "1.0.1"`, `"state": "installed"`,
|
|
137
|
+
`"pf4jState": "STARTED"`. Then hit a page or an entity endpoint to confirm the
|
|
138
|
+
new content is live.
|
|
139
|
+
|
|
140
|
+
## Common mistakes
|
|
141
|
+
|
|
142
|
+
| Symptom | Cause | Fix |
|
|
143
|
+
| --- | --- | --- |
|
|
144
|
+
| "fix didn't ship" | didn't bump `version` on a **shipped** version | bump; re-publish. (A *failed* same-version publish now retries in place.) |
|
|
145
|
+
| `Refusing to overwrite immutable artifact` | retrying a version that already reached `STARTED` on some tenant | go to the next version |
|
|
146
|
+
| `already loaded plugin ... same id` | a load wedged after a crash / DB blip | `erp plugin force-unload <id>` then re-publish (or `publish --force`) |
|
|
147
|
+
| role name/code clash | another plugin owns that role | prefix yours |
|
|
148
|
+
|
|
149
|
+
## What to read next
|
|
150
|
+
|
|
151
|
+
- [Validate and test a plugin](./validate-and-test.md)
|
|
152
|
+
- Troubleshooting: [publishing and install errors](../troubleshooting/publishing-errors.md)
|