@stalfh233/omc-cli 0.4.75 → 0.4.77
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/coverage/m1-coverage-manifest-v1.json +35 -1
- package/dist/artifacts.js +2 -2
- package/dist/capability-overview.js +11 -1
- package/dist/cli.js +91 -40
- package/dist/command-transport.js +1 -1
- package/dist/compute.js +2 -1
- package/dist/contract-resources.js +2 -0
- package/dist/coverage-registry.js +2 -0
- package/dist/discovery.js +18 -0
- package/dist/endpoints.js +16 -0
- package/dist/form-layout.js +25 -2
- package/dist/form-support.js +52 -7
- package/dist/lanes/datarule.js +33 -9
- package/dist/lanes/folder-contract.js +59 -0
- package/dist/lanes/folder-coverage.js +16 -0
- package/dist/lanes/folder.js +241 -0
- package/dist/lanes/form-contract.js +2 -2
- package/dist/lanes/form.js +15 -7
- package/dist/lanes/lane-ids.js +1 -0
- package/dist/lanes/list-view-contract.js +3 -0
- package/dist/lanes/list-view-shapes.js +27 -1
- package/dist/lanes/list-view.js +126 -1
- package/dist/lanes/model-contract.js +5 -0
- package/dist/lanes/model-coverage.js +1 -0
- package/dist/lanes/model.js +69 -0
- package/dist/path-projection.js +129 -0
- package/dist/tools.js +2 -0
- package/dist/write.js +9 -3
- package/package.json +3 -3
- package/skills/omc-capabilities.md +13 -6
package/dist/lanes/list-view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { ENDPOINTS } from "../endpoints.js";
|
|
3
4
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
@@ -195,6 +196,50 @@ export function filterDefaultSystemColumns(columns, hideDefaultSystemFields) {
|
|
|
195
196
|
return [...columns];
|
|
196
197
|
return columns.filter((column) => typeof column.propertyCode !== "string" || !SYSTEM_FIELD_CODES.has(column.propertyCode));
|
|
197
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Decode a BizButtonModel row's `actionConfig` (stored as a JSON STRING) and
|
|
201
|
+
* pull out the script source. Everything else is passed through so the caller
|
|
202
|
+
* can see bindAction/targetCode without a second read.
|
|
203
|
+
*/
|
|
204
|
+
export function decodeButtonScript(input) {
|
|
205
|
+
const button = (input ?? {});
|
|
206
|
+
const rawConfig = button.actionConfig;
|
|
207
|
+
let parsed;
|
|
208
|
+
if (typeof rawConfig === "string" && rawConfig.trim() !== "") {
|
|
209
|
+
try {
|
|
210
|
+
const value = JSON.parse(rawConfig);
|
|
211
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value))
|
|
212
|
+
parsed = value;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
parsed = undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (rawConfig !== null && typeof rawConfig === "object" && !Array.isArray(rawConfig)) {
|
|
219
|
+
parsed = rawConfig;
|
|
220
|
+
}
|
|
221
|
+
const script = typeof parsed?.scriptData === "string" ? parsed.scriptData : undefined;
|
|
222
|
+
return {
|
|
223
|
+
code: button.code ?? null,
|
|
224
|
+
name: button.name ?? null,
|
|
225
|
+
useLocation: button.useLocation ?? null,
|
|
226
|
+
bindAction: button.bindAction ?? null,
|
|
227
|
+
operateType: button.operateType ?? null,
|
|
228
|
+
targetCode: button.targetCode ?? null,
|
|
229
|
+
...(script !== undefined
|
|
230
|
+
? {
|
|
231
|
+
scriptData: script,
|
|
232
|
+
scriptBytes: Buffer.byteLength(script, "utf8"),
|
|
233
|
+
scriptSha256: createHash("sha256").update(script, "utf8").digest("hex"),
|
|
234
|
+
}
|
|
235
|
+
: {
|
|
236
|
+
scriptAbsent: typeof rawConfig === "string" && rawConfig.trim() !== "" && parsed === undefined
|
|
237
|
+
? "actionConfig 不是合法 JSON"
|
|
238
|
+
: "该按钮没有 scriptData(非 SCRIPT 按钮,或脚本未配置)",
|
|
239
|
+
}),
|
|
240
|
+
...(parsed !== undefined ? { actionConfig: parsed } : {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
198
243
|
export function registerListViewLane(tools) {
|
|
199
244
|
tools.push(defineTool({
|
|
200
245
|
name: "listview.get",
|
|
@@ -208,6 +253,8 @@ export function registerListViewLane(tools) {
|
|
|
208
253
|
schemaCode: z.string().min(1),
|
|
209
254
|
code: z.string().optional().describe("视图 code;省略时用默认视图(=schemaCode)"),
|
|
210
255
|
full: z.boolean().optional().describe("返回 query/get 原始报文(大体积);默认返回列/排序/条件/动作/按钮的紧凑清单"),
|
|
256
|
+
buttons: z.boolean().optional().describe("附带按钮明细(code/name/useLocation/bindAction/targetCode + SCRIPT 按钮的 scriptData 原文、actionConfig 解析结果)。按钮脚本存在 h_biz_button.actionConfig.scriptData,不必再翻 artifact"),
|
|
257
|
+
buttonCode: z.string().min(1).optional().describe("只返回这一个按钮的脚本(与 buttons 同效但更省输出);不存在时报 button-absent 并列出可用 code"),
|
|
211
258
|
capability: capabilityArg,
|
|
212
259
|
},
|
|
213
260
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
@@ -255,6 +302,27 @@ export function registerListViewLane(tools) {
|
|
|
255
302
|
conditions,
|
|
256
303
|
actionCodes: readArray(model, "queryActions").map((action) => action.actionCode).filter((value) => value !== undefined),
|
|
257
304
|
buttonCount: readArray(model, "customButtons").length,
|
|
305
|
+
// Button scripts live in h_biz_button.actionConfig (a JSON STRING)
|
|
306
|
+
// whose `scriptData` holds the source for bindAction=SCRIPT. Surfacing
|
|
307
|
+
// them here removes the "listview.get → artifact → JSON.parse →
|
|
308
|
+
// JSON.parse" chain for a plain script inspection.
|
|
309
|
+
...(() => {
|
|
310
|
+
if (args.buttons !== true && args.buttonCode === undefined)
|
|
311
|
+
return {};
|
|
312
|
+
const rows = readArray(model, "customButtons");
|
|
313
|
+
const decoded = rows.map((button) => decodeButtonScript(button));
|
|
314
|
+
if (args.buttonCode !== undefined) {
|
|
315
|
+
const hit = decoded.find((button) => button.code === args.buttonCode);
|
|
316
|
+
if (!hit)
|
|
317
|
+
return {
|
|
318
|
+
buttonAbsent: args.buttonCode,
|
|
319
|
+
availableButtonCodes: decoded.map((button) => button.code),
|
|
320
|
+
buttonHint: `视图 ${code} 没有按钮 ${args.buttonCode};可用:${decoded.map((button) => button.code).join("、") || "(无)"}`,
|
|
321
|
+
};
|
|
322
|
+
return { button: hit };
|
|
323
|
+
}
|
|
324
|
+
return { buttons: decoded };
|
|
325
|
+
})(),
|
|
258
326
|
options: model.options !== undefined && model.options !== null ? parseConditionOptions(model.options) : null,
|
|
259
327
|
// Round 16 (bench65 feedback): the query row's `published=false` is NOT
|
|
260
328
|
// an "inactive view" signal — a list view has no draft/publish
|
|
@@ -264,6 +332,10 @@ export function registerListViewLane(tools) {
|
|
|
264
332
|
};
|
|
265
333
|
}),
|
|
266
334
|
}));
|
|
335
|
+
/**
|
|
336
|
+
* Decode a BizButtonModel row's `actionConfig` (stored as a JSON STRING) and
|
|
337
|
+
* pull out the script source. Everything else is passed through so the caller
|
|
338
|
+
|
|
267
339
|
/** Reverse enum map: the wire stores numeric queryActionType, the tool arg takes the enum name. */
|
|
268
340
|
const QUERY_ACTION_TYPE_NAME = new Map(Object.entries(QUERY_ACTION_TYPE.index).map(([name, index]) => [index, name]));
|
|
269
341
|
/**
|
|
@@ -402,6 +474,10 @@ export function registerListViewLane(tools) {
|
|
|
402
474
|
.array(listViewButtonArg)
|
|
403
475
|
.optional()
|
|
404
476
|
.describe("customButtons; sent as the customButtonJson JSON array string; omitted leaves the existing buttons untouched, provided replaces every button under the QUERY trigger (schema + child tables)"),
|
|
477
|
+
buttonScripts: z
|
|
478
|
+
.array(z.object({ code: z.string().min(1), script: z.string() }))
|
|
479
|
+
.optional()
|
|
480
|
+
.describe("直接改按钮脚本:按 code 把 actionConfig.scriptData 换成 script(不改其它按钮/字段)。内部读回现有按钮再回写,因此不必先 listview.get 再整表重发;与 buttons 互斥"),
|
|
405
481
|
options: listViewOptionsArg.optional(),
|
|
406
482
|
presentationType: presentationTypeArg.optional(),
|
|
407
483
|
gantt: listViewGanttArg.optional(),
|
|
@@ -513,7 +589,50 @@ export function registerListViewLane(tools) {
|
|
|
513
589
|
// expanded into the authoritative actionConfig shape from readback
|
|
514
590
|
// BEFORE normalizeButtons (targetCode/targetObjCode never emitted for
|
|
515
591
|
// ADD_DATA/FORM — measured ineffective, see dry-lint).
|
|
516
|
-
if (args.buttons !== undefined)
|
|
592
|
+
if (args.buttons !== undefined && args.buttonScripts !== undefined)
|
|
593
|
+
throw new ToolRefusalError({ status: "refused", blocker: "listview-buttons-ambiguous", message: "buttons 与 buttonScripts 互斥:整表重配用 buttons,只改脚本用 buttonScripts" });
|
|
594
|
+
if (args.buttonScripts !== undefined) {
|
|
595
|
+
// Patch ONE field of ONE button in place: read the stored rows, rewrite
|
|
596
|
+
// actionConfig.scriptData by code, and send the full list back. This is
|
|
597
|
+
// the write counterpart of `listview.get --buttons`.
|
|
598
|
+
const existing = readArray(model, "customButtons");
|
|
599
|
+
if (existing.length === 0)
|
|
600
|
+
throw new ToolRefusalError({ status: "refused", blocker: "listview-button-absent", schemaCode: args.schemaCode, message: `视图 ${code} 没有自定义按钮——先用 listview.configure 的 buttons 建按钮(bindAction:SCRIPT)` });
|
|
601
|
+
const byCode = new Map(existing.map((button) => [String(button.code ?? ""), button]));
|
|
602
|
+
const missing = args.buttonScripts.map((entry) => entry.code).filter((buttonCode) => !byCode.has(buttonCode));
|
|
603
|
+
if (missing.length > 0)
|
|
604
|
+
throw new ToolRefusalError({
|
|
605
|
+
status: "refused",
|
|
606
|
+
blocker: "listview-button-absent",
|
|
607
|
+
schemaCode: args.schemaCode,
|
|
608
|
+
buttonCodes: missing,
|
|
609
|
+
available: existing.map((button) => String(button.code ?? "")),
|
|
610
|
+
message: `视图 ${code} 没有按钮 ${missing.join("、")};可用:${existing.map((button) => String(button.code ?? "")).join("、")}`,
|
|
611
|
+
});
|
|
612
|
+
const patched = existing.map((button) => {
|
|
613
|
+
const entry = args.buttonScripts.find((candidate) => candidate.code === String(button.code ?? ""));
|
|
614
|
+
if (!entry)
|
|
615
|
+
return button;
|
|
616
|
+
const rawConfig = button.actionConfig;
|
|
617
|
+
let config = {};
|
|
618
|
+
if (typeof rawConfig === "string" && rawConfig.trim() !== "") {
|
|
619
|
+
try {
|
|
620
|
+
const parsed = JSON.parse(rawConfig);
|
|
621
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
|
|
622
|
+
config = parsed;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
config = {};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
else if (rawConfig !== null && typeof rawConfig === "object" && !Array.isArray(rawConfig)) {
|
|
629
|
+
config = { ...rawConfig };
|
|
630
|
+
}
|
|
631
|
+
return { ...button, actionConfig: encodeJsonColumn({ ...config, scriptData: entry.script }) };
|
|
632
|
+
});
|
|
633
|
+
body.customButtonJson = JSON.stringify(normalizeButtons(patched, args.schemaCode, code));
|
|
634
|
+
}
|
|
635
|
+
else if (args.buttons !== undefined) {
|
|
517
636
|
const expandedButtons = await expandButtonActionConfigs(session, args.schemaCode, args.buttons);
|
|
518
637
|
body.customButtonJson = JSON.stringify(normalizeButtons(expandedButtons, args.schemaCode, code));
|
|
519
638
|
}
|
|
@@ -628,6 +747,12 @@ export function registerListViewLane(tools) {
|
|
|
628
747
|
readback.buttons = buttons
|
|
629
748
|
.map(decodeButtonRow)
|
|
630
749
|
.sort((a, b) => Number(a.sortKey ?? 0) - Number(b.sortKey ?? 0));
|
|
750
|
+
if (args.buttonScripts !== undefined) {
|
|
751
|
+
const decoded = buttons.map((button) => decodeButtonScript(button));
|
|
752
|
+
readback.buttonScripts = decoded
|
|
753
|
+
.filter((button) => (args.buttonScripts ?? []).some((entry) => entry.code === button.code))
|
|
754
|
+
.map((button) => ({ code: button.code, bindAction: button.bindAction, scriptBytes: button.scriptBytes ?? 0, scriptSha256: button.scriptSha256 ?? null, scriptData: button.scriptData ?? null }));
|
|
755
|
+
}
|
|
631
756
|
readback.buttonsRenderCompat = buttonsRenderCompat(buttons);
|
|
632
757
|
}
|
|
633
758
|
if (afterModel.options !== undefined && afterModel.options !== null) {
|
|
@@ -126,6 +126,10 @@ export const MODEL_LANE_CONTRACT = Object.freeze({
|
|
|
126
126
|
scope: MODEL_SCOPE,
|
|
127
127
|
description: "Set the model's data title / 数据标题 (POST bizmodels/update_summary {schemaCode, summary}) — the fallback display of relation fields and the portal list title. The platform stores `Summary[]` JSON and runtime resolution runs JsonUtility.toList(summaryExpression, Summary.class) (BizObjectTitleSetter :140-160); Summary is {int isDataItem (1=表单字段, 2=查询字段), String code, String param}. The tool normalizes: canonical Summary[] JSON is validated/re-serialized, a PURE {field} placeholder template is converted to [{isDataItem:1,code}], and anything else (literal text, malformed JSON) refuses `model-summary-invalid` — a raw template stored verbatim poisoned the model so ALL later data.save/data.submit answered a bare 50000 (live-verified). Readback is the sibling GET bizmodels/get_summary face; it warns when the STORED value is not a valid Summary[]. Empty summary refuses `model-summary-empty`; a missing schema answers 300014 数据模型未发布.",
|
|
128
128
|
}),
|
|
129
|
+
"model.move": Object.freeze({
|
|
130
|
+
scope: "move a model's app-tree node to another folder (bizmodels/update parentId)",
|
|
131
|
+
description: "把模型移动到指定应用目录(分组):复用 `POST bizmodels/update`,只把 `parentId` 换成目标目录节点 id(AppFunctionController 补丁语义:bizSchemaCode/id/name/parentId 全量必填,工具从应用树解析)。`folderId` 必须是 `folder.tree` 里 type=Folder 的 **id**,给目录名会 `folder-not-found` 并列出可用目录。移动只改菜单节点归属:schema/字段/表单/视图/发布状态不动。回读用应用树按 `parentId` 核对(`moved:true`)。",
|
|
132
|
+
}),
|
|
129
133
|
"model.update": Object.freeze({
|
|
130
134
|
scope: MODEL_SCOPE,
|
|
131
135
|
description: "Rename a model's app-tree node (POST bizmodels/update with AppFunctionVO; source-pinned BizSchemaController.update:266-305 — bizSchemaCode, id=<AppFunction node id>, name, parentId are ALL validateNotEmpty-required; the tool resolves id/parentId from the app tree). The endpoint patches the MENU NODE only: the underlying schema is fixed at create (modelType refused `model-type-immutable`; get_all keeps the original type/name — the authoritative rename readback is the tree node, recordName reported informationally). Absent tree node refuses `model-update-target-absent`; an empty patch refuses `model-update-empty-patch` (data title belongs to model.setSummary).",
|
|
@@ -145,6 +149,7 @@ export function assertModelContract() {
|
|
|
145
149
|
throw new Error("model contract must require per-step authoritative readbacks");
|
|
146
150
|
assertDescriptionCarries(MODEL_LANE_CONTRACT, "model.deleteMany", ["physically_delete", "List<AppDeleteParamVO>", "blockedTargets", "wait-and-recheck"]);
|
|
147
151
|
assertDescriptionCarries(MODEL_LANE_CONTRACT, "model.setSummary", ["update_summary", "get_summary", "数据标题", "300014", "model-summary-empty", "model-summary-invalid", "Summary[]"]);
|
|
152
|
+
assertDescriptionCarries(MODEL_LANE_CONTRACT, "model.move", ["bizmodels/update", "parentId", "folder.tree", "folder-not-found", "moved"]);
|
|
148
153
|
assertDescriptionCarries(MODEL_LANE_CONTRACT, "model.update", ["bizmodels/update", "get_all", "model-type-immutable", "model-update-target-absent", "model-update-empty-patch"]);
|
|
149
154
|
if (!MODEL_WIRE.summaryWire.includes("validateNotEmpty"))
|
|
150
155
|
throw new Error("model contract must carry the update_summary validateNotEmpty fact");
|
|
@@ -8,6 +8,7 @@ export const MODEL_COVERAGE = declareLaneCoverage(MODEL_LANE_CONTRACT.lane, [
|
|
|
8
8
|
tools: [
|
|
9
9
|
claim("model.create", T["model.create"].scope),
|
|
10
10
|
claim("model.update", T["model.update"].scope),
|
|
11
|
+
claim("model.move", T["model.move"].scope),
|
|
11
12
|
claim("model.delete", T["model.delete"].scope),
|
|
12
13
|
claim("model.deleteMany", T["model.deleteMany"].scope),
|
|
13
14
|
claim("model.setSummary", T["model.setSummary"].scope),
|
package/dist/lanes/model.js
CHANGED
|
@@ -42,6 +42,8 @@ function modelRollbackSpec(toolName) {
|
|
|
42
42
|
const snapshot = before;
|
|
43
43
|
if (toolName === "model.update")
|
|
44
44
|
return { kind: "calls", calls: snapshot.node?.name === undefined ? [] : [{ tool: "model.update", args: { schemaCode: code, name: String(snapshot.node.name) } }] };
|
|
45
|
+
if (toolName === "model.move")
|
|
46
|
+
return { kind: "calls", calls: snapshot.node?.parentId === undefined ? [] : [{ tool: "model.move", args: { schemaCode: code, folderId: String(snapshot.node.parentId) } }] };
|
|
45
47
|
return { kind: "calls", calls: [{ tool: "model.create", args: { code, name: String(snapshot.node?.name ?? snapshot.record?.name ?? code) } }], notes: ["字段/表单/视图不随行恢复,需重建"] };
|
|
46
48
|
},
|
|
47
49
|
};
|
|
@@ -812,6 +814,73 @@ export function registerModelLane(tools) {
|
|
|
812
814
|
message: "模型类型在创建时固化——bizmodels/update 只更新应用菜单节点显示,不能把底层 schema 从 LIST 迁移为 TREE(get_all 仍返回原类型与树系统字段,live 取证)。要树模型请建新模型",
|
|
813
815
|
});
|
|
814
816
|
}, undefined, modelRollbackSpec("model.update"));
|
|
817
|
+
writeTool(tools, "model.move", MODEL_LANE, "move a model's app-tree node to another folder (bizmodels/update parentId)", "把模型移动到指定应用目录(分组):`POST bizmodels/update` 的 `parentId`(与 model.update 同一端点,AppFunctionController 补丁语义:bizSchemaCode/id/name/parentId 全量必填,工具从应用树解析)。`folderId` 必须是目录节点 id(`folder.tree` 的 id),**不能直接给目录名**——重名目录会定位错。移动后按应用树回读 `parentId/path` 确认;模型的 schema、字段、表单、视图都不动,只换菜单节点归属。目标目录不存在时拒绝 `folder-not-found` 并列出目录树。", {
|
|
818
|
+
schemaCode: z.string().min(1),
|
|
819
|
+
folderId: z.string().min(1).describe("目标目录 id(folder.tree 的 id);顶层目录也是真实 id,不要传空"),
|
|
820
|
+
applicationCode: z.string().optional().describe("模型所在应用 code(跨应用时显式给)"),
|
|
821
|
+
}, async (session, args) => {
|
|
822
|
+
const resolved = await resolveModelTreeNode(session, args.schemaCode, args.applicationCode);
|
|
823
|
+
if (!resolved.node)
|
|
824
|
+
throw new ToolRefusalError({
|
|
825
|
+
status: "refused",
|
|
826
|
+
blocker: "model-update-target-absent",
|
|
827
|
+
schemaCode: args.schemaCode,
|
|
828
|
+
message: `模型 ${args.schemaCode} 无应用树节点——确认 schemaCode/应用 code(model.move 只改 AppFunction 节点归属)`,
|
|
829
|
+
});
|
|
830
|
+
const appCode = resolved.applicationCode;
|
|
831
|
+
const tree = resolved.treeRows.length > 0 ? resolved.treeRows : flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data));
|
|
832
|
+
const target = tree.find((row) => String(row.id ?? "") === args.folderId);
|
|
833
|
+
if (!target || String(target.type ?? "") !== "Folder") {
|
|
834
|
+
// A folder from ANOTHER application is the common miss (the model and
|
|
835
|
+
// the folder must live in the same app tree). Name that instead of a
|
|
836
|
+
// bare "not found".
|
|
837
|
+
const apps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
|
|
838
|
+
let otherApp;
|
|
839
|
+
for (const app of apps) {
|
|
840
|
+
const candidate = String(app.appCode ?? app.code ?? "");
|
|
841
|
+
if (!candidate || candidate === appCode)
|
|
842
|
+
continue;
|
|
843
|
+
const rows = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: candidate } })).data));
|
|
844
|
+
if (rows.some((row) => String(row.id ?? "") === args.folderId && String(row.type ?? "") === "Folder")) {
|
|
845
|
+
otherApp = candidate;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
throw new ToolRefusalError({
|
|
850
|
+
status: "refused",
|
|
851
|
+
blocker: otherApp ? "folder-other-application" : "folder-not-found",
|
|
852
|
+
folderId: args.folderId,
|
|
853
|
+
applicationCode: appCode,
|
|
854
|
+
...(otherApp ? { folderApplicationCode: otherApp } : {}),
|
|
855
|
+
available: tree.filter((row) => row.type === "Folder").map((row) => ({ id: row.id, name: row.name })).slice(0, 40),
|
|
856
|
+
message: otherApp
|
|
857
|
+
? `目录 ${args.folderId} 属于应用 ${otherApp},而模型 ${args.schemaCode} 在应用 ${appCode}——模型的目录必须同应用;跨应用请先在 ${appCode} 建同名目录再移动`
|
|
858
|
+
: `应用 ${appCode} 没有目录 ${args.folderId}——folderId 必须用 folder.tree 的目录节点 id(不是目录名)`,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const node = resolved.node;
|
|
862
|
+
const nodeId = String(node.id ?? "");
|
|
863
|
+
const beforeParent = String(node.parentId ?? "");
|
|
864
|
+
const performed = await wireCall(session, {
|
|
865
|
+
path: ENDPOINTS.modelUpdate,
|
|
866
|
+
method: "POST",
|
|
867
|
+
body: { bizSchemaCode: args.schemaCode, id: nodeId, parentId: args.folderId, name: String(node.name ?? args.schemaCode) },
|
|
868
|
+
});
|
|
869
|
+
const after = await resolveModelTreeNode(session, args.schemaCode, appCode);
|
|
870
|
+
const parentNow = String(after.node?.parentId ?? "");
|
|
871
|
+
return {
|
|
872
|
+
performed: { errcode: performed.errcode },
|
|
873
|
+
readback: {
|
|
874
|
+
schemaCode: args.schemaCode,
|
|
875
|
+
applicationCode: appCode,
|
|
876
|
+
moved: parentNow === args.folderId,
|
|
877
|
+
beforeParentId: beforeParent,
|
|
878
|
+
afterParentId: parentNow || null,
|
|
879
|
+
folder: { id: args.folderId, name: target.name ?? null },
|
|
880
|
+
note: "只改菜单节点归属;schema/字段/表单/视图与发布状态都不动",
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
}, undefined, undefined, modelRollbackSpec("model.move"));
|
|
815
884
|
registerPackageTool(tools, {
|
|
816
885
|
name: "configure_model",
|
|
817
886
|
lane: MODEL_LANE,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readArtifact } from "./artifacts.js";
|
|
3
|
+
import { artifactRoot } from "./command-transport.js";
|
|
4
|
+
export function projectJsonPath(value, path) {
|
|
5
|
+
// 1.5 of issue 2026-10-23: an array-of-objects selection WITHOUT jq pipes.
|
|
6
|
+
// `.rules[code=Create]` (or `[code="Create"]`) picks the FIRST element whose
|
|
7
|
+
// key matches — exactly the shape callers reached for with
|
|
8
|
+
// `jq '.[] | select(...)'`, which then died on shell quoting.
|
|
9
|
+
const selector = /\[\s*([A-Za-z_][\w.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\]]*?))\s*\]/.exec(path);
|
|
10
|
+
if (selector !== null) {
|
|
11
|
+
const [, key, dq, sq, bare] = selector;
|
|
12
|
+
const expected = String(dq ?? sq ?? bare ?? "").trim();
|
|
13
|
+
const before = path.slice(0, selector.index);
|
|
14
|
+
const after = path.slice(selector.index + selector[0].length);
|
|
15
|
+
const base = projectJsonPath(value, before);
|
|
16
|
+
if (base.missed)
|
|
17
|
+
return base;
|
|
18
|
+
const array = base.value;
|
|
19
|
+
if (!Array.isArray(array))
|
|
20
|
+
return { missed: true, at: before || "$", available: `该层不是数组(是 ${array === null ? "null" : typeof array});选择器 [${key}=${expected}] 需要数组` };
|
|
21
|
+
const readKey = (entry) => (entry !== null && typeof entry === "object" ? String(entry[key] ?? "") : "");
|
|
22
|
+
const index = array.findIndex((entry) => readKey(entry) === expected);
|
|
23
|
+
if (index < 0)
|
|
24
|
+
return { missed: true, at: `${before}[${key}=${expected}]`, available: `数组长度 ${array.length};${key} 的候选值:${[...new Set(array.map(readKey))].slice(0, 12).join("、") || "(无)"}` };
|
|
25
|
+
return after === "" ? { missed: false, value: array[index] } : projectJsonPath(array[index], after);
|
|
26
|
+
}
|
|
27
|
+
const normalized = path
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/^[.\/]+/, "")
|
|
30
|
+
.replace(/\[(\d+)\]/g, ".$1")
|
|
31
|
+
.replace(/\[(?:"([^"]+)"|'([^']+)')\]/g, (_all, dq, sq) => `.${dq || sq}`)
|
|
32
|
+
.replace(/\.{2,}/g, ".");
|
|
33
|
+
const segments = normalized.split(/[./]/).filter((segment) => segment.length > 0);
|
|
34
|
+
let current = value;
|
|
35
|
+
for (const segment of segments) {
|
|
36
|
+
if (Array.isArray(current)) {
|
|
37
|
+
const index = Number(segment);
|
|
38
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length)
|
|
39
|
+
return { missed: true, at: segment, available: `数组长度 ${current.length}(下标 0-${Math.max(current.length - 1, 0)})` };
|
|
40
|
+
current = current[index];
|
|
41
|
+
}
|
|
42
|
+
else if (current && typeof current === "object") {
|
|
43
|
+
const record = current;
|
|
44
|
+
if (!Object.prototype.hasOwnProperty.call(record, segment)) {
|
|
45
|
+
const keys = Object.keys(record);
|
|
46
|
+
return { missed: true, at: segment, available: keys.length > 0 ? keys.slice(0, 12) : "(该层是空对象)" };
|
|
47
|
+
}
|
|
48
|
+
current = record[segment];
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return { missed: true, at: segment, available: `路径在第 ${segments.indexOf(segment) + 1} 段之前已到标量/undefined(前段类型 ${current === null ? "null" : typeof current})` };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { missed: false, value: current };
|
|
55
|
+
}
|
|
56
|
+
/** Collect every value of a repeatable flag (`--extract a --extract b`). */
|
|
57
|
+
function argvValues(args, name) {
|
|
58
|
+
const values = [];
|
|
59
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
60
|
+
if (args[index] !== name)
|
|
61
|
+
continue;
|
|
62
|
+
const value = args[index + 1];
|
|
63
|
+
if (value !== undefined && !value.startsWith("--"))
|
|
64
|
+
values.push(value);
|
|
65
|
+
}
|
|
66
|
+
return values;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `call --extract/--extract-keys`: resolve paths inside the result's own
|
|
70
|
+
* artifact (or inside the result object when the tool returned no artifact).
|
|
71
|
+
* Returns undefined when there is nothing to project so the caller's result is
|
|
72
|
+
* emitted unchanged.
|
|
73
|
+
*/
|
|
74
|
+
export function projectResultExtract(result, paths, keysOnly) {
|
|
75
|
+
const artifactId = typeof result.detailArtifactId === "string" ? result.detailArtifactId : typeof result.artifactId === "string" ? result.artifactId : undefined;
|
|
76
|
+
// Tools hand an in-process artifact as `detailFile` (a local path);
|
|
77
|
+
// command-transport later swaps it for detailArtifactId. Extraction runs
|
|
78
|
+
// BEFORE that swap, so accept both.
|
|
79
|
+
const detailFile = typeof result.detailFile === "string" ? result.detailFile : undefined;
|
|
80
|
+
let source = result;
|
|
81
|
+
let artifact;
|
|
82
|
+
let readFromArtifact = false;
|
|
83
|
+
try {
|
|
84
|
+
const content = artifactId !== undefined
|
|
85
|
+
? (artifact = readArtifact(artifactRoot(), artifactId)).content
|
|
86
|
+
: detailFile !== undefined
|
|
87
|
+
? readFileSync(detailFile)
|
|
88
|
+
: undefined;
|
|
89
|
+
if (content !== undefined) {
|
|
90
|
+
readFromArtifact = true;
|
|
91
|
+
const text = content.toString("utf8");
|
|
92
|
+
try {
|
|
93
|
+
source = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
source = text;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// A pruned/expired artifact must not mask the real tool answer.
|
|
102
|
+
return { ...result, extract: { artifactId: artifactId ?? null, error: "artifact not found", hint: "产物已过期或被清理;去掉 --extract 重新调用该工具即可重新生成" } };
|
|
103
|
+
}
|
|
104
|
+
const out = {
|
|
105
|
+
status: result.status ?? "ok",
|
|
106
|
+
...(artifactId !== undefined ? { artifactId, sha256: artifact?.sha256 } : {}),
|
|
107
|
+
from: readFromArtifact ? "artifact" : "result",
|
|
108
|
+
};
|
|
109
|
+
if (keysOnly) {
|
|
110
|
+
const record = source && typeof source === "object" && !Array.isArray(source) ? source : undefined;
|
|
111
|
+
out.keys = record
|
|
112
|
+
? Object.entries(record)
|
|
113
|
+
.map(([key, value]) => ({ key, bytes: Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8"), kind: Array.isArray(value) ? `array[${value.length}]` : value === null ? "null" : typeof value }))
|
|
114
|
+
.sort((left, right) => right.bytes - left.bytes)
|
|
115
|
+
: [];
|
|
116
|
+
}
|
|
117
|
+
if (paths.length > 0) {
|
|
118
|
+
out.extract = paths.map((path) => {
|
|
119
|
+
const projected = projectJsonPath(source, path);
|
|
120
|
+
return projected.missed
|
|
121
|
+
? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }
|
|
122
|
+
: { path, content: projected.value ?? null };
|
|
123
|
+
});
|
|
124
|
+
if (paths.length === 1)
|
|
125
|
+
out.content = out.extract[0]?.content ?? null;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=path-projection.js.map
|
package/dist/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { registerMetaLane } from "./lanes/meta.js";
|
|
|
4
4
|
import { registerEnvironmentLane } from "./lanes/environment.js";
|
|
5
5
|
import { registerAppLane } from "./lanes/app.js";
|
|
6
6
|
import { registerDictionaryLane } from "./lanes/dictionary.js";
|
|
7
|
+
import { registerFolderLane } from "./lanes/folder.js";
|
|
7
8
|
import { registerModelLane } from "./lanes/model.js";
|
|
8
9
|
import { registerFieldLane } from "./lanes/field.js";
|
|
9
10
|
import { registerDataRuleLane } from "./lanes/datarule.js";
|
|
@@ -35,6 +36,7 @@ export function buildToolRegistry() {
|
|
|
35
36
|
registerAppLane(tools);
|
|
36
37
|
registerDictionaryLane(tools);
|
|
37
38
|
registerModelLane(tools);
|
|
39
|
+
registerFolderLane(tools);
|
|
38
40
|
registerFieldLane(tools);
|
|
39
41
|
registerDataRuleLane(tools);
|
|
40
42
|
registerFormLane(tools);
|
package/dist/write.js
CHANGED
|
@@ -22,7 +22,7 @@ import { plannableArgsRaw } from "./plan.js";
|
|
|
22
22
|
import { defineTool, ToolRefusalError } from "./tool-types.js";
|
|
23
23
|
import { assertFieldFamilyAllowed } from "./field-families.js";
|
|
24
24
|
import { validateBusinessCode } from "./code-rules.js";
|
|
25
|
-
import { validateFormLayout } from "./form-layout.js";
|
|
25
|
+
import { hardLayoutViolations, validateFormLayout } from "./form-layout.js";
|
|
26
26
|
import { prepareRuleGraph, validateRuleNodeGraph } from "./lanes/rule-graph.js";
|
|
27
27
|
import { WireError } from "./wire.js";
|
|
28
28
|
/**
|
|
@@ -219,8 +219,14 @@ export function writeTool(tools, name, lane, scope, description, inputSchema, ex
|
|
|
219
219
|
}
|
|
220
220
|
// CFG-form-layout: a supplied draft layout must satisfy the platform's
|
|
221
221
|
// span/row/coverage invariants before it reaches the environment.
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
// `fromReadback:true` means "these faces came from form.get" — the strict
|
|
223
|
+
// shape gate cannot tell a platform layout from a hand-written one without
|
|
224
|
+
// the session, so defer to form.draft's session-aware comparison (which
|
|
225
|
+
// still refuses a modified face). See issue 2026-10-23 §1.3.
|
|
226
|
+
if (name === "form.draft" && args.fromReadback !== true && typeof args.draftViewJson === "string" && typeof args.draftAttributesJson === "string") {
|
|
227
|
+
// Only HARD violations refuse here: the platform's own scaffold/layouts
|
|
228
|
+
// carry overflow/full-row/child-column shapes (issue 2026-10-23 §1.3).
|
|
229
|
+
const violations = hardLayoutViolations(validateFormLayout(parseJsonOrUndefined(args.draftViewJson), parseJsonOrUndefined(args.draftAttributesJson)));
|
|
224
230
|
if (violations.length > 0)
|
|
225
231
|
throw new ToolRefusalError({ status: "refused", blocker: "form-layout-invalid", message: "表单布局未过出门前校验;按 validation 修正(span 白名单/行宽≤24/整行独占/控件覆盖率)", validation: violations });
|
|
226
232
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.77",
|
|
4
4
|
"description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"access": "public"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@stalfh233/omc-fake-platform": "0.4.
|
|
53
|
+
"@stalfh233/omc-fake-platform": "0.4.77",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
55
|
+
"omc-core": "0.4.77",
|
|
56
56
|
"proper-lockfile": "4.1.2",
|
|
57
57
|
"write-file-atomic": "6.0.0",
|
|
58
58
|
"zod": "^3.23.8",
|
|
@@ -25,6 +25,7 @@ only the section for the capability you need —
|
|
|
25
25
|
| --- | --- | --- | --- |
|
|
26
26
|
| 环境治理 `environment` | 环境发现/指纹/就绪 | 环境、指纹、版本、连接、登录、就绪、发现 | [环境治理](#环境治理-environment) |
|
|
27
27
|
| 应用 `app` | 应用包生命周期 | 应用、门户、分组、app 建删移 | [应用](#应用-app) |
|
|
28
|
+
| 应用目录(分组)`folder` | 应用树目录/分组 | 目录、分组、文件夹、应用树、移动模型到目录 | [应用目录](#应用目录分组-folder) |
|
|
28
29
|
| 数据字典 `dictionary` | 字典与字典记录 | 字典、选项来源、树型、键值、字段下拉来源 | [数据字典](#数据字典-dictionary) |
|
|
29
30
|
| 业务模型 `model` | 模型生命周期 | 模型、对象、表、主数据、改名、物理删除 | [业务模型](#业务模型-model) |
|
|
30
31
|
| 字段 `field` | 字段族 + 子表 + **计算字段** | 字段、属性、子表、明细、下拉、单选、多选、附件、人员、部门、日期、数值、关联、**公式/计算/求和/汇总/合计/回主表** | [字段](#字段-field) |
|
|
@@ -52,9 +53,15 @@ only the section for the capability you need —
|
|
|
52
53
|
|
|
53
54
|
## 应用 `app`
|
|
54
55
|
|
|
55
|
-
-
|
|
56
|
-
- **工具**:`app.list`、`app.create`、`app.update`、`app.delete`
|
|
57
|
-
- **边界 /
|
|
56
|
+
- **能做哪些事**:列出应用包、创建、改名/分组移动、删除(删除级联其模型);**应用树目录(分组)的建/改名/挂载/删**,以及把模型放进目录。
|
|
57
|
+
- **工具**:`app.list`、`app.create`、`app.update`、`app.delete`、`folder.tree`、`folder.create`、`folder.update`、`folder.delete`
|
|
58
|
+
- **边界 / 陷阱**:**目录(分组)是应用树节点**:`folder.create` 省略 `parentId` 建的是顶层目录(平台默认挂应用根)、平台**不校验重名**(工具默认按同父同名拒绝)、`folder.delete` **非递归**(有子节点回 `DIRECTORY_NOT_EMPTY`)、树是嵌套 `children`(`folder.tree` 已递归展平给 `path/depth`,只看顶层会误判“没建成”)。模型进目录用 `model.move`。环境已带默认 `applicationCode`,建模**不必**先建应用;`app` 未进 intent 声明式面。**要把 intent 落到新应用**:`omc call app.create`(原子面)→ 把该环境的 `applicationCode` 指向新应用(`omc setup` 重配或改 `~/.config/omc/` 环境配置)→ 再 `intent plan/apply`,模型即落在新应用(用户实测摩擦:曾误以为应用能进 intent)。
|
|
59
|
+
|
|
60
|
+
## 应用目录(分组)`folder`
|
|
61
|
+
|
|
62
|
+
- **能做哪些事**:读应用树(目录 + 模型,递归展平带 `path/depth`)、建目录、改名/挂载、删除空目录;配合 `model.move` 把模型放进目录。
|
|
63
|
+
- **工具**:`folder.tree`、`folder.create`、`folder.update`、`folder.delete`、`model.move`
|
|
64
|
+
- **边界 / 陷阱**:`folder.create` 省略 `parentId` 建的是**顶层目录**(平台默认挂应用根);平台**不校验重名**(工具默认同父同名拒绝,并列同名传 `allowDuplicate:true`);`folder.delete` **非递归**(有子节点即拒 `folder-not-empty`);应用树是**嵌套** `children`,`folder.tree` 已展平并给 `path/depth`——只看顶层会误判"目录没建成"。模型进目录用 `model.move {schemaCode, folderId}`,`folderId` 必须是目录 **id**(给名字回 `folder-not-found` / 跨应用回 `folder-other-application`)。
|
|
58
65
|
|
|
59
66
|
## 数据字典 `dictionary`
|
|
60
67
|
|
|
@@ -65,10 +72,10 @@ only the section for the capability you need —
|
|
|
65
72
|
|
|
66
73
|
## 业务模型 `model`
|
|
67
74
|
|
|
68
|
-
-
|
|
69
|
-
- **工具**:`model.list`、`model.inspect`、`model.create`、`model.delete`、`model.deleteMany`、`model.setSummary`、`model.update`、`configure_model`
|
|
75
|
+
- **能做哪些事**:模型创建、改名、**移动到目录/分组**、物理删除(`absent:true` 回读)、存在性、批量删除、摘要;一次建模型+字段+表单用 `configure_model`。**应用目录(分组)本身**:`folder.tree` / `folder.create` / `folder.update` / `folder.delete`。
|
|
76
|
+
- **工具**:`model.list`、`model.inspect`、`model.create`、`model.delete`、`model.deleteMany`、`model.setSummary`、`model.update`、`model.move`、`configure_model`、`folder.tree`、`folder.create`、`folder.update`、`folder.delete`
|
|
70
77
|
- **recipe**:`omc describe configure_model --example model-with-fields-form`
|
|
71
|
-
- **边界 / 陷阱**:`model.inspect <schemaCode>` 一次拿全字段/表单/列表/规则/流程摘要,别逐面 6
|
|
78
|
+
- **边界 / 陷阱**:`model.inspect <schemaCode>` 一次拿全字段/表单/列表/规则/流程摘要,别逐面 6 次调用。**目录(分组)**:`folder.create` 省略 `parentId` 建的是**顶层目录**(平台默认挂应用根);`folder.delete` **非递归**(有子节点即拒 `folder-not-empty`);应用树是**嵌套**的,`folder.tree` 已递归展平并给 `path/depth`(只看顶层会误判“目录没建成”)。移动模型用 `model.move {schemaCode, folderId}`,`folderId` 必须是目录 **id**(给目录名会 `folder-not-found`)。**保留 code**:字段 code 用 `name` 会被平台占用,须避开。**每个模型都必须建并发布默认表单**——只建模型不建/不发布表单则无法录入查看(`configure_form` 或 `form.draft(rebuild:true)` + `form.publish`)。**「汇总/统计/按月」类需求**:常见做法是**一张汇总表**(独立业务模型,如 `x_monthly_summary`)接住结果,用定时任务/规则把源数据汇总写进去——**但这按需求定**,不是写死的规则。需求确认时把"汇总表"作为常规选项提出来跟用户确认,别默认往报表或二开上引。
|
|
72
79
|
|
|
73
80
|
## 字段 `field`
|
|
74
81
|
|