@sparkerp/plugin-sdk 0.1.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.
Files changed (176) hide show
  1. package/bundle/blocks.json +169 -0
  2. package/bundle/catalog.json +614 -5
  3. package/bundle/docs/applications/hcm/admin-access-policies.md +38 -0
  4. package/bundle/docs/applications/hcm/admin-approval-hierarchies.md +33 -0
  5. package/bundle/docs/applications/hcm/admin-data-transfer.md +40 -0
  6. package/bundle/docs/applications/hcm/admin-hcm-users.md +35 -0
  7. package/bundle/docs/applications/hcm/admin-logs.md +57 -0
  8. package/bundle/docs/applications/hcm/admin-permissions-catalog.md +64 -0
  9. package/bundle/docs/applications/hcm/ai-intelligence-overview.md +37 -0
  10. package/bundle/docs/applications/hcm/ai-intelligence-people-risk.md +33 -0
  11. package/bundle/docs/applications/hcm/ai-intelligence-recruitment.md +37 -0
  12. package/bundle/docs/applications/hcm/ai-intelligence-tools.md +39 -0
  13. package/bundle/docs/applications/hcm/analytics-operations.md +31 -0
  14. package/bundle/docs/applications/hcm/analytics-overview.md +31 -0
  15. package/bundle/docs/applications/hcm/analytics-people.md +36 -0
  16. package/bundle/docs/applications/hcm/analytics-tools.md +35 -0
  17. package/bundle/docs/applications/hcm/assets-audits.md +30 -0
  18. package/bundle/docs/applications/hcm/assets-custody.md +36 -0
  19. package/bundle/docs/applications/hcm/assets-inventory.md +34 -0
  20. package/bundle/docs/applications/hcm/assets-maintenance.md +25 -0
  21. package/bundle/docs/applications/hcm/assets-software-licenses.md +27 -0
  22. package/bundle/docs/applications/hcm/attendance-core.md +46 -0
  23. package/bundle/docs/applications/hcm/attendance-exceptions.md +33 -0
  24. package/bundle/docs/applications/hcm/attendance-location.md +25 -0
  25. package/bundle/docs/applications/hcm/attendance-policies.md +23 -0
  26. package/bundle/docs/applications/hcm/attendance-reports.md +20 -0
  27. package/bundle/docs/applications/hcm/benefits-allowances.md +39 -0
  28. package/bundle/docs/applications/hcm/benefits-analytics.md +25 -0
  29. package/bundle/docs/applications/hcm/benefits-employee-processes.md +44 -0
  30. package/bundle/docs/applications/hcm/benefits-plans.md +60 -0
  31. package/bundle/docs/applications/hcm/communications-announcements.md +31 -0
  32. package/bundle/docs/applications/hcm/communications-history.md +23 -0
  33. package/bundle/docs/applications/hcm/communications-notifications.md +31 -0
  34. package/bundle/docs/applications/hcm/communications-templates.md +35 -0
  35. package/bundle/docs/applications/hcm/compensation-allowances-benefits.md +44 -0
  36. package/bundle/docs/applications/hcm/compensation-analytics.md +30 -0
  37. package/bundle/docs/applications/hcm/compensation-bonus-incentive.md +35 -0
  38. package/bundle/docs/applications/hcm/compensation-cycles.md +49 -0
  39. package/bundle/docs/applications/hcm/compensation-equity.md +36 -0
  40. package/bundle/docs/applications/hcm/compensation-salary.md +47 -0
  41. package/bundle/docs/applications/hcm/compliance-analytics.md +25 -0
  42. package/bundle/docs/applications/hcm/compliance-audits-calendar.md +52 -0
  43. package/bundle/docs/applications/hcm/compliance-employee-tracking.md +41 -0
  44. package/bundle/docs/applications/hcm/compliance-regulatory.md +31 -0
  45. package/bundle/docs/applications/hcm/country-packs-admin.md +33 -0
  46. package/bundle/docs/applications/hcm/country-packs-compliance.md +31 -0
  47. package/bundle/docs/applications/hcm/country-packs-localization.md +37 -0
  48. package/bundle/docs/applications/hcm/country-packs.md +102 -0
  49. package/bundle/docs/applications/hcm/dashboard.md +38 -0
  50. package/bundle/docs/applications/hcm/designations.md +32 -0
  51. package/bundle/docs/applications/hcm/emp-documents-analytics.md +27 -0
  52. package/bundle/docs/applications/hcm/emp-documents-core.md +33 -0
  53. package/bundle/docs/applications/hcm/emp-documents-letters.md +40 -0
  54. package/bundle/docs/applications/hcm/emp-documents-signatures.md +24 -0
  55. package/bundle/docs/applications/hcm/emp-documents-workflow.md +32 -0
  56. package/bundle/docs/applications/hcm/employee-directory.md +55 -0
  57. package/bundle/docs/applications/hcm/employee-documents.md +51 -0
  58. package/bundle/docs/applications/hcm/employee-info-background.md +41 -0
  59. package/bundle/docs/applications/hcm/employee-info-core.md +32 -0
  60. package/bundle/docs/applications/hcm/employee-info-documents.md +31 -0
  61. package/bundle/docs/applications/hcm/employee-info-other.md +21 -0
  62. package/bundle/docs/applications/hcm/employee-movements.md +46 -0
  63. package/bundle/docs/applications/hcm/employee-reference-data.md +40 -0
  64. package/bundle/docs/applications/hcm/employee-relations-analytics.md +23 -0
  65. package/bundle/docs/applications/hcm/employee-relations-cases.md +26 -0
  66. package/bundle/docs/applications/hcm/employee-relations-conflicts-feedback.md +25 -0
  67. package/bundle/docs/applications/hcm/employee-relations-processes.md +34 -0
  68. package/bundle/docs/applications/hcm/employee-services-daily.md +32 -0
  69. package/bundle/docs/applications/hcm/employee-services-manager.md +21 -0
  70. package/bundle/docs/applications/hcm/employee-services-more.md +41 -0
  71. package/bundle/docs/applications/hcm/employee-services-overview.md +43 -0
  72. package/bundle/docs/applications/hcm/expenses-analytics.md +26 -0
  73. package/bundle/docs/applications/hcm/expenses-claims.md +47 -0
  74. package/bundle/docs/applications/hcm/expenses-self-service.md +30 -0
  75. package/bundle/docs/applications/hcm/expenses-setup.md +43 -0
  76. package/bundle/docs/applications/hcm/health-safety-analytics.md +26 -0
  77. package/bundle/docs/applications/hcm/health-safety-incidents.md +44 -0
  78. package/bundle/docs/applications/hcm/health-safety-medical.md +25 -0
  79. package/bundle/docs/applications/hcm/health-safety-training.md +29 -0
  80. package/bundle/docs/applications/hcm/helpdesk.md +28 -0
  81. package/bundle/docs/applications/hcm/holiday-calendar.md +69 -0
  82. package/bundle/docs/applications/hcm/hr-policies.md +64 -0
  83. package/bundle/docs/applications/hcm/hr-settings.md +98 -0
  84. package/bundle/docs/applications/hcm/index.md +272 -0
  85. package/bundle/docs/applications/hcm/industry-it-software.md +52 -0
  86. package/bundle/docs/applications/hcm/job-classifications.md +48 -0
  87. package/bundle/docs/applications/hcm/learning-analytics.md +21 -0
  88. package/bundle/docs/applications/hcm/learning-assessments.md +22 -0
  89. package/bundle/docs/applications/hcm/learning-catalog.md +25 -0
  90. package/bundle/docs/applications/hcm/learning-certifications.md +27 -0
  91. package/bundle/docs/applications/hcm/learning-enrollment.md +26 -0
  92. package/bundle/docs/applications/hcm/learning-instructors-providers.md +21 -0
  93. package/bundle/docs/applications/hcm/leave-accrual-and-carryforward.md +30 -0
  94. package/bundle/docs/applications/hcm/leave-balance.md +26 -0
  95. package/bundle/docs/applications/hcm/leave-dashboard-and-reports.md +32 -0
  96. package/bundle/docs/applications/hcm/leave-encashment.md +27 -0
  97. package/bundle/docs/applications/hcm/leave-requests.md +26 -0
  98. package/bundle/docs/applications/hcm/leave-setup.md +30 -0
  99. package/bundle/docs/applications/hcm/leave-team-and-calendar.md +25 -0
  100. package/bundle/docs/applications/hcm/navigation-and-approvals.md +61 -0
  101. package/bundle/docs/applications/hcm/offboarding-and-exit.md +71 -0
  102. package/bundle/docs/applications/hcm/onboarding-documents-verification.md +43 -0
  103. package/bundle/docs/applications/hcm/onboarding-orientation-probation.md +65 -0
  104. package/bundle/docs/applications/hcm/onboarding-overview.md +47 -0
  105. package/bundle/docs/applications/hcm/onboarding-provisioning-assets.md +48 -0
  106. package/bundle/docs/applications/hcm/onboarding-reports.md +38 -0
  107. package/bundle/docs/applications/hcm/onboarding-templates-checklists.md +38 -0
  108. package/bundle/docs/applications/hcm/onboarding-to-confirmation.md +45 -0
  109. package/bundle/docs/applications/hcm/org-structure.md +53 -0
  110. package/bundle/docs/applications/hcm/org-units.md +86 -0
  111. package/bundle/docs/applications/hcm/organizations.md +81 -0
  112. package/bundle/docs/applications/hcm/payroll-analytics.md +31 -0
  113. package/bundle/docs/applications/hcm/payroll-post-run.md +40 -0
  114. package/bundle/docs/applications/hcm/payroll-runs.md +30 -0
  115. package/bundle/docs/applications/hcm/payroll-setup.md +38 -0
  116. package/bundle/docs/applications/hcm/payroll-tax.md +21 -0
  117. package/bundle/docs/applications/hcm/payroll-transactions.md +43 -0
  118. package/bundle/docs/applications/hcm/performance-analytics.md +23 -0
  119. package/bundle/docs/applications/hcm/performance-appraisals.md +50 -0
  120. package/bundle/docs/applications/hcm/performance-continuous-feedback.md +23 -0
  121. package/bundle/docs/applications/hcm/performance-cycles-and-goals.md +54 -0
  122. package/bundle/docs/applications/hcm/performance-okrs.md +33 -0
  123. package/bundle/docs/applications/hcm/performance-pips.md +29 -0
  124. package/bundle/docs/applications/hcm/performance-ratings.md +25 -0
  125. package/bundle/docs/applications/hcm/positions.md +67 -0
  126. package/bundle/docs/applications/hcm/recruitment-agencies-and-sources.md +26 -0
  127. package/bundle/docs/applications/hcm/recruitment-analytics.md +25 -0
  128. package/bundle/docs/applications/hcm/recruitment-candidates.md +33 -0
  129. package/bundle/docs/applications/hcm/recruitment-offers.md +32 -0
  130. package/bundle/docs/applications/hcm/recruitment-pipeline.md +46 -0
  131. package/bundle/docs/applications/hcm/recruitment-requisitions-and-openings.md +33 -0
  132. package/bundle/docs/applications/hcm/roles-permissions.md +129 -0
  133. package/bundle/docs/applications/hcm/separation-clearance.md +33 -0
  134. package/bundle/docs/applications/hcm/separation-documents.md +28 -0
  135. package/bundle/docs/applications/hcm/separation-final-settlement.md +24 -0
  136. package/bundle/docs/applications/hcm/separation-reports.md +23 -0
  137. package/bundle/docs/applications/hcm/separation-resignation.md +43 -0
  138. package/bundle/docs/applications/hcm/settings-extensibility.md +31 -0
  139. package/bundle/docs/applications/hcm/settings-general.md +29 -0
  140. package/bundle/docs/applications/hcm/settings-integrations.md +14 -0
  141. package/bundle/docs/applications/hcm/settings-module-defaults.md +41 -0
  142. package/bundle/docs/applications/hcm/settings-process.md +28 -0
  143. package/bundle/docs/applications/hcm/shift-scheduling.md +33 -0
  144. package/bundle/docs/applications/hcm/talent-analytics.md +26 -0
  145. package/bundle/docs/applications/hcm/talent-career.md +31 -0
  146. package/bundle/docs/applications/hcm/talent-competencies-skills.md +30 -0
  147. package/bundle/docs/applications/hcm/talent-profiles.md +40 -0
  148. package/bundle/docs/applications/hcm/talent-succession.md +42 -0
  149. package/bundle/docs/applications/hcm/teams-and-tags.md +46 -0
  150. package/bundle/docs/applications/hcm/timesheets.md +33 -0
  151. package/bundle/docs/applications/hcm/travel-advances-expenses.md +34 -0
  152. package/bundle/docs/applications/hcm/travel-analytics.md +28 -0
  153. package/bundle/docs/applications/hcm/travel-bookings.md +35 -0
  154. package/bundle/docs/applications/hcm/travel-requests.md +38 -0
  155. package/bundle/docs/applications/hcm/workforce-org-design.md +22 -0
  156. package/bundle/docs/applications/hcm/workforce-planning-analytics.md +26 -0
  157. package/bundle/docs/applications/hcm/workforce-planning-core.md +36 -0
  158. package/bundle/docs/applications/hcm/workforce-planning-scenarios.md +29 -0
  159. package/bundle/docs/docs.json +1 -0
  160. package/bundle/docs/guides/add-app-owned-roles-and-permissions.md +144 -0
  161. package/bundle/docs/guides/checkout-an-installed-plugin.md +151 -0
  162. package/bundle/docs/guides/extend-a-shipped-application.md +132 -0
  163. package/bundle/docs/guides/index.md +3 -0
  164. package/bundle/docs/reference/entity-aggregation-config.md +1 -1
  165. package/bundle/docs/reference/entity-document-generator-config.md +1 -1
  166. package/bundle/docs/tutorial/01-create-the-plugin.md +7 -1
  167. package/bundle/docs/tutorial/07-return-due-reminder-job.md +28 -5
  168. package/bundle/docs/tutorial/08-menus-i18n-publish.md +5 -5
  169. package/bundle/manifest.json +4 -4
  170. package/bundle/schemas/page.schema.json +13 -0
  171. package/bundle/schemas/plugin-manifest.schema.json +13 -0
  172. package/bundle/validators/block-engine.mjs +167 -7
  173. package/bundle/validators/page-engine.mjs +226 -21
  174. package/erp-cli/authoring-root.mjs +12 -1
  175. package/erp-cli/erp.mjs +679 -15
  176. package/package.json +1 -1
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) {
@@ -1397,6 +1663,222 @@ public class ${className} implements RestContribution {
1397
1663
  },
1398
1664
  };
