@sparkerp/plugin-sdk 1.0.0 → 1.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/bundle/catalog.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-09-
|
|
2
|
+
"generatedAt": "2026-09-23T08:53:08.093Z",
|
|
3
3
|
"generatedBy": "erp platform catalog --refresh (tools/erp-cli/erp.mjs buildEngineCatalog)",
|
|
4
4
|
"note": "Mechanically-derived from backend/platform-runtime/engine-*/src/main/java (regex class/interface/enum/record + @*Mapping scan) and every V*__*.sql Flyway migration under backend/platform-runtime — NOT a hand-maintained inventory, NOT a full Java AST parse. Regenerate with `erp platform catalog --refresh` whenever engine-* source changes.",
|
|
5
5
|
"engineCount": 43,
|
package/bundle/docs/docs.json
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
{ "title": "Make a plugin work on desktop, tablet, and mobile", "path": "guides/responsive-plugin.md" },
|
|
45
45
|
{ "title": "Add a custom block (a \"widget\" you build yourself)", "path": "guides/add-a-custom-block.md" },
|
|
46
46
|
{ "title": "Build a plugin with custom React + Java code (L4)", "path": "guides/build-a-code-plugin.md" },
|
|
47
|
+
{ "title": "Check out an installed plugin and work on it", "path": "guides/checkout-an-installed-plugin.md" },
|
|
47
48
|
{ "title": "Validate and test a plugin", "path": "guides/validate-and-test.md" },
|
|
48
49
|
{ "title": "Publish and upgrade a plugin", "path": "guides/publish-and-upgrade.md" },
|
|
49
50
|
{ "title": "Build a tenant extension service (L5)", "path": "guides/build-a-tenant-extension-service.md" },
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Check out an installed plugin and work on it
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Check out an installed plugin and work on it
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
You already have a plugin **installed** on an environment — maybe you built
|
|
11
|
+
it yourself weeks ago and lost the local source, maybe you inherited it from
|
|
12
|
+
someone else, maybe you're fixing a real, confirmed bug in your own
|
|
13
|
+
tenant-owned plugin. Either way you want a real local copy: something you
|
|
14
|
+
can `git diff`, edit, and ship back — not a live-database patch, and not a
|
|
15
|
+
raw dump of API responses.
|
|
16
|
+
|
|
17
|
+
**Before you reach for this:** if what you actually want is to *extend* a
|
|
18
|
+
shipped, vendor-owned application (HCM, CRM, ...) rather than edit a plugin
|
|
19
|
+
you own, this is the wrong page — see
|
|
20
|
+
[Extend a shipped application](./extend-a-shipped-application.md) instead.
|
|
21
|
+
Editing another module's own source is never the right move for a feature
|
|
22
|
+
request, only for a confirmed bug in a plugin that's genuinely yours.
|
|
23
|
+
|
|
24
|
+
## The command set
|
|
25
|
+
|
|
26
|
+
Modeled on git, on purpose — the mental model is the same one you already
|
|
27
|
+
have:
|
|
28
|
+
|
|
29
|
+
| git | erp | what it does |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `git branch` / `git remote -v` | `erp plugin list` | Every plugin installed on the current env, as a readable table (`pluginId`, `version`, `state`, `name`) — pick one before cloning. `--json` for the raw response. |
|
|
32
|
+
| `git clone` | `erp plugin clone <pluginId> [--out <dir>]` | A **real, full local checkout** — every page, menu, data service, data view, mobile nav, provider, application/module membership, and this plugin's own i18n keys it owns, written to `<out>/<pluginId>/spk-assembly/metadata/...` in the same on-disk shape a shipped module has. Always gets the latest version of every artifact. |
|
|
33
|
+
| `git checkout <ref>` | `erp plugin checkout <pluginId> --version <n> [--out <dir>]` | Same full checkout as `clone`, but for each artifact independently, prefers version `n` from *that artifact's own* history if it has one that old, else falls back to latest. See the caveat below — this is not a single point-in-time snapshot. |
|
|
34
|
+
| `git add` / `git commit` | plain `git`, inside the checked-out directory | It's a real folder now. `cd <pluginId> && git init && git add . && git commit -m "..."` gives you real, diffable history — no special erp command needed. |
|
|
35
|
+
| `git push` | `erp plugin build <dir> -o out.spk` then `erp plugin publish out.spk --env <name>` (or `erp plugin push`, an alias) | Package your edited `spk-assembly/` into a `.spk` and ship it to an environment. See [Publish and upgrade a plugin](./publish-and-upgrade.md). |
|
|
36
|
+
|
|
37
|
+
`erp plugin pull <pluginId>` (no `--full`) still exists separately — it's
|
|
38
|
+
the *thin* form: just `plugin.json` + install config + install state, no
|
|
39
|
+
artifact bodies. Useful for a quick "what version is installed, what's its
|
|
40
|
+
manifest" check without the full fan-out `clone`/`checkout` do.
|
|
41
|
+
|
|
42
|
+
## The complete example
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# 1. See what's there
|
|
46
|
+
erp plugin list
|
|
47
|
+
|
|
48
|
+
# PLUGIN ID VERSION STATE NAME
|
|
49
|
+
# hcm-foundation 1.0.299 installed HCM Foundation
|
|
50
|
+
# office-equipment 1.0.0 installed Office Equipment
|
|
51
|
+
# ...
|
|
52
|
+
|
|
53
|
+
# 2. Clone the one you own
|
|
54
|
+
erp plugin clone office-equipment
|
|
55
|
+
|
|
56
|
+
# == Checking out plugin "office-equipment" ==
|
|
57
|
+
# -- spk-assembly/plugin.json (version 1.0.0) --
|
|
58
|
+
# -- pages: 3 owned by office-equipment --
|
|
59
|
+
# wrote page/office-equipment-list.json (id 4021, v2)
|
|
60
|
+
# ...
|
|
61
|
+
# -- i18n --
|
|
62
|
+
# wrote i18n/en.json (41 keys under "office-equipment.*")
|
|
63
|
+
#
|
|
64
|
+
# == Full checkout done: 9 artifacts + plugin.json + i18n written to office-equipment/spk-assembly ==
|
|
65
|
+
#
|
|
66
|
+
# Now a real local directory — e.g.:
|
|
67
|
+
# cd office-equipment && git init && git add . && git commit -m "Clone of office-equipment@1.0.0"
|
|
68
|
+
|
|
69
|
+
# 3. Real git, from here on
|
|
70
|
+
cd office-equipment
|
|
71
|
+
git init && git add . && git commit -m "Clone of office-equipment@1.0.0"
|
|
72
|
+
|
|
73
|
+
# 4. Edit under spk-assembly/metadata/, commit as you go
|
|
74
|
+
# (e.g. spk-assembly/metadata/page/office-equipment-list.json)
|
|
75
|
+
git add -A && git commit -m "Add a status filter to the equipment list"
|
|
76
|
+
|
|
77
|
+
# 5. Ship it — dev first, then prod
|
|
78
|
+
erp plugin build . -o office-equipment-1.0.1.spk
|
|
79
|
+
erp plugin publish office-equipment-1.0.1.spk --env dev
|
|
80
|
+
# ...verify it looks right...
|
|
81
|
+
erp plugin publish office-equipment-1.0.1.spk --env prod
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What actually gets written
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
office-equipment/
|
|
88
|
+
├── plugin.json ← THIN pull output (manifest only, kept for compat)
|
|
89
|
+
├── config.json
|
|
90
|
+
├── installation-state.json
|
|
91
|
+
└── spk-assembly/ ← the real, editable, buildable tree
|
|
92
|
+
├── plugin.json
|
|
93
|
+
└── metadata/
|
|
94
|
+
├── page/*.json
|
|
95
|
+
├── menu/*.json
|
|
96
|
+
├── mobile_nav/*.json
|
|
97
|
+
├── provider/*.json
|
|
98
|
+
├── data_service/*.json
|
|
99
|
+
├── data_view/*.json
|
|
100
|
+
├── application/*.json ← membership rows, if this plugin owns any
|
|
101
|
+
├── module/*.json
|
|
102
|
+
├── entity/*.json ← best-effort, see caveat below
|
|
103
|
+
└── i18n/en.json ← only this plugin's own `<pluginId>.*` keys
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Every artifact file is `{ name, description, metadata, definition }` — the
|
|
107
|
+
same shape `erp plugin build` reads when packaging a `.spk`.
|
|
108
|
+
|
|
109
|
+
## Known limits (disclosed, not hidden)
|
|
110
|
+
|
|
111
|
+
- **`--version` is per-artifact, not a plugin-wide snapshot.** `plugin.json`'s
|
|
112
|
+
own `version` (e.g. `1.0.299`) is bumped once per `.spk` release, but each
|
|
113
|
+
individual artifact versions independently, on its own publish cadence.
|
|
114
|
+
There's no server-side record of "which version of every one of this
|
|
115
|
+
plugin's 28 artifacts was live when the plugin itself was at 1.0.298" — so
|
|
116
|
+
`checkout --version 4` takes artifact-level `v4` wherever that artifact
|
|
117
|
+
has one, and its latest otherwise. Good enough to inspect an older cut of
|
|
118
|
+
one screen; not a substitute for real git tags on your own commits going
|
|
119
|
+
forward.
|
|
120
|
+
- **Entities are best-effort.** Unlike pages/menus/data-services/etc.,
|
|
121
|
+
entities have no `ownerPlugin` field at all — the closest available signal
|
|
122
|
+
is a free-text `category` field, matched against the plugin id. Confirmed
|
|
123
|
+
live to hold real plugin ids for entity-heavy modules, but it's not an
|
|
124
|
+
enforced foreign key.
|
|
125
|
+
- **`erp plugin validate`** isn't available in packaged SDK mode (needs
|
|
126
|
+
platform build tooling not shipped in the authoring bundle) — validate
|
|
127
|
+
what you can with `erp schema validate <file> --schema <name>` per file
|
|
128
|
+
instead.
|
|
129
|
+
|
|
130
|
+
## Common mistakes
|
|
131
|
+
|
|
132
|
+
- **Cloning a vendor-owned plugin to "fix" a feature gap.** If it's not a
|
|
133
|
+
confirmed bug in code you own, use
|
|
134
|
+
[Extend a shipped application](./extend-a-shipped-application.md) instead
|
|
135
|
+
— a companion plugin, not an edit to someone else's source.
|
|
136
|
+
- **Editing the THIN `plugin.json`/`config.json`/`installation-state.json`
|
|
137
|
+
files at the top level.** Those are install-state snapshots, not build
|
|
138
|
+
input — edit under `spk-assembly/metadata/` instead; that's what
|
|
139
|
+
`erp plugin build` actually reads.
|
|
140
|
+
- **Forgetting `--env`.** `clone`/`checkout` read from whatever `erp env
|
|
141
|
+
use`'s current environment is (or `--env <name>` for one call) — cloning
|
|
142
|
+
from `prod` when you meant `dev` gets you prod's live content, not a
|
|
143
|
+
sandbox to break.
|
|
144
|
+
|
|
145
|
+
## See also
|
|
146
|
+
|
|
147
|
+
- [Publish and upgrade a plugin](./publish-and-upgrade.md) — the `build`/
|
|
148
|
+
`publish` half of this flow, in more depth.
|
|
149
|
+
- [Extend a shipped application](./extend-a-shipped-application.md) — the
|
|
150
|
+
right tool when you don't own the plugin's source.
|
|
151
|
+
- [Validate and test a plugin](./validate-and-test.md)
|
|
@@ -51,5 +51,6 @@ Every code sample is a real file under
|
|
|
51
51
|
|
|
52
52
|
## Ship it
|
|
53
53
|
|
|
54
|
+
- [Check out an installed plugin and work on it](./checkout-an-installed-plugin.md)
|
|
54
55
|
- [Validate and test a plugin](./validate-and-test.md)
|
|
55
56
|
- [Publish and upgrade a plugin](./publish-and-upgrade.md)
|
package/bundle/manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bundleVersion": "2026-09-23.1",
|
|
3
3
|
"platformVersion": "0.0.0",
|
|
4
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"generatedAt": "2026-09-23T08:53:09.048Z",
|
|
5
5
|
"generatedBy": "erp bundle build (tools/erp-cli/erp.mjs bundleBuildCommand)",
|
|
6
6
|
"schemaCount": 27,
|
|
7
|
-
"docCount":
|
|
7
|
+
"docCount": 249,
|
|
8
8
|
"exampleFileCount": 14,
|
|
9
9
|
"blockCount": 124,
|
|
10
|
-
"catalogGeneratedAt": "2026-09-
|
|
10
|
+
"catalogGeneratedAt": "2026-09-23T08:53:08.093Z",
|
|
11
11
|
"catalogEngineCount": 43,
|
|
12
12
|
"catalogContractUnitCount": 74,
|
|
13
13
|
"sdkMode": "packaged"
|
package/erp-cli/erp.mjs
CHANGED
|
@@ -547,20 +547,57 @@ function envUseCommand(name, opts) {
|
|
|
547
547
|
// back to / also including the local-repo-scoped substring match over
|
|
548
548
|
// `backend/modules/*/spk-assembly/plugin.json` (still useful for plugins
|
|
549
549
|
// that exist on disk but were never registered in the catalog).
|
|
550
|
-
//
|
|
551
|
-
// PluginInstallationController's GET actually returns) into a local file
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
// `erp
|
|
558
|
-
//
|
|
550
|
+
// pull — default is [THIN]: manifest+config+version state (everything
|
|
551
|
+
// PluginInstallationController's GET actually returns) into a local file.
|
|
552
|
+
// `--full` (2026-09-23) is the real integration this used to disclose as
|
|
553
|
+
// future work: walks every per-artifact authoring controller this plugin
|
|
554
|
+
// owns and reconstructs the full spk-assembly page/workflow/entity source
|
|
555
|
+
// tree — see `pluginPullFullReconstruction`'s own doc comment. THIN stays
|
|
556
|
+
// the default (cheap, no N-artifact-type fan-out) for callers that only
|
|
557
|
+
// want install-state (e.g. `erp plugin diff`'s own use of this same list
|
|
558
|
+
// call). If the plugin is ALSO a registered registry package, `erp
|
|
559
|
+
// registry get` (below) returns richer metadata (description, license,
|
|
560
|
+
// dependencies, changelog) than the bare install-state row.
|
|
559
561
|
// ---------------------------------------------------------------------------
|
|
562
|
+
/**
|
|
563
|
+
* 2026-09-23 (direct user request — "developer should be able to list down
|
|
564
|
+
* all modules, then choose which module they want to checkout, similar to
|
|
565
|
+
* git commands") — the `git branch`/`git remote -v` step of the clone/
|
|
566
|
+
* checkout/add/commit/push flow below: a readable table by default so a
|
|
567
|
+
* developer can actually scan it before picking a `pluginId` for `erp
|
|
568
|
+
* plugin clone`, instead of a raw JSON dump of every manifest field. `--json`
|
|
569
|
+
* keeps the exact old output for any script already parsing it.
|
|
570
|
+
*/
|
|
560
571
|
async function pluginListCommand(opts) {
|
|
561
572
|
const cfg = loadConfig();
|
|
562
573
|
const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
|
|
563
|
-
|
|
574
|
+
if (opts.json) {
|
|
575
|
+
console.log(JSON.stringify(list, null, 2));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const rows = list
|
|
579
|
+
.map((p) => {
|
|
580
|
+
const manifest = tryParseJson(p.manifestJson);
|
|
581
|
+
return {
|
|
582
|
+
pluginId: p.pluginId,
|
|
583
|
+
version: p.version,
|
|
584
|
+
state: p.state,
|
|
585
|
+
name: typeof manifest === "object" && manifest ? manifest.name || p.pluginId : p.pluginId,
|
|
586
|
+
};
|
|
587
|
+
})
|
|
588
|
+
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
589
|
+
if (rows.length === 0) {
|
|
590
|
+
console.log("No plugins installed on this tenant.");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const idWidth = Math.max(...rows.map((r) => r.pluginId.length), "PLUGIN ID".length);
|
|
594
|
+
const verWidth = Math.max(...rows.map((r) => r.version.length), "VERSION".length);
|
|
595
|
+
const stateWidth = Math.max(...rows.map((r) => r.state.length), "STATE".length);
|
|
596
|
+
console.log(`${"PLUGIN ID".padEnd(idWidth)} ${"VERSION".padEnd(verWidth)} ${"STATE".padEnd(stateWidth)} NAME`);
|
|
597
|
+
for (const r of rows) {
|
|
598
|
+
console.log(`${r.pluginId.padEnd(idWidth)} ${r.version.padEnd(verWidth)} ${r.state.padEnd(stateWidth)} ${r.name}`);
|
|
599
|
+
}
|
|
600
|
+
console.log(`\n${rows.length} plugin(s) installed. Checkout one: erp plugin clone <pluginId>`);
|
|
564
601
|
}
|
|
565
602
|
|
|
566
603
|
async function pluginSearchCommand(term, opts) {
|
|
@@ -691,6 +728,235 @@ async function pluginPullCommand(pluginId, opts) {
|
|
|
691
728
|
writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
|
|
692
729
|
writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
|
|
693
730
|
console.log(`[THIN — manifest+config+state only, see erp.mjs header] Wrote ${outDir}/{plugin.json,config.json,installation-state.json}`);
|
|
731
|
+
if (opts.full) {
|
|
732
|
+
await pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts);
|
|
733
|
+
} else {
|
|
734
|
+
console.log(`(pass --full for a real editable spk-assembly/ checkout — every page/menu/data-service/etc. this plugin owns, ready for 'erp plugin validate'/'build'/'publish')`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* `clone` (2026-09-23, direct user request — "similar to git commands:
|
|
740
|
+
* clone, checkout, add, commit, push") — `list` (git-`branch`-like) then
|
|
741
|
+
* `clone <pluginId>` (git-`clone`-like: a real local directory, full
|
|
742
|
+
* source, first thing you'd do with a plugin you want to work on) is the
|
|
743
|
+
* intended entry point; `pull --full` above still exists underneath it
|
|
744
|
+
* unchanged (this is a thin, differently-named wrapper, not a second
|
|
745
|
+
* implementation) for anyone already scripting against that name. Unlike
|
|
746
|
+
* `pull`, `clone` always does the full reconstruction — there's no reason
|
|
747
|
+
* to git-clone "thin".
|
|
748
|
+
* <p>
|
|
749
|
+
* Deliberately does NOT reinvent `add`/`commit`: the directory this writes
|
|
750
|
+
* is a real one on disk — `git init && git add . && git commit` right there
|
|
751
|
+
* already does real local staging/history perfectly, no fake local-only
|
|
752
|
+
* command needed. `push` already existed before this change (`erp plugin
|
|
753
|
+
* push`, an alias of `publish`) — that's the real "ship it" step once
|
|
754
|
+
* you're done editing.
|
|
755
|
+
*/
|
|
756
|
+
async function pluginCloneCommand(pluginId, opts) {
|
|
757
|
+
const cfg = loadConfig();
|
|
758
|
+
const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
|
|
759
|
+
const found = list.find((p) => p.pluginId === pluginId);
|
|
760
|
+
if (!found) throw new Error(`no installed plugin "${pluginId}" on this tenant — run: erp plugin list`);
|
|
761
|
+
const outDir = opts.out || path.join(process.cwd(), pluginId);
|
|
762
|
+
mkdirSync(outDir, { recursive: true });
|
|
763
|
+
writeFileSync(path.join(outDir, "plugin.json"), found.manifestJson, "utf8");
|
|
764
|
+
writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
|
|
765
|
+
writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
|
|
766
|
+
await pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts);
|
|
767
|
+
console.log(`\nNow a real local directory — e.g.:`);
|
|
768
|
+
console.log(` cd ${outDir} && git init && git add . && git commit -m "Clone of ${pluginId}@${found.version}"`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* `checkout` (2026-09-23, completing the git-verb set: clone/checkout/add/
|
|
773
|
+
* commit/push) — `clone` always gets latest; this is `git checkout
|
|
774
|
+
* <ref>`'s counterpart when you want something OTHER than latest.
|
|
775
|
+
* <p>
|
|
776
|
+
* Disclosed, real limitation: there is no single "plugin version" snapshot
|
|
777
|
+
* to check out — `plugin.json`'s own `version` (e.g. "1.0.299") is bumped
|
|
778
|
+
* once per `.spk` release, but each individual artifact (a page, a data
|
|
779
|
+
* service, ...) versions independently on its OWN publish cadence (see
|
|
780
|
+
* `createDraft`'s own "next version for THIS name" comment elsewhere in
|
|
781
|
+
* this file) — there is no server-side mapping from "plugin was at 1.0.298"
|
|
782
|
+
* to "which version of every one of its 28 artifacts was live then". So
|
|
783
|
+
* `--version <n>` here means, per artifact independently: "the row whose
|
|
784
|
+
* own `version` field equals `n`, if this artifact has one that old —
|
|
785
|
+
* otherwise its latest" — an honest, disclosed best-effort, not a true
|
|
786
|
+
* point-in-time snapshot. Omitting `--version` is identical to `clone`.
|
|
787
|
+
*/
|
|
788
|
+
async function pluginCheckoutCommand(pluginId, opts) {
|
|
789
|
+
const cfg = loadConfig();
|
|
790
|
+
const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
|
|
791
|
+
const found = list.find((p) => p.pluginId === pluginId);
|
|
792
|
+
if (!found) throw new Error(`no installed plugin "${pluginId}" on this tenant — run: erp plugin list`);
|
|
793
|
+
const outDir = opts.out || path.join(process.cwd(), pluginId);
|
|
794
|
+
mkdirSync(outDir, { recursive: true });
|
|
795
|
+
writeFileSync(path.join(outDir, "plugin.json"), found.manifestJson, "utf8");
|
|
796
|
+
writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
|
|
797
|
+
writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
|
|
798
|
+
if (opts.version) {
|
|
799
|
+
console.log(`== Checking out "${pluginId}" @ per-artifact version ${opts.version} where it exists (see this command's own doc comment — not a true point-in-time snapshot) ==`);
|
|
800
|
+
}
|
|
801
|
+
await pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts);
|
|
802
|
+
console.log(`\nNow a real local directory — e.g.:`);
|
|
803
|
+
console.log(` cd ${outDir} && git init && git add . && git commit -m "Checkout of ${pluginId}@${found.version}${opts.version ? ` (artifacts at v${opts.version} where available)` : ""}"`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* 2026-09-23 (direct user request, "developer should be able to checkout an
|
|
808
|
+
* existing installed plugin, work on it, then package and publish it — this
|
|
809
|
+
* should be part of the SDK CLI") — the real integration [THIN] pull's own
|
|
810
|
+
* header comment above disclosed as future work: walks every
|
|
811
|
+
* JsonArtifactAuthoringController-shaped artifact type this plugin owns
|
|
812
|
+
* (same `ownerPlugin` filter a Studio Explorer tree click already relies
|
|
813
|
+
* on) plus the two membership-only types (Application/Module) and this
|
|
814
|
+
* plugin's own i18n namespace, and writes a REAL local `spk-assembly/
|
|
815
|
+
* metadata/<type>/<name>.json` tree — the same on-disk shape/foldering an
|
|
816
|
+
* already-shipped module has (confirmed against hcm-foundation's own
|
|
817
|
+
* `metadata/` listing: application, data_service, data_view, i18n, menu,
|
|
818
|
+
* mobile_nav, module, page, provider). A developer edits under that real
|
|
819
|
+
* tree, then `erp plugin validate` / `erp plugin build` / `erp plugin
|
|
820
|
+
* publish` — the exact same pipeline every vendor module already ships
|
|
821
|
+
* through, no separate "customization" mechanism to learn.
|
|
822
|
+
* <p>
|
|
823
|
+
* Best-effort per artifact type: a 404/permission error on one type (e.g.
|
|
824
|
+
* an artifact type this tenant's license doesn't expose) is reported and
|
|
825
|
+
* skipped rather than aborting the whole checkout — matches every other
|
|
826
|
+
* "never let one projection helper break the read it decorates" posture
|
|
827
|
+
* elsewhere in this file.
|
|
828
|
+
*/
|
|
829
|
+
const PULL_ARTIFACT_TYPES = [
|
|
830
|
+
["pages", "page"],
|
|
831
|
+
["forms", "form"],
|
|
832
|
+
["blocks", "block"],
|
|
833
|
+
["dashboards", "dashboard"],
|
|
834
|
+
["reports", "report"],
|
|
835
|
+
["bi-reports", "bi_report"],
|
|
836
|
+
["print-templates", "print_template"],
|
|
837
|
+
["menus", "menu"],
|
|
838
|
+
["mobile-navs", "mobile_nav"],
|
|
839
|
+
["providers", "provider"],
|
|
840
|
+
["data-services", "data_service"],
|
|
841
|
+
["data-views", "data_view"],
|
|
842
|
+
["rules", "rule"],
|
|
843
|
+
["workflows", "workflow"],
|
|
844
|
+
["actions", "action"],
|
|
845
|
+
["applications", "application"],
|
|
846
|
+
["modules", "module"],
|
|
847
|
+
];
|
|
848
|
+
|
|
849
|
+
function tryParseJson(s) {
|
|
850
|
+
try {
|
|
851
|
+
return JSON.parse(s);
|
|
852
|
+
} catch {
|
|
853
|
+
return s;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts) {
|
|
858
|
+
const baseDir = path.join(outDir, "spk-assembly");
|
|
859
|
+
const metaDir = path.join(baseDir, "metadata");
|
|
860
|
+
const manifest = tryParseJson(found.manifestJson);
|
|
861
|
+
mkdirSync(baseDir, { recursive: true });
|
|
862
|
+
writeFileSync(path.join(baseDir, "plugin.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
863
|
+
console.log(`-- spk-assembly/plugin.json (version ${manifest.version ?? found.version}) --`);
|
|
864
|
+
|
|
865
|
+
let totalArtifacts = 0;
|
|
866
|
+
for (const [urlSegment, folderName] of PULL_ARTIFACT_TYPES) {
|
|
867
|
+
let list;
|
|
868
|
+
try {
|
|
869
|
+
list = await api(cfg, "GET", `/api/v1/authoring/${urlSegment}?latestOnly=true`, { tenantIdOverride: opts.tenant });
|
|
870
|
+
} catch (e) {
|
|
871
|
+
console.log(` ${urlSegment}: skipped (${e.message.split("\n")[0]})`);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!Array.isArray(list)) continue;
|
|
875
|
+
const owned = list.filter((r) => r.ownerPlugin === pluginId);
|
|
876
|
+
if (owned.length === 0) continue;
|
|
877
|
+
console.log(`-- ${urlSegment}: ${owned.length} owned by ${pluginId} --`);
|
|
878
|
+
for (const summary of owned) {
|
|
879
|
+
let targetId = summary.id;
|
|
880
|
+
if (opts.version) {
|
|
881
|
+
try {
|
|
882
|
+
const history = await api(cfg, "GET", `/api/v1/authoring/${urlSegment}/name/${encodeURIComponent(summary.name)}/history`, { tenantIdOverride: opts.tenant });
|
|
883
|
+
const match = Array.isArray(history) ? history.find((h) => String(h.version) === String(opts.version)) : null;
|
|
884
|
+
if (match) {
|
|
885
|
+
targetId = match.id;
|
|
886
|
+
} else {
|
|
887
|
+
console.log(` ${summary.name}: no v${opts.version} in its own history — using latest (v${summary.version})`);
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
// history lookup failed — fall through to latest, never block the checkout over it
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const full = await api(cfg, "GET", `/api/v1/authoring/${urlSegment}/${targetId}`, { tenantIdOverride: opts.tenant });
|
|
894
|
+
const record = {
|
|
895
|
+
name: full.name,
|
|
896
|
+
description: full.description,
|
|
897
|
+
metadata: full.metadataJson ? tryParseJson(full.metadataJson) : undefined,
|
|
898
|
+
definition: tryParseJson(full.definitionJson),
|
|
899
|
+
};
|
|
900
|
+
const artifactDir = path.join(metaDir, folderName);
|
|
901
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
902
|
+
writeFileSync(path.join(artifactDir, `${full.name}.json`), JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
903
|
+
console.log(` wrote ${folderName}/${full.name}.json (id ${full.id}, v${full.version})`);
|
|
904
|
+
totalArtifacts++;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Entities are NOT a JsonArtifactAuthoringController subclass like the
|
|
909
|
+
// types above — no draft/publish/revision lifecycle, no `ownerPlugin`
|
|
910
|
+
// field at all, a different list endpoint (`/api/v1/entities`, not
|
|
911
|
+
// `/api/v1/authoring/entities` — that path 404s), and no `?latestOnly=`
|
|
912
|
+
// (there's only ever one row per entity). The closest thing to ownership
|
|
913
|
+
// is `category`, confirmed live to hold real plugin ids for entity-heavy
|
|
914
|
+
// modules (e.g. 47 rows with category "hcm-recruitment") — but it's a
|
|
915
|
+
// free-text taxonomy field, not an enforced foreign key the way
|
|
916
|
+
// `ownerPlugin` is, so this is a best-effort match, disclosed as such
|
|
917
|
+
// rather than presented as equally reliable.
|
|
918
|
+
try {
|
|
919
|
+
const entities = await api(cfg, "GET", "/api/v1/entities", { tenantIdOverride: opts.tenant });
|
|
920
|
+
const owned = Array.isArray(entities) ? entities.filter((r) => r.category === pluginId) : [];
|
|
921
|
+
if (owned.length > 0) {
|
|
922
|
+
console.log(`-- entities: ${owned.length} with category "${pluginId}" (best-effort — no real ownerPlugin field on this type) --`);
|
|
923
|
+
const artifactDir = path.join(metaDir, "entity");
|
|
924
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
925
|
+
for (const summary of owned) {
|
|
926
|
+
const full = await api(cfg, "GET", `/api/v1/entities/${summary.id}`, { tenantIdOverride: opts.tenant });
|
|
927
|
+
writeFileSync(path.join(artifactDir, `${full.name}.json`), JSON.stringify(full, null, 2) + "\n", "utf8");
|
|
928
|
+
console.log(` wrote entity/${full.name}.json (id ${full.id})`);
|
|
929
|
+
totalArtifacts++;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch (e) {
|
|
933
|
+
console.log(` entities: skipped (${e.message.split("\n")[0]})`);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
console.log(`-- i18n --`);
|
|
937
|
+
try {
|
|
938
|
+
const translations = await api(cfg, "GET", "/api/v1/authoring/translations", { tenantIdOverride: opts.tenant });
|
|
939
|
+
const enRow = translations.find((t) => t.locale === "en");
|
|
940
|
+
if (enRow) {
|
|
941
|
+
const allEntries = JSON.parse(enRow.entriesJson);
|
|
942
|
+
// A plugin's own spk-assembly i18n file ships only the keys IT owns
|
|
943
|
+
// (its artifact-name-prefixed namespace), never the tenant's whole
|
|
944
|
+
// translation bundle (~31k keys / 2.5MB on this env alone).
|
|
945
|
+
const ownKeys = Object.fromEntries(Object.entries(allEntries).filter(([k]) => k.startsWith(`${pluginId}.`)));
|
|
946
|
+
const i18nDir = path.join(metaDir, "i18n");
|
|
947
|
+
mkdirSync(i18nDir, { recursive: true });
|
|
948
|
+
writeFileSync(path.join(i18nDir, "en.json"), JSON.stringify(ownKeys, null, 2) + "\n", "utf8");
|
|
949
|
+
console.log(` wrote i18n/en.json (${Object.keys(ownKeys).length} keys under "${pluginId}.*")`);
|
|
950
|
+
}
|
|
951
|
+
} catch (e) {
|
|
952
|
+
console.log(` i18n: skipped (${e.message.split("\n")[0]})`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
console.log(`\n== Full checkout done: ${totalArtifacts} artifacts + plugin.json + i18n written to ${baseDir} ==`);
|
|
956
|
+
console.log(`Next: edit under ${metaDir}, then:`);
|
|
957
|
+
console.log(` erp plugin validate ${outDir}`);
|
|
958
|
+
console.log(` erp plugin build ${outDir} -o ${pluginId}-<version>.spk`);
|
|
959
|
+
console.log(` erp plugin publish ${pluginId}-<version>.spk --env <name> # e.g. local first, then prod`);
|
|
694
960
|
}
|
|
695
961
|
|
|
696
962
|
async function pluginDiffCommand(pluginId, opts) {
|
|
@@ -2475,6 +2741,68 @@ async function pluginPublishFrontendCommand(backendModuleDir, opts) {
|
|
|
2475
2741
|
// laptop -> VPS: publishes to the configured env's base_url with a device-flow
|
|
2476
2742
|
// access token (never a hardcoded localhost:8080). `--env <name>` targets a
|
|
2477
2743
|
// specific configured env; `--url` still overrides outright; ERP_TOKEN works for CI.
|
|
2744
|
+
// 2026-09-23 — real gap found live: `hcm-foundation`'s roles-permissions page
|
|
2745
|
+
// had FIVE page rows (versions 1-5, one per historical publish) all claiming
|
|
2746
|
+
// the identical route `/hcm-foundation/roles-permissions`, four of them
|
|
2747
|
+
// `deprecated`. The source only ever had ONE page file — this is an
|
|
2748
|
+
// install-time accumulation, not an authoring mistake `erp plugin
|
|
2749
|
+
// validate`/`erp_validate_plugin_pages` (both file-local, per-page schema
|
|
2750
|
+
// checks) could ever catch. Reported live as an intermittent "no page
|
|
2751
|
+
// registered for route" the frontend's own stale page-list cache could
|
|
2752
|
+
// reproduce. This checks the REAL, live, post-install state right after a
|
|
2753
|
+
// publish succeeds — the only point this class of bug is actually
|
|
2754
|
+
// detectable — instead of a developer discovering it from a confused user
|
|
2755
|
+
// report days later. Best-effort only: never fails the publish itself (the
|
|
2756
|
+
// install already succeeded by the time this runs), and silently skips when
|
|
2757
|
+
// `target` isn't a local directory with readable metadata/page/*.json files
|
|
2758
|
+
// (e.g. a prebuilt .spk with no adjacent source tree).
|
|
2759
|
+
async function checkDuplicateRoutesAfterPublish(cfg, target, opts) {
|
|
2760
|
+
try {
|
|
2761
|
+
const absTarget = path.resolve(target);
|
|
2762
|
+
if (!existsSync(absTarget) || !statSync(absTarget).isDirectory()) return;
|
|
2763
|
+
const manifestPath = findManifestPath(absTarget);
|
|
2764
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
2765
|
+
const pluginId = manifest.id;
|
|
2766
|
+
if (!pluginId) return;
|
|
2767
|
+
const pageDir = path.join(path.dirname(manifestPath), "metadata", "page");
|
|
2768
|
+
if (!existsSync(pageDir)) return;
|
|
2769
|
+
const modules = new Set();
|
|
2770
|
+
for (const f of readdirSync(pageDir).filter((f) => f.endsWith(".json"))) {
|
|
2771
|
+
try {
|
|
2772
|
+
const page = JSON.parse(readFileSync(path.join(pageDir, f), "utf8"));
|
|
2773
|
+
for (const m of page.definition?.modules ?? []) modules.add(m);
|
|
2774
|
+
} catch {
|
|
2775
|
+
/* malformed page file is `erp plugin validate`'s job to catch, not this */
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
if (modules.size === 0) return;
|
|
2779
|
+
const problems = [];
|
|
2780
|
+
for (const moduleId of modules) {
|
|
2781
|
+
const rows = await api(cfg, "GET", `/api/v1/authoring/pages?module=${encodeURIComponent(moduleId)}&latestOnly=true`, {
|
|
2782
|
+
tenantIdOverride: opts.tenant,
|
|
2783
|
+
});
|
|
2784
|
+
const ownRows = (Array.isArray(rows) ? rows : []).filter((r) => r.ownerPlugin === pluginId);
|
|
2785
|
+
const byRoute = new Map();
|
|
2786
|
+
for (const r of ownRows) {
|
|
2787
|
+
const pattern = r.route?.pattern;
|
|
2788
|
+
if (!pattern) continue;
|
|
2789
|
+
if (!byRoute.has(pattern)) byRoute.set(pattern, new Set());
|
|
2790
|
+
byRoute.get(pattern).add(r.id);
|
|
2791
|
+
}
|
|
2792
|
+
for (const [pattern, ids] of byRoute) {
|
|
2793
|
+
if (ids.size > 1) problems.push({ moduleId, pattern, ids: [...ids] });
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
if (problems.length > 0) {
|
|
2797
|
+
console.log(`\nWARNING: ${problems.length} route(s) resolve to more than one "latestOnly" page — this is what produced a real "no page registered for route" bug (see [[page-resolution-version-only-dedup-gap]]):`);
|
|
2798
|
+
for (const p of problems) console.log(` ${p.pattern} (module ${p.moduleId}) -> page ids ${p.ids.join(", ")}`);
|
|
2799
|
+
console.log(` This is a server-side accumulation across past publishes, not something this publish itself broke. Clean up the extra rows via \`erp api delete /api/v1/authoring/pages/<id>\` after confirming (with a human) which id is the one currently live.`);
|
|
2800
|
+
}
|
|
2801
|
+
} catch {
|
|
2802
|
+
/* best-effort post-check; never let it fail or obscure a successful publish */
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2478
2806
|
async function pluginPublishCommand(target, opts) {
|
|
2479
2807
|
const cfg = loadConfig();
|
|
2480
2808
|
if (opts.env) {
|
|
@@ -2496,6 +2824,7 @@ async function pluginPublishCommand(target, opts) {
|
|
|
2496
2824
|
console.log(`erp plugin publish: ${target} -> ${opts.url || env.baseUrl} (env "${env.name}", tenant ${h["X-Tenant-Id"]})`);
|
|
2497
2825
|
if (opts.force) args.push("--force");
|
|
2498
2826
|
runSpark(args);
|
|
2827
|
+
if (!opts.dryRun && !process.exitCode) await checkDuplicateRoutesAfterPublish(cfg, target, opts);
|
|
2499
2828
|
}
|
|
2500
2829
|
|
|
2501
2830
|
// erp plugin force-unload <id> — rough-edge (b) recovery. Force-clears a
|
|
@@ -4085,7 +4414,10 @@ const HELP = `erp — ERP Developer Platform CLI
|
|
|
4085
4414
|
erp plugin list
|
|
4086
4415
|
erp plugin search <term>
|
|
4087
4416
|
erp plugin install <pluginId> [--manifest <path>]
|
|
4088
|
-
erp plugin pull <pluginId> [--out <dir>]
|
|
4417
|
+
erp plugin pull <pluginId> [--out <dir>] (THIN — manifest+config+state only)
|
|
4418
|
+
erp plugin pull <pluginId> --full [--out <dir>] (real checkout — every page/menu/data-service/data-view/provider/i18n-key/etc. this plugin owns, written as a real editable spk-assembly/metadata/ tree under <out>/spk-assembly/ — edit it, then erp plugin validate/build/publish, same pipeline any vendor module ships through)
|
|
4419
|
+
erp plugin clone <pluginId> [--out <dir>] (git-clone-like: same full checkout as 'pull --full', always full, the intended entry point — list, clone, edit with real git (init/add/commit) in the cloned dir, build, push)
|
|
4420
|
+
erp plugin checkout <pluginId> [--version <n>] [--out <dir>] (git-checkout-like: same as clone, but --version <n> takes each owned artifact's own v<n> where it has one in its history, else its latest — NOT a true point-in-time plugin snapshot, see the command's own doc comment)
|
|
4089
4421
|
erp plugin create <pluginId> [--name] [--schema] [--category] [--type <type>] [--runtime-mode embedded|service] [--service-port <n>]
|
|
4090
4422
|
erp menu create <plugin-dir> <menu-id> [--display-name <name>] [--route <path>] [--icon <name>] (scaffolds a real, schema-valid menu artifact — spark create leaves metadata/menu/ empty)
|
|
4091
4423
|
erp plugin diff <pluginId>
|
|
@@ -4291,6 +4623,8 @@ async function main() {
|
|
|
4291
4623
|
"plugin search": () => pluginSearchCommand(positional[0], opts),
|
|
4292
4624
|
"plugin install": () => pluginInstallCommand(positional[0], opts),
|
|
4293
4625
|
"plugin pull": () => pluginPullCommand(positional[0], opts),
|
|
4626
|
+
"plugin clone": () => pluginCloneCommand(positional[0], opts),
|
|
4627
|
+
"plugin checkout": () => pluginCheckoutCommand(positional[0], opts),
|
|
4294
4628
|
"plugin create": () => pluginCreateCommand(positional[0], opts),
|
|
4295
4629
|
"plugin diff": () => pluginDiffCommand(positional[0], opts),
|
|
4296
4630
|
"spec generate": () => specGenerateCommand(positional[0], opts),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sparkerp/plugin-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "ERP Developer Platform CLI + MCP server for authoring plugins against a deployed ERP with zero access to the platform monorepo (platform source is secret to third-party developers). Bundles an offline authoring snapshot (schemas/catalog/blocks/docs/examples/validators) so `erp schema list`, `erp blocks list`, `erp docs search`, and page validation all work before you ever run `erp login`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|