@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.
- package/bundle/blocks.json +169 -0
- package/bundle/catalog.json +614 -5
- package/bundle/docs/applications/hcm/admin-access-policies.md +38 -0
- package/bundle/docs/applications/hcm/admin-approval-hierarchies.md +33 -0
- package/bundle/docs/applications/hcm/admin-data-transfer.md +40 -0
- package/bundle/docs/applications/hcm/admin-hcm-users.md +35 -0
- package/bundle/docs/applications/hcm/admin-logs.md +57 -0
- package/bundle/docs/applications/hcm/admin-permissions-catalog.md +64 -0
- package/bundle/docs/applications/hcm/ai-intelligence-overview.md +37 -0
- package/bundle/docs/applications/hcm/ai-intelligence-people-risk.md +33 -0
- package/bundle/docs/applications/hcm/ai-intelligence-recruitment.md +37 -0
- package/bundle/docs/applications/hcm/ai-intelligence-tools.md +39 -0
- package/bundle/docs/applications/hcm/analytics-operations.md +31 -0
- package/bundle/docs/applications/hcm/analytics-overview.md +31 -0
- package/bundle/docs/applications/hcm/analytics-people.md +36 -0
- package/bundle/docs/applications/hcm/analytics-tools.md +35 -0
- package/bundle/docs/applications/hcm/assets-audits.md +30 -0
- package/bundle/docs/applications/hcm/assets-custody.md +36 -0
- package/bundle/docs/applications/hcm/assets-inventory.md +34 -0
- package/bundle/docs/applications/hcm/assets-maintenance.md +25 -0
- package/bundle/docs/applications/hcm/assets-software-licenses.md +27 -0
- package/bundle/docs/applications/hcm/attendance-core.md +46 -0
- package/bundle/docs/applications/hcm/attendance-exceptions.md +33 -0
- package/bundle/docs/applications/hcm/attendance-location.md +25 -0
- package/bundle/docs/applications/hcm/attendance-policies.md +23 -0
- package/bundle/docs/applications/hcm/attendance-reports.md +20 -0
- package/bundle/docs/applications/hcm/benefits-allowances.md +39 -0
- package/bundle/docs/applications/hcm/benefits-analytics.md +25 -0
- package/bundle/docs/applications/hcm/benefits-employee-processes.md +44 -0
- package/bundle/docs/applications/hcm/benefits-plans.md +60 -0
- package/bundle/docs/applications/hcm/communications-announcements.md +31 -0
- package/bundle/docs/applications/hcm/communications-history.md +23 -0
- package/bundle/docs/applications/hcm/communications-notifications.md +31 -0
- package/bundle/docs/applications/hcm/communications-templates.md +35 -0
- package/bundle/docs/applications/hcm/compensation-allowances-benefits.md +44 -0
- package/bundle/docs/applications/hcm/compensation-analytics.md +30 -0
- package/bundle/docs/applications/hcm/compensation-bonus-incentive.md +35 -0
- package/bundle/docs/applications/hcm/compensation-cycles.md +49 -0
- package/bundle/docs/applications/hcm/compensation-equity.md +36 -0
- package/bundle/docs/applications/hcm/compensation-salary.md +47 -0
- package/bundle/docs/applications/hcm/compliance-analytics.md +25 -0
- package/bundle/docs/applications/hcm/compliance-audits-calendar.md +52 -0
- package/bundle/docs/applications/hcm/compliance-employee-tracking.md +41 -0
- package/bundle/docs/applications/hcm/compliance-regulatory.md +31 -0
- package/bundle/docs/applications/hcm/country-packs-admin.md +33 -0
- package/bundle/docs/applications/hcm/country-packs-compliance.md +31 -0
- package/bundle/docs/applications/hcm/country-packs-localization.md +37 -0
- package/bundle/docs/applications/hcm/country-packs.md +102 -0
- package/bundle/docs/applications/hcm/dashboard.md +38 -0
- package/bundle/docs/applications/hcm/designations.md +32 -0
- package/bundle/docs/applications/hcm/emp-documents-analytics.md +27 -0
- package/bundle/docs/applications/hcm/emp-documents-core.md +33 -0
- package/bundle/docs/applications/hcm/emp-documents-letters.md +40 -0
- package/bundle/docs/applications/hcm/emp-documents-signatures.md +24 -0
- package/bundle/docs/applications/hcm/emp-documents-workflow.md +32 -0
- package/bundle/docs/applications/hcm/employee-directory.md +55 -0
- package/bundle/docs/applications/hcm/employee-documents.md +51 -0
- package/bundle/docs/applications/hcm/employee-info-background.md +41 -0
- package/bundle/docs/applications/hcm/employee-info-core.md +32 -0
- package/bundle/docs/applications/hcm/employee-info-documents.md +31 -0
- package/bundle/docs/applications/hcm/employee-info-other.md +21 -0
- package/bundle/docs/applications/hcm/employee-movements.md +46 -0
- package/bundle/docs/applications/hcm/employee-reference-data.md +40 -0
- package/bundle/docs/applications/hcm/employee-relations-analytics.md +23 -0
- package/bundle/docs/applications/hcm/employee-relations-cases.md +26 -0
- package/bundle/docs/applications/hcm/employee-relations-conflicts-feedback.md +25 -0
- package/bundle/docs/applications/hcm/employee-relations-processes.md +34 -0
- package/bundle/docs/applications/hcm/employee-services-daily.md +32 -0
- package/bundle/docs/applications/hcm/employee-services-manager.md +21 -0
- package/bundle/docs/applications/hcm/employee-services-more.md +41 -0
- package/bundle/docs/applications/hcm/employee-services-overview.md +43 -0
- package/bundle/docs/applications/hcm/expenses-analytics.md +26 -0
- package/bundle/docs/applications/hcm/expenses-claims.md +47 -0
- package/bundle/docs/applications/hcm/expenses-self-service.md +30 -0
- package/bundle/docs/applications/hcm/expenses-setup.md +43 -0
- package/bundle/docs/applications/hcm/health-safety-analytics.md +26 -0
- package/bundle/docs/applications/hcm/health-safety-incidents.md +44 -0
- package/bundle/docs/applications/hcm/health-safety-medical.md +25 -0
- package/bundle/docs/applications/hcm/health-safety-training.md +29 -0
- package/bundle/docs/applications/hcm/helpdesk.md +28 -0
- package/bundle/docs/applications/hcm/holiday-calendar.md +69 -0
- package/bundle/docs/applications/hcm/hr-policies.md +64 -0
- package/bundle/docs/applications/hcm/hr-settings.md +98 -0
- package/bundle/docs/applications/hcm/index.md +272 -0
- package/bundle/docs/applications/hcm/industry-it-software.md +52 -0
- package/bundle/docs/applications/hcm/job-classifications.md +48 -0
- package/bundle/docs/applications/hcm/learning-analytics.md +21 -0
- package/bundle/docs/applications/hcm/learning-assessments.md +22 -0
- package/bundle/docs/applications/hcm/learning-catalog.md +25 -0
- package/bundle/docs/applications/hcm/learning-certifications.md +27 -0
- package/bundle/docs/applications/hcm/learning-enrollment.md +26 -0
- package/bundle/docs/applications/hcm/learning-instructors-providers.md +21 -0
- package/bundle/docs/applications/hcm/leave-accrual-and-carryforward.md +30 -0
- package/bundle/docs/applications/hcm/leave-balance.md +26 -0
- package/bundle/docs/applications/hcm/leave-dashboard-and-reports.md +32 -0
- package/bundle/docs/applications/hcm/leave-encashment.md +27 -0
- package/bundle/docs/applications/hcm/leave-requests.md +26 -0
- package/bundle/docs/applications/hcm/leave-setup.md +30 -0
- package/bundle/docs/applications/hcm/leave-team-and-calendar.md +25 -0
- package/bundle/docs/applications/hcm/navigation-and-approvals.md +61 -0
- package/bundle/docs/applications/hcm/offboarding-and-exit.md +71 -0
- package/bundle/docs/applications/hcm/onboarding-documents-verification.md +43 -0
- package/bundle/docs/applications/hcm/onboarding-orientation-probation.md +65 -0
- package/bundle/docs/applications/hcm/onboarding-overview.md +47 -0
- package/bundle/docs/applications/hcm/onboarding-provisioning-assets.md +48 -0
- package/bundle/docs/applications/hcm/onboarding-reports.md +38 -0
- package/bundle/docs/applications/hcm/onboarding-templates-checklists.md +38 -0
- package/bundle/docs/applications/hcm/onboarding-to-confirmation.md +45 -0
- package/bundle/docs/applications/hcm/org-structure.md +53 -0
- package/bundle/docs/applications/hcm/org-units.md +86 -0
- package/bundle/docs/applications/hcm/organizations.md +81 -0
- package/bundle/docs/applications/hcm/payroll-analytics.md +31 -0
- package/bundle/docs/applications/hcm/payroll-post-run.md +40 -0
- package/bundle/docs/applications/hcm/payroll-runs.md +30 -0
- package/bundle/docs/applications/hcm/payroll-setup.md +38 -0
- package/bundle/docs/applications/hcm/payroll-tax.md +21 -0
- package/bundle/docs/applications/hcm/payroll-transactions.md +43 -0
- package/bundle/docs/applications/hcm/performance-analytics.md +23 -0
- package/bundle/docs/applications/hcm/performance-appraisals.md +50 -0
- package/bundle/docs/applications/hcm/performance-continuous-feedback.md +23 -0
- package/bundle/docs/applications/hcm/performance-cycles-and-goals.md +54 -0
- package/bundle/docs/applications/hcm/performance-okrs.md +33 -0
- package/bundle/docs/applications/hcm/performance-pips.md +29 -0
- package/bundle/docs/applications/hcm/performance-ratings.md +25 -0
- package/bundle/docs/applications/hcm/positions.md +67 -0
- package/bundle/docs/applications/hcm/recruitment-agencies-and-sources.md +26 -0
- package/bundle/docs/applications/hcm/recruitment-analytics.md +25 -0
- package/bundle/docs/applications/hcm/recruitment-candidates.md +33 -0
- package/bundle/docs/applications/hcm/recruitment-offers.md +32 -0
- package/bundle/docs/applications/hcm/recruitment-pipeline.md +46 -0
- package/bundle/docs/applications/hcm/recruitment-requisitions-and-openings.md +33 -0
- package/bundle/docs/applications/hcm/roles-permissions.md +129 -0
- package/bundle/docs/applications/hcm/separation-clearance.md +33 -0
- package/bundle/docs/applications/hcm/separation-documents.md +28 -0
- package/bundle/docs/applications/hcm/separation-final-settlement.md +24 -0
- package/bundle/docs/applications/hcm/separation-reports.md +23 -0
- package/bundle/docs/applications/hcm/separation-resignation.md +43 -0
- package/bundle/docs/applications/hcm/settings-extensibility.md +31 -0
- package/bundle/docs/applications/hcm/settings-general.md +29 -0
- package/bundle/docs/applications/hcm/settings-integrations.md +14 -0
- package/bundle/docs/applications/hcm/settings-module-defaults.md +41 -0
- package/bundle/docs/applications/hcm/settings-process.md +28 -0
- package/bundle/docs/applications/hcm/shift-scheduling.md +33 -0
- package/bundle/docs/applications/hcm/talent-analytics.md +26 -0
- package/bundle/docs/applications/hcm/talent-career.md +31 -0
- package/bundle/docs/applications/hcm/talent-competencies-skills.md +30 -0
- package/bundle/docs/applications/hcm/talent-profiles.md +40 -0
- package/bundle/docs/applications/hcm/talent-succession.md +42 -0
- package/bundle/docs/applications/hcm/teams-and-tags.md +46 -0
- package/bundle/docs/applications/hcm/timesheets.md +33 -0
- package/bundle/docs/applications/hcm/travel-advances-expenses.md +34 -0
- package/bundle/docs/applications/hcm/travel-analytics.md +28 -0
- package/bundle/docs/applications/hcm/travel-bookings.md +35 -0
- package/bundle/docs/applications/hcm/travel-requests.md +38 -0
- package/bundle/docs/applications/hcm/workforce-org-design.md +22 -0
- package/bundle/docs/applications/hcm/workforce-planning-analytics.md +26 -0
- package/bundle/docs/applications/hcm/workforce-planning-core.md +36 -0
- package/bundle/docs/applications/hcm/workforce-planning-scenarios.md +29 -0
- package/bundle/docs/docs.json +1 -0
- package/bundle/docs/guides/add-app-owned-roles-and-permissions.md +144 -0
- package/bundle/docs/guides/checkout-an-installed-plugin.md +151 -0
- package/bundle/docs/guides/extend-a-shipped-application.md +132 -0
- package/bundle/docs/guides/index.md +3 -0
- package/bundle/docs/reference/entity-aggregation-config.md +1 -1
- package/bundle/docs/reference/entity-document-generator-config.md +1 -1
- package/bundle/docs/tutorial/01-create-the-plugin.md +7 -1
- package/bundle/docs/tutorial/07-return-due-reminder-job.md +28 -5
- package/bundle/docs/tutorial/08-menus-i18n-publish.md +5 -5
- package/bundle/manifest.json +4 -4
- package/bundle/schemas/page.schema.json +13 -0
- package/bundle/schemas/plugin-manifest.schema.json +13 -0
- package/bundle/validators/block-engine.mjs +167 -7
- package/bundle/validators/page-engine.mjs +226 -21
- package/erp-cli/authoring-root.mjs +12 -1
- package/erp-cli/erp.mjs +679 -15
- 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
|
-
//
|
|
551
|
-
// PluginInstallationController's GET actually returns) into a local file
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
// `erp
|
|
558
|
-
//
|
|
550
|
+
// pull — default is [THIN]: manifest+config+version state (everything
|
|
551
|
+
// PluginInstallationController's GET actually returns) into a local file.
|
|
552
|
+
// `--full` (2026-09-23) is the real integration this used to disclose as
|
|
553
|
+
// future work: walks every per-artifact authoring controller this plugin
|
|
554
|
+
// owns and reconstructs the full spk-assembly page/workflow/entity source
|
|
555
|
+
// tree — see `pluginPullFullReconstruction`'s own doc comment. THIN stays
|
|
556
|
+
// the default (cheap, no N-artifact-type fan-out) for callers that only
|
|
557
|
+
// want install-state (e.g. `erp plugin diff`'s own use of this same list
|
|
558
|
+
// call). If the plugin is ALSO a registered registry package, `erp
|
|
559
|
+
// registry get` (below) returns richer metadata (description, license,
|
|
560
|
+
// dependencies, changelog) than the bare install-state row.
|
|
559
561
|
// ---------------------------------------------------------------------------
|
|
562
|
+
/**
|
|
563
|
+
* 2026-09-23 (direct user request — "developer should be able to list down
|
|
564
|
+
* all modules, then choose which module they want to checkout, similar to
|
|
565
|
+
* git commands") — the `git branch`/`git remote -v` step of the clone/
|
|
566
|
+
* checkout/add/commit/push flow below: a readable table by default so a
|
|
567
|
+
* developer can actually scan it before picking a `pluginId` for `erp
|
|
568
|
+
* plugin clone`, instead of a raw JSON dump of every manifest field. `--json`
|
|
569
|
+
* keeps the exact old output for any script already parsing it.
|
|
570
|
+
*/
|
|
560
571
|
async function pluginListCommand(opts) {
|
|
561
572
|
const cfg = loadConfig();
|
|
562
573
|
const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
|
|
563
|
-
|
|
574
|
+
if (opts.json) {
|
|
575
|
+
console.log(JSON.stringify(list, null, 2));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const rows = list
|
|
579
|
+
.map((p) => {
|
|
580
|
+
const manifest = tryParseJson(p.manifestJson);
|
|
581
|
+
return {
|
|
582
|
+
pluginId: p.pluginId,
|
|
583
|
+
version: p.version,
|
|
584
|
+
state: p.state,
|
|
585
|
+
name: typeof manifest === "object" && manifest ? manifest.name || p.pluginId : p.pluginId,
|
|
586
|
+
};
|
|
587
|
+
})
|
|
588
|
+
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
589
|
+
if (rows.length === 0) {
|
|
590
|
+
console.log("No plugins installed on this tenant.");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const idWidth = Math.max(...rows.map((r) => r.pluginId.length), "PLUGIN ID".length);
|
|
594
|
+
const verWidth = Math.max(...rows.map((r) => r.version.length), "VERSION".length);
|
|
595
|
+
const stateWidth = Math.max(...rows.map((r) => r.state.length), "STATE".length);
|
|
596
|
+
console.log(`${"PLUGIN ID".padEnd(idWidth)} ${"VERSION".padEnd(verWidth)} ${"STATE".padEnd(stateWidth)} NAME`);
|
|
597
|
+
for (const r of rows) {
|
|
598
|
+
console.log(`${r.pluginId.padEnd(idWidth)} ${r.version.padEnd(verWidth)} ${r.state.padEnd(stateWidth)} ${r.name}`);
|
|
599
|
+
}
|
|
600
|
+
console.log(`\n${rows.length} plugin(s) installed. Checkout one: erp plugin clone <pluginId>`);
|
|
564
601
|
}
|
|
565
602
|
|
|
566
603
|
async function pluginSearchCommand(term, opts) {
|
|
@@ -691,6 +728,235 @@ async function pluginPullCommand(pluginId, opts) {
|
|
|
691
728
|
writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
|
|
692
729
|
writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
|
|
693
730
|
console.log(`[THIN — manifest+config+state only, see erp.mjs header] Wrote ${outDir}/{plugin.json,config.json,installation-state.json}`);
|
|
731
|
+
if (opts.full) {
|
|
732
|
+
await pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts);
|
|
733
|
+
} else {
|
|
734
|
+
console.log(`(pass --full for a real editable spk-assembly/ checkout — every page/menu/data-service/etc. this plugin owns, ready for 'erp plugin validate'/'build'/'publish')`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* `clone` (2026-09-23, direct user request — "similar to git commands:
|
|
740
|
+
* clone, checkout, add, commit, push") — `list` (git-`branch`-like) then
|
|
741
|
+
* `clone <pluginId>` (git-`clone`-like: a real local directory, full
|
|
742
|
+
* source, first thing you'd do with a plugin you want to work on) is the
|
|
743
|
+
* intended entry point; `pull --full` above still exists underneath it
|
|
744
|
+
* unchanged (this is a thin, differently-named wrapper, not a second
|
|
745
|
+
* implementation) for anyone already scripting against that name. Unlike
|
|
746
|
+
* `pull`, `clone` always does the full reconstruction — there's no reason
|
|
747
|
+
* to git-clone "thin".
|
|
748
|
+
* <p>
|
|
749
|
+
* Deliberately does NOT reinvent `add`/`commit`: the directory this writes
|
|
750
|
+
* is a real one on disk — `git init && git add . && git commit` right there
|
|
751
|
+
* already does real local staging/history perfectly, no fake local-only
|
|
752
|
+
* command needed. `push` already existed before this change (`erp plugin
|
|
753
|
+
* push`, an alias of `publish`) — that's the real "ship it" step once
|
|
754
|
+
* you're done editing.
|
|
755
|
+
*/
|
|
756
|
+
async function pluginCloneCommand(pluginId, opts) {
|
|
757
|
+
const cfg = loadConfig();
|
|
758
|
+
const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
|
|
759
|
+
const found = list.find((p) => p.pluginId === pluginId);
|
|
760
|
+
if (!found) throw new Error(`no installed plugin "${pluginId}" on this tenant — run: erp plugin list`);
|
|
761
|
+
const outDir = opts.out || path.join(process.cwd(), pluginId);
|
|
762
|
+
mkdirSync(outDir, { recursive: true });
|
|
763
|
+
writeFileSync(path.join(outDir, "plugin.json"), found.manifestJson, "utf8");
|
|
764
|
+
writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
|
|
765
|
+
writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
|
|
766
|
+
await pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts);
|
|
767
|
+
console.log(`\nNow a real local directory — e.g.:`);
|
|
768
|
+
console.log(` cd ${outDir} && git init && git add . && git commit -m "Clone of ${pluginId}@${found.version}"`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* `checkout` (2026-09-23, completing the git-verb set: clone/checkout/add/
|
|
773
|
+
* commit/push) — `clone` always gets latest; this is `git checkout
|
|
774
|
+
* <ref>`'s counterpart when you want something OTHER than latest.
|
|
775
|
+
* <p>
|
|
776
|
+
* Disclosed, real limitation: there is no single "plugin version" snapshot
|
|
777
|
+
* to check out — `plugin.json`'s own `version` (e.g. "1.0.299") is bumped
|
|
778
|
+
* once per `.spk` release, but each individual artifact (a page, a data
|
|
779
|
+
* service, ...) versions independently on its OWN publish cadence (see
|
|
780
|
+
* `createDraft`'s own "next version for THIS name" comment elsewhere in
|
|
781
|
+
* this file) — there is no server-side mapping from "plugin was at 1.0.298"
|
|
782
|
+
* to "which version of every one of its 28 artifacts was live then". So
|
|
783
|
+
* `--version <n>` here means, per artifact independently: "the row whose
|
|
784
|
+
* own `version` field equals `n`, if this artifact has one that old —
|
|
785
|
+
* otherwise its latest" — an honest, disclosed best-effort, not a true
|
|
786
|
+
* point-in-time snapshot. Omitting `--version` is identical to `clone`.
|
|
787
|
+
*/
|
|
788
|
+
async function pluginCheckoutCommand(pluginId, opts) {
|
|
789
|
+
const cfg = loadConfig();
|
|
790
|
+
const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
|
|
791
|
+
const found = list.find((p) => p.pluginId === pluginId);
|
|
792
|
+
if (!found) throw new Error(`no installed plugin "${pluginId}" on this tenant — run: erp plugin list`);
|
|
793
|
+
const outDir = opts.out || path.join(process.cwd(), pluginId);
|
|
794
|
+
mkdirSync(outDir, { recursive: true });
|
|
795
|
+
writeFileSync(path.join(outDir, "plugin.json"), found.manifestJson, "utf8");
|
|
796
|
+
writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
|
|
797
|
+
writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
|
|
798
|
+
if (opts.version) {
|
|
799
|
+
console.log(`== Checking out "${pluginId}" @ per-artifact version ${opts.version} where it exists (see this command's own doc comment — not a true point-in-time snapshot) ==`);
|
|
800
|
+
}
|
|
801
|
+
await pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts);
|
|
802
|
+
console.log(`\nNow a real local directory — e.g.:`);
|
|
803
|
+
console.log(` cd ${outDir} && git init && git add . && git commit -m "Checkout of ${pluginId}@${found.version}${opts.version ? ` (artifacts at v${opts.version} where available)` : ""}"`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* 2026-09-23 (direct user request, "developer should be able to checkout an
|
|
808
|
+
* existing installed plugin, work on it, then package and publish it — this
|
|
809
|
+
* should be part of the SDK CLI") — the real integration [THIN] pull's own
|
|
810
|
+
* header comment above disclosed as future work: walks every
|
|
811
|
+
* JsonArtifactAuthoringController-shaped artifact type this plugin owns
|
|
812
|
+
* (same `ownerPlugin` filter a Studio Explorer tree click already relies
|
|
813
|
+
* on) plus the two membership-only types (Application/Module) and this
|
|
814
|
+
* plugin's own i18n namespace, and writes a REAL local `spk-assembly/
|
|
815
|
+
* metadata/<type>/<name>.json` tree — the same on-disk shape/foldering an
|
|
816
|
+
* already-shipped module has (confirmed against hcm-foundation's own
|
|
817
|
+
* `metadata/` listing: application, data_service, data_view, i18n, menu,
|
|
818
|
+
* mobile_nav, module, page, provider). A developer edits under that real
|
|
819
|
+
* tree, then `erp plugin validate` / `erp plugin build` / `erp plugin
|
|
820
|
+
* publish` — the exact same pipeline every vendor module already ships
|
|
821
|
+
* through, no separate "customization" mechanism to learn.
|
|
822
|
+
* <p>
|
|
823
|
+
* Best-effort per artifact type: a 404/permission error on one type (e.g.
|
|
824
|
+
* an artifact type this tenant's license doesn't expose) is reported and
|
|
825
|
+
* skipped rather than aborting the whole checkout — matches every other
|
|
826
|
+
* "never let one projection helper break the read it decorates" posture
|
|
827
|
+
* elsewhere in this file.
|
|
828
|
+
*/
|
|
829
|
+
const PULL_ARTIFACT_TYPES = [
|
|
830
|
+
["pages", "page"],
|
|
831
|
+
["forms", "form"],
|
|
832
|
+
["blocks", "block"],
|
|
833
|
+
["dashboards", "dashboard"],
|
|
834
|
+
["reports", "report"],
|
|
835
|
+
["bi-reports", "bi_report"],
|
|
836
|
+
["print-templates", "print_template"],
|
|
837
|
+
["menus", "menu"],
|
|
838
|
+
["mobile-navs", "mobile_nav"],
|
|
839
|
+
["providers", "provider"],
|
|
840
|
+
["data-services", "data_service"],
|
|
841
|
+
["data-views", "data_view"],
|
|
842
|
+
["rules", "rule"],
|
|
843
|
+
["workflows", "workflow"],
|
|
844
|
+
["actions", "action"],
|
|
845
|
+
["applications", "application"],
|
|
846
|
+
["modules", "module"],
|
|
847
|
+
];
|
|
848
|
+
|
|
849
|
+
function tryParseJson(s) {
|
|
850
|
+
try {
|
|
851
|
+
return JSON.parse(s);
|
|
852
|
+
} catch {
|
|
853
|
+
return s;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function pluginPullFullReconstruction(cfg, pluginId, found, outDir, opts) {
|
|
858
|
+
const baseDir = path.join(outDir, "spk-assembly");
|
|
859
|
+
const metaDir = path.join(baseDir, "metadata");
|
|
860
|
+
const manifest = tryParseJson(found.manifestJson);
|
|
861
|
+
mkdirSync(baseDir, { recursive: true });
|
|
862
|
+
writeFileSync(path.join(baseDir, "plugin.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
863
|
+
console.log(`-- spk-assembly/plugin.json (version ${manifest.version ?? found.version}) --`);
|
|
864
|
+
|
|
865
|
+
let totalArtifacts = 0;
|
|
866
|
+
for (const [urlSegment, folderName] of PULL_ARTIFACT_TYPES) {
|
|
867
|
+
let list;
|
|
868
|
+
try {
|
|
869
|
+
list = await api(cfg, "GET", `/api/v1/authoring/${urlSegment}?latestOnly=true`, { tenantIdOverride: opts.tenant });
|
|
870
|
+
} catch (e) {
|
|
871
|
+
console.log(` ${urlSegment}: skipped (${e.message.split("\n")[0]})`);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!Array.isArray(list)) continue;
|
|
875
|
+
const owned = list.filter((r) => r.ownerPlugin === pluginId);
|
|
876
|
+
if (owned.length === 0) continue;
|
|
877
|
+
console.log(`-- ${urlSegment}: ${owned.length} owned by ${pluginId} --`);
|
|
878
|
+
for (const summary of owned) {
|
|
879
|
+
let targetId = summary.id;
|
|
880
|
+
if (opts.version) {
|
|
881
|
+
try {
|
|
882
|
+
const history = await api(cfg, "GET", `/api/v1/authoring/${urlSegment}/name/${encodeURIComponent(summary.name)}/history`, { tenantIdOverride: opts.tenant });
|
|
883
|
+
const match = Array.isArray(history) ? history.find((h) => String(h.version) === String(opts.version)) : null;
|
|
884
|
+
if (match) {
|
|
885
|
+
targetId = match.id;
|
|
886
|
+
} else {
|
|
887
|
+
console.log(` ${summary.name}: no v${opts.version} in its own history — using latest (v${summary.version})`);
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
// history lookup failed — fall through to latest, never block the checkout over it
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const full = await api(cfg, "GET", `/api/v1/authoring/${urlSegment}/${targetId}`, { tenantIdOverride: opts.tenant });
|
|
894
|
+
const record = {
|
|
895
|
+
name: full.name,
|
|
896
|
+
description: full.description,
|
|
897
|
+
metadata: full.metadataJson ? tryParseJson(full.metadataJson) : undefined,
|
|
898
|
+
definition: tryParseJson(full.definitionJson),
|
|
899
|
+
};
|
|
900
|
+
const artifactDir = path.join(metaDir, folderName);
|
|
901
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
902
|
+
writeFileSync(path.join(artifactDir, `${full.name}.json`), JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
903
|
+
console.log(` wrote ${folderName}/${full.name}.json (id ${full.id}, v${full.version})`);
|
|
904
|
+
totalArtifacts++;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Entities are NOT a JsonArtifactAuthoringController subclass like the
|
|
909
|
+
// types above — no draft/publish/revision lifecycle, no `ownerPlugin`
|
|
910
|
+
// field at all, a different list endpoint (`/api/v1/entities`, not
|
|
911
|
+
// `/api/v1/authoring/entities` — that path 404s), and no `?latestOnly=`
|
|
912
|
+
// (there's only ever one row per entity). The closest thing to ownership
|
|
913
|
+
// is `category`, confirmed live to hold real plugin ids for entity-heavy
|
|
914
|
+
// modules (e.g. 47 rows with category "hcm-recruitment") — but it's a
|
|
915
|
+
// free-text taxonomy field, not an enforced foreign key the way
|
|
916
|
+
// `ownerPlugin` is, so this is a best-effort match, disclosed as such
|
|
917
|
+
// rather than presented as equally reliable.
|
|
918
|
+
try {
|
|
919
|
+
const entities = await api(cfg, "GET", "/api/v1/entities", { tenantIdOverride: opts.tenant });
|
|
920
|
+
const owned = Array.isArray(entities) ? entities.filter((r) => r.category === pluginId) : [];
|
|
921
|
+
if (owned.length > 0) {
|
|
922
|
+
console.log(`-- entities: ${owned.length} with category "${pluginId}" (best-effort — no real ownerPlugin field on this type) --`);
|
|
923
|
+
const artifactDir = path.join(metaDir, "entity");
|
|
924
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
925
|
+
for (const summary of owned) {
|
|
926
|
+
const full = await api(cfg, "GET", `/api/v1/entities/${summary.id}`, { tenantIdOverride: opts.tenant });
|
|
927
|
+
writeFileSync(path.join(artifactDir, `${full.name}.json`), JSON.stringify(full, null, 2) + "\n", "utf8");
|
|
928
|
+
console.log(` wrote entity/${full.name}.json (id ${full.id})`);
|
|
929
|
+
totalArtifacts++;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch (e) {
|
|
933
|
+
console.log(` entities: skipped (${e.message.split("\n")[0]})`);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
console.log(`-- i18n --`);
|
|
937
|
+
try {
|
|
938
|
+
const translations = await api(cfg, "GET", "/api/v1/authoring/translations", { tenantIdOverride: opts.tenant });
|
|
939
|
+
const enRow = translations.find((t) => t.locale === "en");
|
|
940
|
+
if (enRow) {
|
|
941
|
+
const allEntries = JSON.parse(enRow.entriesJson);
|
|
942
|
+
// A plugin's own spk-assembly i18n file ships only the keys IT owns
|
|
943
|
+
// (its artifact-name-prefixed namespace), never the tenant's whole
|
|
944
|
+
// translation bundle (~31k keys / 2.5MB on this env alone).
|
|
945
|
+
const ownKeys = Object.fromEntries(Object.entries(allEntries).filter(([k]) => k.startsWith(`${pluginId}.`)));
|
|
946
|
+
const i18nDir = path.join(metaDir, "i18n");
|
|
947
|
+
mkdirSync(i18nDir, { recursive: true });
|
|
948
|
+
writeFileSync(path.join(i18nDir, "en.json"), JSON.stringify(ownKeys, null, 2) + "\n", "utf8");
|
|
949
|
+
console.log(` wrote i18n/en.json (${Object.keys(ownKeys).length} keys under "${pluginId}.*")`);
|
|
950
|
+
}
|
|
951
|
+
} catch (e) {
|
|
952
|
+
console.log(` i18n: skipped (${e.message.split("\n")[0]})`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
console.log(`\n== Full checkout done: ${totalArtifacts} artifacts + plugin.json + i18n written to ${baseDir} ==`);
|
|
956
|
+
console.log(`Next: edit under ${metaDir}, then:`);
|
|
957
|
+
console.log(` erp plugin validate ${outDir}`);
|
|
958
|
+
console.log(` erp plugin build ${outDir} -o ${pluginId}-<version>.spk`);
|
|
959
|
+
console.log(` erp plugin publish ${pluginId}-<version>.spk --env <name> # e.g. local first, then prod`);
|
|
694
960
|
}
|
|
695
961
|
|
|
696
962
|
async function pluginDiffCommand(pluginId, opts) {
|
|
@@ -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
|
|
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, "<").replace(/>/g, ">");
|
|
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
|
|
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),
|