1399
1665
 
1666
+ // 2026-09-23 — the other ~10 curated extension-point interfaces in
1667
+ // engine-plugin-api (`erp platform describe <Name>`) had NO real shipped
1668
+ // implementation anywhere in the codebase yet, unlike rest-contribution's
1669
+ // real HelloRestContribution.java model — so these are generated directly
1670
+ // from each interface's own real method/record signatures instead (never
1671
+ // invented syntax, just not "modeled on a real example" the way
1672
+ // rest-contribution's doc comment could honestly claim). Each nested record
1673
+ // the interface itself declares (e.g. PaymentProviderContribution.ChargeResult)
1674
+ // is referenced via its qualified inner-class import, exactly as javac requires.
1675
+ function contributionTemplate({ interfaceName, methods, extraImports = [] }) {
1676
+ return {
1677
+ describe: `A plugin-contributed ${interfaceName} — no real shipped implementation exists in this codebase yet, so this is generated directly from the real interface contract (see \`erp platform describe ${interfaceName}\`), not modeled on a working example.`,
1678
+ render: ({ packageName, className, pluginId }) => `package ${packageName};
1679
+
1680
+ import com.erp.platform.engine.plugin.api.${interfaceName};
1681
+ ${extraImports.map((i) => `import ${i};`).join("\n")}
1682
+ import org.pf4j.Extension;
1683
+
1684
+ /**
1685
+ * Scaffolded by \`erp plugin scaffold\` from the real ${interfaceName}
1686
+ * interface contract — no shipped reference implementation existed in this
1687
+ * codebase yet (see this template's own note in erp.mjs). Replace every
1688
+ * TODO with a real call to the actual provider this plugin integrates.
1689
+ */
1690
+ @Extension
1691
+ public class ${className} implements ${interfaceName} {
1692
+ ${methods}
1693
+ }
1694
+ `,
1695
+ };
1696
+ }
1697
+
1698
+ Object.assign(SCAFFOLD_TEMPLATES, {
1699
+ "authentication-provider": contributionTemplate({
1700
+ interfaceName: "AuthenticationProviderContribution",
1701
+ extraImports: ["java.util.Optional"],
1702
+ methods: `
1703
+ @Override
1704
+ public String providerId() {
1705
+ return "${"${pluginId}"}";
1706
+ }
1707
+
1708
+ @Override
1709
+ public Optional<String> authenticate(String username, String credential) {
1710
+ // TODO: call the real identity provider; return Optional.of(subjectId) on success.
1711
+ return Optional.empty();
1712
+ }`.replace("${pluginId}", "REPLACE_provider_id"),
1713
+ }),
1714
+ "banking-provider": contributionTemplate({
1715
+ interfaceName: "BankingProviderContribution",
1716
+ extraImports: ["java.math.BigDecimal", "java.time.LocalDate", "java.util.List"],
1717
+ methods: `
1718
+ @Override
1719
+ public String providerId() {
1720
+ return "REPLACE_provider_id";
1721
+ }
1722
+
1723
+ @Override
1724
+ public AccountBalance getBalance(byte[] decryptedCredentials, String accountId) {
1725
+ // TODO: call the real banking API.
1726
+ throw new UnsupportedOperationException("TODO");
1727
+ }
1728
+
1729
+ @Override
1730
+ public List<BankTransaction> getStatement(byte[] decryptedCredentials, String accountId, LocalDate from, LocalDate to) {
1731
+ // TODO: call the real banking API.
1732
+ throw new UnsupportedOperationException("TODO");
1733
+ }
1734
+
1735
+ @Override
1736
+ public PaymentResult initiatePayment(byte[] decryptedCredentials, String fromAccountId, String toAccountNumber, String toRoutingCode, BigDecimal amount, String currency, String reference, String remarks) {
1737
+ // TODO: call the real banking API.
1738
+ throw new UnsupportedOperationException("TODO");
1739
+ }
1740
+
1741
+ @Override
1742
+ public PaymentResult checkPaymentStatus(byte[] decryptedCredentials, String paymentReference) {
1743
+ // TODO: call the real banking API.
1744
+ throw new UnsupportedOperationException("TODO");
1745
+ }`,
1746
+ }),
1747
+ "calendar-sync-provider": contributionTemplate({
1748
+ interfaceName: "CalendarSyncProviderContribution",
1749
+ extraImports: ["java.time.Instant"],
1750
+ methods: `
1751
+ @Override
1752
+ public String providerId() {
1753
+ return "REPLACE_provider_id";
1754
+ }
1755
+
1756
+ @Override
1757
+ public SyncResult createEvent(byte[] decryptedCredentials, String title, String description, Instant startAt, Instant endAt) {
1758
+ // TODO: call the real calendar API.
1759
+ throw new UnsupportedOperationException("TODO");
1760
+ }
1761
+
1762
+ @Override
1763
+ public SyncResult updateEvent(byte[] decryptedCredentials, String externalEventId, String title, String description, Instant startAt, Instant endAt) {
1764
+ // TODO: call the real calendar API.
1765
+ throw new UnsupportedOperationException("TODO");
1766
+ }
1767
+
1768
+ @Override
1769
+ public boolean deleteEvent(byte[] decryptedCredentials, String externalEventId) {
1770
+ // TODO: call the real calendar API.
1771
+ throw new UnsupportedOperationException("TODO");
1772
+ }`,
1773
+ }),
1774
+ "embedding-provider": contributionTemplate({
1775
+ interfaceName: "EmbeddingProviderContribution",
1776
+ methods: `
1777
+ @Override
1778
+ public String providerId() {
1779
+ return "REPLACE_provider_id";
1780
+ }
1781
+
1782
+ @Override
1783
+ public double[] embed(String text) {
1784
+ // TODO: call the real embedding model.
1785
+ throw new UnsupportedOperationException("TODO");
1786
+ }`,
1787
+ }),
1788
+ "llm-provider": contributionTemplate({
1789
+ interfaceName: "LlmProviderContribution",
1790
+ methods: `
1791
+ @Override
1792
+ public String providerId() {
1793
+ return "REPLACE_provider_id";
1794
+ }
1795
+
1796
+ @Override
1797
+ public String complete(String promptText) {
1798
+ // TODO: call the real LLM.
1799
+ throw new UnsupportedOperationException("TODO");
1800
+ }`,
1801
+ }),
1802
+ "notification-provider": contributionTemplate({
1803
+ interfaceName: "NotificationProviderContribution",
1804
+ methods: `
1805
+ @Override
1806
+ public String channel() {
1807
+ return "REPLACE_channel";
1808
+ }
1809
+
1810
+ @Override
1811
+ public boolean send(String recipient, String subject, String body) {
1812
+ // TODO: call the real notification channel.
1813
+ throw new UnsupportedOperationException("TODO");
1814
+ }`,
1815
+ }),
1816
+ "payment-provider": contributionTemplate({
1817
+ interfaceName: "PaymentProviderContribution",
1818
+ methods: `
1819
+ @Override
1820
+ public String providerId() {
1821
+ return "REPLACE_provider_id";
1822
+ }
1823
+
1824
+ @Override
1825
+ public ChargeResult charge(long amountMinorUnits, String currencyCode, String reference) {
1826
+ // TODO: call the real payment gateway.
1827
+ throw new UnsupportedOperationException("TODO");
1828
+ }`,
1829
+ }),
1830
+ "search-provider": contributionTemplate({
1831
+ interfaceName: "SearchProviderContribution",
1832
+ extraImports: ["java.util.List"],
1833
+ methods: `
1834
+ @Override
1835
+ public String providerId() {
1836
+ return "REPLACE_provider_id";
1837
+ }
1838
+
1839
+ @Override
1840
+ public List<SearchHit> search(long tenantId, String query, int limit) {
1841
+ // TODO: call the real search backend.
1842
+ throw new UnsupportedOperationException("TODO");
1843
+ }`,
1844
+ }),
1845
+ "storage-provider": contributionTemplate({
1846
+ interfaceName: "StorageProviderContribution",
1847
+ methods: `
1848
+ @Override
1849
+ public String providerId() {
1850
+ return "REPLACE_provider_id";
1851
+ }
1852
+
1853
+ @Override
1854
+ public String store(long tenantId, byte[] content) {
1855
+ // TODO: call the real storage backend; return its storagePath.
1856
+ throw new UnsupportedOperationException("TODO");
1857
+ }
1858
+
1859
+ @Override
1860
+ public byte[] retrieve(String storagePath) {
1861
+ // TODO: call the real storage backend.
1862
+ throw new UnsupportedOperationException("TODO");
1863
+ }`,
1864
+ }),
1865
+ "vector-search": contributionTemplate({
1866
+ interfaceName: "VectorSearchContribution",
1867
+ extraImports: ["java.util.List"],
1868
+ methods: `
1869
+ @Override
1870
+ public String collectionId() {
1871
+ return "REPLACE_collection_id";
1872
+ }
1873
+
1874
+ @Override
1875
+ public List<VectorHit> query(double[] queryEmbedding, int limit) {
1876
+ // TODO: call the real vector index.
1877
+ throw new UnsupportedOperationException("TODO");
1878
+ }`,
1879
+ }),
1880
+ });
1881
+
1400
1882
  function pluginScaffoldCommand(extensionPoint, opts) {
1401
1883
  if (!extensionPoint || !SCAFFOLD_TEMPLATES[extensionPoint]) {
1402
1884
  console.log(`usage: erp plugin scaffold <extension-point> [--package <pkg>] [--class <Name>] [--plugin-id <id>] [--out <file.java>]\nKnown extension points: ${Object.keys(SCAFFOLD_TEMPLATES).join(", ")}`);
@@ -1405,7 +1887,8 @@ function pluginScaffoldCommand(extensionPoint, opts) {
1405
1887
  const tpl = SCAFFOLD_TEMPLATES[extensionPoint];
1406
1888
  const pluginId = opts.pluginId || "my-plugin";
1407
1889
  const packageName = opts.package || `com.example.${pluginId.replace(/[^a-zA-Z0-9]/g, "")}`;
1408
- const className = opts.class || "MyRestContribution";
1890
+ const defaultClassName = "My" + extensionPoint.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase());
1891
+ const className = opts.class || defaultClassName;
1409
1892
  const rendered = tpl.render({ packageName, className, pluginId });
1410
1893
  if (opts.out) {
1411
1894
  mkdirSync(path.dirname(opts.out), { recursive: true });
@@ -2258,6 +2741,68 @@ async function pluginPublishFrontendCommand(backendModuleDir, opts) {
2258
2741
  // laptop -> VPS: publishes to the configured env's base_url with a device-flow
2259
2742
  // access token (never a hardcoded localhost:8080). `--env <name>` targets a
2260
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
+
2261
2806
  async function pluginPublishCommand(target, opts) {
2262
2807
  const cfg = loadConfig();
2263
2808
  if (opts.env) {
@@ -2279,6 +2824,7 @@ async function pluginPublishCommand(target, opts) {
2279
2824
  console.log(`erp plugin publish: ${target} -> ${opts.url || env.baseUrl} (env "${env.name}", tenant ${h["X-Tenant-Id"]})`);
2280
2825
  if (opts.force) args.push("--force");
2281
2826
  runSpark(args);
2827
+ if (!opts.dryRun && !process.exitCode) await checkDuplicateRoutesAfterPublish(cfg, target, opts);
2282
2828
  }
2283
2829
 
2284
2830
  // erp plugin force-unload <id> — rough-edge (b) recovery. Force-clears a
@@ -2841,6 +3387,107 @@ async function blocksListCommand(opts) {
2841
3387
  console.log(`Pass --type <exact-or-partial-name> for one type's full definition (all designer/labelKey detail), e.g. \`erp blocks list --type core.lookup\`.`);
2842
3388
  }
2843
3389
 
3390
+ // ---------------------------------------------------------------------------
3391
+ // erp page check <route> — [REAL, added 2026-09-23]. A real browser render
3392
+ // check via Playwright (`playwright`, already a repo-root dependency), for
3393
+ // exactly the gap this session hit live: the API layer can be 100% verified
3394
+ // through `erp api`, yet a real production bug (the roles-permissions grid
3395
+ // showing zero rows despite a healthy 200 response with real data) only
3396
+ // exists in the DOM, invisible to any REST-only check. This generalizes the
3397
+ // one-off diagnostic scripts written ad hoc during that investigation into
3398
+ // one reusable command, instead of a new throwaway .mjs per page (see
3399
+ // [[feedback-common-playwright-script-not-per-page]]).
3400
+ //
3401
+ // Login is real browser form automation against {baseUrl}/app/admin/user/login
3402
+ // (not a session-storage shortcut) — the CLI's own stored device-flow token
3403
+ // doesn't map onto the frontend's session bootstrap without a much deeper
3404
+ // integration, and driving the real form is what actually exercises the
3405
+ // real login path a user hits. Requires --user/--password (or
3406
+ // ERP_UI_USER/ERP_UI_PASSWORD) since the CLI's stored credentials are a
3407
+ // token, never a password.
3408
+ // ---------------------------------------------------------------------------
3409
+ async function pageCheckCommand(routePath, opts) {
3410
+ if (!routePath) {
3411
+ console.log("usage: erp page check <route> [--user <email>] [--password <pw>] [--base-url <url>] [--timeout <ms>]\n route: the app path after the base URL, e.g. /app/hcm/home/hcm-foundation/roles-permissions\n credentials default to ERP_UI_USER/ERP_UI_PASSWORD env vars if not passed.");
3412
+ return;
3413
+ }
3414
+ let chromium;
3415
+ try {
3416
+ ({ chromium } = await import("playwright"));
3417
+ } catch {
3418
+ console.log("`playwright` is not installed/resolvable from here — run `npm install` at the repo root first.");
3419
+ process.exitCode = 1;
3420
+ return;
3421
+ }
3422
+ const cfg = loadConfig();
3423
+ const env = currentEnv(cfg);
3424
+ const baseUrl = (opts.baseUrl || env.baseUrl).replace(/\/+$/, "");
3425
+ const user = opts.user || process.env.ERP_UI_USER;
3426
+ const password = opts.password || process.env.ERP_UI_PASSWORD;
3427
+ const timeout = Number(opts.timeout) || 45000;
3428
+
3429
+ const consoleErrors = [];
3430
+ const pageErrors = [];
3431
+ const failedRequests = [];
3432
+ const notableResponses = [];
3433
+
3434
+ const browser = await chromium.launch();
3435
+ const page = await browser.newPage();
3436
+ page.setDefaultTimeout(timeout);
3437
+ page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); });
3438
+ page.on("pageerror", (err) => pageErrors.push(err.message));
3439
+ page.on("requestfailed", (req) => failedRequests.push(`${req.method()} ${req.url()} — ${req.failure()?.errorText ?? "unknown"}`));
3440
+ page.on("response", async (res) => {
3441
+ if (res.status() >= 400) {
3442
+ notableResponses.push({ status: res.status(), url: res.url() });
3443
+ }
3444
+ });
3445
+
3446
+ try {
3447
+ if (user && password) {
3448
+ await page.goto(`${baseUrl}/app/admin/user/login`, { waitUntil: "networkidle" });
3449
+ const inputs = page.locator("input");
3450
+ await inputs.nth(0).fill(user);
3451
+ await inputs.nth(1).fill(password);
3452
+ await page.getByRole("button", { name: /login|sign in/i }).first().click();
3453
+ await page.waitForTimeout(2000);
3454
+ }
3455
+
3456
+ await page.goto(`${baseUrl}${routePath}`, { waitUntil: "networkidle" });
3457
+ await page.waitForTimeout(1500);
3458
+
3459
+ const tableChecks = await page.locator("table").evaluateAll((tables) =>
3460
+ tables.map((t, i) => ({
3461
+ index: i,
3462
+ headerCells: t.querySelectorAll("thead th").length,
3463
+ bodyRows: t.querySelectorAll("tbody tr").length,
3464
+ })),
3465
+ );
3466
+ const untranslatedKeys = await page.locator("body").evaluate((body) => {
3467
+ const matches = body.innerText.match(/⟦[^⟧]+⟧/g);
3468
+ return matches ? [...new Set(matches)] : [];
3469
+ });
3470
+ const bodyTextTail = (await page.locator("body").innerText()).slice(-600);
3471
+
3472
+ const report = {
3473
+ route: routePath,
3474
+ url: `${baseUrl}${routePath}`,
3475
+ tables: tableChecks,
3476
+ untranslatedI18nKeys: untranslatedKeys,
3477
+ consoleErrors,
3478
+ pageErrors,
3479
+ failedRequests,
3480
+ httpErrorResponses: notableResponses,
3481
+ bodyTextTail,
3482
+ };
3483
+ console.log(JSON.stringify(report, null, 2));
3484
+ const hasTableWithNoRows = tableChecks.some((t) => t.bodyRows === 0 && t.headerCells > 0);
3485
+ if (hasTableWithNoRows || untranslatedKeys.length > 0 || pageErrors.length > 0) process.exitCode = 1;
3486
+ } finally {
3487
+ await browser.close();
3488
+ }
3489
+ }
3490
+
2844
3491
  // ---------------------------------------------------------------------------
2845
3492
  // erp ai init / erp mcp install
2846
3493
  // [REAL] — scaffolds a plugin's ai/ subtree (agents.json/tools.json/
@@ -3292,8 +3939,18 @@ async function envSyncCommand(opts = {}) {
3292
3939
  // ---------------------------------------------------------------------------
3293
3940
  // erp docs gen-reference — turn the bundle's schemas/ into one Markdown page each
3294
3941
  // ---------------------------------------------------------------------------
3942
+ // A schema/property description can legitimately contain placeholder syntax
3943
+ // like "${field:<name>}" or "row #<key>" — real bug found 2026-09-22 (blocked
3944
+ // the whole developer-docs site build): embedded verbatim into a Markdown
3945
+ // table cell, VitePress's Vue-based compiler reads a bare `<name>` as an
3946
+ // unclosed HTML tag and fails the entire build, not just that page. Escape
3947
+ // angle brackets in any free-text description before it goes into the page.
3948
+ function escapeMdAngleBrackets(s) {
3949
+ return String(s).replace(/</g, "&lt;").replace(/>/g, "&gt;");
3950
+ }
3951
+
3295
3952
  function renderSchemaMarkdown(name, schema) {
3296
- const desc = SCHEMA_REGISTRY[name]?.description || schema.description || "";
3953
+ const desc = escapeMdAngleBrackets(SCHEMA_REGISTRY[name]?.description || schema.description || "");
3297
3954
  const lines = [
3298
3955
  "---",
3299
3956
  `title: ${name} schema`,
@@ -3316,7 +3973,7 @@ function renderSchemaMarkdown(name, schema) {
3316
3973
  const type = v.type ? (Array.isArray(v.type) ? v.type.join(" \\| ") : v.type) : v.$ref ? `\`${v.$ref}\`` : v.enum ? "enum" : "—";
3317
3974
  const notesParts = [];
3318
3975
  if (v.enum) notesParts.push("one of: " + v.enum.map((e) => `\`${e}\``).join(", "));
3319
- if (v.description) notesParts.push(String(v.description).replace(/\n+/g, " ").trim());
3976
+ if (v.description) notesParts.push(escapeMdAngleBrackets(String(v.description).replace(/\n+/g, " ").trim()));
3320
3977
  lines.push(`| \`${k}\` | ${type} | ${required.has(k) ? "yes" : ""} | ${notesParts.join(" — ") || ""} |`);
3321
3978
  }
3322
3979
  lines.push("");
@@ -3757,14 +4414,17 @@ const HELP = `erp — ERP Developer Platform CLI
3757
4414
  erp plugin list
3758
4415
  erp plugin search <term>
3759
4416
  erp plugin install <pluginId> [--manifest <path>]
3760
- 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)
3761
4421
  erp plugin create <pluginId> [--name] [--schema] [--category] [--type <type>] [--runtime-mode embedded|service] [--service-port <n>]
3762
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)
3763
4423
  erp plugin diff <pluginId>
3764
4424
  erp spec generate <pluginId> [--out <file.md>] (mechanically-derived entity/rule/page/permission inventory from LOCAL backend/modules/<id>/spk-assembly metadata — a starting point for ai/domains/*.md, not a replacement for its hand-curated narrative)
3765
4425
  erp platform catalog [--engine <engine-name>] [--refresh] (mechanically-derived class/REST-mapping/Flyway-table inventory of every backend/platform-runtime/engine-* module — cached at ai/platform/engine-capability-catalog.json; erp docs search also searches this, labeled [live engine-* source], so a stale doc claim is never the only answer)
3766
4426
  erp platform describe <ClassName> [--refresh] (real method signatures/field lists for a curated set of extension-point/gateway-client classes — *Contribution interfaces, WorkflowGatewayClient+PendingHumanTask/InstanceView/etc, DmsGatewayClient/CommunicationGatewayClient per-plugin copies — the concrete "what does this class actually contain" answer without opening the file; erp docs search also surfaces these, labeled [java contract])
3767
- erp plugin scaffold <extension-point> [--package <pkg>] [--class <Name>] [--plugin-id <id>] [--out <file.java>] (emits a minimal, correctly-typed Java skeleton for a curated extension point, pulled from a real shipped implementation — currently: rest-contribution)
4427
+ erp plugin scaffold <extension-point> [--package <pkg>] [--class <Name>] [--plugin-id <id>] [--out <file.java>] (emits a minimal, correctly-typed Java skeleton for any curated extension point — 12 covered: rest-contribution, seed-data-config, authentication-provider, banking-provider, calendar-sync-provider, embedding-provider, llm-provider, notification-provider, payment-provider, search-provider, storage-provider, vector-search; rest-contribution/seed-data-config are modeled on a real shipped example, the other 10 are generated directly from the real interface contract since no shipped implementation exists yet run \`erp plugin scaffold\` with no args for the full list)
3768
4428
  erp plugin validate <dir>
3769
4429
  erp plugin compile <source.tsx> [--out <page.json>]
3770
4430
  erp plugin test <dir>
@@ -3801,6 +4461,7 @@ const HELP = `erp — ERP Developer Platform CLI
3801
4461
  erp examples search <pattern> [--kind page|workflow|entities|provider] [--max <n>] (real shipped metadata JSON across every backend/modules/* plugin, matched by filename/plugin name — pure-CLI mirror of the MCP erp_search_examples tool)
3802
4462
  erp examples patterns [--kind workflow] (curated named SHAPES, not just filenames — e.g. single-stage-self-decide, multi-stage-linear, multi-stage-conditional-branching — each pointing at a real shipped file confirmed to have that shape)
3803
4463
  erp blocks list [--type <name>] [--category <cat>] (the real widget/block catalog, derived live from @erp/block-engine's own registry — every property/event/output any block type accepts, no source reading needed; pass --type for one type's full definition)
4464
+ erp page check <route> [--user <email>] [--password <pw>] [--base-url <url>] [--timeout <ms>] (real browser render check via Playwright — logs in through the real login form if --user/--password given, navigates to <route>, reports every table's header/body-row counts, untranslated ⟦i18n.key⟧ placeholders, console errors, page errors, and failed/4xx+/5xx network responses as JSON; exits non-zero if a table has headers but zero rows, an i18n key leaked, or a page error fired — for catching DOM-only bugs no REST-level check like \`erp api\` can see)
3804
4465
  erp api get|post|put|delete <path> [--tenant <id>] [--body <json-string-or-@file>] (authenticated call against ANY real backend endpoint using your logged-in session — for post-deploy verification: browse real entity records, live menu/workflow/artifact state, Data Service output. Never hand-write curl with guessed headers for this.)
3805
4466
  erp logs tail [--lines <n>] [--grep <text>] [--follow] [--file <path>] (real server-side log, incl. full stack traces — join a failed call's own X-Correlation-Id response header against --grep to see exactly what the server saw for that request; default path is engine-api's own logs/engine-api.log)
3806
4467
  erp ai init [dir]
@@ -3962,6 +4623,8 @@ async function main() {
3962
4623
  "plugin search": () => pluginSearchCommand(positional[0], opts),
3963
4624
  "plugin install": () => pluginInstallCommand(positional[0], opts),
3964
4625
  "plugin pull": () => pluginPullCommand(positional[0], opts),
4626
+ "plugin clone": () => pluginCloneCommand(positional[0], opts),
4627
+ "plugin checkout": () => pluginCheckoutCommand(positional[0], opts),
3965
4628
  "plugin create": () => pluginCreateCommand(positional[0], opts),
3966
4629
  "plugin diff": () => pluginDiffCommand(positional[0], opts),
3967
4630
  "spec generate": () => specGenerateCommand(positional[0], opts),
@@ -4005,6 +4668,7 @@ async function main() {
4005
4668
  "examples search": () => examplesSearchCommand(positional[0], opts),
4006
4669
  "examples patterns": () => examplesPatternsCommand(opts),
4007
4670
  "blocks list": () => blocksListCommand(opts),
4671
+ "page check": () => pageCheckCommand(positional[0], opts),
4008
4672
  "api get": () => apiCallCommand("GET", positional[0], opts),
4009
4673
  "api post": () => apiCallCommand("POST", positional[0], opts),
4010
4674
  "api put": () => apiCallCommand("PUT", positional[0], opts),