@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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/bundle/blocks.json +12229 -0
- package/bundle/catalog.json +32475 -0
- package/bundle/docs/applications/hcm/employee-profile.md +144 -0
- package/bundle/docs/concepts/artifact-types.md +80 -0
- package/bundle/docs/concepts/glossary.md +124 -0
- package/bundle/docs/concepts/overview.md +88 -0
- package/bundle/docs/concepts/page-data-flow.md +84 -0
- package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
- package/bundle/docs/concepts/sdk-modes.md +71 -0
- package/bundle/docs/docs.json +109 -0
- package/bundle/docs/guides/add-a-cadence-job.md +134 -0
- package/bundle/docs/guides/add-a-compliance-job.md +139 -0
- package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
- package/bundle/docs/guides/add-a-custom-block.md +225 -0
- package/bundle/docs/guides/add-a-data-provider.md +216 -0
- package/bundle/docs/guides/add-a-kpi.md +154 -0
- package/bundle/docs/guides/add-a-reminder-job.md +181 -0
- package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
- package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
- package/bundle/docs/guides/add-an-entity.md +170 -0
- package/bundle/docs/guides/add-business-rules.md +147 -0
- package/bundle/docs/guides/add-i18n.md +121 -0
- package/bundle/docs/guides/add-menus.md +149 -0
- package/bundle/docs/guides/build-a-code-plugin.md +141 -0
- package/bundle/docs/guides/build-a-page.md +213 -0
- package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
- package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
- package/bundle/docs/guides/create-a-plugin.md +172 -0
- package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
- package/bundle/docs/guides/index.md +53 -0
- package/bundle/docs/guides/publish-and-upgrade.md +152 -0
- package/bundle/docs/guides/responsive-plugin.md +117 -0
- package/bundle/docs/guides/seed-data-on-install.md +127 -0
- package/bundle/docs/guides/set-up-the-sdk.md +120 -0
- package/bundle/docs/guides/use-the-mcp-server.md +104 -0
- package/bundle/docs/guides/validate-and-test.md +119 -0
- package/bundle/docs/guides/wire-a-pages-data.md +153 -0
- package/bundle/docs/quickstart/index.md +118 -0
- package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
- package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
- package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
- package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
- package/bundle/docs/recipes/index.md +40 -0
- package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
- package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
- package/bundle/docs/recipes/master-detail-page.md +84 -0
- package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
- package/bundle/docs/recipes/per-row-document-generator.md +65 -0
- package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
- package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
- package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
- package/bundle/docs/reference/action.md +18 -0
- package/bundle/docs/reference/block-definition.md +35 -0
- package/bundle/docs/reference/block-instance.md +33 -0
- package/bundle/docs/reference/composite-pack-manifest.md +33 -0
- package/bundle/docs/reference/connector-definition.md +36 -0
- package/bundle/docs/reference/data-service-definition.md +22 -0
- package/bundle/docs/reference/data-view-definition.md +26 -0
- package/bundle/docs/reference/entity-aggregation-config.md +38 -0
- package/bundle/docs/reference/entity-cadence-config.md +33 -0
- package/bundle/docs/reference/entity-compliance-config.md +37 -0
- package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
- package/bundle/docs/reference/entity-definition.md +18 -0
- package/bundle/docs/reference/entity-document-generator-config.md +35 -0
- package/bundle/docs/reference/entity-rule-definition.md +29 -0
- package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
- package/bundle/docs/reference/form.md +35 -0
- package/bundle/docs/reference/index.md +37 -0
- package/bundle/docs/reference/menu.md +26 -0
- package/bundle/docs/reference/mobile-nav.md +22 -0
- package/bundle/docs/reference/page-template.md +29 -0
- package/bundle/docs/reference/page.md +44 -0
- package/bundle/docs/reference/plugin-manifest.md +54 -0
- package/bundle/docs/reference/plugin-seed-data.md +21 -0
- package/bundle/docs/reference/print-template-definition.md +22 -0
- package/bundle/docs/reference/provider-definition.md +22 -0
- package/bundle/docs/reference/report-definition.md +22 -0
- package/bundle/docs/reference/rule.md +30 -0
- package/bundle/docs/reference/workflow-definition.md +28 -0
- package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
- package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
- package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
- package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
- package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
- package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
- package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
- package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
- package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
- package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
- package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
- package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
- package/bundle/docs/tutorial/index.md +72 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
- package/bundle/examples/example-patterns.mjs +94 -0
- package/bundle/manifest.json +14 -0
- package/bundle/schemas/action.schema.json +102 -0
- package/bundle/schemas/block-definition.schema.json +222 -0
- package/bundle/schemas/block-instance.schema.json +139 -0
- package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
- package/bundle/schemas/connector-definition.schema.json +29 -0
- package/bundle/schemas/data-service-definition.schema.json +64 -0
- package/bundle/schemas/data-view-definition.schema.json +148 -0
- package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
- package/bundle/schemas/entity-cadence-config.schema.json +27 -0
- package/bundle/schemas/entity-compliance-config.schema.json +31 -0
- package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
- package/bundle/schemas/entity-definition.schema.json +126 -0
- package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
- package/bundle/schemas/entity-rule-definition.schema.json +75 -0
- package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
- package/bundle/schemas/form.schema.json +281 -0
- package/bundle/schemas/menu.schema.json +106 -0
- package/bundle/schemas/mobile-nav.schema.json +53 -0
- package/bundle/schemas/page-template.schema.json +46 -0
- package/bundle/schemas/page.schema.json +292 -0
- package/bundle/schemas/plugin-manifest.schema.json +139 -0
- package/bundle/schemas/plugin-seed-data.schema.json +24 -0
- package/bundle/schemas/print-template-definition.schema.json +90 -0
- package/bundle/schemas/provider-definition.schema.json +27 -0
- package/bundle/schemas/report-definition.schema.json +96 -0
- package/bundle/schemas/rule.schema.json +82 -0
- package/bundle/schemas/workflow-definition.schema.json +42 -0
- package/bundle/validators/block-engine.mjs +23222 -0
- package/bundle/validators/page-engine.mjs +21645 -0
- package/erp-cli/authoring-root.mjs +344 -0
- package/erp-cli/erp.mjs +4065 -0
- package/erp-cli/example-patterns.mjs +94 -0
- package/erp-mcp-server/index.mjs +818 -0
- package/package.json +27 -0
package/erp-cli/erp.mjs
ADDED
|
@@ -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}\` · 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, "<")}</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 };
|