@sparkerp/plugin-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bundle/blocks.json +12229 -0
  4. package/bundle/catalog.json +32475 -0
  5. package/bundle/docs/applications/hcm/employee-profile.md +144 -0
  6. package/bundle/docs/concepts/artifact-types.md +80 -0
  7. package/bundle/docs/concepts/glossary.md +124 -0
  8. package/bundle/docs/concepts/overview.md +88 -0
  9. package/bundle/docs/concepts/page-data-flow.md +84 -0
  10. package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
  11. package/bundle/docs/concepts/sdk-modes.md +71 -0
  12. package/bundle/docs/docs.json +109 -0
  13. package/bundle/docs/guides/add-a-cadence-job.md +134 -0
  14. package/bundle/docs/guides/add-a-compliance-job.md +139 -0
  15. package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
  16. package/bundle/docs/guides/add-a-custom-block.md +225 -0
  17. package/bundle/docs/guides/add-a-data-provider.md +216 -0
  18. package/bundle/docs/guides/add-a-kpi.md +154 -0
  19. package/bundle/docs/guides/add-a-reminder-job.md +181 -0
  20. package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
  21. package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
  22. package/bundle/docs/guides/add-an-entity.md +170 -0
  23. package/bundle/docs/guides/add-business-rules.md +147 -0
  24. package/bundle/docs/guides/add-i18n.md +121 -0
  25. package/bundle/docs/guides/add-menus.md +149 -0
  26. package/bundle/docs/guides/build-a-code-plugin.md +141 -0
  27. package/bundle/docs/guides/build-a-page.md +213 -0
  28. package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
  29. package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
  30. package/bundle/docs/guides/create-a-plugin.md +172 -0
  31. package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
  32. package/bundle/docs/guides/index.md +53 -0
  33. package/bundle/docs/guides/publish-and-upgrade.md +152 -0
  34. package/bundle/docs/guides/responsive-plugin.md +117 -0
  35. package/bundle/docs/guides/seed-data-on-install.md +127 -0
  36. package/bundle/docs/guides/set-up-the-sdk.md +120 -0
  37. package/bundle/docs/guides/use-the-mcp-server.md +104 -0
  38. package/bundle/docs/guides/validate-and-test.md +119 -0
  39. package/bundle/docs/guides/wire-a-pages-data.md +153 -0
  40. package/bundle/docs/quickstart/index.md +118 -0
  41. package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
  42. package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
  43. package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
  44. package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
  45. package/bundle/docs/recipes/index.md +40 -0
  46. package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
  47. package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
  48. package/bundle/docs/recipes/master-detail-page.md +84 -0
  49. package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
  50. package/bundle/docs/recipes/per-row-document-generator.md +65 -0
  51. package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
  52. package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
  53. package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
  54. package/bundle/docs/reference/action.md +18 -0
  55. package/bundle/docs/reference/block-definition.md +35 -0
  56. package/bundle/docs/reference/block-instance.md +33 -0
  57. package/bundle/docs/reference/composite-pack-manifest.md +33 -0
  58. package/bundle/docs/reference/connector-definition.md +36 -0
  59. package/bundle/docs/reference/data-service-definition.md +22 -0
  60. package/bundle/docs/reference/data-view-definition.md +26 -0
  61. package/bundle/docs/reference/entity-aggregation-config.md +38 -0
  62. package/bundle/docs/reference/entity-cadence-config.md +33 -0
  63. package/bundle/docs/reference/entity-compliance-config.md +37 -0
  64. package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
  65. package/bundle/docs/reference/entity-definition.md +18 -0
  66. package/bundle/docs/reference/entity-document-generator-config.md +35 -0
  67. package/bundle/docs/reference/entity-rule-definition.md +29 -0
  68. package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
  69. package/bundle/docs/reference/form.md +35 -0
  70. package/bundle/docs/reference/index.md +37 -0
  71. package/bundle/docs/reference/menu.md +26 -0
  72. package/bundle/docs/reference/mobile-nav.md +22 -0
  73. package/bundle/docs/reference/page-template.md +29 -0
  74. package/bundle/docs/reference/page.md +44 -0
  75. package/bundle/docs/reference/plugin-manifest.md +54 -0
  76. package/bundle/docs/reference/plugin-seed-data.md +21 -0
  77. package/bundle/docs/reference/print-template-definition.md +22 -0
  78. package/bundle/docs/reference/provider-definition.md +22 -0
  79. package/bundle/docs/reference/report-definition.md +22 -0
  80. package/bundle/docs/reference/rule.md +30 -0
  81. package/bundle/docs/reference/workflow-definition.md +28 -0
  82. package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
  83. package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
  84. package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
  85. package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
  86. package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
  87. package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
  88. package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
  89. package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
  90. package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
  91. package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
  92. package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
  93. package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
  94. package/bundle/docs/tutorial/index.md +72 -0
  95. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
  96. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
  97. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
  98. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
  99. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
  100. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
  101. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
  102. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
  103. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
  104. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
  105. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
  106. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
  107. package/bundle/examples/example-patterns.mjs +94 -0
  108. package/bundle/manifest.json +14 -0
  109. package/bundle/schemas/action.schema.json +102 -0
  110. package/bundle/schemas/block-definition.schema.json +222 -0
  111. package/bundle/schemas/block-instance.schema.json +139 -0
  112. package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
  113. package/bundle/schemas/connector-definition.schema.json +29 -0
  114. package/bundle/schemas/data-service-definition.schema.json +64 -0
  115. package/bundle/schemas/data-view-definition.schema.json +148 -0
  116. package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
  117. package/bundle/schemas/entity-cadence-config.schema.json +27 -0
  118. package/bundle/schemas/entity-compliance-config.schema.json +31 -0
  119. package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
  120. package/bundle/schemas/entity-definition.schema.json +126 -0
  121. package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
  122. package/bundle/schemas/entity-rule-definition.schema.json +75 -0
  123. package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
  124. package/bundle/schemas/form.schema.json +281 -0
  125. package/bundle/schemas/menu.schema.json +106 -0
  126. package/bundle/schemas/mobile-nav.schema.json +53 -0
  127. package/bundle/schemas/page-template.schema.json +46 -0
  128. package/bundle/schemas/page.schema.json +292 -0
  129. package/bundle/schemas/plugin-manifest.schema.json +139 -0
  130. package/bundle/schemas/plugin-seed-data.schema.json +24 -0
  131. package/bundle/schemas/print-template-definition.schema.json +90 -0
  132. package/bundle/schemas/provider-definition.schema.json +27 -0
  133. package/bundle/schemas/report-definition.schema.json +96 -0
  134. package/bundle/schemas/rule.schema.json +82 -0
  135. package/bundle/schemas/workflow-definition.schema.json +42 -0
  136. package/bundle/validators/block-engine.mjs +23222 -0
  137. package/bundle/validators/page-engine.mjs +21645 -0
  138. package/erp-cli/authoring-root.mjs +344 -0
  139. package/erp-cli/erp.mjs +4065 -0
  140. package/erp-cli/example-patterns.mjs +94 -0
  141. package/erp-mcp-server/index.mjs +818 -0
  142. package/package.json +27 -0
