@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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-09-23T05:56:28.078Z",
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,
@@ -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)
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "bundleVersion": "2026-09-23.1",
3
3
  "platformVersion": "0.0.0",
4
- "generatedAt": "2026-09-23T05:56:28.964Z",
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": 248,
7
+ "docCount": 249,
8
8
  "exampleFileCount": 14,
9
9
  "blockCount": 124,
10
- "catalogGeneratedAt": "2026-09-23T05:56:28.078Z",
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
- // [THIN] pull — reconstructs manifest+config+version state (everything
551
- // PluginInstallationController's GET actually returns) into a local file;
552
- // does NOT reconstruct the full spk-assembly page/workflow/entity source
553
- // tree (those live in per-artifact authoring controllers with different
554
- // shapes each a genuinely separate, larger integration per artifact type,
555
- // disclosed as future work, not attempted here to avoid a half-correct
556
- // reconstruction). If the plugin is ALSO a registered registry package,
557
- // `erp registry get` (below) returns richer metadata (description,
558
- // license, dependencies, changelog) than the bare install-state row.
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
- console.log(JSON.stringify(list, null, 2));
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.0.0",
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",