@@ -0,0 +1,4065 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `erp` — the ERP Developer Platform CLI, per
4
+ * `low-code-docs/AI Native/AI_NATIVE_Developer platform.md` §8-10.
5
+ * See `ai/domains/ai-native-developer-platform-gap-analysis.md` for the
6
+ * full gap analysis; every command below is marked with its real status.
7
+ *
8
+ * Every command wraps a REAL mechanism already in this codebase (the real
9
+ * `/api/v1/auth/login` endpoint (and, since mcp-08, the real
10
+ * `/api/v1/auth/device/{code,approve,token}` device-authorization endpoints
11
+ * `erp login` now uses by default — see that command's own header comment),
12
+ * `PluginInstallationController`, spark.js's
13
+ * package/create/publish, the page-validator, the MCP server's schema map)
14
+ * — nothing here talks to a service that doesn't exist. Commands whose
15
+ * upstream capability is narrower than the doc's own mockup are marked
16
+ * [THIN] with a one-line note on the trim; nothing is marked done that
17
+ * isn't real.
18
+ *
19
+ * Credentials/env config: `~/.erp/config.json` — `{ envs: {name: {baseUrl}},
20
+ * currentEnv, sessions: {envName: {tenantId, userId, sessionToken,
21
+ * accessToken, expiresAt}} }`. Never printed/logged in full (see
22
+ * feedback-plugins-must-be-environment-portable's sibling concern — a
23
+ * session token is exactly the kind of environment-specific secret that
24
+ * must never leak into a committed plugin file).
25
+ */
26
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, statSync, createReadStream, rmSync, chmodSync, realpathSync } from "node:fs";
27
+ import { spawnSync, spawn } from "node:child_process";
28
+ import path from "node:path";
29
+ import os from "node:os";
30
+ import http from "node:http";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import readline from "node:readline";
33
+ import { EXAMPLE_PATTERNS } from "./example-patterns.mjs";
34
+ import {
35
+ SCHEMA_REGISTRY,
36
+ detectMode,
37
+ isPackaged,
38
+ requireMonorepo,
39
+ bundleDir,
40
+ cacheDir,
41
+ schemaPathByName,
42
+ schemaPathByRepoRel,
43
+ docsSearchRoots,
44
+ examplesModulesRoot,
45
+ exampleFileAbs,
46
+ frozenCatalog,
47
+ blockEngineModuleUrl,
48
+ pageEngineModuleUrl,
49
+ blocksCatalogPath,
50
+ readLiveJson,
51
+ readManifest,
52
+ } from "./authoring-root.mjs";
53
+
54
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
+ const REPO_ROOT = path.resolve(__dirname, "..", "..");
56
+ const CONFIG_DIR = path.join(os.homedir(), ".erp");
57
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Config / credential store
61
+ // ---------------------------------------------------------------------------
62
+ function loadConfig() {
63
+ if (!existsSync(CONFIG_FILE)) {
64
+ return { envs: { local: { baseUrl: "http://localhost:8080" } }, currentEnv: "local", sessions: {} };
65
+ }
66
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
67
+ }
68
+
69
+ function saveConfig(cfg) {
70
+ mkdirSync(CONFIG_DIR, { recursive: true });
71
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
72
+ // The `mode` option is a no-op if the file already existed — force it, then verify.
73
+ try {
74
+ chmodSync(CONFIG_FILE, 0o600);
75
+ const mode = statSync(CONFIG_FILE).mode & 0o777;
76
+ // Windows POSIX mode bits are not meaningful; only warn on a real Unix over-share.
77
+ if (process.platform !== "win32" && (mode & 0o077) !== 0) {
78
+ console.error(`warning: ${CONFIG_FILE} is mode ${mode.toString(8)} — expected 600 (contains access tokens). Run: chmod 600 ${CONFIG_FILE}`);
79
+ }
80
+ } catch { /* best effort */ }
81
+ }
82
+
83
+ function currentEnv(cfg) {
84
+ const name = cfg.currentEnv || "local";
85
+ const env = cfg.envs[name];
86
+ if (!env) throw new Error(`unknown env "${name}" — run: erp env list`);
87
+ return { name, ...env };
88
+ }
89
+
90
+ function currentSession(cfg) {
91
+ const env = currentEnv(cfg);
92
+ const session = cfg.sessions?.[env.name];
93
+ if (!session) throw new Error(`not logged in to env "${env.name}" — run: erp login`);
94
+ return { env, session };
95
+ }
96
+
97
+ // Real gap found + fixed 2026-09-03 (user flagged `erp login`'s UX as too
98
+ // thin): a raw network failure (server down, wrong --base-url, DNS/refused)
99
+ // surfaced as Node's own bare `fetch failed` with zero context — no target
100
+ // URL, no hint of what actually went wrong. Every login/session/whoami call
101
+ // site now routes through this so the failure at least names WHERE it tried
102
+ // to reach and WHY (refused vs. unknown-host vs. something else), instead of
103
+ // a caller having to re-derive it from a one-line Node error.
104
+ async function fetchOrExplain(url, init) {
105
+ try {
106
+ return await fetch(url, init);
107
+ } catch (e) {
108
+ const cause = e?.cause?.code || e?.code || (e instanceof Error ? e.message : String(e));
109
+ throw new Error(`could not reach ${url} (${cause}) — is the server up? check the env's base URL with \`erp env list\`.`);
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // authHeaders(cfg) — the single Authorization/session header builder for every
115
+ // networked CLI command (2026-09-10, remote plugin SDK).
116
+ // * ERP_TOKEN env var wins (CI path): sent verbatim as `Authorization:
117
+ // Bearer <ERP_TOKEN>`, no stored creds needed / consulted.
118
+ // * otherwise the stored `{accessToken, refreshToken/sessionToken, expiresAt}`
119
+ // for the current env; when the access token is within 60s of expiry it's
120
+ // silently refreshed via POST /api/v1/auth/device/refresh (the opaque
121
+ // session token is the refresh token) and the new value persisted 0600.
122
+ // Returns {} (unauthenticated) only when neither ERP_TOKEN nor a session exists.
123
+ // ---------------------------------------------------------------------------
124
+ async function refreshAccessToken(cfg, envName) {
125
+ const env = cfg.envs[envName];
126
+ const session = cfg.sessions?.[envName];
127
+ const refreshToken = session?.refreshToken || session?.sessionToken;
128
+ if (!refreshToken) return null;
129
+ try {
130
+ const res = await fetch(env.baseUrl.replace(/\/$/, "") + "/api/v1/auth/device/refresh", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json", "X-Tenant-Id": String(session.tenantId ?? 2) },
133
+ body: JSON.stringify({ refreshToken }),
134
+ });
135
+ if (!res.ok) return null;
136
+ const json = await res.json();
137
+ const accessToken = json.access_token || json.accessToken;
138
+ if (!accessToken) return null;
139
+ session.accessToken = accessToken;
140
+ session.expiresAt = new Date(Date.now() + (json.expires_in ? json.expires_in * 1000 : 15 * 60 * 1000)).toISOString();
141
+ saveConfig(cfg);
142
+ return accessToken;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ async function authHeaders(cfg, { tenantIdOverride } = {}) {
149
+ const env = currentEnv(cfg);
150
+ if (process.env.ERP_TOKEN) {
151
+ return { "X-Tenant-Id": String(tenantIdOverride ?? 2), Authorization: `Bearer ${process.env.ERP_TOKEN}` };
152
+ }
153
+ const session = cfg.sessions?.[env.name];
154
+ if (!session) return { "X-Tenant-Id": String(tenantIdOverride ?? 2) };
155
+ const soonMs = Date.parse(session.expiresAt || 0) - Date.now();
156
+ if (!Number.isNaN(soonMs) && soonMs < 60_000) {
157
+ await refreshAccessToken(cfg, env.name);
158
+ }
159
+ return {
160
+ "X-Tenant-Id": String(tenantIdOverride ?? session.tenantId ?? 2),
161
+ "X-Actor": session.userId,
162
+ "X-Session-Token": session.sessionToken,
163
+ Authorization: `Bearer ${session.accessToken}`,
164
+ };
165
+ }
166
+
167
+ async function api(cfg, method, urlPath, { body, headers, requireAuth = true, tenantIdOverride } = {}) {
168
+ const env = currentEnv(cfg);
169
+ const session = requireAuth ? cfg.sessions?.[env.name] : null;
170
+ if (requireAuth && !session && !process.env.ERP_TOKEN) throw new Error(`not logged in to env "${env.name}" — run: erp login`);
171
+ const h = {
172
+ "Content-Type": "application/json",
173
+ ...(requireAuth || session || process.env.ERP_TOKEN
174
+ ? await authHeaders(cfg, { tenantIdOverride })
175
+ : { "X-Tenant-Id": String(tenantIdOverride ?? 2) }),
176
+ ...headers,
177
+ };
178
+ const res = await fetchOrExplain(env.baseUrl + urlPath, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
179
+ const text = await res.text();
180
+ let json;
181
+ try {
182
+ json = text ? JSON.parse(text) : null;
183
+ } catch {
184
+ json = text;
185
+ }
186
+ if (!res.ok) {
187
+ const msg = typeof json === "object" && json?.message ? json.message : text;
188
+ throw new Error(`${method} ${urlPath} -> HTTP ${res.status}: ${msg}`);
189
+ }
190
+ return json;
191
+ }
192
+
193
+ function prompt(question, { hidden = false } = {}) {
194
+ return new Promise((resolve) => {
195
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
196
+ if (hidden) {
197
+ // Minimal masked-input read (no external dep) — good enough for a local dev CLI, not a hardened terminal UI.
198
+ rl._writeToOutput = () => {};
199
+ }
200
+ rl.question(question, (answer) => {
201
+ rl.close();
202
+ if (hidden) process.stdout.write("\n");
203
+ resolve(answer);
204
+ });
205
+ });
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // erp login / logout / whoami
210
+ // [REAL, 2026-09-03 mcp-08] — default path is the real OAuth-device-
211
+ // authorization-shaped flow: POST /api/v1/auth/device/code (unauthenticated,
212
+ // DeviceAuthController), print the verification URL + user code, open the
213
+ // browser, then poll POST /api/v1/auth/device/token at the server-given
214
+ // interval until a human completes the REAL login (AuthController#login)
215
+ // on `platform-launcher.device` and that page's own call to
216
+ // /api/v1/auth/device/approve stamps the resulting tokens onto the pending
217
+ // row. `--direct` keeps the original one-shot username/password call to
218
+ // /api/v1/auth/login for anyone who prefers it (or whose account needs the
219
+ // MFA step this device flow doesn't support either — same disclosed gap).
220
+ // ---------------------------------------------------------------------------
221
+ function openBrowser(url) {
222
+ try {
223
+ const plat = process.platform;
224
+ if (plat === "win32") spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
225
+ else if (plat === "darwin") spawnSync("open", [url], { stdio: "ignore" });
226
+ else spawnSync("xdg-open", [url], { stdio: "ignore" });
227
+ } catch { /* best-effort only — the printed URL is the real fallback */ }
228
+ }
229
+
230
+ function sleep(ms) {
231
+ return new Promise((resolve) => setTimeout(resolve, ms));
232
+ }
233
+
234
+ async function loginDeviceCommand(opts) {
235
+ const cfg = loadConfig();
236
+ const env = currentEnv(cfg);
237
+ // The device-authorization row lives under the one well-known "platform"
238
+ // tenant (same real tenant `platform-launcher.login`/`.device` are always
239
+ // reachable under pre-auth, per PlatformTenantResolutionController's own
240
+ // doc comment) — NOT the target tenant the user will actually land on,
241
+ // which is only known once AuthController#login's own real cross-tenant
242
+ // resolution runs inside the browser step. `--tenant` is accepted but
243
+ // unused here for exactly that reason (kept only so `--direct`'s flag
244
+ // shape stays familiar); the real destination tenant comes back from the
245
+ // server's own approved-token response.
246
+ const platformTenant = await fetchOrExplain(env.baseUrl + "/api/v1/platform-tenant").then((r) => r.json());
247
+ const tenantId = String(platformTenant.tenantId);
248
+ const codeRes = await fetchOrExplain(env.baseUrl + "/api/v1/auth/device/code", {
249
+ method: "POST",
250
+ headers: { "Content-Type": "application/json", "X-Tenant-Id": tenantId },
251
+ });
252
+ const code = await codeRes.json();
253
+ if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}: ${JSON.stringify(code)}`);
254
+
255
+ console.log(`\nTo authorize this CLI, open:\n\n ${code.verificationUriComplete}\n`);
256
+ console.log(`(or go to ${code.verificationUri} and enter code: ${code.userCode})\n`);
257
+ console.log(`Waiting for you to sign in and approve in the browser...`);
258
+ if (!opts.noBrowser) openBrowser(code.verificationUriComplete);
259
+
260
+ let intervalMs = Math.max(code.interval || 5, 2) * 1000;
261
+ const deadline = Date.now() + (code.expiresIn || 600) * 1000;
262
+ while (Date.now() < deadline) {
263
+ await sleep(intervalMs);
264
+ const pollRes = await fetchOrExplain(env.baseUrl + "/api/v1/auth/device/token", {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json", "X-Tenant-Id": tenantId },
267
+ body: JSON.stringify({ deviceCode: code.deviceCode }),
268
+ });
269
+ const poll = await pollRes.json();
270
+ if (!pollRes.ok) throw new Error(`device/token failed: HTTP ${pollRes.status}: ${JSON.stringify(poll)}`);
271
+ if (poll.status === "approved") {
272
+ cfg.sessions = cfg.sessions || {};
273
+ cfg.sessions[env.name] = {
274
+ tenantId: poll.tenantId ?? Number(tenantId),
275
+ userId: poll.userId,
276
+ sessionToken: poll.sessionToken,
277
+ refreshToken: poll.refresh_token ?? poll.sessionToken,
278
+ accessToken: poll.access_token ?? poll.accessToken,
279
+ expiresAt: poll.expiresAt,
280
+ };
281
+ saveConfig(cfg);
282
+ const mode = (statSync(CONFIG_FILE).mode & 0o777).toString(8);
283
+ console.log(`\nLogged in to ${env.name} (${env.baseUrl}) as ${poll.userId} on tenant ${poll.tenantId} — session expires ${poll.expiresAt}.`);
284
+ console.log(`Credentials stored in ${CONFIG_FILE} (mode ${mode}).`);
285
+ return;
286
+ }
287
+ if (poll.status === "denied" || poll.error === "access_denied") throw new Error("authorization was denied.");
288
+ if (poll.status === "expired" || poll.error === "expired_token") throw new Error("the device code expired before it was approved — run `erp login` again.");
289
+ if (poll.error === "slow_down") intervalMs += 5000; // RFC 8628 §3.5
290
+ // "authorization_pending" — keep polling.
291
+ }
292
+ throw new Error("timed out waiting for browser approval — run `erp login` again.");
293
+ }
294
+
295
+ async function loginDirectCommand(opts) {
296
+ const cfg = loadConfig();
297
+ const env = currentEnv(cfg);
298
+ const tenantId = opts.tenant || (await prompt(`Tenant id [2]: `)) || "2";
299
+ const userId = opts.user || (await prompt("User id / email: "));
300
+ const password = opts.password || (await prompt("Password: ", { hidden: true }));
301
+ const res = await fetchOrExplain(env.baseUrl + "/api/v1/auth/login", {
302
+ method: "POST",
303
+ headers: { "Content-Type": "application/json", "X-Tenant-Id": String(tenantId) },
304
+ body: JSON.stringify({ userId, password, sourceApp: "erp-cli" }),
305
+ });
306
+ const json = await res.json();
307
+ if (!res.ok) {
308
+ // Real gap found + fixed 2026-09-03: this used to throw the server's raw
309
+ // message verbatim with no interpretation — a wrong tenant id (a real,
310
+ // easy-to-make mistake, e.g. `--tenant 999999`) came back as a bare
311
+ // `HTTP 500: Failed to obtain JDBC Connection`, which reads like an
312
+ // infra outage, not "that tenant doesn't exist". The underlying 500 is a
313
+ // genuine, separate backend gap (SchemaScopedDataSource leaking a raw
314
+ // JDBC error instead of a clean 404/400 for an unresolvable tenant
315
+ // schema — out of scope to fix here, a platform-runtime change) but the
316
+ // CLI can at least stop presenting it as an unexplained crash.
317
+ const serverMsg = json?.message ?? JSON.stringify(json);
318
+ const hint =
319
+ res.status === 500 && /JDBC Connection|Connection.*refused|schema/i.test(String(serverMsg))
320
+ ? ` (tenant ${tenantId} may not exist on this server — check the id and try again)`
321
+ : "";
322
+ throw new Error(`login failed: HTTP ${res.status}: ${serverMsg}${hint}`);
323
+ }
324
+ if (json.mfaRequired) throw new Error("this account requires MFA — `erp login` does not yet support the MFA challenge step (disclosed gap, not silently ignored).");
325
+ cfg.sessions = cfg.sessions || {};
326
+ cfg.sessions[env.name] = {
327
+ tenantId: json.tenantId ?? Number(tenantId),
328
+ userId: json.userId,
329
+ sessionToken: json.sessionToken,
330
+ accessToken: json.accessToken,
331
+ expiresAt: json.expiresAt,
332
+ };
333
+ saveConfig(cfg);
334
+ console.log(`Logged in to ${env.name} (${env.baseUrl}) as ${json.userId} on tenant ${json.tenantId} — session expires ${json.expiresAt}.`);
335
+ }
336
+
337
+ async function loginCommand(opts) {
338
+ if (opts.direct) return loginDirectCommand(opts);
339
+ return loginDeviceCommand(opts);
340
+ }
341
+
342
+ async function logoutCommand() {
343
+ const cfg = loadConfig();
344
+ const env = currentEnv(cfg);
345
+ const session = cfg.sessions?.[env.name];
346
+ if (session) {
347
+ try {
348
+ await fetch(env.baseUrl + "/api/v1/auth/logout", {
349
+ method: "DELETE",
350
+ headers: { "X-Tenant-Id": String(session.tenantId), "X-Session-Token": session.sessionToken },
351
+ });
352
+ } catch { /* server may already be down/unreachable — still clear local credentials */ }
353
+ delete cfg.sessions[env.name];
354
+ }
355
+ saveConfig(cfg);
356
+ console.log(`Logged out of ${env.name}.`);
357
+ }
358
+
359
+ async function whoamiCommand(opts) {
360
+ const cfg = loadConfig();
361
+ const { env, session } = currentSession(cfg);
362
+ const res = await api(cfg, "GET", "/api/v1/auth/session", { tenantIdOverride: opts.tenant });
363
+ // Real gap found + fixed 2026-09-03 (user flagged `erp login`'s thinness):
364
+ // GET /api/v1/auth/session itself returns no expiry field at all — `res`
365
+ // never carried one, so `whoami` had no way to tell you your session was
366
+ // about to lapse (or already had) short of it failing outright on the
367
+ // NEXT command. The CLI already stores `expiresAt` locally from the
368
+ // original login response (~/.erp/config.json) — surfaced here instead of
369
+ // left unused.
370
+ const expiresAt = session.expiresAt ? new Date(session.expiresAt) : null;
371
+ const expiryStatus = expiresAt
372
+ ? expiresAt.getTime() < Date.now()
373
+ ? `EXPIRED ${Math.round((Date.now() - expiresAt.getTime()) / 60000)}m ago — run \`erp login\` again`
374
+ : `valid for ~${Math.round((expiresAt.getTime() - Date.now()) / 60000)}m more`
375
+ : "unknown (no expiresAt on record — a device-flow or older session)";
376
+ console.log(
377
+ JSON.stringify(
378
+ {
379
+ env: env.name,
380
+ baseUrl: env.baseUrl,
381
+ tenantId: opts.tenant ? Number(opts.tenant) : session.tenantId,
382
+ ...res,
383
+ localSessionExpiresAt: session.expiresAt ?? null,
384
+ localSessionStatus: expiryStatus,
385
+ },
386
+ null,
387
+ 2,
388
+ ),
389
+ );
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // erp api get/post — [REAL, added 2026-09-04] a real gap found and fixed:
394
+ // after deploying a plugin, verifying what actually landed (an entity's real
395
+ // records, a live menu's real nodes, a workflow instance's real state) had
396
+ // no CLI path at all — every prior verification pass in this session had to
397
+ // fall back to hand-written curl with manually-typed X-Tenant-Id/X-Actor
398
+ // headers, which is exactly the kind of guessing (wrong endpoint shapes,
399
+ // wrong header names) the SDK/CLI-only discovery rule exists to prevent.
400
+ // This reuses the SAME authenticated `api()` helper every other command
401
+ // already uses (session tenant/actor/bearer from `erp login`, not
402
+ // hand-typed), so a verification call is indistinguishable in trust from
403
+ // any other real CLI call. Deliberately generic (method + path + optional
404
+ // body) rather than one bespoke subcommand per resource type — an entity's
405
+ // `/api/v1/entities/<name>/records`, an artifact list's
406
+ // `/api/v1/authoring/<kind>`, a specific artifact's `/api/v1/authoring/<kind>/<id>`,
407
+ // a Data Service's `/api/v1/data-services/<name>/execute` are all just paths.
408
+ // ---------------------------------------------------------------------------
409
+ async function apiCallCommand(method, urlPath, opts) {
410
+ if (!urlPath) throw new Error(`usage: erp api ${method.toLowerCase()} <path> [--tenant <id>] [--body <json-string-or-@file>]`);
411
+ const cfg = loadConfig();
412
+ currentSession(cfg); // throws a clear "not logged in" error early if there's no session yet
413
+ let body;
414
+ if (opts.body) {
415
+ const raw = opts.body.startsWith("@") ? readFileSync(opts.body.slice(1), "utf8") : opts.body;
416
+ body = JSON.parse(raw);
417
+ }
418
+ const result = await api(cfg, method, urlPath, { body, tenantIdOverride: opts.tenant });
419
+ console.log(JSON.stringify(result, null, 2));
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // erp logs tail — [REAL, added 2026-09-04] a real gap found and fixed:
424
+ // engine-api logged to console ONLY (see application.yml's own `logging`
425
+ // section, edited alongside this) — whatever stack trace explained a real
426
+ // 500 existed nowhere durable/queryable once that terminal scrolled past.
427
+ // Neither a developer nor an AI driving purely through the SDK/CLI/MCP had
428
+ // ANY way to see server-side error detail beyond whatever text happened to
429
+ // land in the HTTP response body — exactly the failure mode that led to
430
+ // misdiagnosing a real BadSqlGrammarException as a missing-column error
431
+ // during HCM Talent Batch 1 (see ai/domains/hcm-talent-management.md) before
432
+ // actually reading the engine source to find the real cause. Every real API
433
+ // response now carries a real `X-Correlation-Id` response header
434
+ // (CorrelationIdFilter, engine-api) that matches the log's own `[cid=...]`
435
+ // token on every line for that request — the join key this command exists
436
+ // to search on: call an endpoint, note the correlationId, `erp logs tail
437
+ // --grep <correlationId>` to see the exact request's real server-side trace.
438
+ // Default log path is this repo's own dev convention (relative
439
+ // `logs/engine-api.log` under erp-suite's own working directory when run
440
+ // via `mvn spring-boot:run`) — `--file` overrides for any other environment.
441
+ // ---------------------------------------------------------------------------
442
+ function logsTailCommand(opts) {
443
+ requireMonorepo("erp logs tail");
444
+ const logPath = opts.file
445
+ ? path.resolve(opts.file)
446
+ : path.join(REPO_ROOT, "backend/platform-runtime/erp-suite/logs/engine-api.log");
447
+ if (!existsSync(logPath)) {
448
+ throw new Error(
449
+ `no log file at ${path.relative(REPO_ROOT, logPath)} — engine-api needs \`logging.file.name\` set (already is, in application.yml) ` +
450
+ `and must have been restarted since; pass --file <path> if your log lives elsewhere.`,
451
+ );
452
+ }
453
+ const lines = Number(opts.lines || 200);
454
+ const printMatching = (allLines) => {
455
+ let matched = allLines;
456
+ if (opts.grep) {
457
+ const needle = opts.grep;
458
+ matched = matched.filter((l) => l.includes(needle));
459
+ }
460
+ if (matched.length === 0) {
461
+ console.log(opts.grep ? `(no log lines matched "${opts.grep}")` : "(log file is empty)");
462
+ return;
463
+ }
464
+ console.log(matched.slice(-lines).join("\n"));
465
+ };
466
+ if (!opts.follow) {
467
+ printMatching(readFileSync(logPath, "utf8").split("\n"));
468
+ return;
469
+ }
470
+ console.log(`(following ${path.relative(REPO_ROOT, logPath)}${opts.grep ? ` for "${opts.grep}"` : ""} — Ctrl+C to stop)\n`);
471
+ printMatching(readFileSync(logPath, "utf8").split("\n"));
472
+ let lastSize = statSync(logPath).size;
473
+ return new Promise((resolve) => {
474
+ const interval = setInterval(() => {
475
+ let stat;
476
+ try {
477
+ stat = statSync(logPath);
478
+ } catch {
479
+ return;
480
+ }
481
+ if (stat.size > lastSize) {
482
+ const stream = createReadStream(logPath, { start: lastSize, end: stat.size });
483
+ let buf = "";
484
+ stream.on("data", (chunk) => (buf += chunk));
485
+ stream.on("end", () => {
486
+ const newLines = buf.split("\n").filter(Boolean);
487
+ const toPrint = opts.grep ? newLines.filter((l) => l.includes(opts.grep)) : newLines;
488
+ if (toPrint.length) console.log(toPrint.join("\n"));
489
+ });
490
+ lastSize = stat.size;
491
+ } else if (stat.size < lastSize) {
492
+ lastSize = 0; // log rotated/truncated underneath us — resync from the start next tick
493
+ }
494
+ }, 1000);
495
+ process.on("SIGINT", () => {
496
+ clearInterval(interval);
497
+ resolve();
498
+ process.exit(0);
499
+ });
500
+ });
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // erp org list / erp env list / erp env use
505
+ // [THIN] — this platform has no ERP-Cloud-style multi-org directory above
506
+ // tenant (tenant IS the top-level org boundary here, confirmed via source
507
+ // read); `org list` reports the current session's own tenant rather than
508
+ // a cross-tenant directory (that would be a platform-admin operation, out
509
+ // of scope for a developer CLI). `env`/`env use` ARE fully real — a local
510
+ // named base-URL profile switch, useful today (e.g. local vs a staging
511
+ // engine-api).
512
+ // ---------------------------------------------------------------------------
513
+ async function orgListCommand(opts) {
514
+ const cfg = loadConfig();
515
+ const { env, session } = currentSession(cfg);
516
+ console.log(`[THIN — see erp.mjs header] This platform has no multi-org directory above tenant; reporting the current session's tenant only.`);
517
+ console.log(JSON.stringify({ env: env.name, tenantId: opts.tenant ? Number(opts.tenant) : session.tenantId }, null, 2));
518
+ }
519
+
520
+ function envListCommand() {
521
+ const cfg = loadConfig();
522
+ for (const [name, e] of Object.entries(cfg.envs)) {
523
+ console.log(`${name === cfg.currentEnv ? "*" : " "} ${name} ${e.baseUrl}${cfg.sessions?.[name] ? " (logged in)" : ""}`);
524
+ }
525
+ }
526
+
527
+ function envUseCommand(name, opts) {
528
+ const cfg = loadConfig();
529
+ if (opts.baseUrl || !cfg.envs[name]) {
530
+ cfg.envs[name] = { baseUrl: opts.baseUrl || cfg.envs[name]?.baseUrl || "http://localhost:8080" };
531
+ }
532
+ cfg.currentEnv = name;
533
+ saveConfig(cfg);
534
+ console.log(`Switched to env "${name}" (${cfg.envs[name].baseUrl}).`);
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // erp plugin list / search / install / pull / diff
539
+ // [REAL] list/install — GET/POST /api/v1/authoring/plugins
540
+ // (PluginInstallationController), the actual manifest-row install path —
541
+ // this is INSTALLED-on-this-tenant state, a different bounded context from
542
+ // the registry below (per PackageManifestController's own doc comment).
543
+ // [REAL, 2026-09-03] search — now queries the REAL hosted registry first
544
+ // (`GET /api/v1/marketplace/packages?packageType=plugin`,
545
+ // engine-marketplace's PackageManifestController — a genuine catalog of
546
+ // registered/published packages, distinct from install state), falling
547
+ // back to / also including the local-repo-scoped substring match over
548
+ // `backend/modules/*/spk-assembly/plugin.json` (still useful for plugins
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.
559
+ // ---------------------------------------------------------------------------
560
+ async function pluginListCommand(opts) {
561
+ const cfg = loadConfig();
562
+ const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
563
+ console.log(JSON.stringify(list, null, 2));
564
+ }
565
+
566
+ async function pluginSearchCommand(term, opts) {
567
+ const cfg = loadConfig();
568
+ let registryHits = [];
569
+ try {
570
+ const catalog = await api(cfg, "GET", "/api/v1/marketplace/packages?packageType=plugin", { requireAuth: false, tenantIdOverride: opts.tenant });
571
+ registryHits = catalog.filter(
572
+ (p) => p.packageId.toLowerCase().includes(term.toLowerCase()) || (p.name || "").toLowerCase().includes(term.toLowerCase())
573
+ );
574
+ } catch (e) {
575
+ console.log(`(registry search unavailable: ${e.message})`);
576
+ }
577
+ console.log(`Registry (hosted, published) — ${registryHits.length} match(es):`);
578
+ console.log(JSON.stringify(registryHits.map((p) => ({ packageId: p.packageId, version: p.version, name: p.name, author: p.authorName, license: p.license })), null, 2));
579
+
580
+ const modulesDir = path.join(REPO_ROOT, "backend", "modules");
581
+ const localHits = [];
582
+ for (const dir of readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory())) {
583
+ const manifestPath = path.join(modulesDir, dir.name, "spk-assembly", "plugin.json");
584
+ if (!existsSync(manifestPath)) continue;
585
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
586
+ if (dir.name.toLowerCase().includes(term.toLowerCase()) || (manifest.name || "").toLowerCase().includes(term.toLowerCase())) {
587
+ localHits.push({ id: manifest.id || dir.name, name: manifest.name, version: manifest.version, dir: dir.name });
588
+ }
589
+ }
590
+ console.log(`\nLocal repo (on disk, not necessarily registered) — ${localHits.length} match(es):`);
591
+ console.log(JSON.stringify(localHits, null, 2));
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // erp registry list / mine / get / publish
596
+ // [REAL] — the genuine hosted registry (engine-marketplace's
597
+ // PackageManifestController, `/api/v1/marketplace/packages`) — a real
598
+ // catalog distinct from PluginInstallationController's install-state.
599
+ // `publish` does register+publish in one call (two real API calls under
600
+ // the hood: POST to draft, then POST .../publish). Package TYPES this
601
+ // endpoint actually knows how to auto-INSTALL are narrower than the
602
+ // catalog itself (only "theme"/"template-pack" today, per that
603
+ // controller's own doc comment — a "plugin"-type package is catalog
604
+ // metadata only; the real .spk binary still installs through
605
+ // `erp plugin install`/`spark publish`, a deliberately separate mechanism
606
+ // this pass does not merge).
607
+ // ---------------------------------------------------------------------------
608
+ async function registryListCommand(opts) {
609
+ const cfg = loadConfig();
610
+ const qs = opts.type ? `?packageType=${encodeURIComponent(opts.type)}` : "";
611
+ const list = await api(cfg, "GET", `/api/v1/marketplace/packages${qs}`, { requireAuth: false, tenantIdOverride: opts.tenant });
612
+ console.log(JSON.stringify(list, null, 2));
613
+ }
614
+
615
+ async function registryMineCommand(opts) {
616
+ const cfg = loadConfig();
617
+ const qs = opts.type ? `?packageType=${encodeURIComponent(opts.type)}` : "";
618
+ const list = await api(cfg, "GET", `/api/v1/marketplace/packages/mine${qs}`, { tenantIdOverride: opts.tenant });
619
+ console.log(JSON.stringify(list, null, 2));
620
+ }
621
+
622
+ async function registryGetCommand(packageId, version, opts) {
623
+ const cfg = loadConfig();
624
+ const row = await api(cfg, "GET", `/api/v1/marketplace/packages/${encodeURIComponent(packageId)}/${encodeURIComponent(version)}`, { requireAuth: false, tenantIdOverride: opts.tenant });
625
+ console.log(JSON.stringify(row, null, 2));
626
+ }
627
+
628
+ async function registryPublishCommand(packageId, version, opts) {
629
+ const cfg = loadConfig();
630
+ const body = {
631
+ packageId,
632
+ version,
633
+ packageType: opts.type || "plugin",
634
+ name: opts.name || packageId,
635
+ erpVersion: opts.erpVersion || "*",
636
+ authorName: opts.author || "unknown",
637
+ authorEmail: opts.authorEmail || null,
638
+ license: opts.license || "Proprietary",
639
+ description: opts.description || "",
640
+ dependencies: [],
641
+ payloadJson: opts.payloadFile ? readFileSync(opts.payloadFile, "utf8") : "{}",
642
+ screenshots: [],
643
+ changelog: opts.changelog || "",
644
+ pricingJson: "{}",
645
+ supportedCountries: [],
646
+ supportedIndustries: [],
647
+ };
648
+ const draft = await api(cfg, "POST", "/api/v1/marketplace/packages", { body, tenantIdOverride: opts.tenant });
649
+ console.log(`Registered draft: ${draft.packageId}@${draft.version} (status: ${draft.status})`);
650
+ const published = await api(cfg, "POST", `/api/v1/marketplace/packages/${encodeURIComponent(packageId)}/${encodeURIComponent(version)}/publish`, { tenantIdOverride: opts.tenant });
651
+ console.log(`Published: ${published.packageId}@${published.version} (status: ${published.status})`);
652
+ }
653
+
654
+ /**
655
+ * mcp-15 (2026-09-03) — `POST /api/v1/marketplace/packages/{id}/{version}/install`
656
+ * (`PackageManifestController#install`), real for `template-pack`/`theme`/
657
+ * `plugin` package types. For `plugin`, the package's own `payloadJson` must
658
+ * carry `{"spkFileId": <id>}` — a real DMS fileId (`erp registry publish
659
+ * --payload-file` with a JSON file containing that field, the id returned by
660
+ * a prior `POST /api/v1/files` upload of the real `.spk` bytes) — never the
661
+ * raw `.spk` bytes inlined into the payload itself. The server resolves that
662
+ * file, verifies it, and installs through the EXACT SAME pipeline the real
663
+ * `erp plugin publish`/multipart-upload path uses — this command does not
664
+ * reimplement any install logic itself, only the two real HTTP calls.
665
+ */
666
+ async function registryInstallCommand(packageId, version, opts) {
667
+ const cfg = loadConfig();
668
+ const installed = await api(cfg, "POST", `/api/v1/marketplace/packages/${encodeURIComponent(packageId)}/${encodeURIComponent(version)}/install`, { tenantIdOverride: opts.tenant });
669
+ console.log(JSON.stringify(installed, null, 2));
670
+ }
671
+
672
+ async function pluginInstallCommand(pluginId, opts) {
673
+ const cfg = loadConfig();
674
+ const manifestPath = opts.manifest || path.join(REPO_ROOT, "backend/modules", pluginId, "spk-assembly/plugin.json");
675
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
676
+ const res = await api(cfg, "POST", "/api/v1/authoring/plugins", {
677
+ body: { pluginId: manifest.id || pluginId, version: manifest.version, manifestJson: JSON.stringify(manifest), configJson: "{}", grantedCapabilitiesJson: "[]" },
678
+ tenantIdOverride: opts.tenant,
679
+ });
680
+ console.log(JSON.stringify(res, null, 2));
681
+ }
682
+
683
+ async function pluginPullCommand(pluginId, opts) {
684
+ const cfg = loadConfig();
685
+ const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
686
+ const found = list.find((p) => p.pluginId === pluginId);
687
+ if (!found) throw new Error(`no installed plugin "${pluginId}" on this tenant — run: erp plugin list`);
688
+ const outDir = opts.out || path.join(process.cwd(), pluginId);
689
+ mkdirSync(outDir, { recursive: true });
690
+ writeFileSync(path.join(outDir, "plugin.json"), found.manifestJson, "utf8");
691
+ writeFileSync(path.join(outDir, "config.json"), found.configJson || "{}", "utf8");
692
+ writeFileSync(path.join(outDir, "installation-state.json"), JSON.stringify(found, null, 2), "utf8");
693
+ console.log(`[THIN — manifest+config+state only, see erp.mjs header] Wrote ${outDir}/{plugin.json,config.json,installation-state.json}`);
694
+ }
695
+
696
+ async function pluginDiffCommand(pluginId, opts) {
697
+ const cfg = loadConfig();
698
+ const list = await api(cfg, "GET", "/api/v1/authoring/plugins", { tenantIdOverride: opts.tenant });
699
+ const installed = list.find((p) => p.pluginId === pluginId);
700
+ const localManifestPath = path.join(REPO_ROOT, "backend/modules", pluginId, "spk-assembly/plugin.json");
701
+ if (!existsSync(localManifestPath)) throw new Error(`no local plugin at backend/modules/${pluginId}/spk-assembly/plugin.json`);
702
+ const local = JSON.parse(readFileSync(localManifestPath, "utf8"));
703
+ console.log(JSON.stringify({
704
+ pluginId,
705
+ installedVersion: installed?.version ?? "(not installed)",
706
+ localVersion: local.version,
707
+ upToDate: installed?.version === local.version,
708
+ }, null, 2));
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // erp spec generate — (2026-09-03, hcm-learning Batch 2)
713
+ // [REAL] — introspects a plugin's REAL on-disk metadata (plugin.json roles,
714
+ // spk-assembly/metadata/{entities,rules,page,provider,menu,workflow}/*.json)
715
+ // and emits a starter architecture/spec markdown document: entity/field
716
+ // list, rule trigger/condition/action summary, page/route list, permission
717
+ // list, a "real vs disclosed-deferred" placeholder section left for a human
718
+ // (or an agent) to fill in. This does NOT replace the human-curated
719
+ // narrative quality of ai/domains/*.md — the deferred/trimmed/disclosed-gap
720
+ // reasoning still needs a person or an agent to write — it only auto-derives
721
+ // the mechanically-derivable inventory part so a developer starts from real
722
+ // data instead of a blank page. Reads local files only (same convention as
723
+ // `erp plugin diff`'s backend/modules/<id>/spk-assembly lookup) — no network
724
+ // call, so it works against a plugin that has never been installed anywhere.
725
+ // ---------------------------------------------------------------------------
726
+ function specGenerateCommand(pluginId, opts) {
727
+ if (!pluginId) throw new Error("usage: erp spec generate <plugin-id> [--out <file>]");
728
+ const base = path.join(REPO_ROOT, "backend/modules", pluginId, "spk-assembly");
729
+ const manifestPath = path.join(base, "plugin.json");
730
+ if (!existsSync(manifestPath)) throw new Error(`no local plugin at backend/modules/${pluginId}/spk-assembly/plugin.json`);
731
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
732
+
733
+ const readDirJson = (sub) => {
734
+ const dir = path.join(base, "metadata", sub);
735
+ if (!existsSync(dir)) return [];
736
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => {
737
+ try { return { file: f, json: JSON.parse(readFileSync(path.join(dir, f), "utf8")) }; }
738
+ catch (e) { return { file: f, error: e.message }; }
739
+ });
740
+ };
741
+
742
+ const entities = readDirJson("entities");
743
+ const rules = readDirJson("rules");
744
+ const pages = readDirJson("page");
745
+ const providers = readDirJson("provider");
746
+ const menus = readDirJson("menu");
747
+ const workflows = readDirJson("workflow");
748
+
749
+ const lines = [];
750
+ const push = (s = "") => lines.push(s);
751
+
752
+ push(`# ${manifest.name || pluginId} — generated spec inventory`);
753
+ push();
754
+ push(`Plugin id: \`${pluginId}\` · version: \`${manifest.version}\` · generated by \`erp spec generate ${pluginId}\` on ${new Date().toISOString()}.`);
755
+ push();
756
+ push("This is a MECHANICALLY-DERIVED inventory of this plugin's real, on-disk metadata — entities, rules, pages, providers, permissions, menu contributions. It is a starting point, not a finished architecture doc: the \"real vs disclosed-deferred\" reasoning, scope trims, and platform-bug history a hand-curated `ai/domains/*.md` doc carries still need a person (or an agent) to add below.");
757
+ push();
758
+
759
+ push("## Entities");
760
+ push();
761
+ if (entities.length === 0) push("_(none found under metadata/entities/)_");
762
+ for (const { file, json, error } of entities) {
763
+ if (error) { push(`### ${file} — **parse error**: ${error}`); continue; }
764
+ const e = json.entity || {};
765
+ push(`### \`${e.name || file}\``);
766
+ push(`- table: \`${e.tableName || "?"}\` · label: ${e.label || "?"} · soft-delete: ${e.softDeleteEnabled ?? "?"}`);
767
+ if (e.description) push(`- ${e.description}`);
768
+ push("");
769
+ push("| field | type | required | notes |");
770
+ push("|---|---|---|---|");
771
+ for (const f of json.fields || []) {
772
+ const notes = [f.isUnique && "unique", f.isIndexed && "indexed", f.isReadOnly && "read-only", f.isDisplayField && "display-field"].filter(Boolean).join(", ");
773
+ push(`| ${f.fieldName} | ${f.dataType}${f.typeParams ? ` (${f.typeParams})` : ""} | ${f.required ? "yes" : "no"} | ${notes} |`);
774
+ }
775
+ push();
776
+ }
777
+
778
+ push("## Rules");
779
+ push();
780
+ if (rules.length === 0) push("_(none found under metadata/rules/)_");
781
+ push("| name | entity | trigger | priority | summary |");
782
+ push("|---|---|---|---|---|");
783
+ for (const { file, json, error } of rules) {
784
+ if (error) { push(`| ${file} | | | | **parse error**: ${error} |`); continue; }
785
+ let actionSummary = "?";
786
+ try {
787
+ const actions = typeof json.actions === "string" ? JSON.parse(json.actions) : json.actions;
788
+ actionSummary = (actions || []).map((a) => a.type + (a.field ? `(${a.field})` : "")).join(", ");
789
+ } catch { /* leave as ? */ }
790
+ push(`| ${json.name || file} | ${json.entityType || "?"} | ${json.triggerEvent || "?"} | ${json.priority ?? "?"} | ${actionSummary} |`);
791
+ }
792
+ push();
793
+
794
+ push("## Pages");
795
+ push();
796
+ if (pages.length === 0) push("_(none found under metadata/page/)_");
797
+ for (const { file, json, error } of pages) {
798
+ if (error) { push(`- ${file} — **parse error**: ${error}`); continue; }
799
+ const def = json.definition || {};
800
+ const variants = (def.variants || []).map((v) => v.rule?.deviceClass).filter(Boolean);
801
+ push(`- **${json.name || file}** — route \`${def.route?.pattern || "?"}\`, id \`${def.id || "?"}\`, deviceClass variants: ${variants.length ? variants.join(", ") : "(desktop only)"}`);
802
+ if (json.metadata?.entityDefinition) push(` - entity: \`${json.metadata.entityDefinition}\`, records: \`${json.metadata.recordsBasePath || "?"}\``);
803
+ }
804
+ push();
805
+
806
+ push("## Providers");
807
+ push();
808
+ if (providers.length === 0) push("_(none found under metadata/provider/)_");
809
+ for (const { file, json, error } of providers) {
810
+ if (error) { push(`- ${file} — **parse error**: ${error}`); continue; }
811
+ push(`- **${json.name || file}** — ${json.definition?.kind || "?"} \`${json.definition?.basePath || "?"}\` (${(json.definition?.supports || []).join(", ")})`);
812
+ }
813
+ push();
814
+
815
+ if (workflows.length) {
816
+ push("## Workflows");
817
+ push();
818
+ for (const { file, json, error } of workflows) {
819
+ push(error ? `- ${file} — **parse error**: ${error}` : `- **${json.name || file}**`);
820
+ }
821
+ push();
822
+ }
823
+
824
+ push("## Permissions (from plugin.json roles[])");
825
+ push();
826
+ if (!manifest.roles || manifest.roles.length === 0) push("_(no roles declared)_");
827
+ for (const role of manifest.roles || []) {
828
+ push(`### ${role.name} (\`${role.code}\`)`);
829
+ if (role.description) push(role.description);
830
+ push();
831
+ for (const key of role.permissionKeys || []) push(`- \`${key}\``);
832
+ push();
833
+ }
834
+
835
+ push("## Menu contributions");
836
+ push();
837
+ if (menus.length === 0) push("_(none found under metadata/menu/)_");
838
+ for (const { file, json, error } of menus) {
839
+ if (error) { push(`- ${file} — **parse error**: ${error}`); continue; }
840
+ const flatten = (nodes, depth = 0) => {
841
+ for (const n of nodes || []) {
842
+ const enabled = n.visibility?.enabled ? "enabled" : "disabled";
843
+ push(`${" ".repeat(depth)}- ${n.displayName || n.id} (${enabled}${n.navigation?.route ? `, route: ${n.navigation.route}` : ""})`);
844
+ flatten(n.children, depth + 1);
845
+ }
846
+ };
847
+ flatten(json.definition?.nodes);
848
+ }
849
+ push();
850
+
851
+ push("## Real vs disclosed-deferred (fill in before treating this as a finished spec)");
852
+ push();
853
+ push("_This section is intentionally left for a human or an agent to complete — which of the above is fully real end-to-end (verified via curl/Playwright), which is a scope trim, and why. `erp spec generate` cannot derive this part; see `ai/domains/*.md` for the hand-curated convention this repo uses._");
854
+ push();
855
+
856
+ const output = lines.join("\n");
857
+ if (opts.out) {
858
+ writeFileSync(opts.out, output, "utf8");
859
+ console.log(`wrote ${opts.out} (${entities.length} entities, ${rules.length} rules, ${pages.length} pages, ${providers.length} providers, ${(manifest.roles || []).length} roles)`);
860
+ } else {
861
+ console.log(output);
862
+ }
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // erp platform catalog — [REAL, added 2026-09-04] the fix for a real,
867
+ // demonstrated tooling gap: `erp docs search` (and the MCP `erp_search_docs`
868
+ // tool) only ever grepped hand-written `ai/domains/*.md` narrative docs, with
869
+ // no mechanism to check a claim against the actual platform-runtime source.
870
+ // This let a false "no report-builder infrastructure exists anywhere in this
871
+ // codebase" claim propagate unverified across five separate module docs, even
872
+ // though a real, working report-definition/execution engine
873
+ // (ReportDefinitionService/BiReportDefinitionService, engine-form +
874
+ // engine-entity, backed by V33__report_definition.sql/V86__bi_report_definition.sql)
875
+ // existed the whole time — see ai/domains/*.md's own corrected lines.
876
+ //
877
+ // This is `erp spec generate`'s own mechanical-extraction idea (grep/parse
878
+ // on-disk metadata, don't hand-maintain an inventory), just scoped to the
879
+ // CORE `backend/platform-runtime/engine-*` modules instead of a plugin's
880
+ // spk-assembly. It is a lightweight static/regex scan, not a real Java AST
881
+ // parser (same disclosed-heuristic posture as `lintPageSemantics` below) —
882
+ // good enough to answer "does a class/table/endpoint like this exist", not a
883
+ // full call-graph/type-checker.
884
+ //
885
+ // Cached at ai/platform/engine-capability-catalog.json (regenerate with
886
+ // `--refresh`) since a live full-repo regex scan of every engine-* module on
887
+ // every `docs search` call would be slow — `erp platform catalog --refresh`
888
+ // is deliberately cheap (a few hundred ms) so this cache has no excuse to go
889
+ // stale the way the hand-written docs did.
890
+ // ---------------------------------------------------------------------------
891
+ const ENGINE_CATALOG_CACHE = path.join(REPO_ROOT, "ai/platform/engine-capability-catalog.json");
892
+ const PLATFORM_RUNTIME_ROOT = path.join(REPO_ROOT, "backend/platform-runtime");
893
+
894
+ function walkFilesFiltered(dir, matchFn, excludeDirNames = new Set(["target", "node_modules", ".git"])) {
895
+ const out = [];
896
+ let entries;
897
+ try {
898
+ entries = readdirSync(dir, { withFileTypes: true });
899
+ } catch {
900
+ return out;
901
+ }
902
+ for (const e of entries) {
903
+ if (e.isDirectory()) {
904
+ if (excludeDirNames.has(e.name)) continue;
905
+ out.push(...walkFilesFiltered(path.join(dir, e.name), matchFn, excludeDirNames));
906
+ } else if (matchFn(e.name)) {
907
+ out.push(path.join(dir, e.name));
908
+ }
909
+ }
910
+ return out;
911
+ }
912
+
913
+ // Split a CamelCase/snake_case identifier into lowercase word tokens, e.g.
914
+ // "BiReportDefinitionService" -> ["bi","report","definition","service"],
915
+ // "bi_report_definition" -> ["bi","report","definition"]. Tokens <4 chars are
916
+ // dropped (too many false-positive overlaps otherwise — "id", "get", "api").
917
+ function tokenize(identifier) {
918
+ return identifier
919
+ .replace(/[_.]/g, " ")
920
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
921
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
922
+ .toLowerCase()
923
+ .split(/\s+/)
924
+ .filter((t) => t.length >= 4);
925
+ }
926
+
927
+ function extractJavaCapabilities(javaFiles) {
928
+ const classes = []; // {name, kind, file}
929
+ const controllerPaths = []; // {path, file}
930
+ const classDeclRe = /^\s*(?:@\w+(?:\([^)]*\))?\s*)*public\s+(?:abstract\s+|final\s+|static\s+)*(class|interface|enum|record)\s+(\w+)/gm;
931
+ const mappingRe = /@(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\s*\(\s*(?:value\s*=\s*)?"([^"]*)"/g;
932
+ for (const file of javaFiles) {
933
+ let text;
934
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
935
+ const rel = path.relative(REPO_ROOT, file);
936
+ let m;
937
+ classDeclRe.lastIndex = 0;
938
+ while ((m = classDeclRe.exec(text))) classes.push({ name: m[2], kind: m[1], file: rel });
939
+ mappingRe.lastIndex = 0;
940
+ while ((m = mappingRe.exec(text))) if (m[1]) controllerPaths.push({ path: m[1], file: rel });
941
+ }
942
+ return { classes, controllerPaths };
943
+ }
944
+
945
+ function buildEngineCatalog({ refresh = false } = {}) {
946
+ // Packaged SDK mode: the Java source scan NEVER runs on the client — serve
947
+ // the frozen catalog.json shipped in the authoring bundle (computed once at
948
+ // `erp bundle build` time). `--refresh` is a no-op here.
949
+ if (isPackaged()) return frozenCatalog();
950
+ if (!refresh && existsSync(ENGINE_CATALOG_CACHE)) {
951
+ try { return JSON.parse(readFileSync(ENGINE_CATALOG_CACHE, "utf8")); } catch { /* fall through and regenerate */ }
952
+ }
953
+
954
+ let engineDirNames;
955
+ try {
956
+ engineDirNames = readdirSync(PLATFORM_RUNTIME_ROOT, { withFileTypes: true })
957
+ .filter((d) => d.isDirectory() && d.name.startsWith("engine-"))
958
+ .map((d) => d.name)
959
+ .sort();
960
+ } catch {
961
+ engineDirNames = [];
962
+ }
963
+
964
+ // All Flyway migrations under backend/platform-runtime, wherever they live
965
+ // (many, e.g. V33__report_definition.sql/V86__bi_report_definition.sql,
966
+ // live in the shared `metadata` module rather than per-engine — confirmed
967
+ // by direct scan, not assumed).
968
+ const migrationFiles = walkFilesFiltered(PLATFORM_RUNTIME_ROOT, (name) => /^V\d+__.*\.sql$/.test(name))
969
+ .filter((f) => f.includes(`${path.sep}db${path.sep}migration${path.sep}`));
970
+ const migrations = migrationFiles.map((file) => {
971
+ const rel = path.relative(REPO_ROOT, file);
972
+ const versionMatch = path.basename(file).match(/^V(\d+)__/);
973
+ let text = "";
974
+ try { text = readFileSync(file, "utf8"); } catch { /* unreadable, leave tables empty */ }
975
+ const tables = new Set();
976
+ const tableRe = /create\s+table\s+(?:if\s+not\s+exists\s+)?"?([a-zA-Z0-9_.]+)"?/gi;
977
+ let tm;
978
+ while ((tm = tableRe.exec(text))) tables.add(tm[1].replace(/^public\./, ""));
979
+ const ownerMatch = rel.match(new RegExp(`(engine-[a-z-]+)[\\/]`));
980
+ return { file: rel, version: versionMatch ? Number(versionMatch[1]) : null, tables: [...tables], owner: ownerMatch ? ownerMatch[1] : null };
981
+ });
982
+ const ownedMigrations = migrations.filter((m) => m.owner);
983
+ const sharedMigrations = migrations.filter((m) => !m.owner);
984
+
985
+ const engines = {};
986
+ for (const engineName of engineDirNames) {
987
+ const javaRoot = path.join(PLATFORM_RUNTIME_ROOT, engineName, "src/main/java");
988
+ const javaFiles = walkFilesFiltered(javaRoot, (name) => name.endsWith(".java"));
989
+ const { classes, controllerPaths } = extractJavaCapabilities(javaFiles);
990
+ const classTokens = new Set();
991
+ for (const c of classes) for (const t of tokenize(c.name)) classTokens.add(t);
992
+
993
+ const ownMigrations = ownedMigrations.filter((m) => m.owner === engineName).map(({ owner, ...rest }) => rest);
994
+ // Shared (non-engine-owned) migrations attributed by class-name/table-name
995
+ // token overlap — heuristic, not authoritative package ownership; each
996
+ // entry is labeled as such below so a consumer never mistakes it for a
997
+ // confirmed `@Table`/repository binding.
998
+ const relatedShared = sharedMigrations
999
+ .filter((m) => m.tables.some((t) => tokenize(t).some((tok) => classTokens.has(tok))))
1000
+ .map((m) => ({ file: m.file, version: m.version, tables: m.tables, note: "keyword-matched to this module's class names, not a confirmed repository binding" }));
1001
+
1002
+ engines[engineName] = {
1003
+ module: engineName,
1004
+ classCount: classes.length,
1005
+ classes: classes.sort((a, b) => a.name.localeCompare(b.name)),
1006
+ controllerBasePaths: [...new Set(controllerPaths.map((c) => c.path))].sort(),
1007
+ controllerPathDetails: controllerPaths,
1008
+ ownedMigrations: ownMigrations,
1009
+ relatedSharedMigrations: relatedShared,
1010
+ };
1011
+ }
1012
+
1013
+ const catalog = {
1014
+ generatedAt: new Date().toISOString(),
1015
+ generatedBy: "erp platform catalog --refresh (tools/erp-cli/erp.mjs buildEngineCatalog)",
1016
+ note: "Mechanically-derived from backend/platform-runtime/engine-*/src/main/java (regex class/interface/enum/record + @*Mapping scan) and every V*__*.sql Flyway migration under backend/platform-runtime — NOT a hand-maintained inventory, NOT a full Java AST parse. Regenerate with `erp platform catalog --refresh` whenever engine-* source changes.",
1017
+ engineCount: engineDirNames.length,
1018
+ engines,
1019
+ sharedMigrationsModule: "backend/platform-runtime/metadata (Flyway migrations shared across engine-* modules; attribution above to a specific engine is keyword-based, not authoritative)",
1020
+ contracts: buildContractsCatalog(),
1021
+ };
1022
+
1023
+ try {
1024
+ mkdirSync(path.dirname(ENGINE_CATALOG_CACHE), { recursive: true });
1025
+ writeFileSync(ENGINE_CATALOG_CACHE, JSON.stringify(catalog, null, 2), "utf8");
1026
+ } catch (e) {
1027
+ console.error(`(warning: could not write cache ${ENGINE_CATALOG_CACHE}: ${e.message} — continuing with in-memory catalog)`);
1028
+ }
1029
+ return catalog;
1030
+ }
1031
+
1032
+ // Search the (cached-or-fresh) engine catalog for a substring across module
1033
+ // names, class names, controller paths, and migration table names — the
1034
+ // mechanism `erp docs search`/`erp_search_docs` now also calls so a stale
1035
+ // "doesn't exist" doc claim can never again be the ONLY answer returned.
1036
+ function searchEngineCatalog(query, { refresh = false } = {}) {
1037
+ const catalog = buildEngineCatalog({ refresh });
1038
+ const q = query.toLowerCase();
1039
+ // A multi-word query (e.g. "report builder", "pluggable provider") rarely
1040
+ // appears verbatim in a class/table name — real classes are named
1041
+ // "ReportDefinitionService", not "ReportBuilder". Matching on the whole
1042
+ // phrase would return nothing for exactly the queries this fix exists to
1043
+ // answer, so a candidate also hits if it contains ANY significant
1044
+ // (>=4-char) word from the query — an OR-of-tokens match, same spirit as a
1045
+ // search engine's own term matching, not just a single frozen substring.
1046
+ const queryTokens = q.split(/\s+/).filter((t) => t.length >= 4);
1047
+ const isHit = (s) => {
1048
+ const low = s.toLowerCase();
1049
+ return low.includes(q) || queryTokens.some((t) => low.includes(t));
1050
+ };
1051
+ const hits = [];
1052
+ for (const [engineName, e] of Object.entries(catalog.engines)) {
1053
+ if (isHit(engineName)) hits.push({ engine: engineName, kind: "module", match: engineName });
1054
+ for (const c of e.classes) {
1055
+ if (isHit(c.name)) hits.push({ engine: engineName, kind: c.kind, match: c.name, file: c.file });
1056
+ }
1057
+ for (const p of e.controllerBasePaths) {
1058
+ if (isHit(p)) hits.push({ engine: engineName, kind: "rest-mapping", match: p });
1059
+ }
1060
+ for (const m of [...e.ownedMigrations, ...e.relatedSharedMigrations]) {
1061
+ for (const t of m.tables) {
1062
+ if (isHit(t)) hits.push({ engine: engineName, kind: "migration-table", match: t, file: m.file });
1063
+ }
1064
+ }
1065
+ }
1066
+ // Curated extension-point/gateway-client contracts (real method
1067
+ // signatures/fields, see buildContractsCatalog) — searched separately from
1068
+ // the plain class-name scan above so a query for e.g. "PendingHumanTask"
1069
+ // surfaces the ACTUAL fields/methods, not just "yes, this class exists".
1070
+ const contractHits = [];
1071
+ for (const u of catalog.contracts?.units ?? []) {
1072
+ const nameHit = isHit(u.name);
1073
+ const memberHit = !nameHit && (u.fields.some((f) => isHit(f.name)) || u.methods.some((mo) => isHit(mo.name)));
1074
+ if (nameHit || memberHit) contractHits.push({ engine: u.group, kind: "java-contract", match: u.name, file: u.file, unit: u });
1075
+ }
1076
+ // Class/interface/module hits are the actual answer to "does X exist" —
1077
+ // put them ahead of migration-table hits, which fan out noisily across
1078
+ // many engines once a shared migration's table name shares a token with
1079
+ // several modules' own class names (disclosed heuristic, see relatedShared
1080
+ // above). Contract hits (the richest data — real fields/signatures) sort
1081
+ // first of all. Keeps a capped result (docs search shows the top 30) useful.
1082
+ const kindRank = { "java-contract": -1, module: 0, class: 1, interface: 1, record: 1, enum: 1, "rest-mapping": 2, "migration-table": 3 };
1083
+ const allHits = [...contractHits, ...hits];
1084
+ allHits.sort((a, b) => (kindRank[a.kind] ?? 9) - (kindRank[b.kind] ?? 9));
1085
+ return { query, catalogGeneratedAt: catalog.generatedAt, hits: allHits };
1086
+ }
1087
+
1088
+ function platformCatalogCommand(opts) {
1089
+ const catalog = buildEngineCatalog({ refresh: Boolean(opts.refresh) });
1090
+ if (opts.engine) {
1091
+ const e = catalog.engines[opts.engine];
1092
+ if (!e) throw new Error(`unknown engine "${opts.engine}". Known: ${Object.keys(catalog.engines).join(", ")}`);
1093
+ console.log(JSON.stringify(e, null, 2));
1094
+ return;
1095
+ }
1096
+ console.log(`Engine capability catalog — generated ${catalog.generatedAt} (${Object.keys(catalog.engines).length} engine-* modules; cache: ${path.relative(REPO_ROOT, ENGINE_CATALOG_CACHE)})`);
1097
+ for (const [name, e] of Object.entries(catalog.engines)) {
1098
+ console.log(` ${name} — ${e.classCount} classes, ${e.controllerBasePaths.length} REST mapping(s), ${e.ownedMigrations.length + e.relatedSharedMigrations.length} migration(s)`);
1099
+ }
1100
+ console.log(`\nUse --engine <name> for one module's full detail, --refresh to regenerate the cache.`);
1101
+ }
1102
+
1103
+ // ---------------------------------------------------------------------------
1104
+ // erp platform describe <ClassName> — [REAL, added 2026-09-04, closes a real
1105
+ // gap surfaced live this session]. `erp platform catalog` (above) only ever
1106
+ // inventoried CLASS NAMES/REST PATHS/TABLES — a real third-party plugin
1107
+ // developer (or this session's own agents) had to open Java source
1108
+ // (WorkflowGatewayClient, PendingHumanTask, DmsGatewayClient, the
1109
+ // `*Contribution` family) to learn actual method signatures/field lists
1110
+ // before writing plugin code against them. That defeats the point of an SDK:
1111
+ // nothing about "what does PendingHumanTask actually contain" was
1112
+ // discoverable without this repo's own source.
1113
+ //
1114
+ // Extends the SAME catalog mechanism (mechanically regex-scanned Java
1115
+ // source, cached at ai/platform/engine-capability-catalog.json, same
1116
+ // disclosed-heuristic rigor as buildEngineCatalog above — not a real AST
1117
+ // parser) with a `contracts` section: real method signatures (name/params/
1118
+ // returnType) and real field lists for a CURATED set of genuinely-reusable
1119
+ // extension points:
1120
+ // - every `*Contribution` interface in engine-plugin-api (the actual
1121
+ // plugin extension-point family — RestContribution, WorkflowTaskContribution, ...)
1122
+ // - the canonical `WorkflowGatewayClient` (engine-plugin-client) + its
1123
+ // real nested response records (InstanceView, InstanceHumanTask,
1124
+ // HistoryEntry, PendingHumanTask, DecidedCount) — the exact class that
1125
+ // caused this session's real bug
1126
+ // - every per-plugin `DmsGatewayClient`/`WorkflowGatewayClient`/
1127
+ // `CommunicationGatewayClient` COPY under backend/modules (confirmed via
1128
+ // grep these are hand-copied per plugin, not a shared library — each
1129
+ // copy is catalogued separately, tagged by its owning module, so a
1130
+ // developer can see they're independent and may drift)
1131
+ // ---------------------------------------------------------------------------
1132
+
1133
+ // Split a raw parameter/record-component list on top-level commas only —
1134
+ // i.e. never inside a generic's <...> or a nested (...) — so
1135
+ // "Long id, Map<String, Object> context" splits into exactly 2 entries, not 3.
1136
+ function splitTopLevelCommas(raw) {
1137
+ const out = [];
1138
+ let depth = 0;
1139
+ let cur = "";
1140
+ for (const ch of raw) {
1141
+ if (ch === "<" || ch === "(" || ch === "[") depth++;
1142
+ else if (ch === ">" || ch === ")" || ch === "]") depth--;
1143
+ if (ch === "," && depth === 0) {
1144
+ out.push(cur.trim());
1145
+ cur = "";
1146
+ } else {
1147
+ cur += ch;
1148
+ }
1149
+ }
1150
+ if (cur.trim()) out.push(cur.trim());
1151
+ return out;
1152
+ }
1153
+
1154
+ // One "Type name" entry (a method param or record component) -> {type, name}.
1155
+ // Strips leading annotations (@NotNull, @RequestParam(...) etc) heuristically.
1156
+ function parseTypedEntry(entry) {
1157
+ const stripped = entry.replace(/@\w+(\([^)]*\))?\s*/g, "").trim();
1158
+ const m = stripped.match(/^(.*[\s>\]])(\w+)$/);
1159
+ if (!m) return { type: stripped, name: "" };
1160
+ return { type: m[1].trim().replace(/\s+/g, " "), name: m[2] };
1161
+ }
1162
+
1163
+ // Strip Java comments (block and line) before scanning for methods/fields —
1164
+ // otherwise javadoc prose ("See Also DmsGatewayClient#asLong doc comment")
1165
+ // can false-positive-match the bare-identifier-then-paren method regex used
1166
+ // for interface bodies below.
1167
+ function stripJavaComments(text) {
1168
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
1169
+ }
1170
+
1171
+ // Find the index of the character matching the opening bracket at `openIdx`
1172
+ // (text[openIdx] must be openCh) — simple depth counter, good enough for
1173
+ // well-formed source (same disclosed-heuristic posture as the rest of this
1174
+ // scanner, not a real parser).
1175
+ function findMatchingBracket(text, openIdx, openCh, closeCh) {
1176
+ let depth = 0;
1177
+ for (let i = openIdx; i < text.length; i++) {
1178
+ if (text[i] === openCh) depth++;
1179
+ else if (text[i] === closeCh) {
1180
+ depth--;
1181
+ if (depth === 0) return i;
1182
+ }
1183
+ }
1184
+ return -1;
1185
+ }
1186
+
1187
+ // Mechanically extract every class/interface/enum/record declared in one
1188
+ // Java file (including nested ones, e.g. WorkflowGatewayClient's nested
1189
+ // public records) as a "contract unit": {name, kind, file, fields, methods}.
1190
+ // Regex-based, same rigor as extractJavaCapabilities above — not a real AST
1191
+ // parser, but real enough to answer "what does this class actually contain"
1192
+ // without opening the file.
1193
+ function extractJavaContracts(file) {
1194
+ let text;
1195
+ try { text = readFileSync(file, "utf8"); } catch { return []; }
1196
+ const rel = path.relative(REPO_ROOT, file);
1197
+ // Strip comments from the WHOLE file up front — javadoc routinely contains
1198
+ // stray braces/parens (`{@code Foo(...)}`) that would otherwise throw off
1199
+ // the brace/paren matching below.
1200
+ text = stripJavaComments(text);
1201
+ const units = [];
1202
+ const typeDeclRe = /(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+|abstract\s+)*(class|interface|enum|record)\s+(\w+)/g;
1203
+ let m;
1204
+ while ((m = typeDeclRe.exec(text))) {
1205
+ const kind = m[1];
1206
+ const name = m[2];
1207
+ const afterName = m.index + m[0].length;
1208
+
1209
+ let fields = [];
1210
+ let bodyStart;
1211
+ if (kind === "record") {
1212
+ const parenIdx = text.indexOf("(", afterName);
1213
+ if (parenIdx === -1 || parenIdx - afterName > 40) continue; // not really a record header (generic type param etc)
1214
+ const closeParen = findMatchingBracket(text, parenIdx, "(", ")");
1215
+ if (closeParen === -1) continue;
1216
+ const componentsRaw = text.slice(parenIdx + 1, closeParen);
1217
+ fields = splitTopLevelCommas(componentsRaw).filter(Boolean).map(parseTypedEntry);
1218
+ bodyStart = text.indexOf("{", closeParen);
1219
+ } else {
1220
+ bodyStart = text.indexOf("{", afterName);
1221
+ }
1222
+ if (bodyStart === -1) continue;
1223
+ const bodyEnd = findMatchingBracket(text, bodyStart, "{", "}");
1224
+ if (bodyEnd === -1) continue;
1225
+ const body = stripJavaComments(text.slice(bodyStart + 1, bodyEnd));
1226
+
1227
+ // Methods declared directly in this body (public/protected, or — since
1228
+ // an interface's abstract methods carry no explicit modifier at all —
1229
+ // any bare `ReturnType name(` for a `kind === "interface"` unit), not a
1230
+ // constructor (name !== this unit's own name).
1231
+ const methods = [];
1232
+ const modifierPrefix = kind === "interface" ? "(?:public\\s+|protected\\s+|default\\s+)?" : "(?:public|protected)\\s+";
1233
+ const methodRe = new RegExp(`${modifierPrefix}(?:static\\s+)?(?:final\\s+)?(?:abstract\\s+)?(?:<[^>]*>\\s*)?([\\w$.\\[\\]<>,?\\s]+?)\\s+(\\w+)\\s*\\(`, "g");
1234
+ let mm;
1235
+ while ((mm = methodRe.exec(body))) {
1236
+ const returnType = mm[1].trim().replace(/\s+/g, " ");
1237
+ const methodName = mm[2];
1238
+ if (methodName === name) continue; // constructor
1239
+ const parenIdx = mm.index + mm[0].length - 1;
1240
+ const closeParen = findMatchingBracket(body, parenIdx, "(", ")");
1241
+ if (closeParen === -1) continue;
1242
+ const paramsRaw = body.slice(parenIdx + 1, closeParen).trim();
1243
+ const params = paramsRaw ? splitTopLevelCommas(paramsRaw).map(parseTypedEntry) : [];
1244
+ // Confirm what follows is really a method decl (`{`/`;`/`throws`), not
1245
+ // a false-positive match inside e.g. a lambda or comment.
1246
+ const after = body.slice(closeParen + 1, closeParen + 60).trimStart();
1247
+ if (!/^(throws\b|\{|;)/.test(after)) continue;
1248
+ methods.push({ name: methodName, returnType, params });
1249
+ }
1250
+
1251
+ units.push({ name, kind, file: rel, fields, methods });
1252
+ }
1253
+ return units;
1254
+ }
1255
+
1256
+ const CONTRACT_TARGET_GROUPS = [
1257
+ {
1258
+ group: "engine-plugin-api contribution interfaces (plugin extension points)",
1259
+ files: () => walkFilesFiltered(path.join(PLATFORM_RUNTIME_ROOT, "engine-plugin-api/src/main/java"), (n) => n.endsWith("Contribution.java")),
1260
+ },
1261
+ {
1262
+ group: "canonical WorkflowGatewayClient (engine-plugin-client — the shared client new plugins should depend on)",
1263
+ files: () => {
1264
+ const f = path.join(PLATFORM_RUNTIME_ROOT, "engine-plugin-client/src/main/java/com/erp/platform/engine/plugin/client/WorkflowGatewayClient.java");
1265
+ return existsSync(f) ? [f] : [];
1266
+ },
1267
+ },
1268
+ {
1269
+ group: "per-plugin DmsGatewayClient copies (backend/modules — hand-copied pattern, not a shared library)",
1270
+ files: () => walkFilesFiltered(path.join(REPO_ROOT, "backend/modules"), (n) => n === "DmsGatewayClient.java"),
1271
+ },
1272
+ {
1273
+ group: "per-plugin WorkflowGatewayClient copies (backend/modules — pre-date the canonical engine-plugin-client class above)",
1274
+ files: () => walkFilesFiltered(path.join(REPO_ROOT, "backend/modules"), (n) => n === "WorkflowGatewayClient.java"),
1275
+ },
1276
+ {
1277
+ group: "per-plugin CommunicationGatewayClient copies (backend/modules — hand-copied pattern, not a shared library)",
1278
+ files: () => walkFilesFiltered(path.join(REPO_ROOT, "backend/modules"), (n) => n === "CommunicationGatewayClient.java"),
1279
+ },
1280
+ ];
1281
+
1282
+ function buildContractsCatalog() {
1283
+ const units = [];
1284
+ for (const { group, files } of CONTRACT_TARGET_GROUPS) {
1285
+ for (const file of files()) {
1286
+ for (const unit of extractJavaContracts(file)) {
1287
+ units.push({ ...unit, group });
1288
+ }
1289
+ }
1290
+ }
1291
+ return {
1292
+ note: "Mechanically-derived from the curated extension-point/gateway-client Java source listed in CONTRACT_TARGET_GROUPS (tools/erp-cli/erp.mjs) — real method signatures and record field lists, NOT a hand-maintained inventory, NOT a full AST parse (same disclosed-heuristic posture as the rest of this catalog). Look up one by (partial) name with `erp platform describe <name>`.",
1293
+ unitCount: units.length,
1294
+ units,
1295
+ };
1296
+ }
1297
+
1298
+ // Look up one class/interface/record by partial, case-insensitive name —
1299
+ // the concrete answer to "what does PendingHumanTask actually contain"
1300
+ // without opening a file.
1301
+ function describeContract(name, { refresh = false } = {}) {
1302
+ const catalog = buildEngineCatalog({ refresh });
1303
+ const q = name.toLowerCase();
1304
+ return (catalog.contracts?.units ?? []).filter((u) => u.name.toLowerCase().includes(q));
1305
+ }
1306
+
1307
+ function formatContractUnit(u) {
1308
+ const lines = [];
1309
+ lines.push(`${u.kind} ${u.name} (${u.file})`);
1310
+ lines.push(` from: ${u.group}`);
1311
+ if (u.fields.length) {
1312
+ lines.push(` fields:`);
1313
+ for (const f of u.fields) lines.push(` ${f.type} ${f.name}`);
1314
+ }
1315
+ if (u.methods.length) {
1316
+ lines.push(` methods:`);
1317
+ for (const m of u.methods) {
1318
+ const params = m.params.map((p) => `${p.type} ${p.name}`).join(", ");
1319
+ lines.push(` ${m.returnType} ${m.name}(${params})`);
1320
+ }
1321
+ }
1322
+ if (!u.fields.length && !u.methods.length) lines.push(` (no public fields/methods found by the scan)`);
1323
+ return lines.join("\n");
1324
+ }
1325
+
1326
+ function platformDescribeCommand(name, opts) {
1327
+ if (!name) throw new Error("usage: erp platform describe <ClassName>");
1328
+ const matches = describeContract(name, { refresh: Boolean(opts.refresh) });
1329
+ if (!matches.length) {
1330
+ console.log(`No contract found matching "${name}". Run \`erp platform catalog --refresh\` if this class was added/renamed recently, or \`erp platform catalog\` to browse module/class names first — this command only covers the curated extension-point/gateway-client set (contribution interfaces, WorkflowGatewayClient, DmsGatewayClient, CommunicationGatewayClient), not every class in the repo.`);
1331
+ return;
1332
+ }
1333
+ console.log(matches.map(formatContractUnit).join("\n\n"));
1334
+ }
1335
+
1336
+ // ---------------------------------------------------------------------------
1337
+ // erp plugin scaffold <extension-point> — [REAL, added 2026-09-04]. Emits a
1338
+ // minimal, correctly-typed Java skeleton for one curated extension point,
1339
+ // pulled from a REAL existing implementation in this codebase (never
1340
+ // invented syntax) — currently only `rest-contribution`, modeled on
1341
+ // hello-plugin's own real HelloRestContribution.java (the simplest genuine
1342
+ // example: @Extension/@RestController/@RequestMapping/controllerBean()).
1343
+ // ---------------------------------------------------------------------------
1344
+ const SCAFFOLD_TEMPLATES = {
1345
+ "rest-contribution": {
1346
+ describe: "A plugin-contributed REST controller (RestContribution) — modeled on backend/modules/hello-plugin/src/main/java/com/example/helloplugin/HelloRestContribution.java, the simplest real shipped example.",
1347
+ render: ({ packageName, className, pluginId }) => `package ${packageName};
1348
+
1349
+ import com.erp.platform.engine.plugin.api.RestContribution;
1350
+ import org.pf4j.Extension;
1351
+ import org.springframework.web.bind.annotation.GetMapping;
1352
+ import org.springframework.web.bind.annotation.RequestMapping;
1353
+ import org.springframework.web.bind.annotation.RestController;
1354
+
1355
+ import java.util.Map;
1356
+
1357
+ /**
1358
+ * Scaffolded by \`erp plugin scaffold rest-contribution\` — modeled on the
1359
+ * real HelloRestContribution.java. The base path MUST start with
1360
+ * /api/v1/plugins/${pluginId}/ — the host's PluginRestBridge rejects any
1361
+ * other prefix (see RestContribution's own doc comment,
1362
+ * \`erp platform describe RestContribution\`).
1363
+ */
1364
+ @Extension
1365
+ @RestController
1366
+ @RequestMapping("/api/v1/plugins/${pluginId}")
1367
+ public class ${className} implements RestContribution {
1368
+
1369
+ @GetMapping("/ping")
1370
+ public Map<String, Object> ping() {
1371
+ return Map.of("pluginId", "${pluginId}", "status", "ok");
1372
+ }
1373
+
1374
+ @Override
1375
+ public Object controllerBean() {
1376
+ return this;
1377
+ }
1378
+ }
1379
+ `,
1380
+ },
1381
+ "seed-data-config": {
1382
+ describe: "An install-lifecycle seed-data file (metadata/seed-data/<name>.json) that ships config rows into the shared platform job-config entities so they travel with the .spk (PluginDataSeedInstaller, idempotent upsert by keyFields). Covers a day_offset reminder, an aggregation roll-up, and a cadence generator. Write the output to backend/modules/<plugin>/spk-assembly/metadata/seed-data/<plugin>-job-configs.json. Schemas: erp_get_schema plugin-seed-data | entity-status-date-sweep-config | entity-aggregation-config | entity-cadence-config. Doc: ai/patterns/plugin-install-data-seeding.md + ai/patterns/generic-aggregation-and-cadence-jobs.md.",
1383
+ render: ({ pluginId }) => JSON.stringify({
1384
+ "//": `Seed file for ${pluginId}. One file per target entity. keyFields = the natural key so re-install upserts, never duplicates. Also ship metadata/entities/*.json for any new column and metadata/rules/*.json AFTER_CREATE {type:EXECUTE_SERVICE,service:ensure<Job>Registered}.`,
1385
+ entity: "entity_status_date_sweep_config",
1386
+ keyFields: ["entity_name", "date_field", "set_field", "set_status_to", "day_offset"],
1387
+ source: pluginId,
1388
+ rows: [
1389
+ { entity_name: "REPLACE_entity", status_field: "status", when_status_in: "ACTIVE", date_field: "REPLACE_expiry_date", compare_op: "lte", day_offset: 30, set_field: "REPLACE_alert_30d", set_status_to: "true", active: true },
1390
+ { "//": "day_offset:0 (or omitted) keeps the original 'date already passed' behavior", entity_name: "REPLACE_entity", status_field: "status", when_status_in: "ACTIVE", date_field: "REPLACE_expiry_date", compare_op: "lt", set_field: "REPLACE_expired_flag", set_status_to: "true", active: true },
1391
+ ],
1392
+ "// see also": {
1393
+ "entity_aggregation_config": { sweep_code: `${pluginId}-rollup`, source_entity: "REPLACE_entity", aggregation: "count", group_by_field: "status", target_entity: "REPLACE_summary_entity", target_key_field: "metric_key", target_key_prefix: "status:", target_value_field: "metric_value", target_timestamp_field: "last_reconciled_at", active: true },
1394
+ "entity_cadence_config": { cadence_code: `${pluginId}-generate`, target_entity: "REPLACE_target_entity", interval_days: 30, dedupe_target_field: "cadence_marker", template_json: { status: "DRAFT", ref_number: "REF-${date}-${ts}" }, active: true },
1395
+ },
1396
+ }, null, 2),
1397
+ },
1398
+ };
1399
+
1400
+ function pluginScaffoldCommand(extensionPoint, opts) {
1401
+ if (!extensionPoint || !SCAFFOLD_TEMPLATES[extensionPoint]) {
1402
+ 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(", ")}`);
1403
+ return;
1404
+ }
1405
+ const tpl = SCAFFOLD_TEMPLATES[extensionPoint];
1406
+ const pluginId = opts.pluginId || "my-plugin";
1407
+ const packageName = opts.package || `com.example.${pluginId.replace(/[^a-zA-Z0-9]/g, "")}`;
1408
+ const className = opts.class || "MyRestContribution";
1409
+ const rendered = tpl.render({ packageName, className, pluginId });
1410
+ if (opts.out) {
1411
+ mkdirSync(path.dirname(opts.out), { recursive: true });
1412
+ writeFileSync(opts.out, rendered, "utf8");
1413
+ console.log(`Wrote ${opts.out}`);
1414
+ } else {
1415
+ console.log(rendered);
1416
+ }
1417
+ }
1418
+
1419
+ // ---------------------------------------------------------------------------
1420
+ // erp plugin create / validate / build / test / publish / push
1421
+ // [REAL] — thin wrappers around tools/spark-cli/spark.js's own
1422
+ // create/validate/package/publish (already-real, already-used-by-agents
1423
+ // commands this whole session) plus tools/page-validator's semantic
1424
+ // validator, so `erp` becomes the one entrypoint per the doc's vision
1425
+ // without duplicating spark.js's logic.
1426
+ // ---------------------------------------------------------------------------
1427
+ function runSpark(args) {
1428
+ const res = spawnSync(process.execPath, [path.join(REPO_ROOT, "tools/spark-cli/spark.js"), ...args], { stdio: "inherit" });
1429
+ if (res.status !== 0) process.exitCode = res.status || 1;
1430
+ }
1431
+
1432
+ function pluginCreateCommand(pluginId, opts) {
1433
+ const args = ["create", pluginId];
1434
+ if (opts.name) args.push("--name", opts.name);
1435
+ if (opts.schema) args.push("--schema", opts.schema);
1436
+ if (opts.category) args.push("--category", opts.category);
1437
+ // type/runtime-mode/service-port (2026-09-03, real gap closed) — `spark create` used
1438
+ // to hardcode type:"business-application" and never set runtimeModes at all, so a
1439
+ // scaffolded plugin silently got the implicit ["embedded"] default with no way to
1440
+ // opt into "service" mode at creation time — see PluginManifest#runtimeModes'
1441
+ // Javadoc (engine-plugin-api) for what these fields actually do at runtime.
1442
+ if (opts.type) args.push("--type", opts.type);
1443
+ if (opts.runtimeMode) args.push("--runtime-mode", opts.runtimeMode);
1444
+ if (opts.servicePort) args.push("--service-port", opts.servicePort);
1445
+ runSpark(args);
1446
+ }
1447
+
1448
+ // ---------------------------------------------------------------------------
1449
+ // erp plugin set-runtime-mode — [REAL, added 2026-09-03, closes a real gap]
1450
+ // `--runtime-mode` on `plugin create` only helps at scaffold time — there was
1451
+ // no way at all for a developer to decide an ALREADY-EXISTING module should
1452
+ // switch to standalone-service deployment via the CLI; the only path was
1453
+ // hand-editing plugin.json's runtimeModes/serviceDeployment fields from
1454
+ // memory (easy to get the nested PluginServiceDeploymentSpec shape wrong —
1455
+ // see engine-plugin-api's own Javadoc). This mutates an existing plugin.json
1456
+ // in place, then re-validates it against the corrected schema before
1457
+ // declaring success — so a malformed edit is caught immediately, not at the
1458
+ // next `erp plugin build`.
1459
+ //
1460
+ // Disclosed, honestly: this only sets the DECLARATION (what the plugin says
1461
+ // it CAN run as, plus its recommended service-mode defaults). Declaring
1462
+ // "service" here does not itself switch anything live — the actual running
1463
+ // mode is chosen per-plugin in Studio's Plugin Runtime toggle
1464
+ // (erp_core.application.deployment_type, V28), same as
1465
+ // PluginManifest#runtimeModes' own Javadoc states. And docker/kubernetes/
1466
+ // cloud fields, if set, are real persisted DATA for a future Deployment
1467
+ // Manager — not wired to any live Docker/Kubernetes/cloud-provider API by
1468
+ // this codebase today (per PluginServiceDeploymentSpec's own doc comment) —
1469
+ // this command will not silently imply otherwise.
1470
+ // ---------------------------------------------------------------------------
1471
+ async function pluginSetRuntimeModeCommand(pluginDir, opts) {
1472
+ if (!pluginDir || !opts.mode) throw new Error("usage: erp plugin set-runtime-mode <plugin-dir> --mode embedded|service [--port <n>] [--health-path <path>] [--timeout-ms <n>]");
1473
+ if (!["embedded", "service"].includes(opts.mode)) throw new Error(`--mode must be "embedded" or "service", got "${opts.mode}"`);
1474
+ const manifestPath = findManifestPath(pluginDir);
1475
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1476
+ const previousModes = manifest.runtimeModes || ["embedded"];
1477
+ manifest.runtimeModes = [opts.mode];
1478
+ if (opts.mode === "service") {
1479
+ const existing = manifest.serviceDeployment || {};
1480
+ manifest.serviceDeployment = {
1481
+ process: { port: opts.port ? Number(opts.port) : existing.process?.port ?? null, startupCommand: existing.process?.startupCommand ?? null, workingDirectory: existing.process?.workingDirectory ?? null, env: existing.process?.env ?? {} },
1482
+ docker: existing.docker ?? null,
1483
+ kubernetes: existing.kubernetes ?? null,
1484
+ cloud: existing.cloud ?? null,
1485
+ healthPath: opts.healthPath || existing.healthPath || "/actuator/health",
1486
+ timeoutMs: opts.timeoutMs ? Number(opts.timeoutMs) : existing.timeoutMs ?? 5000,
1487
+ heartbeatIntervalSeconds: existing.heartbeatIntervalSeconds ?? 10,
1488
+ };
1489
+ }
1490
+ // embedded keeps serviceDeployment as-is (real recommended defaults some admin may
1491
+ // want back if they flip to service again later) rather than deleting it — same
1492
+ // "don't destroy data on a mode toggle" posture as everything else in this file.
1493
+ const schema = JSON.parse(readFileSync(schemaPathByName("plugin-manifest"), "utf8"));
1494
+ const issues = simpleSchemaCheck(manifest, schema, "$");
1495
+ if (issues.length > 0) {
1496
+ throw new Error(`Refusing to write ${manifestPath} — the resulting manifest fails plugin-manifest schema validation:\n ${issues.join("\n ")}`);
1497
+ }
1498
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
1499
+ console.log(`Updated ${manifestPath}: runtimeModes ${JSON.stringify(previousModes)} -> ${JSON.stringify(manifest.runtimeModes)}`);
1500
+ if (opts.mode === "service") console.log(` serviceDeployment.process.port = ${manifest.serviceDeployment.process.port ?? "(not set — pass --port <n>)"}`);
1501
+ console.log(` Note: this only declares what the plugin CAN run as — the actual live mode is chosen per-plugin in Studio's Plugin Runtime toggle; docker/kubernetes/cloud fields (if you add them) are recorded data only, not acted on by this platform.`);
1502
+ }
1503
+
1504
+ function findManifestPath(pluginDir) {
1505
+ const candidates = [path.join(path.resolve(pluginDir), "plugin.json"), path.join(path.resolve(pluginDir), "spk-assembly", "plugin.json")];
1506
+ for (const c of candidates) if (existsSync(c)) return c;
1507
+ throw new Error(`no plugin.json found at ${candidates.join(" or ")}`);
1508
+ }
1509
+
1510
+ function pluginValidateCommand(dir) {
1511
+ requireMonorepo("erp plugin validate");
1512
+ runSpark(["validate", dir]);
1513
+ }
1514
+
1515
+ // ---------------------------------------------------------------------------
1516
+ // erp plugin compile — Code Plugin v0 (2026-09-02)
1517
+ // [REAL] — a thin wrapper around tools/erp-code-plugin-compiler/compile.mjs,
1518
+ // which does the actual TSX -> page-definition JSON compile (TypeScript
1519
+ // Compiler API transpile + sandboxed vm evaluation of @erp/ui's real
1520
+ // runtime — see that file's own doc comment). See
1521
+ // ai/patterns/code-plugin-sdk.md for the full v0 contract/boundary.
1522
+ // ---------------------------------------------------------------------------
1523
+ async function pluginCompileCommand(sourcePath, opts) {
1524
+ if (!sourcePath) {
1525
+ console.error("Usage: erp plugin compile <source.tsx> [--out <page.json>]");
1526
+ process.exitCode = 1;
1527
+ return;
1528
+ }
1529
+ const mod = await import(pathToFileURL(path.join(REPO_ROOT, "tools/erp-code-plugin-compiler/compile.mjs")).href);
1530
+ const result = await mod.compilePlugin(sourcePath);
1531
+ if (result.diagnostics.length) {
1532
+ console.error("Diagnostics:");
1533
+ for (const d of result.diagnostics) console.error(` - ${d}`);
1534
+ }
1535
+ if (opts.out && result.pages.length === 1) {
1536
+ writeFileSync(opts.out, JSON.stringify(result.pages[0].definition, null, 2));
1537
+ console.log(`Wrote ${opts.out}`);
1538
+ } else {
1539
+ if (opts.out) console.error(`${result.pages.length} pages compiled — --out only writes a single-page result; printing all pages instead.`);
1540
+ console.log(JSON.stringify(result.pages, null, 2));
1541
+ }
1542
+ }
1543
+
1544
+ function pluginBuildCommand(dir, opts) {
1545
+ const args = ["package", dir];
1546
+ if (opts.out) args.push("-o", opts.out);
1547
+ runSpark(args);
1548
+ }
1549
+
1550
+ // ---------------------------------------------------------------------------
1551
+ // erp plugin install-bundle — [REAL, added 2026-09-14] real bug found live:
1552
+ // seeding an ApplicationCatalog bundle (e.g. "hcm", 38 real plugin ids) onto
1553
+ // a tenant used to mean 38 individual `erp plugin build` + manual multipart
1554
+ // uploads, in a hand-maintained dependency order that was wrong more than
1555
+ // once. This computes the real order from each plugin's own declared
1556
+ // `plugin.json` `dependencies` (a real topological sort, not a guess) and
1557
+ // drives the SAME real upload endpoint (`POST /api/v1/authoring/plugins/
1558
+ // upload`) every other install path in this file already uses — no new
1559
+ // backend install mechanism, just automation over the existing one.
1560
+ // ---------------------------------------------------------------------------
1561
+
1562
+ /**
1563
+ * Kahn's-algorithm topological sort restricted to the given `pluginIds` set —
1564
+ * a dependency on something NOT in that set (e.g. `hcm-foundation` already
1565
+ * installed some other way) is simply ignored, not an error: this only needs
1566
+ * to order what it's actually about to install relative to each other.
1567
+ * Throws with the exact cycle's members named — a silent infinite loop on
1568
+ * real (never-expected) bad data is worse than a clear error.
1569
+ */
1570
+ function resolveInstallOrder(pluginIds, dependenciesOf) {
1571
+ const idSet = new Set(pluginIds);
1572
+ const inDegree = new Map(pluginIds.map((id) => [id, 0]));
1573
+ const dependents = new Map(pluginIds.map((id) => [id, []]));
1574
+ for (const id of pluginIds) {
1575
+ for (const dep of dependenciesOf(id)) {
1576
+ if (!idSet.has(dep)) continue;
1577
+ inDegree.set(id, inDegree.get(id) + 1);
1578
+ dependents.get(dep).push(id);
1579
+ }
1580
+ }
1581
+ const queue = pluginIds.filter((id) => inDegree.get(id) === 0);
1582
+ const order = [];
1583
+ while (queue.length > 0) {
1584
+ const id = queue.shift();
1585
+ order.push(id);
1586
+ for (const dependent of dependents.get(id)) {
1587
+ inDegree.set(dependent, inDegree.get(dependent) - 1);
1588
+ if (inDegree.get(dependent) === 0) queue.push(dependent);
1589
+ }
1590
+ }
1591
+ if (order.length !== pluginIds.length) {
1592
+ const stuck = pluginIds.filter((id) => !order.includes(id));
1593
+ throw new Error(`dependency cycle detected among: ${stuck.join(", ")}`);
1594
+ }
1595
+ return order;
1596
+ }
1597
+
1598
+ async function pluginBundleInstallCommand(applicationKey, opts) {
1599
+ if (!applicationKey) throw new Error("usage: erp plugin install-bundle <applicationKey> [--tenant <id>] [--continue-on-error]");
1600
+ const cfg = loadConfig();
1601
+ const env = currentEnv(cfg);
1602
+ const baseUrl = opts.url || env.baseUrl;
1603
+
1604
+ let tenantId = opts.tenant;
1605
+ if (!tenantId) {
1606
+ const platformTenant = await api(cfg, "GET", "/api/v1/platform-tenant", { requireAuth: false });
1607
+ tenantId = platformTenant.tenantId;
1608
+ console.log(`No --tenant given — defaulting to the platform tenant (id ${tenantId}).`);
1609
+ }
1610
+
1611
+ const catalog = await api(cfg, "GET", `/tenants/application-catalog/${encodeURIComponent(applicationKey)}`, { requireAuth: false, tenantIdOverride: tenantId });
1612
+ const pluginIds = catalog.pluginIds;
1613
+ if (!pluginIds || pluginIds.length === 0) {
1614
+ console.log(`Application catalog key "${applicationKey}" has no plugin members — nothing to install.`);
1615
+ return;
1616
+ }
1617
+ console.log(`"${applicationKey}" bundle: ${pluginIds.length} plugins.`);
1618
+
1619
+ const manifestOf = (id) => JSON.parse(readFileSync(path.join(REPO_ROOT, "backend/modules", id, "spk-assembly/plugin.json"), "utf8"));
1620
+ const order = resolveInstallOrder(pluginIds, (id) => manifestOf(id).dependencies || []);
1621
+ console.log(`Install order (dependency-resolved): ${order.join(" -> ")}`);
1622
+
1623
+ const actor = opts.actor || "erp-plugin-bundle-install";
1624
+ const results = [];
1625
+ for (const id of order) {
1626
+ const moduleDir = path.join(REPO_ROOT, "backend/modules", id, "spk-assembly");
1627
+ const manifest = manifestOf(id);
1628
+ const spkPath = path.join(os.tmpdir(), `${id}-${manifest.version}.spk`);
1629
+ process.stdout.write(`Building ${id} v${manifest.version}... `);
1630
+ runSpark(["package", moduleDir, "-o", spkPath]);
1631
+ if (!existsSync(spkPath)) {
1632
+ const message = `spark package did not produce ${spkPath}`;
1633
+ console.log(`BUILD FAILED: ${message}`);
1634
+ results.push({ id, ok: false, message });
1635
+ if (!opts.continueOnError) break;
1636
+ continue;
1637
+ }
1638
+ const form = new FormData();
1639
+ form.set("file", new Blob([readFileSync(spkPath)]), path.basename(spkPath));
1640
+ const res = await fetch(`${baseUrl}/api/v1/authoring/plugins/upload`, {
1641
+ method: "POST",
1642
+ headers: { "X-Tenant-Id": String(tenantId), "X-Actor": actor },
1643
+ body: form,
1644
+ });
1645
+ const text = await res.text();
1646
+ if (res.ok) {
1647
+ console.log(`OK`);
1648
+ results.push({ id, ok: true });
1649
+ } else if (res.status === 409) {
1650
+ // Already installed at this exact version — the platform's own
1651
+ // idempotency, not a failure. Same "resume = just re-run" posture
1652
+ // every other install path in this codebase already relies on.
1653
+ console.log(`already installed (skipped)`);
1654
+ results.push({ id, ok: true, skipped: true });
1655
+ } else {
1656
+ let message = text;
1657
+ try {
1658
+ message = JSON.parse(text).message || text;
1659
+ } catch {
1660
+ // plain text body — use as-is
1661
+ }
1662
+ console.log(`FAILED (HTTP ${res.status}): ${message}`);
1663
+ results.push({ id, ok: false, message });
1664
+ if (!opts.continueOnError) break;
1665
+ }
1666
+ }
1667
+
1668
+ const succeeded = results.filter((r) => r.ok).length;
1669
+ const failed = results.filter((r) => !r.ok);
1670
+ const pending = order.length - results.length;
1671
+ console.log(`\n${succeeded}/${order.length} succeeded${pending > 0 ? `, ${pending} not attempted` : ""}${failed.length > 0 ? `, ${failed.length} failed` : ""}.`);
1672
+ if (failed.length > 0) {
1673
+ console.log(`Failed: ${failed.map((r) => r.id).join(", ")}`);
1674
+ console.log(`Re-run the same command to resume — already-installed plugins are skipped automatically.`);
1675
+ process.exitCode = 1;
1676
+ }
1677
+ }
1678
+
1679
+ // ---------------------------------------------------------------------------
1680
+ // lintPageSemantics — [REAL, added 2026-09-03] catches the exact bug-class a
1681
+ // live hcm-learning module shipped module-wide: pages that are 100% schema-
1682
+ // valid (pass validate-page-properties.mjs) but semantically wrong because a
1683
+ // field uses the wrong block for its job. Structural validation checks "is
1684
+ // this legal"; this checks "is this the right block" — three checks, each
1685
+ // tied to a real, confirmed platform convention:
1686
+ // 1. A field whose name ends in `_id` (a foreign-key-shaped column, per
1687
+ // the entity's own `_id` naming convention already used everywhere in
1688
+ // this codebase) rendered as core.text-input/core.number-input instead
1689
+ // of core.lookup/core.select/core.autocomplete.
1690
+ // 2. A field on an entity column typed date/datetime (cross-referenced
1691
+ // against the plugin's own entity-definition JSON when metadata.entityDefinition
1692
+ // names one) rendered as core.text-input instead of core.date-picker.
1693
+ // 3. A page with a filter-bar-shaped control (a core.text-input/core.select
1694
+ // outside the grid whose committed handler writes `page.filter*`) whose
1695
+ // core.grid sibling never binds `search`/`externalFilter` — the exact
1696
+ // "filters are decorative" bug.
1697
+ // Heuristic, not exhaustive — false negatives are expected (this catches the
1698
+ // known bug shapes, it is not a general AI reviewer); it reports WARN, not
1699
+ // FAIL, since a real exception (a genuinely free-text external reference id,
1700
+ // a deliberately unfiltered grid) is legitimate and this cannot tell the
1701
+ // difference — a human/agent reviews each WARN, same as any lint tool.
1702
+ // ---------------------------------------------------------------------------
1703
+ function lintPageSemantics(pageJson, entityFieldTypes) {
1704
+ const warnings = [];
1705
+ const instances = [];
1706
+ (function walk(node) {
1707
+ if (!node || typeof node !== "object") return;
1708
+ if (node.blockType) instances.push(node);
1709
+ for (const v of Object.values(node)) {
1710
+ if (Array.isArray(v)) v.forEach(walk);
1711
+ else if (v && typeof v === "object") walk(v);
1712
+ }
1713
+ })(pageJson.rows || pageJson.definition?.rows || pageJson);
1714
+
1715
+ const bindingKeyOf = (props, name) => {
1716
+ const p = props?.[name];
1717
+ if (p?.source === "binding" && p.binding?.scope === "page") return p.binding.key;
1718
+ return undefined;
1719
+ };
1720
+ const filterKeys = new Set();
1721
+ let gridInstance = null;
1722
+ for (const inst of instances) {
1723
+ const props = inst.properties || {};
1724
+ if (inst.blockType === "core.grid") gridInstance = inst;
1725
+ if ((inst.blockType === "core.text-input" || inst.blockType === "core.select") && inst.instanceId?.startsWith("filter-")) {
1726
+ const key = bindingKeyOf(props, "value");
1727
+ if (key) filterKeys.add(key);
1728
+ }
1729
+ if (inst.blockType === "core.text-input" || inst.blockType === "core.number-input") {
1730
+ const key = bindingKeyOf(props, "value");
1731
+ if (!key) continue;
1732
+ const fieldName = String(key).replace(/^form/, "").replace(/^./, (c) => c.toLowerCase());
1733
+ const entityType = entityFieldTypes?.[toSnakeGuess(fieldName)] || entityFieldTypes?.[toSnakeGuess(key)];
1734
+ if (/_?[Ii]d$/.test(key) && key !== "id") {
1735
+ warnings.push(`WARN [lookup-missing] "${inst.instanceId}" (${inst.blockType}, bound to page.${key}) looks like a foreign-key field (name ends in Id) but isn't core.lookup/core.select/core.autocomplete — see \`erp examples patterns\` "related-record-lookup-field".`);
1736
+ } else if (entityType === "date" || entityType === "datetime") {
1737
+ warnings.push(`WARN [date-as-text] "${inst.instanceId}" (${inst.blockType}, bound to page.${key}) is a date/datetime field rendered as plain text — use core.date-picker instead.`);
1738
+ }
1739
+ }
1740
+ }
1741
+ if (filterKeys.size > 0) {
1742
+ const props = gridInstance?.properties || {};
1743
+ const hasSearch = props.search !== undefined;
1744
+ const hasExternalFilter = props.externalFilter !== undefined;
1745
+ if (!gridInstance) warnings.push(`WARN [filters-decorative] page has ${filterKeys.size} filter control(s) (${[...filterKeys].join(", ")}) but no core.grid block was found to wire them to.`);
1746
+ else if (!hasSearch && !hasExternalFilter) warnings.push(`WARN [filters-decorative] core.grid "${gridInstance.instanceId}" has no search/externalFilter property bound — ${filterKeys.size} filter control(s) (${[...filterKeys].join(", ")}) currently do nothing to the grid's data. See crm-leads.json's grid block for the real wiring.`);
1747
+ }
1748
+ return warnings;
1749
+ }
1750
+ function toSnakeGuess(camel) { return String(camel).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); }
1751
+
1752
+ function loadEntityFieldTypes(absDir, entityDefName) {
1753
+ if (!entityDefName) return {};
1754
+ const p = path.join(absDir, "metadata", "entities", `${entityDefName}.json`);
1755
+ if (!existsSync(p)) return {};
1756
+ try {
1757
+ const j = JSON.parse(readFileSync(p, "utf8"));
1758
+ const fields = j.definition?.fields || j.fields || [];
1759
+ const out = {};
1760
+ for (const f of fields) if (f.fieldName) out[f.fieldName] = String(f.dataType || "").toLowerCase();
1761
+ return out;
1762
+ } catch { return {}; }
1763
+ }
1764
+
1765
+ // lintDataServiceSemantics — [REAL, added 2026-09-04] catches, at author
1766
+ // time, the exact class of bug found live building HCM Talent Batch 1
1767
+ // (ai/domains/hcm-talent-management.md): an entity-backed Data Service
1768
+ // filter with a literal `"true"`/`"false"` value targeting a field the
1769
+ // entity itself declares `dataType: boolean` used to be bound as a raw
1770
+ // String against a real boolean column — BiReportQueryExecutor's own
1771
+ // type-coercion bug, now fixed at the platform level (see that same domain
1772
+ // doc), but a mismatched literal is also just as easily a genuine AUTHORING
1773
+ // mistake (e.g. `"1"` against a boolean field, or `"true"` against a text
1774
+ // field that happens to store the words "true"/"false") that the platform
1775
+ // fix does nothing to prevent going forward. This is the static,
1776
+ // pre-deploy half of that fix — per the standing "validation, not just
1777
+ // runtime debugging" principle: whatever can be checked from the JSON
1778
+ // alone, should be, before a developer ever needs `erp logs tail` to find
1779
+ // out something was wrong. Heuristic like lintPageSemantics — WARNs, since
1780
+ // a legitimately non-boolean field storing literal "true"/"false" text is
1781
+ // rare but not impossible.
1782
+ function lintDataServiceSemantics(dsJson, entityFieldTypes) {
1783
+ const warnings = [];
1784
+ const def = dsJson.definition;
1785
+ if (!def) return warnings;
1786
+ const filterSets = [];
1787
+ if (Array.isArray(def.filters)) filterSets.push(def.filters);
1788
+ if (Array.isArray(def.steps)) {
1789
+ // A composite data-service's own steps don't carry filters directly in
1790
+ // this JSON (they reference another named data-service) — nothing to
1791
+ // check here without resolving the referenced file, which this plugin-
1792
+ // local lint deliberately doesn't do (out of scope: cross-file
1793
+ // resolution). Composite services are skipped, not falsely flagged.
1794
+ }
1795
+ for (const filters of filterSets) {
1796
+ for (const f of filters) {
1797
+ if (!f.field || f.value === undefined || f.value === null) continue;
1798
+ const fieldType = entityFieldTypes[f.field];
1799
+ if (!fieldType) continue; // unknown field (not this entity's own, or entity type not resolvable here) — not this lint's job
1800
+ const looksBoolean = f.value === "true" || f.value === "false";
1801
+ if (fieldType === "boolean" && !looksBoolean && typeof f.value === "string" && !f.value.startsWith("${param.")) {
1802
+ warnings.push(`WARN [filter-type-mismatch] filter on "${f.field}" (a boolean field) has literal value ${JSON.stringify(f.value)} — expected "true"/"false".`);
1803
+ } else if (looksBoolean && fieldType !== "boolean") {
1804
+ warnings.push(`WARN [filter-type-mismatch] filter on "${f.field}" (declared dataType "${fieldType}", not boolean) has literal value ${JSON.stringify(f.value)}, which looks like a boolean literal — confirm this is intentional.`);
1805
+ }
1806
+ }
1807
+ }
1808
+ return warnings;
1809
+ }
1810
+
1811
+ // lintI18nSemantics — I18N-001 [REAL, added 2026-09-03, HCM Compensation
1812
+ // Batch 2] catches, at author time, the exact bug class found live building
1813
+ // this very batch: a page JSON referencing an i18n key (`labelKey`,
1814
+ // `headerKey`, `textKey`, `titleKey`, `displayNameKey`, `descriptionKey`, or
1815
+ // a `select`/`lookup` option's own `labelKey`) that has NO entry at all in
1816
+ // the plugin's own `metadata/i18n/*.json` locale files — the runtime then
1817
+ // renders the raw key wrapped in `⟦...⟧` delimiters instead of real text,
1818
+ // exactly what shipped (briefly) on this batch's own allowances/benefits
1819
+ // pages before being caught by Playwright, not by any pre-deploy tool. This
1820
+ // closes that gap the same way `DS-001` (`lintDataServiceSemantics`) closed
1821
+ // its own: a static, pre-deploy, JSON-only check — no server round-trip
1822
+ // needed to know a key is missing.
1823
+ //
1824
+ // A second, heuristic check (still under the I18N-001 umbrella, reported as
1825
+ // its own `[i18n-placeholder]` tag) flags a value that is EXACTLY the
1826
+ // mechanical Title-Case-with-spaces rendering of the key's own last
1827
+ // camelCase/dotted segment — e.g. `"newBtn": "New Btn"` or
1828
+ // `"chart.distributionHeading": "Distribution Heading"` — the real
1829
+ // regression class found in HCM Talent Management Batch 2 (see
1830
+ // ai/domains/hcm-talent-management-module-deferred-backlog.md). This is
1831
+ // necessarily a heuristic (a field like `column.status` legitimately having
1832
+ // the value "Status" looks identical to a lazy placeholder) so it WARNs,
1833
+ // never FAILs, exactly like every other semantic lint in this file — a
1834
+ // human/agent reviews each WARN and decides whether the real copy already
1835
+ // happens to match, same posture as `lintPageSemantics`/`lintDataServiceSemantics`.
1836
+ function extractI18nKeyRefs(node, out) {
1837
+ if (Array.isArray(node)) { for (const v of node) extractI18nKeyRefs(v, out); return; }
1838
+ if (!node || typeof node !== "object") return;
1839
+ const KEY_PROPS = ["labelKey", "headerKey", "textKey", "titleKey", "displayNameKey", "descriptionKey"];
1840
+ for (const [k, v] of Object.entries(node)) {
1841
+ if (KEY_PROPS.includes(k)) {
1842
+ // Every block property in this contract is wrapped
1843
+ // {"source":"static"|"binding"|"expression", "value"/"binding":...} —
1844
+ // a bare string here would mean a hand-authored page skipped the
1845
+ // wrapper, so both shapes are accepted defensively.
1846
+ if (typeof v === "string" && v.length > 0) out.add(v);
1847
+ else if (v && typeof v === "object" && v.source === "static" && typeof v.value === "string" && v.value.length > 0) out.add(v.value);
1848
+ }
1849
+ extractI18nKeyRefs(v, out);
1850
+ }
1851
+ }
1852
+ function mechanicalPlaceholder(key, value) {
1853
+ const lastSegment = key.split(".").pop();
1854
+ // Only camelCase-compound tails ending in a known abbreviation/compound
1855
+ // suffix (Btn, Heading, Msg, Label, Toast) are worth flagging — these are
1856
+ // the shapes that actually shipped as real regressions ("newBtn" -> "New
1857
+ // Btn", "distributionHeading" -> "Distribution Heading"). A single plain
1858
+ // word tail ("status" -> "Status", "category" -> "Category") is normal,
1859
+ // correct field-label copy, not a placeholder bug — never flagged.
1860
+ if (!/[a-z](Btn|Heading|Msg|Label|Toast)$/.test(lastSegment)) return false;
1861
+ const words = lastSegment
1862
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase -> spaced
1863
+ .replace(/[_-]+/g, " ")
1864
+ .trim()
1865
+ .split(/\s+/)
1866
+ .filter(Boolean);
1867
+ if (words.length < 2) return false;
1868
+ const mechanical = words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
1869
+ return value === mechanical;
1870
+ }
1871
+ // showToast's `message` config is LITERAL display text — never resolved through i18n
1872
+ // (only labelKey/headerKey/textKey/titleKey/displayNameKey/descriptionKey are). Authoring
1873
+ // a dotted key there (e.g. "my-plugin.page.toast.saved") renders that raw key string to the
1874
+ // user verbatim, with no error anywhere pre-deploy — found live authoring hcm-communications
1875
+ // (25 occurrences across all 5 pages, invisible until a real Playwright click surfaced the
1876
+ // raw key text in a toast). Flag any showToast `message` that both looks like a dotted key
1877
+ // AND actually resolves in this plugin's own i18n map (strong signal of exactly this mistake,
1878
+ // not a coincidental literal string with dots in it).
1879
+ function extractToastMessageMistakes(node, i18nKeys, warnings) {
1880
+ if (Array.isArray(node)) { for (const v of node) extractToastMessageMistakes(v, i18nKeys, warnings); return; }
1881
+ if (!node || typeof node !== "object") return;
1882
+ if (node.type === "showToast" && node.config && typeof node.config.message === "string") {
1883
+ const msg = node.config.message;
1884
+ if (/^[a-z][a-z0-9-]*(\.[a-zA-Z0-9_-]+){2,}$/.test(msg) && i18nKeys.has(msg)) {
1885
+ warnings.push(`WARN [i18n-toast-key-not-resolved] showToast config.message = "${msg}" looks like an i18n key and one exists with that name in metadata/i18n/*.json, but showToast's "message" is literal display text (no i18n lookup) — it will render as the raw key string "${msg}" to the user. Use the actual text directly, e.g. ${JSON.stringify(i18nKeys.get(msg))}.`);
1886
+ }
1887
+ }
1888
+ for (const v of Object.values(node)) extractToastMessageMistakes(v, i18nKeys, warnings);
1889
+ }
1890
+ function lintI18nSemantics(pageJson, i18nKeys) {
1891
+ const warnings = [];
1892
+ extractToastMessageMistakes(pageJson.rows || pageJson.definition?.rows || pageJson, i18nKeys, warnings);
1893
+ const refs = new Set();
1894
+ extractI18nKeyRefs(pageJson.rows || pageJson.definition?.rows || pageJson, refs);
1895
+ const topLevel = pageJson.definition || pageJson;
1896
+ if (typeof topLevel.title === "string") refs.add(topLevel.title);
1897
+ if (topLevel.designer) {
1898
+ for (const k of ["displayNameKey", "descriptionKey"]) {
1899
+ if (typeof topLevel.designer[k] === "string") refs.add(topLevel.designer[k]);
1900
+ }
1901
+ }
1902
+ for (const key of refs) {
1903
+ if (!i18nKeys.has(key)) {
1904
+ warnings.push(`WARN [i18n-missing-key] page references i18n key "${key}" which has no entry in metadata/i18n/*.json — will render as a raw ⟦${key}⟧ placeholder at runtime.`);
1905
+ continue;
1906
+ }
1907
+ const value = i18nKeys.get(key);
1908
+ if (typeof value === "string" && mechanicalPlaceholder(key, value)) {
1909
+ warnings.push(`WARN [i18n-placeholder] "${key}" = ${JSON.stringify(value)} looks like a mechanically Title-Cased key tail, not real copy (the "New Btn"/"X Heading" regression class) — consider a real label ("New Btn" -> "New Allowance", "X Heading" -> real section heading).`);
1910
+ }
1911
+ }
1912
+ return warnings;
1913
+ }
1914
+ function loadI18nKeys(absDir) {
1915
+ const i18nDir = path.join(absDir, "metadata", "i18n");
1916
+ const map = new Map();
1917
+ let files = [];
1918
+ try { files = readdirSync(i18nDir).filter((f) => f.endsWith(".json")); } catch { return map; }
1919
+ for (const f of files) {
1920
+ try {
1921
+ const j = JSON.parse(readFileSync(path.join(i18nDir, f), "utf8"));
1922
+ for (const [k, v] of Object.entries(j)) if (!map.has(k)) map.set(k, v);
1923
+ } catch { /* malformed locale file — reported separately, not this lint's job */ }
1924
+ }
1925
+ return map;
1926
+ }
1927
+
1928
+ // lintPageDataSourceSemantics — PAGE-DS-001 [REAL, added 2026-09-04] closes
1929
+ // the exact gap disclosed in
1930
+ // ai/domains/ai-native-developer-platform-gap-analysis.md's "core.grid's
1931
+ // real data-source wiring" section: `core.grid`'s own block-property
1932
+ // contract has NO `dataSource`/`entity`/`recordType` property at all — the
1933
+ // real wiring is a PAGE-level top-level `"metadata": {"dataSource":
1934
+ // "<provider-name>"}` field (a sibling of "definition", not nested inside
1935
+ // it). A page with a core.grid block that omits/empties this field, or
1936
+ // names a provider that doesn't resolve, silently renders "0 rows" with
1937
+ // ZERO error — this shipped for real, three separate times, on the
1938
+ // hcm-employee-documents plugin alone before a live Playwright run caught
1939
+ // it. `erp plugin test`/`erp validate`/`erp workflow validate` all passed
1940
+ // cleanly every one of those times; this is the static, pre-deploy check
1941
+ // that would have caught it — same "validation, not just runtime
1942
+ // debugging" posture DS-001/I18N-001 already established.
1943
+ //
1944
+ // Two checks:
1945
+ // 1. [page-ds-missing] — a page whose rows (INCLUDING every responsive
1946
+ // variant's own rows) contain a core.grid block instance, but whose
1947
+ // top-level metadata.dataSource is missing or an empty string.
1948
+ // 2. [page-ds-unresolved] — metadata.dataSource IS set, but no
1949
+ // metadata/provider/*.json file (same plugin, or any OTHER already-
1950
+ // shipped plugin under backend/modules/ — a cross-plugin provider is
1951
+ // a real, valid case) has a "name" field matching it byte-for-byte.
1952
+ // Both WARN, not FAIL, for the same reason every other semantic lint in
1953
+ // this file does: a provider genuinely supplied by an install-time/runtime
1954
+ // mechanism this static scan can't see is a legitimate (if rare) exception,
1955
+ // and a human/agent should review each warning rather than have the build
1956
+ // hard-fail on it.
1957
+ function pageHasCoreGrid(pageJson) {
1958
+ let found = false;
1959
+ (function walk(node) {
1960
+ if (found || !node || typeof node !== "object") return;
1961
+ if (node.blockType === "core.grid") { found = true; return; }
1962
+ for (const v of Object.values(node)) {
1963
+ if (found) return;
1964
+ if (Array.isArray(v)) v.forEach(walk);
1965
+ else if (v && typeof v === "object") walk(v);
1966
+ }
1967
+ })(pageJson);
1968
+ return found;
1969
+ }
1970
+ function lintPageDataSourceSemantics(pageJson, providerNamesSamePlugin, providerNamesRepoWide) {
1971
+ const warnings = [];
1972
+ if (!pageHasCoreGrid(pageJson)) return warnings;
1973
+ const dataSource = pageJson.metadata?.dataSource;
1974
+ if (typeof dataSource !== "string" || dataSource.length === 0) {
1975
+ warnings.push(
1976
+ `WARN [page-ds-missing] page has a core.grid block but its top-level "metadata.dataSource" is missing/empty — ` +
1977
+ `the grid has no provider to resolve, so it will silently render 0 rows at runtime with NO error (not a ` +
1978
+ `validation failure). Set metadata.dataSource to a real metadata/provider/*.json "name". See ai/patterns/grid-data-source-wiring.md.`,
1979
+ );
1980
+ return warnings;
1981
+ }
1982
+ const resolvedSamePlugin = providerNamesSamePlugin.has(dataSource);
1983
+ const resolvedElsewhere = providerNamesRepoWide?.has(dataSource) ?? false;
1984
+ if (!resolvedSamePlugin && !resolvedElsewhere) {
1985
+ warnings.push(
1986
+ `WARN [page-ds-unresolved] page's metadata.dataSource "${dataSource}" does not match any metadata/provider/*.json ` +
1987
+ `"name" field in this plugin (checked every other shipped plugin under backend/modules/ too, still unresolved) — ` +
1988
+ `a byte-for-byte name mismatch here produces the identical silent "0 rows, no error" symptom as a missing dataSource. ` +
1989
+ `See ai/patterns/grid-data-source-wiring.md.`,
1990
+ );
1991
+ }
1992
+ return warnings;
1993
+ }
1994
+ function loadProviderNamesFrom(providerDir) {
1995
+ const names = new Set();
1996
+ let files = [];
1997
+ try { files = readdirSync(providerDir).filter((f) => f.endsWith(".json")); } catch { return names; }
1998
+ for (const f of files) {
1999
+ try {
2000
+ const j = JSON.parse(readFileSync(path.join(providerDir, f), "utf8"));
2001
+ if (typeof j.name === "string" && j.name.length > 0) names.add(j.name);
2002
+ } catch { /* malformed provider file — not this lint's job */ }
2003
+ }
2004
+ return names;
2005
+ }
2006
+ // Cheap, cache-once, purely local (no live API call — an honest scope trim
2007
+ // disclosed in the gap-analysis doc's own PAGE-DS-001 item 2) scan of every
2008
+ // OTHER already-shipped plugin's own provider names, so a real cross-plugin
2009
+ // dataSource reference isn't false-flagged as unresolved.
2010
+ let _allRepoProviderNamesCache = null;
2011
+ function loadAllRepoProviderNames() {
2012
+ if (_allRepoProviderNamesCache) return _allRepoProviderNamesCache;
2013
+ const names = new Set();
2014
+ const modulesDir = path.join(REPO_ROOT, "backend", "modules");
2015
+ let moduleDirs = [];
2016
+ try { moduleDirs = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { /* not a full checkout */ }
2017
+ for (const m of moduleDirs) {
2018
+ const providerDir = path.join(modulesDir, m, "spk-assembly", "metadata", "provider");
2019
+ for (const n of loadProviderNamesFrom(providerDir)) names.add(n);
2020
+ }
2021
+ _allRepoProviderNamesCache = names;
2022
+ return names;
2023
+ }
2024
+
2025
+ // lintJobContributionUsage — JOB-001 [REAL, added 2026-09-04] — standing rule
2026
+ // [[feedback-json-first-scheduled-jobs]] / ai/domains/project-generic-erp-job-engine-target-architecture.md:
2027
+ // "one generic config-driven sweep handler, not a job class per need." A new
2028
+ // bespoke `@Extension implements ErpJobContribution` class is the sanctioned
2029
+ // escape hatch for genuinely complex job logic (same "Java is the escape
2030
+ // hatch for real complexity" posture as everywhere else in this codebase),
2031
+ // but it should be a deliberate choice, not a silent default — this makes
2032
+ // that tradeoff visible in `erp plugin test` output instead of only living
2033
+ // in a human's memory of the standing rule. Scan is deliberately a plain
2034
+ // regex over each plugin's OWN `src/main/java/**/*.java` (same rigor as
2035
+ // PAGE-DS-001/DS-001/I18N-001 — no AST, no "is this logic complex enough"
2036
+ // classifier; that judgment call is for the reader, per the warning's own
2037
+ // message). WARN, not FAIL — several real jobs in this codebase (the
2038
+ // *EffectiveDateJob family, generic sweep engines themselves) legitimately
2039
+ // need custom Java; this only asks the author to have actually considered
2040
+ // the JSON-first alternative first.
2041
+ function lintJobContributionUsage(javaDir) {
2042
+ const warnings = [];
2043
+ const javaFiles = walkFilesFiltered(javaDir, (name) => name.endsWith(".java"));
2044
+ for (const file of javaFiles) {
2045
+ let src;
2046
+ try { src = readFileSync(file, "utf8"); } catch { continue; }
2047
+ if (!/\bimplements\b[^{;]*\bErpJobContribution\b/.test(src)) continue;
2048
+ const classMatch = src.match(/\bclass\s+(\w+)\s+implements\b[^{;]*\bErpJobContribution\b/);
2049
+ const className = classMatch ? classMatch[1] : path.basename(file, ".java");
2050
+ warnings.push(
2051
+ `WARN [JOB-001] New scheduled-job class found: \`${className}\` (${path.relative(REPO_ROOT, file)}). Prefer a JSON job ` +
2052
+ `definition against the generic sweep engine (see ai/domains/project-generic-erp-job-engine-target-architecture.md / ` +
2053
+ `[[feedback-json-first-scheduled-jobs]] — e.g. EntityStatusDateSweepJob's entity_status_date_sweep_config for an ` +
2054
+ `"entity status/date -> flip a field" shape, WorkflowReminderSweepJob's workflow_reminder_config for a "remind on a ` +
2055
+ `stale workflow task" shape) unless this job's logic is genuinely too complex to express declaratively — if so, ` +
2056
+ `document why in a comment on the class.`,
2057
+ );
2058
+ }
2059
+ return warnings;
2060
+ }
2061
+
2062
+ async function pluginTestCommand(dir) {
2063
+ requireMonorepo("erp plugin test");
2064
+ // [REAL, narrow] — the doc's own §10 mockup ("Schema validation / Page
2065
+ // validation / Permission validation / ... 42 passed"). This runs the
2066
+ // REAL checks that exist today (page schema+semantics via
2067
+ // validate-page-properties.mjs, plugin.json JSON-validity) and reports
2068
+ // them in that shape; checks the doc lists that have no real engine yet
2069
+ // (workflow validation beyond structural JSON, security-rule scanning,
2070
+ // responsive-configuration linting, AI-tool-permission checks, full
2071
+ // regression tests) are reported as SKIPPED, not fabricated as passing.
2072
+ const absDir = path.resolve(dir);
2073
+ const mod = await import(pathToFileURL(path.join(REPO_ROOT, "tools/page-validator/validate-page-properties.mjs")).href);
2074
+ const registry = await mod.buildRegistry();
2075
+ const pageDir = path.join(absDir, "metadata", "page");
2076
+ let pageFiles = [];
2077
+ try {
2078
+ pageFiles = readdirSync(pageDir).filter((f) => f.endsWith(".json"));
2079
+ } catch { /* no pages */ }
2080
+ let passed = 0, failed = 0, warned = 0;
2081
+ const results = [];
2082
+ const i18nKeys = loadI18nKeys(absDir);
2083
+ const providerNamesSamePlugin = loadProviderNamesFrom(path.join(absDir, "metadata", "provider"));
2084
+ for (const f of pageFiles) {
2085
+ const issues = mod.validatePage(path.join(pageDir, f), registry);
2086
+ if (issues.length === 0) { passed++; results.push({ check: `page:${f}`, status: "PASS" }); }
2087
+ else { failed++; results.push({ check: `page:${f}`, status: "FAIL", issues }); }
2088
+ // page-semantic-lint (2026-09-03, REAL — replaces the old always-SKIPPED
2089
+ // "responsive-configuration-lint" placeholder below with an actual check):
2090
+ // catches schema-valid-but-semantically-wrong field choices (plain text
2091
+ // instead of core.lookup/core.date-picker, decorative filter bars) — see
2092
+ // lintPageSemantics' own doc comment for the exact bug class this closes.
2093
+ try {
2094
+ const pageJson = JSON.parse(readFileSync(path.join(pageDir, f), "utf8"));
2095
+ const entityTypes = loadEntityFieldTypes(absDir, pageJson.metadata?.entityDefinition);
2096
+ const warnings = lintPageSemantics(pageJson, entityTypes);
2097
+ if (warnings.length > 0) { warned += warnings.length; results.push({ check: `page-semantic-lint:${f}`, status: "WARN", issues: warnings }); }
2098
+ } catch (e) {
2099
+ results.push({ check: `page-semantic-lint:${f}`, status: "SKIPPED", note: `could not lint: ${e.message}` });
2100
+ }
2101
+ // i18n-semantic-lint (I18N-001, 2026-09-03, REAL) — see lintI18nSemantics'
2102
+ // own doc comment for the exact bug class this catches pre-deploy: a
2103
+ // page referencing an i18n key with no en.json entry (renders as a raw
2104
+ // ⟦key⟧ at runtime), or a mechanically Title-Cased placeholder value.
2105
+ try {
2106
+ const pageJson = JSON.parse(readFileSync(path.join(pageDir, f), "utf8"));
2107
+ const warnings = lintI18nSemantics(pageJson, i18nKeys);
2108
+ if (warnings.length > 0) { warned += warnings.length; results.push({ check: `i18n-semantic-lint:${f}`, status: "WARN", issues: warnings }); }
2109
+ } catch (e) {
2110
+ results.push({ check: `i18n-semantic-lint:${f}`, status: "SKIPPED", note: `could not lint: ${e.message}` });
2111
+ }
2112
+ // page-datasource-lint (PAGE-DS-001, 2026-09-04, REAL) — see
2113
+ // lintPageDataSourceSemantics' own doc comment for the exact bug class
2114
+ // this catches pre-deploy: a core.grid block whose page never sets (or
2115
+ // mis-names) the page-level metadata.dataSource that actually wires it
2116
+ // to a real Data Provider — silently 0 rows, no error, at runtime.
2117
+ try {
2118
+ const pageJson = JSON.parse(readFileSync(path.join(pageDir, f), "utf8"));
2119
+ const warnings = lintPageDataSourceSemantics(pageJson, providerNamesSamePlugin, loadAllRepoProviderNames());
2120
+ if (warnings.length > 0) { warned += warnings.length; results.push({ check: `page-datasource-lint:${f}`, status: "WARN", issues: warnings }); }
2121
+ } catch (e) {
2122
+ results.push({ check: `page-datasource-lint:${f}`, status: "SKIPPED", note: `could not lint: ${e.message}` });
2123
+ }
2124
+ }
2125
+ // data-service-semantic-lint (2026-09-04, REAL) — see lintDataServiceSemantics'
2126
+ // own doc comment for the exact bug class this catches pre-deploy.
2127
+ const dsDir = path.join(absDir, "metadata", "data_service");
2128
+ let dsFiles = [];
2129
+ try {
2130
+ dsFiles = readdirSync(dsDir).filter((f) => f.endsWith(".json"));
2131
+ } catch { /* no data services */ }
2132
+ for (const f of dsFiles) {
2133
+ try {
2134
+ const dsJson = JSON.parse(readFileSync(path.join(dsDir, f), "utf8"));
2135
+ const entityName = dsJson.definition?.source?.kind === "entity" ? dsJson.definition.source.entityName : null;
2136
+ const entityTypes = loadEntityFieldTypes(absDir, entityName);
2137
+ const warnings = lintDataServiceSemantics(dsJson, entityTypes);
2138
+ if (warnings.length > 0) { warned += warnings.length; results.push({ check: `data-service-semantic-lint:${f}`, status: "WARN", issues: warnings }); }
2139
+ } catch (e) {
2140
+ results.push({ check: `data-service-semantic-lint:${f}`, status: "SKIPPED", note: `could not lint: ${e.message}` });
2141
+ }
2142
+ }
2143
+ // job-contribution-lint (JOB-001, 2026-09-04, REAL) — see
2144
+ // lintJobContributionUsage's own doc comment for the standing rule this
2145
+ // makes visible pre-deploy: a new bespoke ErpJobContribution class instead
2146
+ // of a JSON job definition against an already-generic sweep engine.
2147
+ const javaDir = path.join(absDir, "..", "src", "main", "java");
2148
+ try {
2149
+ const warnings = lintJobContributionUsage(javaDir);
2150
+ if (warnings.length > 0) { warned += warnings.length; results.push({ check: "job-contribution-lint", status: "WARN", issues: warnings }); }
2151
+ } catch (e) {
2152
+ results.push({ check: "job-contribution-lint", status: "SKIPPED", note: `could not lint: ${e.message}` });
2153
+ }
2154
+ const manifestPath = path.join(absDir, "plugin.json");
2155
+ if (existsSync(manifestPath)) {
2156
+ try {
2157
+ JSON.parse(readFileSync(manifestPath, "utf8"));
2158
+ passed++; results.push({ check: "plugin.json:valid-json", status: "PASS" });
2159
+ } catch (e) {
2160
+ failed++; results.push({ check: "plugin.json:valid-json", status: "FAIL", issues: [e.message] });
2161
+ }
2162
+ }
2163
+ for (const skipped of ["security-rule-scan", "ai-tool-permission-check", "regression-suite"]) {
2164
+ results.push({ check: skipped, status: "SKIPPED", note: "no real engine for this check exists yet — see ai/domains/ai-native-developer-platform-gap-analysis.md" });
2165
+ }
2166
+ console.log(`Plugin Tests`);
2167
+ console.log("─".repeat(40));
2168
+ for (const r of results) {
2169
+ const icon = r.status === "PASS" ? "✓" : r.status === "FAIL" ? "✗" : r.status === "WARN" ? "⚠" : "○";
2170
+ console.log(`${icon} ${r.check}${r.status === "FAIL" || r.status === "WARN" ? " — " + JSON.stringify(r.issues, null, 2) : ""}`);
2171
+ }
2172
+ console.log("");
2173
+ const skipped = results.filter((r) => r.status === "SKIPPED").length;
2174
+ console.log(`${passed} passed, ${failed} failed, ${warned} semantic warning(s), ${skipped} skipped`);
2175
+ if (failed > 0) process.exitCode = 1;
2176
+ }
2177
+
2178
+ // erp plugin publish-frontend — dynamic frontend-plugin-loading (2026-09-03)
2179
+ // [REAL] — uploads a plugin's compiled, self-contained browser bundle
2180
+ // (`<frontend-package>/dist/browser.js`, see `tools/erp-code-plugin-compiler`'s
2181
+ // sibling doc `ai/patterns/code-plugin-sdk.md` for the tsup/esbuild `alias`
2182
+ // convention that bundle must be built with) via the real, generic
2183
+ // `engine-file` upload endpoint (`POST /api/v1/files`), stamps the resulting
2184
+ // real fileId onto `<backend-module-dir>/spk-assembly/plugin.json`'s new
2185
+ // `frontendBundle` field, repackages the module (`spark package`), and
2186
+ // re-installs it via the real, already-existing upgrade path (`POST
2187
+ // /api/v1/authoring/plugins/{pluginId}/upload`) — no new install/upgrade
2188
+ // mechanism invented, every step reuses a real, pre-existing endpoint.
2189
+ async function pluginPublishFrontendCommand(backendModuleDir, opts) {
2190
+ const cfg = loadConfig();
2191
+ const env = currentEnv(cfg);
2192
+ const session = cfg.sessions?.[env.name];
2193
+ const baseUrl = opts.url || env.baseUrl;
2194
+ const tenantId = String(opts.tenant || session?.tenantId || 2);
2195
+ const actor = opts.actor || session?.userId || "erp-cli";
2196
+ const authHeaders = session?.sessionToken
2197
+ ? { "X-Session-Token": session.sessionToken, Authorization: `Bearer ${session.accessToken}` }
2198
+ : {};
2199
+
2200
+ const absDir = path.resolve(backendModuleDir);
2201
+ const manifestPath = path.join(absDir, "spk-assembly", "plugin.json");
2202
+ if (!existsSync(manifestPath)) throw new Error(`no spk-assembly/plugin.json found under ${absDir}`);
2203
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
2204
+
2205
+ const frontendPkgDir = opts.frontendDir
2206
+ ? path.resolve(opts.frontendDir)
2207
+ : path.join(REPO_ROOT, "frontend/packages", manifest.id);
2208
+ const bundlePath = path.join(frontendPkgDir, "dist", "browser.js");
2209
+ if (!existsSync(bundlePath)) {
2210
+ throw new Error(
2211
+ `no browser bundle at ${bundlePath} — build it first (e.g. \`pnpm --filter <pkg> build:browser\`), ` +
2212
+ `producing a self-contained ESM bundle per ai/patterns/code-plugin-sdk.md's dynamic-loading convention`,
2213
+ );
2214
+ }
2215
+ const bundleBytes = readFileSync(bundlePath);
2216
+
2217
+ console.log(`Uploading ${bundlePath} (${bundleBytes.length} bytes) to ${baseUrl}/api/v1/files (cabinet ${opts.cabinetId})...`);
2218
+ const uploadForm = new FormData();
2219
+ uploadForm.set("cabinetId", String(opts.cabinetId));
2220
+ uploadForm.set("file", new Blob([bundleBytes], { type: "text/javascript" }), `${manifest.id}-browser.js`);
2221
+ const uploadRes = await fetch(`${baseUrl}/api/v1/files`, {
2222
+ method: "POST",
2223
+ headers: { "X-Tenant-Id": tenantId, "X-Actor": actor, ...authHeaders },
2224
+ body: uploadForm,
2225
+ });
2226
+ const uploadText = await uploadRes.text();
2227
+ if (!uploadRes.ok) throw new Error(`file upload failed: HTTP ${uploadRes.status} — ${uploadText}`);
2228
+ const fileId = String(JSON.parse(uploadText).id);
2229
+ console.log(`Uploaded — fileId ${fileId}.`);
2230
+
2231
+ manifest.frontendBundle = { fileId, entrypointExport: opts.entrypointExport || "default" };
2232
+ if (opts.bumpVersion !== false) {
2233
+ const parts = String(manifest.version).split(".").map(Number);
2234
+ parts[2] = (parts[2] || 0) + 1;
2235
+ manifest.version = parts.join(".");
2236
+ }
2237
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
2238
+ console.log(`Stamped frontendBundle onto ${manifestPath} (version now ${manifest.version}).`);
2239
+
2240
+ const spkPath = path.join(os.tmpdir(), `${manifest.id}-${manifest.version}.spk`);
2241
+ runSpark(["package", path.join(absDir, "spk-assembly"), "-o", spkPath]);
2242
+ if (!existsSync(spkPath)) throw new Error(`spark package did not produce ${spkPath}`);
2243
+
2244
+ console.log(`Upgrading "${manifest.id}" via ${baseUrl}/api/v1/authoring/plugins/${manifest.id}/upload...`);
2245
+ const spkForm = new FormData();
2246
+ spkForm.set("file", new Blob([readFileSync(spkPath)]), path.basename(spkPath));
2247
+ const upgradeRes = await fetch(`${baseUrl}/api/v1/authoring/plugins/${encodeURIComponent(manifest.id)}/upload`, {
2248
+ method: "POST",
2249
+ headers: { "X-Tenant-Id": tenantId, "X-Actor": actor, ...authHeaders },
2250
+ body: spkForm,
2251
+ });
2252
+ const upgradeText = await upgradeRes.text();
2253
+ if (!upgradeRes.ok) throw new Error(`plugin upgrade failed: HTTP ${upgradeRes.status} — ${upgradeText}`);
2254
+ console.log(`Upgraded:`);
2255
+ console.log(upgradeText);
2256
+ }
2257
+
2258
+ // laptop -> VPS: publishes to the configured env's base_url with a device-flow
2259
+ // access token (never a hardcoded localhost:8080). `--env <name>` targets a
2260
+ // specific configured env; `--url` still overrides outright; ERP_TOKEN works for CI.
2261
+ async function pluginPublishCommand(target, opts) {
2262
+ const cfg = loadConfig();
2263
+ if (opts.env) {
2264
+ if (!cfg.envs[opts.env]) throw new Error(`unknown env "${opts.env}" — run: erp env list`);
2265
+ cfg.currentEnv = opts.env;
2266
+ }
2267
+ const env = currentEnv(cfg);
2268
+ const session = cfg.sessions?.[env.name];
2269
+ const h = await authHeaders(cfg, { tenantIdOverride: opts.tenant });
2270
+ const bearer = process.env.ERP_TOKEN || session?.accessToken;
2271
+ if (!bearer && !opts.dryRun) {
2272
+ throw new Error(`not logged in to env "${env.name}" — run \`erp login\` (or set ERP_TOKEN) before publishing.`);
2273
+ }
2274
+ const args = ["publish", target, "--url", opts.url || env.baseUrl, "--tenant", h["X-Tenant-Id"]];
2275
+ if (opts.actor || session?.userId) args.push("--actor", opts.actor || session.userId);
2276
+ if (session?.sessionToken) args.push("--session", session.sessionToken);
2277
+ if (bearer) args.push("--bearer", bearer);
2278
+ if (opts.dryRun) args.push("--dry-run");
2279
+ console.log(`erp plugin publish: ${target} -> ${opts.url || env.baseUrl} (env "${env.name}", tenant ${h["X-Tenant-Id"]})`);
2280
+ if (opts.force) args.push("--force");
2281
+ runSpark(args);
2282
+ }
2283
+
2284
+ // erp plugin force-unload <id> — rough-edge (b) recovery. Force-clears a
2285
+ // WEDGED fleet-wide PF4J load (plugin still STARTED in-process while its
2286
+ // install row is already `uninstalled`) without a backend restart. Safe and
2287
+ // idempotent when nothing is actually wedged.
2288
+ async function pluginForceUnloadCommand(pluginId, opts) {
2289
+ if (!pluginId) throw new Error("usage: erp plugin force-unload <pluginId> [--tenant <id>]");
2290
+ const cfg = loadConfig();
2291
+ const result = await api(cfg, "POST", `/api/v1/authoring/plugins/${encodeURIComponent(pluginId)}/force-unload`, {
2292
+ tenantIdOverride: opts.tenant,
2293
+ });
2294
+ console.log(JSON.stringify(result, null, 2));
2295
+ if (result?.loaded) {
2296
+ console.log(`\nWARNING: "${pluginId}" is still loaded (pf4jState=${result.pf4jState}). A backend restart may be needed.`);
2297
+ process.exitCode = 1;
2298
+ } else {
2299
+ console.log(`\n"${pluginId}" is now cleared from PF4J (pf4jState=${result.pf4jState}). Re-publish to reinstall.`);
2300
+ }
2301
+ }
2302
+
2303
+ // ---------------------------------------------------------------------------
2304
+ // erp workflow list / instance / tasks / history — [REAL] live inspection of
2305
+ // a running workflow instance, against engine-api's own base URL. In this
2306
+ // deployment engine-workflow's REST runs as a separate service, so these hit
2307
+ // the read-proxy engine-api re-exposes at /api/v1/workflows/* (see
2308
+ // WorkflowReadProxyController). No workflow-service URL needed.
2309
+ // ---------------------------------------------------------------------------
2310
+ async function workflowListCommand(opts) {
2311
+ const definition = opts.definition || opts.def;
2312
+ if (!definition) throw new Error("usage: erp workflow list --definition <workflowName> [--record <id>] [--tenant <id>]");
2313
+ const cfg = loadConfig();
2314
+ const instances = await api(cfg, "GET", `/api/v1/workflows/instances?definitionName=${encodeURIComponent(definition)}`, {
2315
+ tenantIdOverride: opts.tenant,
2316
+ });
2317
+ let rows = Array.isArray(instances) ? instances : [];
2318
+ if (opts.record != null && opts.record !== true) {
2319
+ const needle = String(opts.record);
2320
+ const matched = [];
2321
+ for (const i of rows) {
2322
+ const id = i.instanceId ?? i.id;
2323
+ let detail = i;
2324
+ try {
2325
+ detail = await api(cfg, "GET", `/api/v1/workflows/instances/${encodeURIComponent(id)}`, { tenantIdOverride: opts.tenant });
2326
+ } catch {
2327
+ /* fall back to the summary row */
2328
+ }
2329
+ const ctx = detail.context || detail.contextJson || {};
2330
+ const idFieldName = (typeof ctx === "object" && ctx.idField) || "id";
2331
+ const ctxId = typeof ctx === "object" ? ctx[idFieldName] : undefined;
2332
+ const corr = String(i.correlationId ?? "");
2333
+ if (
2334
+ String(ctxId ?? "") === needle ||
2335
+ corr === needle ||
2336
+ corr.endsWith(`:${needle}`) ||
2337
+ JSON.stringify(ctx).includes(`"${needle}"`)
2338
+ ) {
2339
+ matched.push({ ...i, context: typeof ctx === "object" ? ctx : undefined });
2340
+ }
2341
+ }
2342
+ rows = matched;
2343
+ }
2344
+ console.log(JSON.stringify(rows, null, 2));
2345
+ console.log(`\n${rows.length} instance(s)${opts.record && opts.record !== true ? ` for record ${opts.record}` : ""} of "${definition}".`);
2346
+ }
2347
+
2348
+ async function workflowInstanceCommand(instanceId, opts) {
2349
+ if (!instanceId) throw new Error("usage: erp workflow instance <instanceId> [--tenant <id>]");
2350
+ const cfg = loadConfig();
2351
+ const instance = await api(cfg, "GET", `/api/v1/workflows/instances/${encodeURIComponent(instanceId)}`, { tenantIdOverride: opts.tenant });
2352
+ console.log(JSON.stringify(instance, null, 2));
2353
+ }
2354
+
2355
+ async function workflowTasksCommand(instanceId, opts) {
2356
+ if (!instanceId) throw new Error("usage: erp workflow tasks <instanceId> [--tenant <id>]");
2357
+ const cfg = loadConfig();
2358
+ const tasks = await api(cfg, "GET", `/api/v1/workflows/instances/${encodeURIComponent(instanceId)}/human-tasks`, { tenantIdOverride: opts.tenant });
2359
+ console.log(JSON.stringify(tasks, null, 2));
2360
+ }
2361
+
2362
+ async function workflowHistoryCommand(instanceId, opts) {
2363
+ if (!instanceId) throw new Error("usage: erp workflow history <instanceId> [--tenant <id>]");
2364
+ const cfg = loadConfig();
2365
+ const history = await api(cfg, "GET", `/api/v1/workflows/instances/${encodeURIComponent(instanceId)}/history`, { tenantIdOverride: opts.tenant });
2366
+ console.log(JSON.stringify(history, null, 2));
2367
+ }
2368
+
2369
+ // ---------------------------------------------------------------------------
2370
+ // erp schema pull / erp schema validate
2371
+ // [REAL] — the exact same schema map the MCP server (tools/erp-mcp-server)
2372
+ // serves, reused here so `erp schema pull` and Claude Code's MCP tool call
2373
+ // return byte-identical schemas — one source of truth, not two.
2374
+ // ---------------------------------------------------------------------------
2375
+ // Derived from the shared SCHEMA_REGISTRY (tools/erp-cli/authoring-root.mjs) so
2376
+ // the CLI, the MCP server, and the bundle emitter can never carry drifted copies.
2377
+ // dashboard: no separate schema — a dashboard's definitionJson is the same
2378
+ // PageDefinition shape as `page`; use `page`.
2379
+ const SCHEMA_FILES = Object.fromEntries(Object.entries(SCHEMA_REGISTRY).map(([k, v]) => [k, v.file]));
2380
+
2381
+ function schemaPullCommand(name, opts) {
2382
+ const rel = SCHEMA_FILES[name];
2383
+ if (!rel) throw new Error(`unknown schema "${name}". Known: ${Object.keys(SCHEMA_FILES).join(", ")}`);
2384
+ const content = readFileSync(schemaPathByName(name), "utf8");
2385
+ const outPath = opts.out || `${name}.schema.json`;
2386
+ writeFileSync(outPath, content, "utf8");
2387
+ console.log(`Wrote ${outPath}`);
2388
+ }
2389
+
2390
+ async function schemaValidateCommand(file, opts) {
2391
+ if (!opts.schema) throw new Error("usage: erp schema validate <file> --schema <name>");
2392
+ const rel = SCHEMA_FILES[opts.schema];
2393
+ if (!rel) throw new Error(`unknown schema "${opts.schema}". Known: ${Object.keys(SCHEMA_FILES).join(", ")}`);
2394
+ const schema = JSON.parse(readFileSync(schemaPathByName(opts.schema), "utf8"));
2395
+ const data = JSON.parse(readFileSync(file, "utf8"));
2396
+ const issues = simpleSchemaCheck(data, schema, "$");
2397
+ if (issues.length === 0) console.log(`OK — ${file} matches schema "${opts.schema}"`);
2398
+ else {
2399
+ console.log(`FAIL — ${file} against schema "${opts.schema}"`);
2400
+ for (const i of issues) console.log(` ${i}`);
2401
+ process.exitCode = 1;
2402
+ }
2403
+ }
2404
+
2405
+ /** A deliberately small structural checker (required/type/enum only) — NOT a full
2406
+ * draft-07 implementation. [THIN, disclosed]: a complete JSON-Schema validator
2407
+ * (oneOf/allOf/$ref/pattern/etc.) is a real, separate library-shaped undertaking;
2408
+ * pulling in an npm dependency (ajv) would be the right real fix but conflicts
2409
+ * with this tool family's own zero-new-dependency, standalone-script convention
2410
+ * — flagged here as the one place that convention costs real completeness. */
2411
+ function simpleSchemaCheck(data, schema, pathLabel) {
2412
+ const issues = [];
2413
+ if (schema.required) {
2414
+ for (const req of schema.required) {
2415
+ if (data == null || !(req in data)) issues.push(`${pathLabel}: missing required property "${req}"`);
2416
+ }
2417
+ }
2418
+ if (schema.type === "object" && schema.properties && data && typeof data === "object") {
2419
+ for (const [key, value] of Object.entries(data)) {
2420
+ const propSchema = schema.properties[key];
2421
+ if (propSchema?.enum && !propSchema.enum.includes(value)) {
2422
+ issues.push(`${pathLabel}.${key}: "${value}" not in allowed values [${propSchema.enum.join(", ")}]`);
2423
+ }
2424
+ }
2425
+ }
2426
+ return issues;
2427
+ }
2428
+
2429
+ // ---------------------------------------------------------------------------
2430
+ // erp workflow validate / erp workflow test
2431
+ // [REAL, narrow] structural check against workflow-definition.schema.json
2432
+ // plus a real graph check (every transition's from/to is a declared stage,
2433
+ // every stage is reachable from the first). Not a live engine-workflow
2434
+ // dry-run (that needs a real tenant+DB instance) — disclosed.
2435
+ // ---------------------------------------------------------------------------
2436
+ /** Parses the REAL on-disk shape — stagesJson/tasksJson/transitionsJson are
2437
+ * themselves JSON-encoded strings (confirmed against live shipped files,
2438
+ * e.g. employee.transfer.json), not nested objects. See
2439
+ * workflow-definition.schema.json's own $comment for why. */
2440
+ function parseWorkflowArtifact(file) {
2441
+ const raw = JSON.parse(readFileSync(file, "utf8"));
2442
+ return {
2443
+ raw,
2444
+ stages: JSON.parse(raw.stagesJson || "[]"),
2445
+ tasks: JSON.parse(raw.tasksJson || "[]"),
2446
+ transitions: JSON.parse(raw.transitionsJson || "[]"),
2447
+ approvalPermissions: raw.approvalPermissions || [],
2448
+ };
2449
+ }
2450
+
2451
+ function workflowValidateCommand(file) {
2452
+ const schema = JSON.parse(readFileSync(schemaPathByName("workflow-definition"), "utf8"));
2453
+ const raw = JSON.parse(readFileSync(file, "utf8"));
2454
+ const issues = simpleSchemaCheck(raw, schema, "$");
2455
+ let def;
2456
+ try {
2457
+ def = parseWorkflowArtifact(file);
2458
+ } catch (e) {
2459
+ issues.push(`one of stagesJson/tasksJson/transitionsJson is not valid JSON: ${e.message}`);
2460
+ }
2461
+ if (def) {
2462
+ const stages = new Set(def.stages);
2463
+ for (const t of def.transitions) {
2464
+ if (!stages.has(t.fromStage)) issues.push(`transition references unknown "fromStage" "${t.fromStage}"`);
2465
+ if (!stages.has(t.toStage)) issues.push(`transition references unknown "toStage" "${t.toStage}"`);
2466
+ }
2467
+ for (const task of def.tasks) {
2468
+ if (!stages.has(task.stage)) issues.push(`task "${task.taskKey}" references unknown stage "${task.stage}"`);
2469
+ }
2470
+ const approvalObjects = new Set(def.tasks.map((t) => t.payload?.approvalObject).filter(Boolean));
2471
+ for (const perm of def.approvalPermissions) {
2472
+ if (!approvalObjects.has(perm.approvalObject)) issues.push(`approvalPermissions entry references unknown approvalObject "${perm.approvalObject}" (no task declares it)`);
2473
+ }
2474
+ }
2475
+ if (issues.length === 0) console.log(`OK — ${file}`);
2476
+ else { console.log(`FAIL — ${file}`); for (const i of issues) console.log(` ${i}`); process.exitCode = 1; }
2477
+ }
2478
+
2479
+ function workflowTestCommand(file) {
2480
+ const def = parseWorkflowArtifact(file);
2481
+ console.log(`Workflow "${def.raw.name}" — ${def.stages.length} stage(s): ${def.stages.join(" → ")}`);
2482
+ const reachable = new Set([def.stages[0]]);
2483
+ let changed = true;
2484
+ while (changed) {
2485
+ changed = false;
2486
+ for (const t of def.transitions) {
2487
+ if (reachable.has(t.fromStage) && !reachable.has(t.toStage)) { reachable.add(t.toStage); changed = true; }
2488
+ }
2489
+ }
2490
+ const unreachable = def.stages.filter((s) => !reachable.has(s));
2491
+ if (unreachable.length > 0) {
2492
+ console.log(`FAIL — unreachable stage(s) from "${def.stages[0]}": ${unreachable.join(", ")}`);
2493
+ process.exitCode = 1;
2494
+ } else {
2495
+ console.log(`OK — every stage reachable from "${def.stages[0]}"`);
2496
+ }
2497
+ const noTransitionOut = def.stages.filter((s) => !def.transitions.some((t) => t.fromStage === s));
2498
+ console.log(`Stage(s) with no outgoing transition (instance completes here): ${noTransitionOut.join(", ") || "(none)"}`);
2499
+ for (const stage of def.stages) {
2500
+ const perms = def.approvalPermissions.filter((p) => def.tasks.some((t) => t.stage === stage && t.payload?.approvalObject === p.approvalObject));
2501
+ console.log(` stage "${stage}": approvers = ${perms.map((p) => p.roleCode).join(", ") || "(none declared)"}`);
2502
+ }
2503
+ }
2504
+
2505
+ // ---------------------------------------------------------------------------
2506
+ // erp connector create/validate/list/test — [REAL, added 2026-09-04] the
2507
+ // SDK/CLI-only authoring surface for engine-provider-connector's generic
2508
+ // external_provider_definition rows (backend/platform-runtime/engine-provider-connector).
2509
+ // Mirrors `erp workflow validate/test`'s split: `validate` is a pure local
2510
+ // schema check (connector-definition.schema.json), `create` PUTs the
2511
+ // validated file to the real backend (ExternalProviderDefinitionController),
2512
+ // `list` and `test` are real authenticated calls against that same live
2513
+ // endpoint via the shared `api()` helper — no hand-typed curl for any of
2514
+ // this, matching the standing SDK-CLI-only rule.
2515
+ // ---------------------------------------------------------------------------
2516
+ function connectorValidateFile(file) {
2517
+ const schema = JSON.parse(readFileSync(schemaPathByName("connector-definition"), "utf8"));
2518
+ const data = JSON.parse(readFileSync(file, "utf8"));
2519
+ const issues = simpleSchemaCheck(data, schema, "$");
2520
+ if (data.connectorKind === "HTTP" && !data.baseUrl) issues.push('connectorKind="HTTP" requires "baseUrl"');
2521
+ return { data, issues };
2522
+ }
2523
+
2524
+ function connectorValidateCommand(file) {
2525
+ const { issues } = connectorValidateFile(file);
2526
+ if (issues.length === 0) console.log(`OK — ${file} matches schema "connector-definition"`);
2527
+ else { console.log(`FAIL — ${file}`); for (const i of issues) console.log(` ${i}`); process.exitCode = 1; }
2528
+ }
2529
+
2530
+ async function connectorCreateCommand(file, opts) {
2531
+ const { data, issues } = connectorValidateFile(file);
2532
+ if (issues.length > 0) {
2533
+ console.log(`FAIL — ${file} did not pass connector-definition validation, not sending to backend`);
2534
+ for (const i of issues) console.log(` ${i}`);
2535
+ process.exitCode = 1;
2536
+ return;
2537
+ }
2538
+ const cfg = loadConfig();
2539
+ currentSession(cfg);
2540
+ const { domain, providerKey, ...body } = data;
2541
+ const result = await api(cfg, "PUT", `/api/v1/provider-connectors/definitions/${encodeURIComponent(domain)}/${encodeURIComponent(providerKey)}`,
2542
+ { body, tenantIdOverride: opts.tenant });
2543
+ console.log(JSON.stringify(result, null, 2));
2544
+ }
2545
+
2546
+ async function connectorListCommand(opts) {
2547
+ if (!opts.domain) throw new Error("usage: erp connector list --domain <domain>");
2548
+ const cfg = loadConfig();
2549
+ currentSession(cfg);
2550
+ const result = await api(cfg, "GET", `/api/v1/provider-connectors/definitions?domain=${encodeURIComponent(opts.domain)}`, { tenantIdOverride: opts.tenant });
2551
+ console.log(JSON.stringify(result, null, 2));
2552
+ }
2553
+
2554
+ async function connectorTestCommand(domain, providerKey, opts) {
2555
+ if (!domain || !providerKey) throw new Error("usage: erp connector test <domain> <providerKey> [--body <json-string-or-@file>]");
2556
+ const cfg = loadConfig();
2557
+ currentSession(cfg);
2558
+ let fieldValues = {};
2559
+ if (opts.body) {
2560
+ const raw = opts.body.startsWith("@") ? readFileSync(opts.body.slice(1), "utf8") : opts.body;
2561
+ fieldValues = JSON.parse(raw);
2562
+ }
2563
+ const result = await api(cfg, "POST", `/api/v1/provider-connectors/definitions/${encodeURIComponent(domain)}/${encodeURIComponent(providerKey)}/invoke`,
2564
+ { body: { fieldValues }, tenantIdOverride: opts.tenant });
2565
+ console.log(JSON.stringify(result, null, 2));
2566
+ }
2567
+
2568
+ // ---------------------------------------------------------------------------
2569
+ // erp docs search — thin wrapper reusing the same walk the MCP server uses.
2570
+ // [FIXED 2026-09-04, real gap] — this used to grep ONLY hand-written
2571
+ // ai/domains/*.md narrative docs, with zero way to check a doc's claim
2572
+ // against the actual platform-runtime source. That let a false "no
2573
+ // report-builder infrastructure exists anywhere in this codebase" claim
2574
+ // propagate unverified across five module docs even though a real
2575
+ // ReportDefinitionService/BiReportDefinitionService engine existed the whole
2576
+ // time. Every result is now labeled `[ai/domains doc]` (a human-curated
2577
+ // narrative claim — may be stale) or `[live engine-* source]` (mechanically
2578
+ // re-derived from `erp platform catalog`, just scanned — cannot be stale in
2579
+ // the same way) so a search never again returns ONLY the possibly-stale half.
2580
+ // ---------------------------------------------------------------------------
2581
+ function docsSearchCommand(query) {
2582
+ const hits = [];
2583
+ for (const root of docsSearchRoots()) {
2584
+ (function walk(dir) {
2585
+ let entries;
2586
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
2587
+ for (const e of entries) {
2588
+ const full = path.join(dir, e.name);
2589
+ if (e.isDirectory()) walk(full);
2590
+ else if (e.name.endsWith(".md")) {
2591
+ const lines = readFileSync(full, "utf8").split("\n");
2592
+ lines.forEach((line, i) => { if (line.toLowerCase().includes(query.toLowerCase())) hits.push(`[${root.source}] ${path.relative(root.relBase, full)}:${i + 1}: ${line.trim()}`); });
2593
+ }
2594
+ }
2595
+ })(root.dir);
2596
+ }
2597
+
2598
+ let engineHits = [];
2599
+ try {
2600
+ engineHits = searchEngineCatalog(query).hits.map((h) => {
2601
+ const loc = h.file ? ` (${h.file})` : "";
2602
+ const label = h.kind === "java-contract" ? "[java contract]" : "[live engine-* source]";
2603
+ return `${label} ${h.engine}: ${h.kind} \`${h.match}\`${loc}`;
2604
+ });
2605
+ } catch (e) {
2606
+ engineHits = [`[live engine-* source] (catalog unavailable: ${e.message})`];
2607
+ }
2608
+
2609
+ const out = [...hits.slice(0, 30), ...engineHits.slice(0, 30)];
2610
+ console.log(out.join("\n") || "(no matches)");
2611
+ }
2612
+
2613
+ // ---------------------------------------------------------------------------
2614
+ // erp examples search — [REAL, added 2026-09-03] real shipped metadata JSON
2615
+ // (page/workflow/entities/provider) across every backend/modules/* plugin,
2616
+ // matched by filename or plugin name. This is the exact same logic as the
2617
+ // MCP server's `erp_search_examples` tool (tools/erp-mcp-server/index.mjs) —
2618
+ // added here as a pure-CLI mirror because a developer working strictly
2619
+ // through the CLI (no MCP client available, or the MCP server not wired in)
2620
+ // had no way to discover real precedent for authoring a WorkflowDefinition
2621
+ // (or any other kind) without reading another plugin's source directly.
2622
+ // `erp docs search` only greps ai/*.md narrative docs — it never searched
2623
+ // shipped example JSON, which is the actual gap this closes.
2624
+ // ---------------------------------------------------------------------------
2625
+ // ---------------------------------------------------------------------------
2626
+ // erp examples patterns — [REAL, added 2026-09-03] a curated catalog of named
2627
+ // SHAPES (not just filenames) for the kinds a developer most often has to
2628
+ // reinvent from scratch, each pointing at a real shipped file confirmed to
2629
+ // actually have that shape (grep-verified against the file's own
2630
+ // stagesJson/transitionsJson at the time this catalog was written — if a
2631
+ // referenced file is ever deleted/renamed, `erp examples patterns` degrades
2632
+ // to printing the pattern description without a dead path, it never throws).
2633
+ // This exists because `erp examples search <term>` returns every filename
2634
+ // matching a substring, which still requires reading N files to find the
2635
+ // ONE real precedent for e.g. "single-actor self-decide workflow" — this
2636
+ // catalog names that shape directly.
2637
+ // ---------------------------------------------------------------------------
2638
+
2639
+
2640
+ function examplesPatternsCommand(opts) {
2641
+ const kind = opts.kind;
2642
+ const kinds = kind ? [kind] : Object.keys(EXAMPLE_PATTERNS);
2643
+ for (const k of kinds) {
2644
+ const patterns = EXAMPLE_PATTERNS[k];
2645
+ if (!patterns) { console.log(`(no curated patterns for kind "${k}" — known kinds: ${Object.keys(EXAMPLE_PATTERNS).join(", ")})`); continue; }
2646
+ console.log(`\n== ${k} ==`);
2647
+ for (const p of patterns) {
2648
+ const exists = existsSync(exampleFileAbs(p.file));
2649
+ console.log(`\n[${p.name}]${exists ? "" : " (referenced file missing — repo may have moved/renamed it since this catalog was written)"}`);
2650
+ console.log(p.description);
2651
+ console.log(`Real example: ${p.file}`);
2652
+ }
2653
+ }
2654
+ console.log("");
2655
+ }
2656
+
2657
+ // ---------------------------------------------------------------------------
2658
+ // erp menu create — [REAL, added 2026-09-03, closes a real gap] `spark create`
2659
+ // makes an empty spk-assembly/metadata/menu/ directory but writes zero starter
2660
+ // content, and there was no way at all to add a NEW menu artifact to an
2661
+ // EXISTING plugin without hand-writing the full nested MenuNode shape from
2662
+ // memory (id/name/displayName/sequence/children/navigation/visibility all
2663
+ // required per backend/platform-runtime/schema/menu-definition.schema.json).
2664
+ // This scaffolds one real, schema-valid root node — disabled by default (a
2665
+ // developer must deliberately flip visibility.enabled + set navigation.route
2666
+ // once a real page exists, same convention every shipped module's own
2667
+ // pre-build menu stub already follows) — into <pluginDir>/spk-assembly/
2668
+ // metadata/menu/<menuId>.json, refusing to overwrite an existing file.
2669
+ // ---------------------------------------------------------------------------
2670
+ function menuCreateCommand(pluginDir, menuId, opts) {
2671
+ if (!pluginDir || !menuId) throw new Error("usage: erp menu create <plugin-dir> <menu-id> [--display-name <name>] [--route <path>] [--icon <name>]");
2672
+ const menuDir = path.join(path.resolve(pluginDir), "spk-assembly", "metadata", "menu");
2673
+ const outPath = path.join(menuDir, `${menuId}.json`);
2674
+ if (existsSync(outPath)) throw new Error(`${outPath} already exists — refusing to overwrite. Edit it directly, or pick a different menu id.`);
2675
+ if (!existsSync(menuDir)) mkdirSync(menuDir, { recursive: true });
2676
+ const displayName = opts.displayName || menuId.replace(/[-.]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
2677
+ const rootNodeId = `${menuId}.root`;
2678
+ const menu = {
2679
+ name: menuId,
2680
+ description: `Scaffolded by \`erp menu create\`. One disabled root node — set navigation.route and flip visibility.enabled once a real page exists at that route.`,
2681
+ definition: {
2682
+ contractVersion: 1,
2683
+ name: menuId,
2684
+ description: null,
2685
+ tags: [],
2686
+ nodes: [
2687
+ {
2688
+ id: rootNodeId,
2689
+ name: menuId.split(/[.-]/).pop(),
2690
+ displayName,
2691
+ displayNameKey: null,
2692
+ description: null,
2693
+ icon: opts.icon || null,
2694
+ color: null,
2695
+ tags: [],
2696
+ sequence: 1,
2697
+ children: [],
2698
+ navigation: { route: opts.route || null, targetPage: null, openMode: "current-tab", externalUrl: null },
2699
+ visibility: { visible: true, enabled: Boolean(opts.route), devices: [] }
2700
+ }
2701
+ ]
2702
+ },
2703
+ metadata: {},
2704
+ modules: []
2705
+ };
2706
+ writeFileSync(outPath, JSON.stringify(menu, null, 2) + "\n", "utf8");
2707
+ console.log(`Wrote ${outPath}`);
2708
+ console.log(` Root node id: ${rootNodeId}${opts.route ? ` (route: ${opts.route}, enabled)` : " (no --route given — visibility.enabled left false; set navigation.route and flip it true once a real page exists)"}`);
2709
+ console.log(` Validate: node tools/erp-cli/erp.mjs schema validate ${outPath} --schema menu`);
2710
+ }
2711
+
2712
+ function examplesSearchCommand(pattern, opts) {
2713
+ if (!pattern) throw new Error("usage: erp examples search <pattern> [--kind page|workflow|entities|provider|data-service|data-view|rules|menu]");
2714
+ const kind = opts.kind || "page";
2715
+ // 2026-09-03, real gap found and fixed: data-service/data-view/rules/menu were
2716
+ // entirely unsearchable — a developer looking for a real "employee search lookup"
2717
+ // Data Service precedent (the exact core.lookup optionsSourceKey recipe) had no way
2718
+ // to find one via this command at all, even though the shipped directory
2719
+ // (spk-assembly/metadata/data_service/) has real examples in multiple modules.
2720
+ const kindDirs = { page: "page", workflow: "workflow", entities: "entities", provider: "provider", "data-service": "data_service", "data-view": "data_view", rules: "rules", menu: "menu" };
2721
+ const sub = kindDirs[kind];
2722
+ if (!sub) throw new Error(`unknown kind "${kind}". Known kinds: ${Object.keys(kindDirs).join(", ")}`);
2723
+ const modulesDir = examplesModulesRoot();
2724
+ let moduleDirs;
2725
+ try {
2726
+ moduleDirs = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory());
2727
+ } catch {
2728
+ console.log("(no backend/modules directory found)");
2729
+ return;
2730
+ }
2731
+ const p = pattern.toLowerCase();
2732
+ const results = [];
2733
+ const maxResults = Number(opts.max || 10);
2734
+ for (const mod of moduleDirs) {
2735
+ const target = path.join(modulesDir, mod.name, "spk-assembly", "metadata", sub);
2736
+ let files;
2737
+ try {
2738
+ files = readdirSync(target).filter((f) => f.endsWith(".json"));
2739
+ } catch {
2740
+ continue;
2741
+ }
2742
+ for (const f of files) {
2743
+ if (f.toLowerCase().includes(p) || mod.name.toLowerCase().includes(p)) {
2744
+ results.push(path.join("backend", "modules", path.relative(modulesDir, path.join(target, f))).replace(/\\/g, "/"));
2745
+ }
2746
+ if (results.length >= maxResults) break;
2747
+ }
2748
+ if (results.length >= maxResults) break;
2749
+ }
2750
+ console.log(results.length ? results.join("\n") : `(no ${kind} examples matched "${pattern}" — try a broader pattern or a different --kind: ${Object.keys(kindDirs).join(", ")})`);
2751
+ }
2752
+
2753
+ // ---------------------------------------------------------------------------
2754
+ // erp blocks list — [REAL, added 2026-09-04] the real widget/block catalog,
2755
+ // derived directly from @erp/block-engine's own `coreBlockTypes()` registry
2756
+ // (frontend/packages/block-engine/dist/index.js — the built dist, same
2757
+ // resolution convention as block-adapter-mui's own dist-only requirement),
2758
+ // never hand-maintained. Real gap found and fixed: before this, a developer
2759
+ // building a plugin purely through this CLI/MCP had NO way to discover "what
2760
+ // widgets exist and what properties/events does each one accept" without
2761
+ // either already knowing a widget's name (to `erp examples search` for real
2762
+ // usage) or reading block-engine source directly — a real violation of the
2763
+ // standing "SDK/CLI-only discovery, never read platform source" rule this
2764
+ // exact gap forced developers into. `erp_list_schemas`/`erp_get_schema`'s
2765
+ // `block-definition`/`block-instance` schemas only describe the GENERIC
2766
+ // contract shape every block instance must follow, not a per-type catalog of
2767
+ // the ~124 real registered types (core.grid, core.lookup, core.date-picker,
2768
+ // core.file-upload, etc.) and what each specifically accepts.
2769
+ //
2770
+ // `coreBlockTypes()` takes several optional runtime data-source arguments
2771
+ // (gridDataSource, selectOptionsSource, ...) that only affect each entry's
2772
+ // `.logic` (the live behavior) — calling it with none supplied still returns
2773
+ // every entry's full `.definition` (properties/events/outputs/designer
2774
+ // metadata), which is all this catalog needs; `.logic` is deliberately never
2775
+ // touched or invoked here.
2776
+ // ---------------------------------------------------------------------------
2777
+ async function loadBlockDefinitions() {
2778
+ // Packaged mode: the frozen blocks.json in the authoring bundle (emitted by
2779
+ // `erp bundle build` from this same registry). Monorepo mode: derived live
2780
+ // from @erp/block-engine's own built dist.
2781
+ const catalogPath = blocksCatalogPath();
2782
+ if (catalogPath) {
2783
+ return JSON.parse(readFileSync(catalogPath, "utf8"));
2784
+ }
2785
+ const mod = await import(blockEngineModuleUrl());
2786
+ return mod.coreBlockTypes().map((bt) => bt.definition);
2787
+ }
2788
+
2789
+ function summarizeBlockDefinition(def) {
2790
+ return {
2791
+ type: def.type,
2792
+ category: def.designer?.category ?? null,
2793
+ displayNameKey: def.designer?.displayNameKey ?? null,
2794
+ descriptionKey: def.designer?.descriptionKey ?? null,
2795
+ icon: def.designer?.icon ?? null,
2796
+ allowedTargets: def.designer?.allowedTargets ?? [],
2797
+ inputClass: !!def.inputClass,
2798
+ properties: (def.properties ?? []).map((p) => ({
2799
+ name: p.name,
2800
+ type: p.type,
2801
+ sources: p.sources ?? [],
2802
+ editor: p.designer?.editor ?? null,
2803
+ })),
2804
+ outputs: (def.outputs ?? []).map((o) => ({ name: o.name, type: o.type })),
2805
+ events: (def.events ?? []).map((e) => ({ name: e.name, catalogType: e.catalogType ?? null })),
2806
+ };
2807
+ }
2808
+
2809
+ async function blocksListCommand(opts) {
2810
+ const defs = await loadBlockDefinitions();
2811
+ let filtered = defs;
2812
+ if (opts.type) {
2813
+ const needle = opts.type.toLowerCase();
2814
+ filtered = filtered.filter((d) => d.type.toLowerCase().includes(needle));
2815
+ }
2816
+ if (opts.category) {
2817
+ const needle = opts.category.toLowerCase();
2818
+ filtered = filtered.filter((d) => (d.designer?.category ?? "").toLowerCase() === needle);
2819
+ }
2820
+ if (filtered.length === 0) {
2821
+ console.log(`(no block type matched — ${defs.length} total registered; try a broader --type substring or omit --category)`);
2822
+ return;
2823
+ }
2824
+ if (opts.type && filtered.length === 1) {
2825
+ // A single exact-ish match: print the FULL definition (every property's
2826
+ // designer group/labelKey, every event's full shape) — the detail a
2827
+ // developer actually needs when they already know which widget they want.
2828
+ console.log(JSON.stringify(filtered[0], null, 2));
2829
+ return;
2830
+ }
2831
+ console.log(`${filtered.length} block type(s)${opts.type ? ` matching "${opts.type}"` : ""}${opts.category ? ` in category "${opts.category}"` : ""} (of ${defs.length} total):\n`);
2832
+ for (const def of filtered.map(summarizeBlockDefinition)) {
2833
+ const propNames = def.properties.map((p) => `${p.name}:${p.type}`).join(", ") || "(none)";
2834
+ const eventNames = def.events.map((e) => e.name).join(", ") || "(none)";
2835
+ console.log(`${def.type} [${def.category ?? "uncategorized"}]`);
2836
+ console.log(` properties: ${propNames}`);
2837
+ console.log(` events: ${eventNames}`);
2838
+ console.log(` targets: ${def.allowedTargets.join(", ") || "(unrestricted)"}`);
2839
+ console.log("");
2840
+ }
2841
+ 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
+ }
2843
+
2844
+ // ---------------------------------------------------------------------------
2845
+ // erp ai init / erp mcp install
2846
+ // [REAL] — scaffolds a plugin's ai/ subtree (agents.json/tools.json/
2847
+ // prompts.json, per the doc's own plugin layout) and ensures a .mcp.json
2848
+ // pointing at tools/erp-mcp-server exists at the invoking directory.
2849
+ // ---------------------------------------------------------------------------
2850
+ function aiInitCommand(dir) {
2851
+ const target = dir || process.cwd();
2852
+ const aiDir = path.join(target, "ai");
2853
+ mkdirSync(aiDir, { recursive: true });
2854
+ const files = {
2855
+ "agents.json": { agents: [] },
2856
+ "tools.json": { tools: [] },
2857
+ "prompts.json": { prompts: [] },
2858
+ };
2859
+ for (const [name, content] of Object.entries(files)) {
2860
+ const fp = path.join(aiDir, name);
2861
+ if (!existsSync(fp)) writeFileSync(fp, JSON.stringify(content, null, 2) + "\n", "utf8");
2862
+ }
2863
+ console.log(`Scaffolded ${aiDir}/{agents,tools,prompts}.json`);
2864
+ }
2865
+
2866
+ function mcpInstallCommand(opts) {
2867
+ const target = opts.dir || process.cwd();
2868
+ const mcpPath = path.join(target, ".mcp.json");
2869
+ // erp-mcp-server/index.mjs is always this file's sibling directory — true
2870
+ // in the monorepo (tools/erp-cli + tools/erp-mcp-server under tools/) AND
2871
+ // in a packaged @erp/plugin-sdk install (erp-cli/ + erp-mcp-server/ under
2872
+ // the package root) — so resolve relative to __dirname, never REPO_ROOT,
2873
+ // which is meaningless once this file is npm-installed outside the repo.
2874
+ const mcpEntryAbs = path.join(__dirname, "..", "erp-mcp-server", "index.mjs");
2875
+ const entry = { command: "node", args: [path.relative(target, mcpEntryAbs).replace(/\\/g, "/")] };
2876
+ let cfg = { mcpServers: {} };
2877
+ if (existsSync(mcpPath)) cfg = JSON.parse(readFileSync(mcpPath, "utf8"));
2878
+ cfg.mcpServers = cfg.mcpServers || {};
2879
+ cfg.mcpServers["erp-plugin-language"] = entry;
2880
+ writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
2881
+ console.log(`Wrote/updated ${mcpPath}`);
2882
+ }
2883
+
2884
+ // ===========================================================================
2885
+ // erp bundle build — the authoring-bundle emitter
2886
+ // ---------------------------------------------------------------------------
2887
+ // Produces a single self-contained directory (erp-authoring-bundle/) holding
2888
+ // everything the MCP server + CLI need to ground a third-party plugin
2889
+ // developer who has NO access to the platform monorepo, and NOTHING secret.
2890
+ //
2891
+ // CLASSIFICATION BOUNDARY = the secret-code boundary. A file enters the bundle
2892
+ // only if it is on the explicit allowlist below AND (if a doc) its frontmatter
2893
+ // says `audience: tenant` or `audience: all`. Everything under ai/, all Java,
2894
+ // low-code-docs/, docs-platform/, and any internal gap-analysis doc is excluded.
2895
+ // ===========================================================================
2896
+
2897
+ // The ONLY things that ever go into the bundle. Grep this list to audit it.
2898
+ const BUNDLE_ALLOWLIST = [
2899
+ { name: "schemas", into: "schemas/", from: "SCHEMA_REGISTRY (authoring-root.mjs) — the *.schema.json files only" },
2900
+ { name: "catalog", into: "catalog.json", from: "buildEngineCatalog({refresh:true}) frozen output — the Java source scan runs HERE at build time, never on the client" },
2901
+ { name: "examples", into: "examples/", from: "tools/erp-cli/example-patterns.mjs + the backend/modules/**/metadata/**.json files each pattern references" },
2902
+ { name: "docs", into: "docs/", from: "developer-docs/**/*.md whose frontmatter declares `audience: tenant|all`, plus developer-docs/docs.json" },
2903
+ { name: "blocks", into: "blocks.json", from: "@erp/block-engine coreBlockTypes() .definition list — the ~124 core block property/event contracts, frozen for packaged-mode `erp blocks list` / erp_list_blocks" },
2904
+ { name: "validators", into: "validators/", from: "self-contained esbuild bundles of @erp/block-engine + @erp/page-engine dist entrypoints (every workspace dep inlined) — the REAL validateInstanceConfig/validatePageDefinition, so packaged-mode page validation is the same code, not a re-derivation" },
2905
+ { name: "manifest", into: "manifest.json", from: "generated at build time" },
2906
+ ];
2907
+
2908
+ // Hard denylist — a path matching any of these is NEVER copied, whatever its source.
2909
+ const BUNDLE_DENY = [
2910
+ /\.java$/i,
2911
+ /(^|[/\\])ai[/\\]/,
2912
+ /(^|[/\\])low-code-docs[/\\]/,
2913
+ /(^|[/\\])docs-platform[/\\]/,
2914
+ /gap-analysis/i,
2915
+ /\.env(\.|$)/i,
2916
+ /(^|[/\\])src[/\\]main[/\\]java[/\\]/,
2917
+ ];
2918
+
2919
+ function bundleDenied(p) {
2920
+ const s = p.replace(/\\/g, "/");
2921
+ return BUNDLE_DENY.find((re) => re.test(s)) || null;
2922
+ }
2923
+
2924
+ /** A markdown doc is bundle-public only if its YAML frontmatter says audience: tenant|all. */
2925
+ function docAudienceIsPublic(text) {
2926
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2927
+ if (!m) return false;
2928
+ const a = m[1].match(/^\s*audience:\s*["']?([a-z]+)["']?\s*$/im);
2929
+ return !!a && (a[1].toLowerCase() === "tenant" || a[1].toLowerCase() === "all");
2930
+ }
2931
+
2932
+ function walkAll(dir, out = []) {
2933
+ let entries;
2934
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
2935
+ for (const e of entries) {
2936
+ const full = path.join(dir, e.name);
2937
+ if (e.isDirectory()) walkAll(full, out);
2938
+ else out.push(full);
2939
+ }
2940
+ return out;
2941
+ }
2942
+
2943
+ function findExternalRefs(node, acc = []) {
2944
+ if (Array.isArray(node)) node.forEach((n) => findExternalRefs(n, acc));
2945
+ else if (node && typeof node === "object") {
2946
+ for (const [k, v] of Object.entries(node)) {
2947
+ if (k === "$ref" && typeof v === "string" && !v.startsWith("#")) acc.push(v);
2948
+ else findExternalRefs(v, acc);
2949
+ }
2950
+ }
2951
+ return acc;
2952
+ }
2953
+
2954
+ function copyInto(srcAbs, destAbs) {
2955
+ const deny = bundleDenied(srcAbs);
2956
+ if (deny) throw new Error(`refusing to bundle ${srcAbs} — matches denylist ${deny}`);
2957
+ mkdirSync(path.dirname(destAbs), { recursive: true });
2958
+ copyFileSync(srcAbs, destAbs);
2959
+ }
2960
+
2961
+ function dirSizeBytes(dir) {
2962
+ return walkAll(dir).reduce((n, f) => n + statSync(f).size, 0);
2963
+ }
2964
+
2965
+ /**
2966
+ * esbuild one frontend engine's dist entrypoint into a single self-contained
2967
+ * ESM file (every @erp/* workspace dep inlined) so packaged-mode SDK installs
2968
+ * run the REAL validators with no repo, no npm install, no build step. esbuild
2969
+ * is resolved from frontend/node_modules — only reached in monorepo mode
2970
+ * (`erp bundle build` is requireMonorepo), where it is always present.
2971
+ */
2972
+ async function esbuildEngine(engineRelDir, outFileAbs) {
2973
+ const pkgDir = path.join(REPO_ROOT, engineRelDir);
2974
+ const entry = path.join(pkgDir, "dist", "index.js");
2975
+ if (!existsSync(entry)) throw new Error(`${engineRelDir}/dist/index.js not found — build the frontend packages first.`);
2976
+ const pnpmRoot = path.join(REPO_ROOT, "frontend", "node_modules", ".pnpm");
2977
+ let esbuildMain = null;
2978
+ try {
2979
+ esbuildMain = readdirSync(pnpmRoot)
2980
+ .filter((d) => /^esbuild@/.test(d))
2981
+ .map((d) => path.join(pnpmRoot, d, "node_modules", "esbuild", "lib", "main.js"))
2982
+ .find((p) => existsSync(p));
2983
+ } catch { /* no pnpm store */ }
2984
+ if (!esbuildMain) throw new Error(`esbuild not found under ${pnpmRoot} — run \`pnpm install\` in frontend/ first.`);
2985
+ const esbuild = await import(pathToFileURL(esbuildMain).href);
2986
+ await esbuild.build({
2987
+ entryPoints: [entry],
2988
+ bundle: true,
2989
+ format: "esm",
2990
+ platform: "node",
2991
+ absWorkingDir: pkgDir,
2992
+ outfile: outFileAbs,
2993
+ logLevel: "silent",
2994
+ banner: { js: "// GENERATED by `erp bundle build` (esbuild of @erp/*-engine dist) — do not edit." },
2995
+ });
2996
+ return statSync(outFileAbs).size;
2997
+ }
2998
+
2999
+ async function bundleBuildCommand(opts) {
3000
+ requireMonorepo("erp bundle build");
3001
+ const outDir = opts.out ? path.resolve(opts.out) : bundleDir();
3002
+ const t0 = Date.now();
3003
+ console.log(`Building authoring bundle -> ${outDir}`);
3004
+
3005
+ // Wipe the generated subtree (never touches anything outside outDir).
3006
+ for (const sub of ["schemas", "examples", "docs", "validators", "blocks.json", "catalog.json", "manifest.json", "README.md"]) {
3007
+ const p = path.join(outDir, sub);
3008
+ if (existsSync(p)) rmSync(p, { recursive: true, force: true });
3009
+ }
3010
+ mkdirSync(outDir, { recursive: true });
3011
+
3012
+ // 1. schemas/
3013
+ const externalRefFlags = [];
3014
+ let schemaCount = 0;
3015
+ for (const [name, entry] of Object.entries(SCHEMA_REGISTRY)) {
3016
+ const srcAbs = path.join(REPO_ROOT, entry.file);
3017
+ const raw = readFileSync(srcAbs, "utf8");
3018
+ const ext = findExternalRefs(JSON.parse(raw));
3019
+ if (ext.length) externalRefFlags.push({ schema: name, externalRefs: [...new Set(ext)] });
3020
+ if (bundleDenied(srcAbs)) throw new Error(`schema ${name} source is denylisted: ${srcAbs}`);
3021
+ mkdirSync(path.join(outDir, "schemas"), { recursive: true });
3022
+ writeFileSync(path.join(outDir, "schemas", `${name}.schema.json`), raw, "utf8");
3023
+ schemaCount++;
3024
+ }
3025
+
3026
+ // 2. catalog.json — the frozen engine-capability catalog. THE JAVA SCAN RUNS HERE.
3027
+ const catalog = buildEngineCatalog({ refresh: true });
3028
+ writeFileSync(path.join(outDir, "catalog.json"), JSON.stringify(catalog, null, 2), "utf8");
3029
+
3030
+ // 3. examples/ — the curated pattern data + every real file it references.
3031
+ copyInto(path.join(__dirname, "example-patterns.mjs"), path.join(outDir, "examples", "example-patterns.mjs"));
3032
+ let exampleFileCount = 0;
3033
+ const missingExamples = [];
3034
+ for (const patterns of Object.values(EXAMPLE_PATTERNS)) {
3035
+ for (const p of patterns) {
3036
+ const srcAbs = path.join(REPO_ROOT, p.file);
3037
+ if (!existsSync(srcAbs)) { missingExamples.push(p.file); continue; }
3038
+ copyInto(srcAbs, path.join(outDir, "examples", p.file));
3039
+ exampleFileCount++;
3040
+ }
3041
+ }
3042
+
3043
+ // 4. docs/ — ONLY developer-docs/*.md with audience: tenant|all.
3044
+ const devDocsRoot = path.join(REPO_ROOT, "developer-docs");
3045
+ let docCount = 0;
3046
+ const skippedDocs = [];
3047
+ if (existsSync(devDocsRoot)) {
3048
+ for (const abs of walkAll(devDocsRoot)) {
3049
+ const rel = path.relative(devDocsRoot, abs);
3050
+ if (path.basename(abs) === "docs.json") { copyInto(abs, path.join(outDir, "docs", rel)); continue; }
3051
+ if (!abs.endsWith(".md")) continue;
3052
+ if (bundleDenied(abs)) { skippedDocs.push(rel + " (denylist)"); continue; }
3053
+ if (!docAudienceIsPublic(readFileSync(abs, "utf8"))) { skippedDocs.push(rel + " (no audience: tenant|all)"); continue; }
3054
+ copyInto(abs, path.join(outDir, "docs", rel));
3055
+ docCount++;
3056
+ }
3057
+ }
3058
+
3059
+ // 5. blocks.json — the frozen core block catalog (property/event contracts).
3060
+ let blockCount = 0;
3061
+ {
3062
+ const defs = (await import(blockEngineModuleUrl())).coreBlockTypes().map((bt) => bt.definition);
3063
+ blockCount = defs.length;
3064
+ writeFileSync(path.join(outDir, "blocks.json"), JSON.stringify(defs, null, 2), "utf8");
3065
+ }
3066
+
3067
+ // 6. validators/ — self-contained esbuild of the two real engine validators.
3068
+ mkdirSync(path.join(outDir, "validators"), { recursive: true });
3069
+ const blockValidatorBytes = await esbuildEngine("frontend/packages/block-engine", path.join(outDir, "validators", "block-engine.mjs"));
3070
+ const pageValidatorBytes = await esbuildEngine("frontend/packages/page-engine", path.join(outDir, "validators", "page-engine.mjs"));
3071
+
3072
+ // 7. manifest.json
3073
+ const manifest = {
3074
+ bundleVersion: opts.bundleVersion || new Date().toISOString().slice(0, 10) + ".1",
3075
+ platformVersion: detectPlatformVersion(),
3076
+ generatedAt: new Date().toISOString(),
3077
+ generatedBy: "erp bundle build (tools/erp-cli/erp.mjs bundleBuildCommand)",
3078
+ schemaCount,
3079
+ docCount,
3080
+ exampleFileCount,
3081
+ blockCount,
3082
+ catalogGeneratedAt: catalog.generatedAt,
3083
+ catalogEngineCount: catalog.engineCount,
3084
+ catalogContractUnitCount: catalog.contracts?.unitCount ?? 0,
3085
+ sdkMode: "packaged",
3086
+ };
3087
+ writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
3088
+
3089
+ writeFileSync(
3090
+ path.join(outDir, "README.md"),
3091
+ `# erp-authoring-bundle (generated)\n\nGenerated by \`erp bundle build\`. Do not hand-edit. This is the offline grounding\nthe ERP plugin SDK (CLI + MCP server) reads when \`ERP_SDK_MODE=packaged\` or no\nplatform repo is present. Contains ONLY tenant-public material — see\n\`BUNDLE_ALLOWLIST\` / \`BUNDLE_DENY\` in tools/erp-cli/erp.mjs.\n\n- schemas/ ${schemaCount} JSON Schemas\n- catalog.json engine-capability catalog, frozen at build time (no client-side Java scan)\n- examples/ curated pattern catalog + ${exampleFileCount} referenced real files\n- blocks.json ${blockCount} core block property/event contracts\n- validators/ self-contained esbuild of @erp/block-engine + @erp/page-engine (real page validation, packaged mode)\n- docs/ ${docCount} tenant-public developer docs\n- live/ (populated by \`erp env sync\` from a deployed env — installed entity + theme names)\n- manifest.json\n`,
3092
+ "utf8",
3093
+ );
3094
+
3095
+ // 6. Verify. HARD FAIL: any file in the bundle that IS a .java file, or whose
3096
+ // path is denylisted (i.e. an internal doc/source file got copied in).
3097
+ // ADVISORY only: a public schema/example whose text merely *mentions* an
3098
+ // internal doc path in a $comment/doc-string — a pointer, not leaked content.
3099
+ const leaks = [];
3100
+ const advisories = [];
3101
+ for (const abs of walkAll(outDir)) {
3102
+ const rel = path.relative(outDir, abs).replace(/\\/g, "/");
3103
+ if (rel === "catalog.json" || rel === "manifest.json") continue; // catalog legitimately names .java contract files
3104
+ const deny = bundleDenied(rel);
3105
+ if (deny) { leaks.push(`${rel} (path matches ${deny})`); continue; }
3106
+ if (/\.(json|md|mjs)$/.test(rel)) {
3107
+ const txt = readFileSync(abs, "utf8");
3108
+ if (/\bai\/(domains|platform)\//.test(txt)) advisories.push(`${rel} references an ai/ doc path in its text`);
3109
+ if (/gap-analysis/i.test(txt)) advisories.push(`${rel} mentions "gap-analysis" in its text`);
3110
+ }
3111
+ }
3112
+
3113
+ const bytes = dirSizeBytes(outDir);
3114
+ console.log(`\nBundle built in ${Date.now() - t0}ms`);
3115
+ console.log(` schemas: ${schemaCount}`);
3116
+ console.log(` catalog: ${catalog.engineCount} engines, ${catalog.contracts?.unitCount ?? 0} contract units (frozen ${catalog.generatedAt})`);
3117
+ console.log(` example files: ${exampleFileCount}${missingExamples.length ? ` (missing: ${missingExamples.join(", ")})` : ""}`);
3118
+ console.log(` blocks.json: ${blockCount} core block definitions`);
3119
+ console.log(` validators: block-engine.mjs ${(blockValidatorBytes / 1024).toFixed(0)} KiB, page-engine.mjs ${(pageValidatorBytes / 1024).toFixed(0)} KiB (self-contained esbuild)`);
3120
+ console.log(` docs: ${docCount} public${skippedDocs.length ? `, ${skippedDocs.length} skipped` : ""}`);
3121
+ if (skippedDocs.length) skippedDocs.forEach((d) => console.log(` - skipped ${d}`));
3122
+ if (externalRefFlags.length) {
3123
+ console.log(` WARNING external $refs (must be inlined before shipping):`);
3124
+ externalRefFlags.forEach((f) => console.log(` - ${f.schema}: ${f.externalRefs.join(", ")}`));
3125
+ } else {
3126
+ console.log(` external $refs: none (all schemas self-contained)`);
3127
+ }
3128
+ console.log(` total size: ${(bytes / 1024).toFixed(1)} KiB`);
3129
+ if (advisories.length) {
3130
+ console.log(` advisory (public files that mention an internal doc path — pointer, not leaked content):`);
3131
+ advisories.forEach((a) => console.log(` - ${a}`));
3132
+ }
3133
+ if (leaks.length) {
3134
+ console.log(`\nFAIL — a .java file or denylisted path leaked into the bundle:`);
3135
+ leaks.forEach((l) => console.log(` - ${l}`));
3136
+ process.exitCode = 1;
3137
+ } else {
3138
+ console.log(`\nOK — no .java files, no denylisted paths in the bundle (catalog.json's contract-file references excepted).`);
3139
+ }
3140
+ }
3141
+
3142
+ function detectPlatformVersion() {
3143
+ for (const rel of ["frontend/package.json", "package.json"]) {
3144
+ try {
3145
+ const j = JSON.parse(readFileSync(path.join(REPO_ROOT, rel), "utf8"));
3146
+ if (j.version) return j.version;
3147
+ } catch { /* keep looking */ }
3148
+ }
3149
+ try {
3150
+ return spawnSync("git", ["-C", REPO_ROOT, "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim() || "unknown";
3151
+ } catch { return "unknown"; }
3152
+ }
3153
+
3154
+ // ---------------------------------------------------------------------------
3155
+ // erp env sync — pull this environment's authoring bundle from the deployed
3156
+ // GET /api/v1/dev/bundle (DevBundleController, engine-api) and extract it into
3157
+ // the packaged-mode cache overlay ~/.erp/cache/<envKey>/ that authoring-root.mjs
3158
+ // reads first. `--check` = manifest only, report drift, download nothing.
3159
+ //
3160
+ // envKey: ERP_ENV_KEY if set (per-env isolation), else "default" — matching
3161
+ // authoring-root.mjs's cacheDir(). The URL slug is printed so it can be set.
3162
+ // ---------------------------------------------------------------------------
3163
+ function envSlug(baseUrl) {
3164
+ return baseUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "env";
3165
+ }
3166
+
3167
+ async function envSyncCommand(opts = {}) {
3168
+ const cfg = loadConfig();
3169
+ const env = currentEnv(cfg);
3170
+ const base = env.baseUrl.replace(/\/$/, "");
3171
+ const tenantId = String(cfg.sessions?.[env.name]?.tenantId ?? 2);
3172
+ // Gated behind a device-flow access token (or ERP_TOKEN) — DevBundleController
3173
+ // resolves the tenant from the token claim. Falls back to a bare X-Tenant-Id
3174
+ // only against a monorepo/dev backend that sets erp.dev-bundle.allow-tenant-header.
3175
+ const headers = await authHeaders(cfg, { tenantIdOverride: tenantId });
3176
+ const unauthedHint = () => {
3177
+ console.log(` -> run \`erp login\` first (this environment requires an authenticated SDK), or set ERP_TOKEN for CI.`);
3178
+ process.exitCode = 1;
3179
+ };
3180
+ const slug = envSlug(base);
3181
+ const cache = cacheDir();
3182
+
3183
+ const localManifest = readManifest();
3184
+ const localBundleVersion = localManifest?.bundleVersion ?? "(none)";
3185
+
3186
+ const manifestUrl = `${base}/api/v1/dev/bundle/manifest`;
3187
+ console.log(`erp env sync: ${env.name} (${base}) — tenant ${tenantId}, cache key "${process.env.ERP_ENV_KEY || "default"}" (url slug: ${slug})`);
3188
+
3189
+ let mres;
3190
+ try {
3191
+ mres = await fetch(manifestUrl, { headers });
3192
+ } catch (e) {
3193
+ console.log(` network error reaching ${manifestUrl}: ${e.cause?.code || e.message}`);
3194
+ console.log(` Nothing synced. The bundled snapshot at ${bundleDir()} is used as-is.`);
3195
+ process.exitCode = 1;
3196
+ return;
3197
+ }
3198
+ if (mres.status === 404) {
3199
+ console.log(` this ERP does not expose /api/v1/dev/bundle (HTTP 404) — it predates the DevBundleController, or no bundle is built on it.`);
3200
+ console.log(` Nothing synced. The bundled snapshot at ${bundleDir()} is used as-is.`);
3201
+ process.exitCode = 1;
3202
+ return;
3203
+ }
3204
+ if (mres.status === 401 || mres.status === 403) {
3205
+ console.log(` GET /api/v1/dev/bundle/manifest -> HTTP ${mres.status} (not authenticated).`);
3206
+ unauthedHint();
3207
+ return;
3208
+ }
3209
+ if (!mres.ok) {
3210
+ console.log(` GET /api/v1/dev/bundle/manifest -> HTTP ${mres.status}. Nothing synced.`);
3211
+ process.exitCode = 1;
3212
+ return;
3213
+ }
3214
+ const remoteManifest = await mres.json();
3215
+ const drift = [];
3216
+ if (remoteManifest.bundleVersion !== localBundleVersion) drift.push(`bundleVersion ${localBundleVersion} -> ${remoteManifest.bundleVersion}`);
3217
+ if (remoteManifest.platformVersion !== localManifest?.platformVersion) drift.push(`platformVersion ${localManifest?.platformVersion ?? "(none)"} -> ${remoteManifest.platformVersion}`);
3218
+ if ((remoteManifest.schemaCount ?? 0) !== (localManifest?.schemaCount ?? 0)) drift.push(`schemaCount ${localManifest?.schemaCount ?? 0} -> ${remoteManifest.schemaCount}`);
3219
+ console.log(` remote manifest: bundleVersion ${remoteManifest.bundleVersion}, platformVersion ${remoteManifest.platformVersion}, ` +
3220
+ `${remoteManifest.schemaCount} schemas, ${remoteManifest.docCount ?? 0} docs, ${remoteManifest.blockCount ?? "?"} blocks; ` +
3221
+ `live: ${remoteManifest.live?.entityCount ?? 0} entities, ${remoteManifest.live?.themeCount ?? 0} themes`);
3222
+ console.log(drift.length ? ` drift vs bundled snapshot: ${drift.join("; ")}` : ` no drift vs the bundled snapshot.`);
3223
+
3224
+ if (opts.check) {
3225
+ console.log(` --check: manifest only, nothing downloaded.`);
3226
+ return;
3227
+ }
3228
+
3229
+ let bres;
3230
+ try {
3231
+ bres = await fetch(`${base}/api/v1/dev/bundle`, { headers });
3232
+ } catch (e) {
3233
+ console.log(` network error downloading the bundle: ${e.cause?.code || e.message}`);
3234
+ process.exitCode = 1;
3235
+ return;
3236
+ }
3237
+ if (bres.status === 401 || bres.status === 403) {
3238
+ console.log(` GET /api/v1/dev/bundle -> HTTP ${bres.status} (not authenticated).`);
3239
+ unauthedHint();
3240
+ return;
3241
+ }
3242
+ if (!bres.ok) {
3243
+ console.log(` GET /api/v1/dev/bundle -> HTTP ${bres.status}. Nothing extracted.`);
3244
+ process.exitCode = 1;
3245
+ return;
3246
+ }
3247
+ let envelope;
3248
+ try {
3249
+ envelope = await bres.json();
3250
+ } catch (e) {
3251
+ console.log(` response was not valid JSON (${e.message}) — is something proxying this endpoint? Nothing extracted.`);
3252
+ process.exitCode = 1;
3253
+ return;
3254
+ }
3255
+ if (!envelope || typeof envelope.files !== "object" || !envelope.manifest) {
3256
+ console.log(` response was not a bundle envelope ({manifest, files}) — nothing extracted.`);
3257
+ process.exitCode = 1;
3258
+ return;
3259
+ }
3260
+
3261
+ // Fresh overlay: wipe the generated subtree under the cache dir, then write.
3262
+ for (const sub of ["schemas", "examples", "docs", "validators", "live", "blocks.json", "catalog.json", "manifest.json"]) {
3263
+ const p = path.join(cache, sub);
3264
+ if (existsSync(p)) rmSync(p, { recursive: true, force: true });
3265
+ }
3266
+ mkdirSync(cache, { recursive: true });
3267
+ let written = 0;
3268
+ let schemaN = 0;
3269
+ let docN = 0;
3270
+ let liveN = 0;
3271
+ for (const [rel, content] of Object.entries(envelope.files)) {
3272
+ if (rel.includes("..") || path.isAbsolute(rel)) continue; // path-traversal guard
3273
+ const dest = path.join(cache, rel);
3274
+ mkdirSync(path.dirname(dest), { recursive: true });
3275
+ writeFileSync(dest, typeof content === "string" ? content : JSON.stringify(content, null, 2), "utf8");
3276
+ written++;
3277
+ if (rel.startsWith("schemas/")) schemaN++;
3278
+ else if (rel.startsWith("docs/") && rel.endsWith(".md")) docN++;
3279
+ else if (rel.startsWith("live/")) liveN++;
3280
+ }
3281
+ writeFileSync(path.join(cache, "manifest.json"), JSON.stringify(envelope.manifest, null, 2), "utf8");
3282
+
3283
+ console.log(` extracted ${written} files -> ${cache}`);
3284
+ console.log(` schemas: ${schemaN}, docs: ${docN}, live/: ${liveN}, validators: ${envelope.files["validators/block-engine.mjs"] ? "yes" : "no"}, blocks.json: ${envelope.files["blocks.json"] ? "yes" : "no"}`);
3285
+ console.log(` platformVersion ${envelope.manifest.platformVersion}, bundleVersion ${envelope.manifest.bundleVersion}`);
3286
+ console.log(` live entities: ${(envelope.manifest.liveEntityNames || []).length}, live themes: ${(envelope.manifest.liveThemeNames || []).length}`);
3287
+ if (!process.env.ERP_ENV_KEY) {
3288
+ console.log(` (set ERP_ENV_KEY=${slug} to keep this env's cache separate from other environments.)`);
3289
+ }
3290
+ }
3291
+
3292
+ // ---------------------------------------------------------------------------
3293
+ // erp docs gen-reference — turn the bundle's schemas/ into one Markdown page each
3294
+ // ---------------------------------------------------------------------------
3295
+ function renderSchemaMarkdown(name, schema) {
3296
+ const desc = SCHEMA_REGISTRY[name]?.description || schema.description || "";
3297
+ const lines = [
3298
+ "---",
3299
+ `title: ${name} schema`,
3300
+ "audience: tenant",
3301
+ "generated: true",
3302
+ "---",
3303
+ "",
3304
+ `# \`${name}\` schema`,
3305
+ "",
3306
+ desc,
3307
+ "",
3308
+ `Pull the full JSON Schema: \`erp schema pull ${name}\` &nbsp;·&nbsp; MCP: \`erp_get_schema {"name":"${name}"}\``,
3309
+ "",
3310
+ ];
3311
+ const props = schema.properties && typeof schema.properties === "object" ? schema.properties : null;
3312
+ if (props) {
3313
+ const required = new Set(schema.required || []);
3314
+ lines.push("## Top-level properties", "", "| Property | Type | Required | Notes |", "| --- | --- | --- | --- |");
3315
+ for (const [k, v] of Object.entries(props)) {
3316
+ const type = v.type ? (Array.isArray(v.type) ? v.type.join(" \\| ") : v.type) : v.$ref ? `\`${v.$ref}\`` : v.enum ? "enum" : "—";
3317
+ const notesParts = [];
3318
+ 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());
3320
+ lines.push(`| \`${k}\` | ${type} | ${required.has(k) ? "yes" : ""} | ${notesParts.join(" — ") || ""} |`);
3321
+ }
3322
+ lines.push("");
3323
+ } else {
3324
+ lines.push("_This schema's root is not a plain object (see the full schema for its shape)._", "");
3325
+ }
3326
+ const defs = schema.$defs || schema.definitions;
3327
+ if (defs && Object.keys(defs).length) {
3328
+ lines.push("## Named sub-definitions", "", Object.keys(defs).map((d) => `\`${d}\``).join(", "), "");
3329
+ }
3330
+ return lines.join("\n");
3331
+ }
3332
+
3333
+ function docsGenReferenceCommand(opts) {
3334
+ const schemasDir = path.join(bundleDir(), "schemas");
3335
+ if (!existsSync(schemasDir)) throw new Error(`no bundle schemas at ${schemasDir} — run \`erp bundle build\` first.`);
3336
+ const outDir = opts.out ? path.resolve(opts.out) : path.join(REPO_ROOT, "developer-docs", "reference");
3337
+ mkdirSync(outDir, { recursive: true });
3338
+ const files = readdirSync(schemasDir).filter((f) => f.endsWith(".schema.json"));
3339
+ const names = [];
3340
+ for (const f of files) {
3341
+ const nm = f.replace(/\.schema\.json$/, "");
3342
+ const schema = JSON.parse(readFileSync(path.join(schemasDir, f), "utf8"));
3343
+ writeFileSync(path.join(outDir, `${nm}.md`), renderSchemaMarkdown(nm, schema) + "\n", "utf8");
3344
+ names.push(nm);
3345
+ }
3346
+ writeFileSync(
3347
+ path.join(outDir, "index.md"),
3348
+ `---\ntitle: Schema reference\naudience: tenant\ngenerated: true\n---\n\n# Schema reference\n\nOne page per authoring artifact schema, generated by \`erp docs gen-reference\` from the SDK authoring bundle. Regenerate after \`erp bundle build\`.\n\n${names.map((n) => `- [\`${n}\`](./${n}.md)`).join("\n")}\n`,
3349
+ "utf8",
3350
+ );
3351
+ console.log(`Wrote ${files.length} reference pages + index.md -> ${outDir}`);
3352
+ }
3353
+
3354
+ // ---------------------------------------------------------------------------
3355
+ // erp docs serve — local dev-preview of developer-docs/, offline.
3356
+ // Roots: monorepo -> REPO_ROOT/developer-docs (byte-identical live tree);
3357
+ // packaged -> the bundle's own docs/ (bundle/docs, synced overlay first via
3358
+ // cacheDir()/docs) so a tenant developer with no repo access can still
3359
+ // browse the exact docs their installed SDK shipped with. Renders docs.json's
3360
+ // nav as the sidebar (never hand-duplicated) and each .md file via a small
3361
+ // dependency-free Markdown-to-HTML pass — good enough for a dev-preview, not
3362
+ // a production renderer (the real one is the future hosted website, see
3363
+ // low-code-docs/REMOTE-SDK-BATCH-C-D-STATUS.md).
3364
+ // ---------------------------------------------------------------------------
3365
+ function docsRoot() {
3366
+ if (!isPackaged()) return path.join(REPO_ROOT, "developer-docs");
3367
+ const cache = path.join(cacheDir(), "docs");
3368
+ if (existsSync(cache)) return cache;
3369
+ return path.join(bundleDir(), "docs");
3370
+ }
3371
+
3372
+ function mdToHtml(md) {
3373
+ // Strip YAML frontmatter, then a minimal Markdown subset: headings, code
3374
+ // fences, links, bold/italic, lists, paragraphs. Not a full CommonMark
3375
+ // implementation — deliberately small, zero deps, for a local dev preview.
3376
+ const body = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
3377
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3378
+ const lines = body.split(/\r?\n/);
3379
+ let html = "";
3380
+ let inCode = false;
3381
+ let inList = false;
3382
+ for (const line of lines) {
3383
+ if (/^```/.test(line)) {
3384
+ inCode = !inCode;
3385
+ html += inCode ? "<pre><code>" : "</code></pre>\n";
3386
+ continue;
3387
+ }
3388
+ if (inCode) { html += esc(line) + "\n"; continue; }
3389
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
3390
+ if (h) {
3391
+ if (inList) { html += "</ul>\n"; inList = false; }
3392
+ html += `<h${h[1].length}>${inlineMd(esc(h[2]))}</h${h[1].length}>\n`;
3393
+ continue;
3394
+ }
3395
+ const li = line.match(/^[-*]\s+(.*)$/);
3396
+ if (li) {
3397
+ if (!inList) { html += "<ul>\n"; inList = true; }
3398
+ html += `<li>${inlineMd(esc(li[1]))}</li>\n`;
3399
+ continue;
3400
+ }
3401
+ if (inList && line.trim() === "") { html += "</ul>\n"; inList = false; continue; }
3402
+ if (line.trim() === "") { html += "\n"; continue; }
3403
+ html += `<p>${inlineMd(esc(line))}</p>\n`;
3404
+ }
3405
+ if (inList) html += "</ul>\n";
3406
+ return html;
3407
+ }
3408
+
3409
+ function inlineMd(s) {
3410
+ return s
3411
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
3412
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
3413
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, href) => `<a href="${href.replace(/\.md$/, "")}">${text}</a>`);
3414
+ }
3415
+
3416
+ function docsServeCommand(opts) {
3417
+ const root = docsRoot();
3418
+ if (!existsSync(root)) throw new Error(`no docs at ${root} — in packaged mode run \`erp env sync\`, or ship the bundle's docs/.`);
3419
+ const navPath = path.join(root, "docs.json");
3420
+ const nav = existsSync(navPath) ? JSON.parse(readFileSync(navPath, "utf8")) : { title: "Developer docs", nav: [] };
3421
+ const port = Number(opts.port) || 4400;
3422
+
3423
+ const sidebarHtml = nav.nav
3424
+ .map((section) => `<h4>${section.section}</h4><ul>` +
3425
+ section.items.map((it) => `<li><a href="/${it.path.replace(/\.md$/, "")}">${it.title}</a></li>`).join("") +
3426
+ `</ul>`)
3427
+ .join("\n");
3428
+
3429
+ const page = (title, contentHtml) => `<!doctype html><html><head><meta charset="utf-8">
3430
+ <title>${title} — ${nav.title || "ERP Developer Docs"}</title>
3431
+ <style>
3432
+ body{font-family:system-ui,sans-serif;display:flex;margin:0;color:#1a1a1a;background:#fff}
3433
+ nav{width:280px;padding:16px;border-right:1px solid #ddd;height:100vh;overflow:auto;box-sizing:border-box;position:sticky;top:0}
3434
+ nav h4{margin:16px 0 4px;font-size:12px;text-transform:uppercase;color:#888}
3435
+ nav ul{list-style:none;padding-left:0;margin:0}
3436
+ nav li{margin:2px 0}
3437
+ nav a{color:#333;text-decoration:none;font-size:13px}
3438
+ nav a:hover{text-decoration:underline}
3439
+ main{padding:24px 40px;max-width:800px}
3440
+ pre{background:#f5f5f5;padding:12px;overflow:auto;border-radius:4px}
3441
+ code{background:#f5f5f5;padding:1px 4px;border-radius:3px}
3442
+ pre code{background:none;padding:0}
3443
+ </style></head><body><nav><h3>${nav.title || "Developer docs"}</h3>${sidebarHtml}</nav><main>${contentHtml}</main></body></html>`;
3444
+
3445
+ const server = http.createServer((req, res) => {
3446
+ const urlPath = decodeURIComponent(req.url.split("?")[0]);
3447
+ if (urlPath === "/") {
3448
+ res.writeHead(200, { "Content-Type": "text/html" });
3449
+ res.end(page("Home", `<h1>${nav.title || "ERP Developer Docs"}</h1><p>Pick a page from the sidebar. Served from <code>${root}</code>.</p>`));
3450
+ return;
3451
+ }
3452
+ const rel = urlPath.replace(/^\//, "") + ".md";
3453
+ const abs = path.join(root, rel);
3454
+ if (!abs.startsWith(root) || !existsSync(abs)) {
3455
+ res.writeHead(404, { "Content-Type": "text/html" });
3456
+ res.end(page("Not found", `<h1>Not found</h1><p><code>${urlPath}</code> has no matching doc under ${root}.</p>`));
3457
+ return;
3458
+ }
3459
+ const md = readFileSync(abs, "utf8");
3460
+ const titleMatch = md.match(/^#\s+(.*)$/m);
3461
+ res.writeHead(200, { "Content-Type": "text/html" });
3462
+ res.end(page(titleMatch?.[1] || rel, mdToHtml(md)));
3463
+ });
3464
+ server.listen(port, () => {
3465
+ console.log(`erp docs serve: ${root}`);
3466
+ console.log(` http://localhost:${port}/`);
3467
+ if (!opts.open) console.log(` (Ctrl+C to stop)`);
3468
+ });
3469
+ }
3470
+
3471
+ // ---------------------------------------------------------------------------
3472
+ // erp dev — local static preview server for one page JSON.
3473
+ // [THIN, disclosed] — NOT a full mock-database + workflow-engine + AI-tools
3474
+ // local runtime (the doc's §9 "ERP Local Development Environment" mockup);
3475
+ // that needs the real Spring Boot backend + Postgres this platform actually
3476
+ // runs on, which a static Node server cannot stand in for without becoming
3477
+ // a second, divergent runtime implementation (a real anti-pattern — see
3478
+ // render-adapter-framework-agnostic/ui-unit-named-block's own "don't build
3479
+ // a parallel mechanism" discipline). What IS real and useful: a local HTTP
3480
+ // server that serves a page's JSON + the real, already-built
3481
+ // @erp/block-adapter-mui/@erp/page-engine dist bundles so the page renders
3482
+ // in a browser against LIVE data from whatever `erp env`/`erp login`
3483
+ // session is active — i.e. local static hosting + real remote data, not a
3484
+ // local mock backend.
3485
+ //
3486
+ // [REAL, 2026-09-03 mcp-10] — the piece above was honestly disclosed as not
3487
+ // orchestrating a real backend at all; it now does. Before serving, this
3488
+ // probes the REAL `engine-api` (`GET {baseUrl}/actuator/health`, same probe
3489
+ // this session ran via curl throughout, e.g. against the live instance on
3490
+ // :8080). If it's down, `erp dev` offers (or, with `--yes`/`--start`, just
3491
+ // does) launching the REAL Spring Boot process — not a mock — using the
3492
+ // exact two-step command this session's own
3493
+ // maven-spring-boot-run-am-fails-on-root-pom finding requires (a root-level
3494
+ // `-am spring-boot:run` reproducibly fails against the aggregator root pom):
3495
+ // (1) `mvn -o install -DskipTests` from `backend/` once, then (2) `mvn -o
3496
+ // spring-boot:run` from `backend/platform-runtime/erp-suite/` directly (the
3497
+ // S5 cutover retired engine-api's own entrypoint; erp-suite is canonical). Step 2 is `spawn`ed (not
3498
+ // `spawnSync`) and left running detached from this call so `erp dev` doesn't
3499
+ // block on the server's own lifetime; both steps stream real stdout/stderr
3500
+ // back out prefixed `[engine-api]` line by line — real Maven/Spring Boot
3501
+ // boot log, not a canned message. Health is then polled for up to 5 minutes
3502
+ // (a cold boot is genuinely that slow) before falling through to the exact
3503
+ // same preview-serving code path as the already-healthy case. Postgres/Redis
3504
+ // being down is NOT auto-remediated here (out of scope per this pass's own
3505
+ // brief) — a boot failure's own stderr is surfaced as-is, with a pointer to
3506
+ // this repo's local Postgres notes.
3507
+ // ---------------------------------------------------------------------------
3508
+ // S5 cutover: the standalone engine-api entrypoint was retired; erp-suite is
3509
+ // the canonical single-process deployable. `spring-boot:run` runs from here.
3510
+ const SUITE_DIR = path.join(REPO_ROOT, "backend", "platform-runtime", "erp-suite");
3511
+ const BACKEND_ROOT = path.join(REPO_ROOT, "backend");
3512
+
3513
+ async function checkHealth(baseUrl, timeoutMs = 3000) {
3514
+ try {
3515
+ const ctrl = new AbortController();
3516
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
3517
+ const res = await fetch(`${baseUrl}/actuator/health`, { signal: ctrl.signal });
3518
+ clearTimeout(t);
3519
+ if (!res.ok) return { healthy: false };
3520
+ const body = await res.json();
3521
+ return { healthy: body.status === "UP", body };
3522
+ } catch {
3523
+ return { healthy: false };
3524
+ }
3525
+ }
3526
+
3527
+ /** Runs a command to completion, streaming stdout/stderr prefixed `[engine-api]`. Rejects on non-zero exit. */
3528
+ function runStreamingToCompletion(cmd, args, cwd) {
3529
+ return new Promise((resolve, reject) => {
3530
+ console.log(`[engine-api] $ ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
3531
+ const child = spawn(cmd, args, { cwd, shell: true });
3532
+ const pipe = (stream) => stream.on("data", (buf) => {
3533
+ for (const line of buf.toString().split(/\r?\n/)) if (line) console.log(`[engine-api] ${line}`);
3534
+ });
3535
+ pipe(child.stdout);
3536
+ pipe(child.stderr);
3537
+ child.on("error", reject);
3538
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with code ${code}`))));
3539
+ });
3540
+ }
3541
+
3542
+ /** Spawns the long-lived `spring-boot:run` process, streaming its output, and returns the child (left running — not awaited to exit). */
3543
+ function spawnEngineApi() {
3544
+ const cmd = "mvn";
3545
+ const args = ["-o", "spring-boot:run"];
3546
+ console.log(`[erp-suite] $ ${cmd} ${args.join(" ")} (cwd: ${SUITE_DIR})`);
3547
+ const child = spawn(cmd, args, { cwd: SUITE_DIR, shell: true });
3548
+ const pipe = (stream) => stream.on("data", (buf) => {
3549
+ for (const line of buf.toString().split(/\r?\n/)) if (line) console.log(`[engine-api] ${line}`);
3550
+ });
3551
+ pipe(child.stdout);
3552
+ pipe(child.stderr);
3553
+ child.on("exit", (code) => console.log(`[engine-api] process exited with code ${code}`));
3554
+ child.unref();
3555
+ return child;
3556
+ }
3557
+
3558
+ async function ensureBackendUp(baseUrl, opts) {
3559
+ const initial = await checkHealth(baseUrl);
3560
+ if (initial.healthy) return;
3561
+ console.log(`erp dev: engine-api at ${baseUrl} is NOT reachable (GET /actuator/health failed).`);
3562
+ const autoStart = opts.yes || opts.start;
3563
+ if (!autoStart) {
3564
+ const answer = await prompt(`Start the real Spring Boot engine-api now? [Y/n] `);
3565
+ if (answer && !/^y/i.test(answer)) {
3566
+ throw new Error("engine-api not running and not started — re-run with --start/--yes to skip this prompt, or start it yourself.");
3567
+ }
3568
+ }
3569
+ console.log(`erp dev: starting engine-api for real. This is the exact command a developer would run by hand:`);
3570
+ console.log(` (1) cd ${BACKEND_ROOT} && mvn -o install -DskipTests`);
3571
+ console.log(` (2) cd ${SUITE_DIR} && mvn -o spring-boot:run`);
3572
+ console.log(`See maven-spring-boot-run-am-fails-on-root-pom: the README's own root-level -am spring-boot:run reproducibly fails against the aggregator pom — this two-step, module-directory form is the real working command.`);
3573
+ await runStreamingToCompletion("mvn", ["-o", "install", "-DskipTests"], BACKEND_ROOT);
3574
+ spawnEngineApi();
3575
+ console.log(`erp dev: polling ${baseUrl}/actuator/health — cold Spring Boot boots can genuinely take a few minutes; waiting up to 5 minutes.`);
3576
+ const deadline = Date.now() + 5 * 60 * 1000;
3577
+ while (Date.now() < deadline) {
3578
+ const { healthy } = await checkHealth(baseUrl, 3000);
3579
+ if (healthy) {
3580
+ console.log(`erp dev: engine-api is healthy.`);
3581
+ return;
3582
+ }
3583
+ await sleep(5000);
3584
+ }
3585
+ throw new Error(
3586
+ `engine-api did not become healthy within 5 minutes. If the boot log above shows a DB connection failure, ` +
3587
+ `start local Postgres first (this repo's local setup: pg_ctl start against its data dir — see this session's ` +
3588
+ `postgres-local-setup notes; starting Postgres itself is out of scope for \`erp dev\`).`
3589
+ );
3590
+ }
3591
+
3592
+ async function devCommand(pagePath, opts) {
3593
+ const port = Number(opts.port || 4300);
3594
+ const cfg = loadConfig();
3595
+ const env = currentEnv(cfg);
3596
+ await ensureBackendUp(env.baseUrl, opts);
3597
+ const session = cfg.sessions?.[env.name];
3598
+ const abs = path.resolve(pagePath);
3599
+ const pageJson = readFileSync(abs, "utf8");
3600
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>erp dev — ${path.basename(abs)}</title></head>
3601
+ <body style="margin:0;font-family:system-ui">
3602
+ <div id="banner" style="background:#111;color:#fff;padding:8px 16px;font:13px monospace">erp dev — ${path.basename(abs)} — env: ${env.name} (${env.baseUrl}) — ${session ? "logged in as " + session.userId : "NOT logged in (run: erp login)"}</div>
3603
+ <pre id="page" style="padding:16px;white-space:pre-wrap">${pageJson.replace(/</g, "&lt;")}</pre>
3604
+ <p style="padding:0 16px;color:#666;font:13px system-ui">[THIN] Full block-tree rendering here (rather than this raw-JSON preview) requires wiring @erp/page-engine + @erp/block-adapter-mui's built dist bundles into this static page — same dist-only-resolution caveat as Studio's own TenantPageHost. Not wired in this pass; the JSON above is the real, current file content, live-reloaded on save.</p>
3605
+ </body></html>`;
3606
+ const server = http.createServer((req, res) => {
3607
+ if (req.url === "/") {
3608
+ res.writeHead(200, { "Content-Type": "text/html" });
3609
+ res.end(html);
3610
+ } else if (req.url === "/page.json") {
3611
+ res.writeHead(200, { "Content-Type": "application/json" });
3612
+ res.end(readFileSync(abs, "utf8"));
3613
+ } else {
3614
+ res.writeHead(404);
3615
+ res.end("not found");
3616
+ }
3617
+ });
3618
+ server.listen(port, () => console.log(`erp dev preview: http://localhost:${port} (Ctrl+C to stop)`));
3619
+ }
3620
+
3621
+ // ---------------------------------------------------------------------------
3622
+ // erp sdk list / download / download-all
3623
+ // [REAL, disclosed shape] — the 7 real `@erp/*` Code Plugin SDK packages
3624
+ // (`frontend/packages/erp-{ui,data,auth,permissions,workflows,
3625
+ // notifications,ai}`) are `"private": true` pnpm workspace packages with
3626
+ // `workspace:*` deps — not installable from any registry today (this
3627
+ // platform runs no private npm registry of its own). "download" here means
3628
+ // exactly what `npm pack` produces: a real, standard, redistributable
3629
+ // `.tgz` a developer `npm install`s locally into their own plugin
3630
+ // workspace — NOT a hosted `npm install -g @erp/x`. Building first
3631
+ // (`pnpm --filter @erp/<name> build`, same `spawnSync` shape as
3632
+ // `runSpark`) means the `.tgz` always contains a real, current `dist/`.
3633
+ // ---------------------------------------------------------------------------
3634
+ const SDK_PACKAGES = ["ui", "data", "auth", "permissions", "workflows", "notifications", "ai"];
3635
+
3636
+ function sdkPackageJson(name) {
3637
+ const dir = path.join(REPO_ROOT, "frontend/packages", `erp-${name}`);
3638
+ const pkgPath = path.join(dir, "package.json");
3639
+ if (!existsSync(pkgPath)) throw new Error(`unknown SDK package "${name}" — known: ${SDK_PACKAGES.join(", ")}`);
3640
+ return { dir, pkg: JSON.parse(readFileSync(pkgPath, "utf8")) };
3641
+ }
3642
+
3643
+ function sdkListCommand() {
3644
+ console.log(`The 7 real @erp/* Code Plugin SDK packages (see ai/patterns/code-plugin-sdk.md):\n`);
3645
+ for (const name of SDK_PACKAGES) {
3646
+ const { pkg } = sdkPackageJson(name);
3647
+ console.log(` ${pkg.name}@${pkg.version}`);
3648
+ console.log(` ${pkg.description || "(no description)"}\n`);
3649
+ }
3650
+ }
3651
+
3652
+ function runPnpm(args, cwd) {
3653
+ // `shell: true` (not a bare `.cmd` invocation) — Windows' spawnSync throws EINVAL calling a .cmd
3654
+ // shim directly without a shell, found live running this exact command.
3655
+ const res = spawnSync("pnpm", args, { stdio: "inherit", cwd, shell: true });
3656
+ if (res.status !== 0) throw new Error(`pnpm ${args.join(" ")} failed (exit ${res.status})`);
3657
+ }
3658
+
3659
+ /** Builds `@erp/<name>`, `npm pack`s it, and copies the resulting `.tgz` into `outDir`. Returns the copied path. */
3660
+ function sdkDownloadOne(name, outDir) {
3661
+ const { dir, pkg } = sdkPackageJson(name);
3662
+ console.log(`Building ${pkg.name}...`);
3663
+ runPnpm(["--filter", pkg.name, "build"], path.join(REPO_ROOT, "frontend"));
3664
+ console.log(`Packing ${pkg.name}...`);
3665
+ const packRes = spawnSync("npm", ["pack", "--json"], { cwd: dir, encoding: "utf8", shell: true });
3666
+ if (packRes.status !== 0) throw new Error(`npm pack failed for ${pkg.name}: ${packRes.stderr || packRes.stdout}`);
3667
+ const [{ filename }] = JSON.parse(packRes.stdout);
3668
+ mkdirSync(outDir, { recursive: true });
3669
+ const dest = path.join(outDir, filename);
3670
+ copyFileSync(path.join(dir, filename), dest);
3671
+ unlinkSync(path.join(dir, filename));
3672
+ return dest;
3673
+ }
3674
+
3675
+ function sdkDownloadCommand(name, opts) {
3676
+ if (!name) throw new Error("usage: erp sdk download <name> [--out <dir>] — see `erp sdk list` for names");
3677
+ const outDir = path.resolve(opts.out || process.cwd());
3678
+ const dest = sdkDownloadOne(name, outDir);
3679
+ console.log(`\nWrote ${dest}`);
3680
+ console.log(`\nTo use it in another project:\n\n npm install ${dest}\n`);
3681
+ const { pkg } = sdkPackageJson(name);
3682
+ const workspaceDeps = Object.entries(pkg.dependencies || {}).filter(([, v]) => v === "workspace:*").map(([n]) => n);
3683
+ if (workspaceDeps.length > 0) {
3684
+ console.log(`${pkg.name} depends on ${workspaceDeps.join(", ")} — download and \`npm install\` those .tgz files FIRST (dependency order), or just run \`erp sdk download-all\`.`);
3685
+ }
3686
+ }
3687
+
3688
+ function sdkDownloadAllCommand(opts) {
3689
+ const outDir = path.resolve(opts.out || process.cwd());
3690
+ console.log(`Downloading all 7 @erp/* SDK packages, in dependency order, into ${outDir}...\n`);
3691
+ const installLines = [];
3692
+ for (const name of SDK_PACKAGES) {
3693
+ const dest = sdkDownloadOne(name, outDir);
3694
+ installLines.push(`npm install ${dest}`);
3695
+ console.log(`Wrote ${dest}\n`);
3696
+ }
3697
+ console.log(`To use the whole SDK in another project, run these IN ORDER:\n`);
3698
+ console.log(installLines.join("\n"));
3699
+ }
3700
+
3701
+ // ---------------------------------------------------------------------------
3702
+ // Arg parsing + dispatch
3703
+ // ---------------------------------------------------------------------------
3704
+ // Short-flag aliases for the handful of single-dash flags this CLI's own
3705
+ // help text documents (e.g. `erp plugin build <dir> [-o out.spk]`). Real bug
3706
+ // fixed here: parseArgs previously only recognized `--long` flags, so a
3707
+ // documented `-o foo.spk` was silently treated as a stray positional arg —
3708
+ // opts.out stayed undefined, pluginBuildCommand/packageCommand fell back to
3709
+ // their own default filename, and the .spk landed in cwd instead of the
3710
+ // path the user asked for, with zero error or warning. Found live via
3711
+ // `erp plugin build ... -o backend/modules/hcm-learning/hcm-learning-1.0.10.spk`
3712
+ // writing to `hcm-learning-1.0.10.spk` at the repo root instead.
3713
+ const SHORT_FLAG_ALIASES = { "-o": "out" };
3714
+
3715
+ function parseArgs(argv) {
3716
+ const opts = {};
3717
+ const positional = [];
3718
+ for (let i = 0; i < argv.length; i++) {
3719
+ const a = argv[i];
3720
+ if (a.startsWith("--")) {
3721
+ const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
3722
+ const next = argv[i + 1];
3723
+ if (next && !next.startsWith("--")) { opts[key] = next; i++; } else { opts[key] = true; }
3724
+ } else if (Object.prototype.hasOwnProperty.call(SHORT_FLAG_ALIASES, a)) {
3725
+ const key = SHORT_FLAG_ALIASES[a];
3726
+ const next = argv[i + 1];
3727
+ if (next && !next.startsWith("-")) { opts[key] = next; i++; } else { opts[key] = true; }
3728
+ } else positional.push(a);
3729
+ }
3730
+ return { opts, positional };
3731
+ }
3732
+
3733
+ const HELP = `erp — ERP Developer Platform CLI
3734
+
3735
+ Global: any command below that talks to the backend also accepts
3736
+ --tenant <id> to run against a tenant other than the current session's own
3737
+ (sent as X-Tenant-Id, overriding the logged-in tenant for that one call —
3738
+ note this only actually works where the session's own token is valid
3739
+ against that tenant too; it does not re-authenticate you as someone else).
3740
+
3741
+ Auth: 'erp login' stores an access + refresh token per env in ~/.erp/config.json
3742
+ (mode 0600); networked commands auto-refresh an expired access token. Set
3743
+ ERP_TOKEN=<access token> to override the stored creds entirely (CI path).
3744
+
3745
+ erp login [--tenant <id>] [--no-browser] (real OAuth 2.0 device authorization grant, RFC 8628 — default)
3746
+ erp login --direct [--tenant <id>] [--user <id>] [--password <pw>] (original one-shot username/password)
3747
+ erp logout
3748
+ erp whoami
3749
+ erp org list
3750
+ erp env list
3751
+ erp env use <name> [--base-url <url>]
3752
+ erp env sync [--check] (pull this env's authoring bundle from {baseUrl}/api/v1/dev/bundle into ~/.erp/cache/<envKey>/ — schemas/catalog/docs/blocks/validators + live entity+theme names; --check = manifest drift only, no download)
3753
+
3754
+ erp bundle build [--out <dir>] [--bundle-version <v>] (emit erp-authoring-bundle/ — schemas + frozen engine catalog + curated examples + tenant-public docs + blocks.json + self-contained validators/ — the offline grounding a packaged SDK install reads; the Java source scan + esbuild run HERE, never on the client)
3755
+ erp docs gen-reference [--out <dir>] (turn the built bundle's schemas/ into one Markdown reference page each, under developer-docs/reference/)
3756
+
3757
+ erp plugin list
3758
+ erp plugin search <term>
3759
+ erp plugin install <pluginId> [--manifest <path>]
3760
+ erp plugin pull <pluginId> [--out <dir>]
3761
+ erp plugin create <pluginId> [--name] [--schema] [--category] [--type <type>] [--runtime-mode embedded|service] [--service-port <n>]
3762
+ 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
+ erp plugin diff <pluginId>
3764
+ 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
+ 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
+ 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)
3768
+ erp plugin validate <dir>
3769
+ erp plugin compile <source.tsx> [--out <page.json>]
3770
+ erp plugin test <dir>
3771
+ erp plugin build <dir> [-o out.spk]
3772
+ erp plugin install-bundle <applicationKey> [--tenant <id>] [--continue-on-error] (builds+installs an ENTIRE ApplicationCatalog bundle, e.g. "hcm", in one command — real dependency order computed from each plugin's own plugin.json, not hand-maintained; already-installed members are skipped, so re-running after a partial failure safely resumes)
3773
+ erp plugin set-runtime-mode <plugin-dir> --mode embedded|service [--port <n>] [--health-path <path>] [--timeout-ms <n>] (lets a developer decide an EXISTING module should run standalone as its own service — declares runtimeModes+serviceDeployment on plugin.json, re-validated before writing; does not itself start/deploy anything — see the command's own note)
3774
+ erp plugin publish <dir-or-.spk> [--env <name>] [--url <base>] [--tenant <id>] [--dry-run]
3775
+ (publishes to the configured env's base_url with your 'erp login' token — never a hardcoded host)
3776
+ erp plugin push <dir-or-.spk> (alias of publish; --force runs force-unload first if a prior load is wedged)
3777
+ erp plugin force-unload <pluginId> [--tenant <id>] (recovery: force-clears a wedged fleet-wide PF4J load — plugin still STARTED in-process while its install row is 'uninstalled' — without a backend restart; idempotent)
3778
+ erp plugin publish-frontend <backend-module-dir> --cabinet-id <id> [--entrypoint-export <name>] [--frontend-dir <dir>]
3779
+
3780
+ erp registry list [--type <plugin|theme|template-pack|...>] (hosted catalog, published only)
3781
+ erp registry mine [--type <...>] (your tenant's own listings, any status)
3782
+ erp registry get <packageId> <version>
3783
+ erp registry publish <packageId> <version> [--type plugin] [--name] [--author] [--license] [--description] [--payload-file <file>]
3784
+ erp registry install <packageId> <version> (plugin type: payload's spkFileId -> the real PluginInstallPipeline)
3785
+
3786
+ erp schema pull <name> [--out <file>]
3787
+ erp schema validate <file> --schema <name>
3788
+ erp workflow validate <file>
3789
+ erp workflow test <file>
3790
+ erp workflow list --definition <workflowName> [--record <id>] [--tenant <id>] (live: every running instance of a definition, optionally filtered to one record's correlation/context id — via engine-api's workflow read-proxy, no workflow-service URL needed)
3791
+ erp workflow instance <instanceId> [--tenant <id>] (live: full instance view — stage, status, context)
3792
+ erp workflow tasks <instanceId> [--tenant <id>] (live: the instance's human tasks, incl. pending approver)
3793
+ erp workflow history <instanceId> [--tenant <id>] (live: the instance's step/transition history)
3794
+ erp connector validate <file> (schema-check a connector-definition JSON file against engine-provider-connector's real schema, e.g. spk-assembly/metadata/connector/*.json)
3795
+ erp connector create <file> [--tenant <id>] (validates, then PUTs to the real, live ExternalProviderDefinitionController — creates/updates one external_provider_definition row)
3796
+ erp connector list --domain <domain> [--tenant <id>] (lists real registered rows for a domain, e.g. "hcm-employee-documents.signature")
3797
+ erp connector test <domain> <providerKey> [--body <json-string-or-@file>] [--tenant <id>] (invokes the real GenericProviderConnectorExecutor for that row — DIRECT rows return a synthesized pending-confirm result, HTTP rows make a real outbound call)
3798
+ erp --version | erp version (prints @erp/plugin-sdk's installed version, or 0.0.0-monorepo in a repo checkout)
3799
+ erp docs search <query>
3800
+ erp docs serve [--port 4400] (local offline dev-preview of developer-docs/ — monorepo: the live tree; packaged: the bundle's/synced docs/, sidebar from docs.json's nav)
3801
+ 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
+ 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
+ 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)
3804
+ 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
+ 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
+ erp ai init [dir]
3807
+ erp mcp install [--dir <dir>]
3808
+ erp sdk list (the 7 real @erp/* Code Plugin SDK packages)
3809
+ erp sdk download <name> [--out <dir>] (builds + npm-packs one into a local .tgz — no hosted registry)
3810
+ erp sdk download-all [--out <dir>] (all 7, in dependency order, into one dir)
3811
+ erp dev <page.json> [--port 4300] [--yes|--start] (auto-detects/starts real engine-api if down)
3812
+
3813
+ Every command's real vs. thin/disclosed status is documented at the top of
3814
+ tools/erp-cli/erp.mjs and in ai/domains/ai-native-developer-platform-gap-analysis.md.
3815
+ `;
3816
+
3817
+ // ---------------------------------------------------------------------------
3818
+ // erp extension create-service <name> — [REAL, Tenant-Safe Plugin & Extension
3819
+ // Architecture E1/E3, spec §55]. Copies the hardened, real, runnable skeleton
3820
+ // at extensions/archetype/ (see its own README — the request/response wire
3821
+ // contract EXACTLY matches ExtensionRouter/ExtensionContext, including the
3822
+ // HMAC service-auth bearer token E3 added) rather than hand-emitting a
3823
+ // second, divergent template — this used to write its own inline template
3824
+ // (POST /extension-api/${name}/invoke, a nested "context" body key) which
3825
+ // did NOT match ExtensionRouter's real contract (POST /extension-api/{code},
3826
+ // a FLAT body) — fixed by making this command a copy + light rename of the
3827
+ // one real archetype instead of a second hand-maintained copy.
3828
+ // ---------------------------------------------------------------------------
3829
+ function extensionCreateServiceCommand(name, opts) {
3830
+ if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
3831
+ throw new Error('usage: erp extension create-service <name> (lowercase, e.g. "tenant-c-payroll") [--out <dir>]');
3832
+ }
3833
+ const artifactId = `${name}-extension-service`;
3834
+ const namespace = `tenant-${name}`;
3835
+ const archetypeDir = path.join(REPO_ROOT, "extensions", "archetype");
3836
+ if (!existsSync(archetypeDir)) throw new Error(`Archetype not found at ${archetypeDir} — repo layout changed?`);
3837
+ const parent = opts.out ? path.resolve(opts.out) : path.join(REPO_ROOT, "extensions");
3838
+ const root = path.join(parent, artifactId);
3839
+ if (existsSync(root)) throw new Error(`${root} already exists — pick another name or delete it first.`);
3840
+
3841
+ const TEXT_EXT = new Set([".java", ".xml", ".yml", ".yaml", ".md", "", ".properties"]);
3842
+ const copied = [];
3843
+ const copyDir = (src, dest) => {
3844
+ mkdirSync(dest, { recursive: true });
3845
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
3846
+ if (entry.name === "target") continue; // never copy a stale local build
3847
+ const srcPath = path.join(src, entry.name);
3848
+ const destPath = path.join(dest, entry.name.startsWith("Dockerfile") ? entry.name : entry.name);
3849
+ if (entry.isDirectory()) {
3850
+ copyDir(srcPath, destPath);
3851
+ } else {
3852
+ const ext = path.extname(entry.name);
3853
+ if (TEXT_EXT.has(ext)) {
3854
+ let body = readFileSync(srcPath, "utf8");
3855
+ // Namespace token first (contains "extension-archetype" as a substring) so it
3856
+ // doesn't get mangled by the shorter artifactId replacement below.
3857
+ body = body.replaceAll("tenant-extension-archetype", namespace);
3858
+ body = body.replaceAll("extension-archetype", artifactId);
3859
+ writeFileSync(destPath, body);
3860
+ } else {
3861
+ copyFileSync(srcPath, destPath);
3862
+ }
3863
+ copied.push(path.relative(root, destPath));
3864
+ }
3865
+ }
3866
+ };
3867
+ copyDir(archetypeDir, root);
3868
+
3869
+ console.log(`Scaffolded ${root} (copied from extensions/archetype/)`);
3870
+ copied.sort().forEach((f) => console.log(` ${f}`));
3871
+ console.log(`\nJava package/class names kept as the archetype's own (com.tenant.extension.archetype /`);
3872
+ console.log(`ExtensionApiController) — rename them yourself if you want a project-specific package.`);
3873
+ console.log(`\nNext: cd ${path.relative(process.cwd(), root) || root} && mvn spring-boot:run`);
3874
+ console.log(`Then: erp tenant plugin install <tenantId> ${name} --runtime service --endpoint https://<host>:8480 --application <APP>`);
3875
+ }
3876
+
3877
+ // ---------------------------------------------------------------------------
3878
+ // erp tenant plugin install|enable|disable|list <tenant> <extensionCode>
3879
+ // [REAL, E1, spec §56] — drives the tenant_extension API
3880
+ // (TenantExtensionController, /api/v1/platform/tenant-extensions).
3881
+ // ---------------------------------------------------------------------------
3882
+ async function tenantPluginCommand(positional, opts) {
3883
+ const [action, tenant, extensionCode] = positional;
3884
+ const usage = "usage: erp tenant plugin <install|enable|disable|list> <tenant> [<extensionCode>] "
3885
+ + "[--application HCM] [--version <v>] [--runtime service|shared] [--endpoint <url>]";
3886
+ if (!action || !tenant) throw new Error(usage);
3887
+ const cfg = loadConfig();
3888
+ const application = opts.application || opts.app || "HCM";
3889
+ const base = `/api/v1/platform/tenant-extensions/${encodeURIComponent(tenant)}`;
3890
+
3891
+ if (action === "list") {
3892
+ const rows = await api(cfg, "GET", base, { tenantIdOverride: opts.tenant });
3893
+ console.log(JSON.stringify(rows, null, 2));
3894
+ console.log(`\n${Array.isArray(rows) ? rows.length : 0} extension binding(s) for tenant ${tenant}.`);
3895
+ return;
3896
+ }
3897
+ if (!extensionCode) throw new Error(usage);
3898
+
3899
+ if (action === "install") {
3900
+ const runtimeType = (opts.runtime || "service").toUpperCase();
3901
+ if (runtimeType === "SERVICE" && !opts.endpoint) {
3902
+ throw new Error("a --runtime service binding needs --endpoint <url> (where the extension service listens).");
3903
+ }
3904
+ const body = {
3905
+ applicationId: application,
3906
+ extensionCode,
3907
+ extensionType: opts.type || "java-extension",
3908
+ version: opts.version || null,
3909
+ runtimeType,
3910
+ endpoint: opts.endpoint || null,
3911
+ status: "INSTALLED",
3912
+ };
3913
+ const row = await api(cfg, "POST", base, { body, tenantIdOverride: opts.tenant });
3914
+ console.log(JSON.stringify(row, null, 2));
3915
+ console.log(`\nInstalled "${extensionCode}" for tenant ${tenant} / ${application} (status INSTALLED — run \`erp tenant plugin enable\` to activate).`);
3916
+ return;
3917
+ }
3918
+ if (action === "enable" || action === "disable") {
3919
+ const status = action === "enable" ? "ENABLED" : "DISABLED";
3920
+ const url = `${base}/${encodeURIComponent(application)}/${encodeURIComponent(extensionCode)}/status?status=${status}`;
3921
+ const row = await api(cfg, "PUT", url, { tenantIdOverride: opts.tenant });
3922
+ console.log(JSON.stringify(row, null, 2));
3923
+ console.log(`\n"${extensionCode}" -> ${status} for tenant ${tenant} / ${application}.`);
3924
+ return;
3925
+ }
3926
+ throw new Error(usage);
3927
+ }
3928
+
3929
+ // Reads the package.json that ships alongside this file when installed as
3930
+ // @erp/plugin-sdk (packages/erp-plugin-sdk/package.json, one dir up from
3931
+ // erp-cli/). In the monorepo checkout no such file exists at that path —
3932
+ // falls back to "0.0.0-monorepo" so `erp --version` never throws.
3933
+ function cliVersion() {
3934
+ try {
3935
+ const p = path.join(__dirname, "..", "package.json");
3936
+ if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")).version || "0.0.0";
3937
+ } catch { /* fall through */ }
3938
+ return "0.0.0-monorepo";
3939
+ }
3940
+
3941
+ async function main() {
3942
+ const argv = process.argv.slice(2);
3943
+ if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
3944
+ console.log(`@erp/plugin-sdk ${cliVersion()}`);
3945
+ return;
3946
+ }
3947
+ const command = argv[0];
3948
+ const sub = argv[1];
3949
+ const twoWordKey = sub && !sub.startsWith("--") ? `${command} ${sub}` : null;
3950
+
3951
+ let opts = {};
3952
+ let positional = [];
3953
+
3954
+ const table = {
3955
+ login: () => loginCommand(opts),
3956
+ logout: () => logoutCommand(),
3957
+ whoami: () => whoamiCommand(opts),
3958
+ "org list": () => orgListCommand(opts),
3959
+ "env list": () => envListCommand(),
3960
+ "env use": () => envUseCommand(positional[0], opts),
3961
+ "plugin list": () => pluginListCommand(opts),
3962
+ "plugin search": () => pluginSearchCommand(positional[0], opts),
3963
+ "plugin install": () => pluginInstallCommand(positional[0], opts),
3964
+ "plugin pull": () => pluginPullCommand(positional[0], opts),
3965
+ "plugin create": () => pluginCreateCommand(positional[0], opts),
3966
+ "plugin diff": () => pluginDiffCommand(positional[0], opts),
3967
+ "spec generate": () => specGenerateCommand(positional[0], opts),
3968
+ "platform catalog": () => platformCatalogCommand(opts),
3969
+ "platform describe": () => platformDescribeCommand(positional[0], opts),
3970
+ "plugin scaffold": () => pluginScaffoldCommand(positional[0], opts),
3971
+ "extension create-service": () => extensionCreateServiceCommand(positional[0], opts),
3972
+ "tenant plugin": () => tenantPluginCommand(positional, opts),
3973
+ "registry list": () => registryListCommand(opts),
3974
+ "registry mine": () => registryMineCommand(opts),
3975
+ "registry get": () => registryGetCommand(positional[0], positional[1], opts),
3976
+ "registry publish": () => registryPublishCommand(positional[0], positional[1], opts),
3977
+ "registry install": () => registryInstallCommand(positional[0], positional[1], opts),
3978
+ "plugin validate": () => pluginValidateCommand(positional[0]),
3979
+ "plugin compile": () => pluginCompileCommand(positional[0], opts),
3980
+ "plugin test": () => pluginTestCommand(positional[0]),
3981
+ "plugin build": () => pluginBuildCommand(positional[0], opts),
3982
+ "plugin install-bundle": () => pluginBundleInstallCommand(positional[0], opts),
3983
+ "plugin set-runtime-mode": () => pluginSetRuntimeModeCommand(positional[0], opts),
3984
+ "plugin publish": () => pluginPublishCommand(positional[0], opts),
3985
+ "plugin publish-frontend": () => pluginPublishFrontendCommand(positional[0], opts),
3986
+ "plugin push": () => pluginPublishCommand(positional[0], opts),
3987
+ "plugin force-unload": () => pluginForceUnloadCommand(positional[0], opts),
3988
+ "schema pull": () => schemaPullCommand(positional[0], opts),
3989
+ "schema validate": () => schemaValidateCommand(positional[0], opts),
3990
+ "workflow validate": () => workflowValidateCommand(positional[0]),
3991
+ "workflow test": () => workflowTestCommand(positional[0]),
3992
+ "workflow list": () => workflowListCommand(opts),
3993
+ "workflow instance": () => workflowInstanceCommand(positional[0], opts),
3994
+ "workflow tasks": () => workflowTasksCommand(positional[0], opts),
3995
+ "workflow history": () => workflowHistoryCommand(positional[0], opts),
3996
+ "connector validate": () => connectorValidateCommand(positional[0]),
3997
+ "connector create": () => connectorCreateCommand(positional[0], opts),
3998
+ "connector list": () => connectorListCommand(opts),
3999
+ "connector test": () => connectorTestCommand(positional[0], positional[1], opts),
4000
+ "bundle build": () => bundleBuildCommand(opts),
4001
+ "env sync": () => envSyncCommand(opts),
4002
+ "docs search": () => docsSearchCommand(positional.join(" ")),
4003
+ "docs gen-reference": () => docsGenReferenceCommand(opts),
4004
+ "docs serve": () => docsServeCommand(opts),
4005
+ "examples search": () => examplesSearchCommand(positional[0], opts),
4006
+ "examples patterns": () => examplesPatternsCommand(opts),
4007
+ "blocks list": () => blocksListCommand(opts),
4008
+ "api get": () => apiCallCommand("GET", positional[0], opts),
4009
+ "api post": () => apiCallCommand("POST", positional[0], opts),
4010
+ "api put": () => apiCallCommand("PUT", positional[0], opts),
4011
+ "api delete": () => apiCallCommand("DELETE", positional[0], opts),
4012
+ "logs tail": () => logsTailCommand(opts),
4013
+ "menu create": () => menuCreateCommand(positional[0], positional[1], opts),
4014
+ "ai init": () => aiInitCommand(positional[0]),
4015
+ "mcp install": () => mcpInstallCommand(opts),
4016
+ "sdk list": () => sdkListCommand(),
4017
+ "sdk download": () => sdkDownloadCommand(positional[0], opts),
4018
+ "sdk download-all": () => sdkDownloadAllCommand(opts),
4019
+ dev: () => devCommand(positional[0], opts),
4020
+ };
4021
+
4022
+ const key = twoWordKey && table[twoWordKey] ? twoWordKey : command;
4023
+ const argsForParsing = key === twoWordKey ? argv.slice(2) : argv.slice(1);
4024
+ ({ opts, positional } = parseArgs(argsForParsing));
4025
+
4026
+ const fn = table[key];
4027
+ if (!fn) {
4028
+ console.log(HELP);
4029
+ process.exitCode = key ? 1 : 0;
4030
+ return;
4031
+ }
4032
+ await fn();
4033
+ }
4034
+
4035
+ // Guarded (2026-09-03, added for I18N-001's MCP wiring): erp.mjs used to run
4036
+ // `main()` unconditionally at module-load time, which made it unsafe to
4037
+ // `import()` from anywhere else (e.g. the MCP server) — importing it would
4038
+ // re-parse THAT process's own argv and potentially execute a CLI command as
4039
+ // a side effect. Only invoking `main()` when this file is the actual entry
4040
+ // point (`node erp.mjs ...`) — not when another ESM module imports it for
4041
+ // its exported functions — makes it a safely reusable module too, the same
4042
+ // "provider exposes a real capability, consumers get a thin wrapper"
4043
+ // convention already used for `ReferenceResolutionService`/`DmsGatewayClient`
4044
+ // elsewhere in this codebase.
4045
+ // Real gap found + fixed while proving @erp/plugin-sdk's npm portability
4046
+ // (packages/erp-plugin-sdk, Batch C): a plain string compare of
4047
+ // import.meta.url vs argv[1]'s file URL breaks for ANY symlinked/junction
4048
+ // install — `npm install -g <local-dir>` and pnpm's global installs both
4049
+ // link rather than copy, so import.meta.url resolves through the symlink to
4050
+ // its target while argv[1] (as the invoking shim passed it) stays on the
4051
+ // link path. The two URLs then never match, the guard is silently false,
4052
+ // and `erp` exits 0 having done nothing — no error, just a no-op. Compare
4053
+ // realpaths (falls back to the raw path if realpath fails, e.g. it's already
4054
+ // gone) so a linked global install behaves exactly like a copied one.
4055
+ function samePath(a, b) {
4056
+ try { return realpathSync(a) === realpathSync(b); } catch { return a === b; }
4057
+ }
4058
+ if (process.argv[1] && samePath(fileURLToPath(import.meta.url), process.argv[1])) {
4059
+ main().catch((err) => {
4060
+ console.error(`error: ${err.message}`);
4061
+ process.exitCode = 1;
4062
+ });
4063
+ }
4064
+
4065
+ export { lintPageSemantics, lintDataServiceSemantics, lintI18nSemantics, lintPageDataSourceSemantics, lintJobContributionUsage, loadEntityFieldTypes, loadI18nKeys, loadProviderNamesFrom, loadAllRepoProviderNames, simpleSchemaCheck, buildEngineCatalog, searchEngineCatalog, describeContract, pluginScaffoldCommand, SCAFFOLD_TEMPLATES };