@stalfh233/omc-cli 0.4.76 → 0.4.78

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.
@@ -815,6 +815,7 @@
815
815
  "tools": [
816
816
  "model.create",
817
817
  "model.update",
818
+ "model.move",
818
819
  "model.delete",
819
820
  "model.deleteMany",
820
821
  "model.setSummary",
@@ -823,7 +824,8 @@
823
824
  "model.inspect"
824
825
  ],
825
826
  "scopes": [
826
- "create publish physical delete absence read list inspect"
827
+ "create publish physical delete absence read list inspect",
828
+ "move a model's app-tree node to another folder (bizmodels/update parentId)"
827
829
  ]
828
830
  },
829
831
  {
@@ -1328,6 +1330,38 @@
1328
1330
  "scopes": [
1329
1331
  "report tree read page metadata BI config chart data create update delete configure"
1330
1332
  ]
1333
+ },
1334
+ {
1335
+ "capability": "App-tree folders (目录/分组): read flattened tree, create, rename/move, non-recursive delete",
1336
+ "status": "executable",
1337
+ "scope": "App application-tree folders: flattened tree read with path/depth, folder create (omitted parentId = root), rename/reparent, non-recursive delete, and model reparenting.",
1338
+ "version_family": "8.6",
1339
+ "anchor_version": "8.6.26",
1340
+ "evidence": [
1341
+ {
1342
+ "path": "cp-sources/backend/decompiled-full/launcher-8.6.26/cloudpivot-engine-8.6.26/com/authine/cloudpivot/web/api/controller/app/AppFunctionController.java"
1343
+ },
1344
+ {
1345
+ "path": "cp-sources/backend/decompiled-full/launcher-8.6.26/cloudpivot-metadata-8.6.26/com/authine/cloudpivot/metadata/history/apppackage/service/ApplicationFacadeImpl.java"
1346
+ },
1347
+ {
1348
+ "path": "packages/omc-cli/src/lanes/folder.ts"
1349
+ }
1350
+ ],
1351
+ "lane": "app-tree-folders",
1352
+ "notes": "Live 2026-10-23 customer HAR: POST /api/app/folders/create answered errcode 0 成功创建目录; trees are nested (children[]) and must be flattened.",
1353
+ "tools": [
1354
+ "folder.tree",
1355
+ "folder.create",
1356
+ "folder.update",
1357
+ "folder.delete"
1358
+ ],
1359
+ "scopes": [
1360
+ "app-tree folder/model tree (flattened with path/depth)",
1361
+ "app-tree folder create (folders/create)",
1362
+ "app-tree folder rename/move (folders/update)",
1363
+ "app-tree folder delete (folders/delete, non-recursive)"
1364
+ ]
1331
1365
  }
1332
1366
  ]
1333
1367
  }
@@ -36,6 +36,16 @@ const SURFACES = [
36
36
  intent: { level: "none", note: "未覆盖;用 omc call app.*。" },
37
37
  notes: "环境已带默认 applicationCode,建模不必先建应用。",
38
38
  },
39
+ {
40
+ id: "folder",
41
+ title: "应用目录(分组)",
42
+ lane: LANES.folder,
43
+ description: "应用树目录:读(递归展平带 path/depth)、建、改名/挂载、非递归删除。",
44
+ operations: ["tree", "create", "update", "delete"],
45
+ requires: ["app"],
46
+ intent: { level: "none", note: "未覆盖;用 omc call folder.*。移动模型用 model.move。" },
47
+ notes: "省略 parentId 建顶层目录(平台默认挂应用根);平台不校验重名(工具默认同父同名拒绝);删除非递归(有子节点回 DIRECTORY_NOT_EMPTY);应用树是嵌套 children,folder.tree 已展平。",
48
+ },
39
49
  {
40
50
  id: "dictionary",
41
51
  title: "数据字典",
@@ -51,7 +61,7 @@ const SURFACES = [
51
61
  title: "业务模型",
52
62
  lane: LANES.model,
53
63
  description: "模型生命周期:创建、改名、物理删除、存在性。",
54
- operations: ["create", "update", "delete", "deleteMany", "setSummary"],
64
+ operations: ["create", "update", "move", "delete", "deleteMany", "setSummary"],
55
65
  intent: { level: "partial", note: "create/update 与 delete(absent:true)已声明式;deleteMany/setSummary 未覆盖。" },
56
66
  },
57
67
  {
@@ -5,6 +5,7 @@ import { DATARULE_LANE_CONTRACT } from "./lanes/datarule-contract.js";
5
5
  import { DICTIONARY_LANE_CONTRACT } from "./lanes/dictionary-contract.js";
6
6
  import { ENVIRONMENT_LANE_CONTRACT } from "./lanes/environment-contract.js";
7
7
  import { FIELD_LANE_CONTRACT } from "./lanes/field-contract.js";
8
+ import { FOLDER_LANE_CONTRACT } from "./lanes/folder-contract.js";
8
9
  import { FORM_LANE_CONTRACT } from "./lanes/form-contract.js";
9
10
  import { LIST_VIEW_LANE_CONTRACT } from "./lanes/list-view-contract.js";
10
11
  import { META_LANE_CONTRACT } from "./lanes/meta-contract.js";
@@ -22,6 +23,7 @@ export const LANE_CONTRACTS = Object.freeze([
22
23
  DICTIONARY_LANE_CONTRACT,
23
24
  ENVIRONMENT_LANE_CONTRACT,
24
25
  FIELD_LANE_CONTRACT,
26
+ FOLDER_LANE_CONTRACT,
25
27
  FORM_LANE_CONTRACT,
26
28
  LIST_VIEW_LANE_CONTRACT,
27
29
  META_LANE_CONTRACT,
@@ -7,6 +7,7 @@ import { DATARULE_COVERAGE } from "./lanes/datarule-coverage.js";
7
7
  import { FIELD_COVERAGE } from "./lanes/field-coverage.js";
8
8
  import { FORM_COVERAGE } from "./lanes/form-coverage.js";
9
9
  import { LIST_VIEW_COVERAGE } from "./lanes/list-view-coverage.js";
10
+ import { FOLDER_COVERAGE } from "./lanes/folder-coverage.js";
10
11
  import { MODEL_COVERAGE } from "./lanes/model-coverage.js";
11
12
  import { ONLINE_JS_COVERAGE } from "./lanes/online-js-coverage.js";
12
13
  import { RULE_LIFECYCLE_COVERAGE } from "./lanes/rule-lifecycle-coverage.js";
@@ -23,6 +24,7 @@ const DECLARATIONS = Object.freeze([
23
24
  ...APP_COVERAGE,
24
25
  ...DICTIONARY_COVERAGE,
25
26
  ...MODEL_COVERAGE,
27
+ ...FOLDER_COVERAGE,
26
28
  ...FIELD_COVERAGE,
27
29
  ...DATARULE_COVERAGE,
28
30
  ...FORM_COVERAGE,
package/dist/endpoints.js CHANGED
@@ -17,6 +17,22 @@ export const ENDPOINTS = {
17
17
  modelGetSummary: "/api/api/app/bizmodels/get_summary",
18
18
  modelUpdateSummary: "/api/api/app/bizmodels/update_summary",
19
19
  modelUpdate: "/api/api/app/bizmodels/update",
20
+ /**
21
+ * App-tree FOLDERS (目录/分组). AppFunctionController (/api/app/folders,
22
+ * 8.6.26 :90-260):
23
+ * POST create body AppFunctionVO {appCode, name, name_i18n?, parentId?, code?, icon?}
24
+ * GET get ?folderId=
25
+ * POST update body AppFunctionVO {id, name, name_i18n?, parentId?}
26
+ * POST delete ?folderId= (refuses a non-empty folder: DIRECTORY_NOT_EMPTY)
27
+ * createFolder defaults parentId to the app package id (root) and forces
28
+ * type=Folder; updateFolder refuses parentId === id. Live 2026-10-23 customer
29
+ * evidence: /api/app/folders/create answers 0 "成功创建目录"; tree readback is
30
+ * NESTED (children[]), so a caller must flatten recursively.
31
+ */
32
+ folderCreate: "/api/api/app/folders/create",
33
+ folderGet: "/api/api/app/folders/get",
34
+ folderUpdate: "/api/api/app/folders/update",
35
+ folderDelete: "/api/api/app/folders/delete",
20
36
  appPackagePhysicallyDelete: "/api/api/app/apppackage/physically_delete",
21
37
  fieldList: "/api/api/app/bizproperty/list",
22
38
  fieldGet: "/api/api/app/bizproperty/get",
@@ -719,21 +719,25 @@ export function validateFormButtons(buttons, tool) {
719
719
  if (!FORM_BUTTON_LOCATIONS.includes(useLocation))
720
720
  throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].useLocation 只能是 ${FORM_BUTTON_LOCATIONS.join("|")}(表单工具栏按钮用 FORM)` });
721
721
  const bindAction = typeof button.bindAction === "string" ? button.bindAction : "";
722
+ // `script` is the ergonomic spelling of actionConfig.scriptData (same as
723
+ // listview.configure); the explicit script wins over a stale config blob.
724
+ const scriptShorthand = typeof button.script === "string" ? button.script : undefined;
722
725
  if (!FORM_BUTTON_BIND_ACTIONS.has(bindAction))
723
726
  throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].bindAction 必须是 ButtonBindAction 名(SCRIPT/BUSINESS_RULE/FLOW/FORM/ADD_DATA/CUSTOM_PAGE/…)` });
724
727
  const operateType = typeof button.operateType === "string" ? button.operateType : "";
725
728
  if (!FORM_BUTTON_OPERATE_TYPES.has(operateType))
726
729
  throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].operateType 必须是 WITH_VIEW 或 WITH_OUT_VIEW` });
730
+ // Decode once: the SCRIPT guard and the script shorthand both read it.
731
+ const rawConfig = button.actionConfig;
732
+ const parsedConfigRaw = typeof rawConfig === "string" ? (() => { try {
733
+ return JSON.parse(rawConfig);
734
+ }
735
+ catch {
736
+ return undefined;
737
+ } })() : rawConfig;
738
+ const config = parsedConfigRaw && typeof parsedConfigRaw === "object" && !Array.isArray(parsedConfigRaw) ? parsedConfigRaw : undefined;
727
739
  if (bindAction === "SCRIPT" || bindAction === "BUSINESS_RULE" || bindAction === "FLOW" || bindAction === "ADD_DATA" || bindAction === "FORM") {
728
- const actionConfig = button.actionConfig;
729
- const record = typeof actionConfig === "string" ? (() => { try {
730
- return JSON.parse(actionConfig);
731
- }
732
- catch {
733
- return undefined;
734
- } })() : actionConfig;
735
- const config = record && typeof record === "object" && !Array.isArray(record) ? record : undefined;
736
- if (bindAction === "SCRIPT" && typeof config?.scriptData !== "string")
740
+ if (bindAction === "SCRIPT" && scriptShorthand === undefined && typeof config?.scriptData !== "string")
737
741
  throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}] SCRIPT 需要 actionConfig.scriptData(前端脚本源码字符串)` });
738
742
  if ((bindAction === "BUSINESS_RULE" || bindAction === "FLOW") && (typeof button.targetCode !== "string" || button.targetCode === ""))
739
743
  throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}] ${bindAction} 需要 targetCode` });
@@ -745,9 +749,13 @@ export function validateFormButtons(buttons, tool) {
745
749
  useLocation,
746
750
  bindAction,
747
751
  operateType,
748
- // The platform stores actionConfig as a string; a caller's object is
749
- // serialized once here (double-encoding breaks the renderer).
750
- ...(button.actionConfig !== undefined && typeof button.actionConfig !== "string" ? { actionConfig: JSON.stringify(button.actionConfig) } : {}),
752
+ // actionConfig is a String column: serialize exactly once (double
753
+ // encoding breaks the renderer). `script` merges into scriptData.
754
+ ...(scriptShorthand !== undefined
755
+ ? { actionConfig: JSON.stringify({ ...(config ?? {}), scriptData: scriptShorthand }) }
756
+ : button.actionConfig !== undefined && typeof button.actionConfig !== "string"
757
+ ? { actionConfig: JSON.stringify(button.actionConfig) }
758
+ : {}),
751
759
  sortKey: typeof button.sortKey === "number" ? button.sortKey : index,
752
760
  };
753
761
  });
@@ -779,7 +787,8 @@ export function mergeFormOptionsWithButtons(existingOptions, input, tool) {
779
787
  const removeCodes = input.removeButtonCodes ?? [];
780
788
  if (hasButtons && hasJson)
781
789
  throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-ambiguous", message: `${tool}: buttons 与 customButtonJson 只能给一种(buttons 数组更好读;customButtonJson 用于原样透传读回值)` });
782
- if (!hasButtons && !hasJson && removeCodes.length === 0)
790
+ const scriptPatches = input.buttonScripts ?? [];
791
+ if (!hasButtons && !hasJson && removeCodes.length === 0 && scriptPatches.length === 0)
783
792
  return undefined;
784
793
  let incoming = [];
785
794
  if (hasButtons) {
@@ -802,6 +811,21 @@ export function mergeFormOptionsWithButtons(existingOptions, input, tool) {
802
811
  // `replace` restores the whole-list semantics.
803
812
  const mode = input.buttonsMode ?? "merge";
804
813
  const existing = readFormButtons(options) ?? [];
814
+ if (scriptPatches.length > 0) {
815
+ // Patch scriptData on EXISTING buttons only — the write counterpart of
816
+ // form.get --buttonScripts. An unknown code refuses rather than silently
817
+ // creating a button with no bindAction.
818
+ const known = new Set(existing.map((button) => String(button.code ?? "")));
819
+ const missing = scriptPatches.filter((entry) => !known.has(entry.code)).map((entry) => entry.code);
820
+ if (missing.length > 0)
821
+ throw new ToolRefusalError({
822
+ status: "refused",
823
+ blocker: "form-button-absent",
824
+ buttonCodes: missing,
825
+ available: [...known],
826
+ message: `${tool}: 表单没有按钮 ${missing.join("、")}(buttonScripts 只改已有按钮的脚本);可用:${[...known].join("、") || "(无)"}。新建按钮用 buttons + script`,
827
+ });
828
+ }
805
829
  let final;
806
830
  if (mode === "replace") {
807
831
  final = incoming;
@@ -812,6 +836,27 @@ export function mergeFormOptionsWithButtons(existingOptions, input, tool) {
812
836
  byCode.set(String(button.code), { ...(byCode.get(String(button.code)) ?? {}), ...button });
813
837
  for (const code of removeCodes)
814
838
  byCode.delete(String(code));
839
+ for (const patch of scriptPatches) {
840
+ const current = byCode.get(patch.code);
841
+ if (!current)
842
+ continue;
843
+ let config = {};
844
+ const rawConfig = current.actionConfig;
845
+ if (typeof rawConfig === "string" && rawConfig.trim() !== "") {
846
+ try {
847
+ const parsed = JSON.parse(rawConfig);
848
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
849
+ config = parsed;
850
+ }
851
+ catch {
852
+ config = {};
853
+ }
854
+ }
855
+ else if (rawConfig !== null && typeof rawConfig === "object" && !Array.isArray(rawConfig)) {
856
+ config = { ...rawConfig };
857
+ }
858
+ byCode.set(patch.code, { ...current, actionConfig: JSON.stringify({ ...config, scriptData: patch.script }) });
859
+ }
815
860
  final = [...byCode.values()].map((button, index) => ({ ...button, sortKey: typeof button.sortKey === "number" ? button.sortKey : index }));
816
861
  }
817
862
  if (removeCodes.length > 0 && mode === "replace")
@@ -1062,7 +1107,7 @@ export async function executeFormDraftWrite(session, args) {
1062
1107
  // turns them into h_biz_button rows (triggerType=FORM). Merge rather than
1063
1108
  // replace so unrelated options (presetButtonManage, sheetPresetButtonManage)
1064
1109
  // survive.
1065
- const mergedOptions = mergeFormOptionsWithButtons(pre?.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes }, "form.draft");
1110
+ const mergedOptions = mergeFormOptionsWithButtons(pre?.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes, buttonScripts: args.buttonScripts }, "form.draft");
1066
1111
  const payload = {
1067
1112
  ...(pre ?? {}),
1068
1113
  schemaCode: args.schemaCode,
@@ -1243,7 +1288,8 @@ export async function executeFormPublishWrite(session, args) {
1243
1288
  // drop the buttons (live 2026-10-23).
1244
1289
  const hasButtonDelta = args.buttons !== undefined ||
1245
1290
  args.customButtonJson !== undefined ||
1246
- (Array.isArray(args.removeButtonCodes) && args.removeButtonCodes.length > 0);
1291
+ (Array.isArray(args.removeButtonCodes) && args.removeButtonCodes.length > 0) ||
1292
+ (Array.isArray(args.buttonScripts) && args.buttonScripts.length > 0);
1247
1293
  const forcePromote = args.force === true || args.rebuild === true || hasButtonDelta;
1248
1294
  if (pre.published === true && !forcePromote)
1249
1295
  return { performed: { skipped: "already-published" }, readback: { published: true } };
@@ -1274,7 +1320,7 @@ export async function executeFormPublishWrite(session, args) {
1274
1320
  // h_biz_button(triggerType=FORM) rows during THIS publish call
1275
1321
  // (ModelManagementFacadeImpl.publishBizForm:1022). Passing them here is the
1276
1322
  // only way to create form buttons without the designer.
1277
- const mergedOptions = mergeFormOptionsWithButtons(pre.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes }, "form.publish");
1323
+ const mergedOptions = mergeFormOptionsWithButtons(pre.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes, buttonScripts: args.buttonScripts }, "form.publish");
1278
1324
  const payload = {
1279
1325
  ...pre,
1280
1326
  ...(mergedOptions !== undefined ? { options: mergedOptions } : {}),
@@ -0,0 +1,59 @@
1
+ import { CONTRACT_FAMILY, CONTRACT_REFERENCE_VERSION, assertDescriptionCarries, behaviorBinding } from "./contract-support.js";
2
+ /**
3
+ * App-tree folder (目录/分组) wire contract — issue
4
+ * 2026-10-23-omc-app-folder-create-and-move. Source: AppFunctionController
5
+ * (/api/app/folders, 8.6.26 :90-260) and
6
+ * ApplicationFacadeImpl.createFolder/updateFolder/deleteFolder (:586-640).
7
+ */
8
+ export const FOLDER_WIRE = Object.freeze({
9
+ endpoints: Object.freeze({
10
+ create: "POST /api/api/app/folders/create body {appCode,name[,name_i18n,parentId,code,icon]}",
11
+ get: "GET /api/api/app/folders/get?folderId=",
12
+ update: "POST /api/api/app/folders/update body {id,name[,name_i18n,parentId]}",
13
+ delete: "POST /api/api/app/folders/delete?folderId=",
14
+ }),
15
+ defaults: "createFolder forces type=Folder, defaults parentId to the app package id (root) and does NOT reject duplicate names; updateFolder refuses parentId===id and only writes parentId when non-empty; deleteFolder refuses a non-empty folder (DIRECTORY_NOT_EMPTY) — delete is NOT recursive",
16
+ tree: "apppackage/trees?appCode= returns a NESTED tree (folders carry children[]); reading only the top-level array makes a freshly created child folder look absent — flatten recursively and report path/depth",
17
+ live: "2026-10-23 customer HAR replay: folders/create answered errcode 0 成功创建目录; after the token was attached apppackage/trees returned the full tree",
18
+ });
19
+ export const FOLDER_LANE_CONTRACT = Object.freeze({
20
+ lane: "app-tree-folders",
21
+ family: CONTRACT_FAMILY,
22
+ referenceVersion: CONTRACT_REFERENCE_VERSION,
23
+ endpoints: Object.freeze({
24
+ tree: "apppackage/trees",
25
+ create: "folders/create",
26
+ get: "folders/get",
27
+ update: "folders/update",
28
+ delete: "folders/delete",
29
+ }),
30
+ wire: FOLDER_WIRE,
31
+ wireBindings: Object.freeze({
32
+ endpoints: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#model.move reparents the model; folder.delete refuses a non-empty folder", assertFolderContract)]),
33
+ defaults: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#model.move reparents the model; folder.delete refuses a non-empty folder", assertFolderContract)]),
34
+ tree: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#folder.create then folder.tree sees the folder (nested tree is flattened)", assertFolderContract)]),
35
+ live: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#folder.create then folder.tree sees the folder (nested tree is flattened)", assertFolderContract)]),
36
+ }),
37
+ evidence: Object.freeze([
38
+ "cp-sources/backend/decompiled-full/launcher-8.6.26/cloudpivot-engine-8.6.26/com/authine/cloudpivot/web/api/controller/app/AppFunctionController.java",
39
+ "cp-sources/backend/decompiled-full/launcher-8.6.26/cloudpivot-metadata-8.6.26/com/authine/cloudpivot/metadata/history/apppackage/service/ApplicationFacadeImpl.java",
40
+ "issue/2026-10-23-omc-app-folder-create-and-move.md",
41
+ ]),
42
+ tools: Object.freeze({
43
+ "folder.tree": Object.freeze({ scope: "app-tree folder/model tree (flattened with path/depth)", description: "读应用树(目录/分组 + 模型):平台 apppackage/trees 返回嵌套 children,本工具递归展平并给每节点 path/depth" }),
44
+ "folder.create": Object.freeze({ scope: "app-tree folder create (folders/create)", description: "新建目录:folders/create 省略 parentId 建顶层目录;同父同名默认拒绝(folder-duplicate)" }),
45
+ "folder.update": Object.freeze({ scope: "app-tree folder rename/move (folders/update)", description: "重命名/挂载目录(不移动目录内模型,模型用 model.move);parentId 不可等于自身" }),
46
+ "folder.delete": Object.freeze({ scope: "app-tree folder delete (folders/delete, non-recursive)", description: "删除空目录:非递归,有子节点回 DIRECTORY_NOT_EMPTY(工具先查子节点,拒 folder-not-empty)" }),
47
+ }),
48
+ });
49
+ export function assertFolderContract() {
50
+ assertDescriptionCarries(FOLDER_LANE_CONTRACT, "folder.tree", ["children", "path", "depth"]);
51
+ assertDescriptionCarries(FOLDER_LANE_CONTRACT, "folder.create", ["parentId", "顶层", "folder-duplicate"]);
52
+ assertDescriptionCarries(FOLDER_LANE_CONTRACT, "folder.update", ["parentId", "model.move"]);
53
+ assertDescriptionCarries(FOLDER_LANE_CONTRACT, "folder.delete", ["非递归", "folder-not-empty"]);
54
+ if (!FOLDER_WIRE.defaults.includes("DIRECTORY_NOT_EMPTY"))
55
+ throw new Error("folder contract must pin the non-recursive delete refusal");
56
+ if (!FOLDER_WIRE.tree.includes("NESTED"))
57
+ throw new Error("folder contract must pin the nested tree shape");
58
+ }
59
+ //# sourceMappingURL=folder-contract.js.map
@@ -0,0 +1,16 @@
1
+ import { claim, declareLaneCoverage } from "./coverage-declaration.js";
2
+ import { FOLDER_LANE_CONTRACT } from "./folder-contract.js";
3
+ const T = FOLDER_LANE_CONTRACT.tools;
4
+ export const FOLDER_COVERAGE = declareLaneCoverage(FOLDER_LANE_CONTRACT.lane, [
5
+ {
6
+ capability: "App-tree folders (目录/分组): read flattened tree, create, rename/move, non-recursive delete",
7
+ evidence: ["issue/2026-10-23-omc-app-folder-create-and-move.md", "packages/omc-cli/src/lanes/folder.ts"],
8
+ tools: [
9
+ claim("folder.tree", T["folder.tree"].scope),
10
+ claim("folder.create", T["folder.create"].scope),
11
+ claim("folder.update", T["folder.update"].scope),
12
+ claim("folder.delete", T["folder.delete"].scope),
13
+ ],
14
+ },
15
+ ]);
16
+ //# sourceMappingURL=folder-coverage.js.map
@@ -0,0 +1,241 @@
1
+ import { z } from "zod";
2
+ import { ToolRefusalError, defineTool } from "../tool-types.js";
3
+ import { governedRead } from "../read.js";
4
+ import { writeTool } from "../write.js";
5
+ import { wireCall, wireRows } from "../wire.js";
6
+ import { ENDPOINTS } from "../endpoints.js";
7
+ import { capabilityArg, environmentArg } from "../args.js";
8
+ import { resolveApplicationCode } from "../support.js";
9
+ import { LANES } from "./lane-ids.js";
10
+ import { FOLDER_LANE_CONTRACT } from "./folder-contract.js";
11
+ /**
12
+ * App-tree folders (目录/分组) and model reparenting.
13
+ *
14
+ * issue 2026-10-23-omc-app-folder-create-and-move: a customer had to build 8
15
+ * folders and move 33 models by hand (or with a raw HAR-replayed script) because
16
+ * OMC could only attach a NEW model to an EXISTING folder at create time.
17
+ *
18
+ * Wire facts (decompiled 8.6.26 AppFunctionController :90-260 +
19
+ * ApplicationFacadeImpl.createFolder/updateFolder/deleteFolder :586-640, and the
20
+ * customer's HAR replay which answered 0「成功创建目录」):
21
+ * - POST /api/app/folders/create {appCode, name, name_i18n?, parentId?, code?, icon?}
22
+ * createFolder forces type=Folder and defaults parentId to the app package id
23
+ * (root) — so an omitted parentId creates a TOP-LEVEL folder, not a child.
24
+ * - POST /api/app/folders/update {id, name, name_i18n?, parentId?}
25
+ * refuses parentId === id (FOLDER_PARENTID_EQUALS_ID); parentId is only
26
+ * written when non-empty (omitted keeps the current parent).
27
+ * - POST /api/app/folders/delete ?folderId= refuses a non-empty folder
28
+ * (DIRECTORY_NOT_EMPTY) — it is NOT a recursive delete.
29
+ * - GET /api/app/folders/get ?folderId=
30
+ * - The tree face (apppackage/trees?appCode=) is NESTED: folders carry their
31
+ * children in `children[]`. Reading only the top-level array reports "folder
32
+ * created but not found" (the customer's first failure) — this lane flattens
33
+ * recursively and reports each node's depth/path.
34
+ *
35
+ * Moving a MODEL is the same bizmodels/update call model.update already makes,
36
+ * with the target folder as parentId — exposed as `model.move` so it has its own
37
+ * intent, dry-run preview, and readback.
38
+ */
39
+ const LANE = LANES.folder;
40
+ const T = FOLDER_LANE_CONTRACT.tools;
41
+ /** Flatten the nested app tree, keeping the path so a caller can tell WHICH "合同" it means. */
42
+ /**
43
+ * Flatten the nested app tree. Live 1.1.25 readback: `apppackage/trees?appCode=`
44
+ * returns the TOP-LEVEL folders directly (no app-package container row), each
45
+ * carrying its children — so a top-level folder is depth 0 and a child is
46
+ * "父 / 子" at depth 1. Reading only the top array makes a freshly created child
47
+ * folder look absent (the customer's first failure).
48
+ */
49
+ function flattenFolders(rows, parentPath = []) {
50
+ const out = [];
51
+ for (const raw of rows) {
52
+ if (raw === null || typeof raw !== "object")
53
+ continue;
54
+ const node = raw;
55
+ const name = String(node.name ?? node.code ?? "");
56
+ const path = [...parentPath, name];
57
+ out.push({
58
+ id: node.id ?? null,
59
+ type: node.type ?? null,
60
+ code: node.code ?? null,
61
+ name: node.name ?? null,
62
+ parentId: node.parentId ?? null,
63
+ depth: path.length - 1,
64
+ path: path.join(" / "),
65
+ });
66
+ if (Array.isArray(node.children) && node.children.length > 0)
67
+ out.push(...flattenFolders(node.children, path));
68
+ }
69
+ return out;
70
+ }
71
+ async function readTree(session, appCode) {
72
+ return flattenFolders(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data));
73
+ }
74
+ function folderRefusal(schemaCode, message, extra = {}) {
75
+ throw new ToolRefusalError({ status: "refused", blocker: "folder-not-found", ...(schemaCode ? { applicationCode: schemaCode } : {}), ...extra, message });
76
+ }
77
+ /**
78
+ * Resolve a folder by id, code, or by full path ("合同管理 / 归档"). A bare name
79
+ * that matches several folders is ambiguous — name them with their paths instead
80
+ * of guessing (the customer's 8 folders included duplicates by design).
81
+ */
82
+ function resolveFolder(rows, ref) {
83
+ const byId = rows.find((row) => String(row.id ?? "") === ref);
84
+ if (byId)
85
+ return byId;
86
+ const byCode = rows.filter((row) => String(row.code ?? "") === ref);
87
+ if (byCode.length === 1)
88
+ return byCode[0];
89
+ if (byCode.length > 1)
90
+ folderRefusal(undefined, `目录 code「${ref}」不唯一(${byCode.length} 个)——改用 id 或完整 path:${byCode.map((row) => String(row.path)).join("、")}`);
91
+ const byPath = rows.filter((row) => String(row.path ?? "") === ref);
92
+ if (byPath.length === 1)
93
+ return byPath[0];
94
+ const byName = rows.filter((row) => String(row.name ?? "") === ref);
95
+ if (byName.length === 1)
96
+ return byName[0];
97
+ if (byName.length > 1)
98
+ folderRefusal(undefined, `目录名「${ref}」不唯一(${byName.length} 个)——用完整 path 指定:${byName.map((row) => String(row.path)).join("、")}`);
99
+ folderRefusal(undefined, `找不到目录「${ref}」——先 folder.tree 看 id/path;注意 create 省略 parentId 建的是顶层目录`, {
100
+ available: rows.filter((row) => row.type === "Folder").map((row) => ({ id: row.id, path: row.path })).slice(0, 40),
101
+ });
102
+ }
103
+ function folderTools(tools) {
104
+ tools.push(defineTool({
105
+ name: "folder.tree",
106
+ layer: "low",
107
+ kind: "read",
108
+ lane: LANE,
109
+ scope: T["folder.tree"].scope,
110
+ description: "读取应用树(目录/分组 + 模型节点),**递归展平**并给出每个节点的 `path` 与 `depth`(平台 `apppackage/trees?appCode=` 返回的是嵌套 children,只看顶层会误判“目录没建成”——issue 2026-10-23 的真实失败)。`foldersOnly:true` 只回目录(type=Folder)。目录的 id 就是 folder.update/delete 与 model.move 的 `folderId/parentId`。",
111
+ inputSchema: {
112
+ environment: environmentArg,
113
+ applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用"),
114
+ foldersOnly: z.boolean().optional().describe("只回目录节点(type=Folder)"),
115
+ capability: capabilityArg,
116
+ },
117
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
118
+ const appCode = await resolveApplicationCode(session, args.applicationCode);
119
+ const rows = await readTree(session, appCode);
120
+ const nodes = args.foldersOnly === true ? rows.filter((row) => row.type === "Folder") : rows;
121
+ return {
122
+ applicationCode: appCode,
123
+ nodeCount: nodes.length,
124
+ folderCount: rows.filter((row) => row.type === "Folder").length,
125
+ modelCount: rows.filter((row) => row.type === "BizModel").length,
126
+ nodes,
127
+ note: "type=Folder 是目录/分组;parentId 指向父目录(顶层目录的 parentId = 应用包 id)。嵌套 children 已展平,path 用「 / 」连接。",
128
+ };
129
+ }),
130
+ }));
131
+ writeTool(tools, "folder.create", LANE, T["folder.create"].scope, "新建应用目录(分组):`POST /api/app/folders/create`(AppFunctionController:105;`ApplicationFacadeImpl.createFolder:586` 强制 `type=Folder`,**省略 `parentId` 会挂到应用根**,即顶层目录)。给 `parentId` 才是子目录。写入后按 `path` 回读确认(树是嵌套的)。同名不等于重复:平台不校验重名,工具默认按「同父目录下同名」拒绝(`folder-duplicate`),要并列同名传 `allowDuplicate:true`。", {
132
+ applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用"),
133
+ name: z.string().min(1).describe("目录显示名(平台校验非空、中文长度 ≤50)"),
134
+ parentId: z.string().optional().describe("父目录 id;**省略=应用根(顶层目录)**。子目录必须显式给,值来自 folder.tree 的 id"),
135
+ code: z.string().optional().describe("目录 code(可选;平台只校验 code 唯一,空则自动生成)"),
136
+ name_i18n: z.string().optional().describe("多语言名(JSON 字符串,如 {\"zh-CN\":\"合同\",\"en\":\"Contract\"})"),
137
+ allowDuplicate: z.boolean().optional().describe("允许同父目录下同名(默认拒绝,避免建出两个同名目录后无法用 path 定位)"),
138
+ }, async (session, args) => {
139
+ const appCode = await resolveApplicationCode(session, args.applicationCode);
140
+ const before = await readTree(session, appCode);
141
+ if (args.allowDuplicate !== true) {
142
+ const siblings = before.filter((row) => String(row.parentId ?? "") === String(args.parentId ?? "") || (args.parentId === undefined && row.depth === 0));
143
+ if (siblings.some((row) => row.type === "Folder" && String(row.name ?? "") === args.name))
144
+ throw new ToolRefusalError({
145
+ status: "refused",
146
+ blocker: "folder-duplicate",
147
+ applicationCode: appCode,
148
+ path: `${args.parentId ?? "(root)"} / ${args.name}`,
149
+ message: `同一层级已有同名目录「${args.name}」——平台允许重名,但重名后 folder.tree 的 path 无法唯一定位;确认要并列同名再传 allowDuplicate:true`,
150
+ });
151
+ }
152
+ const performed = await wireCall(session, {
153
+ path: ENDPOINTS.folderCreate,
154
+ method: "POST",
155
+ body: {
156
+ appCode,
157
+ name: args.name,
158
+ ...(args.parentId !== undefined ? { parentId: args.parentId } : {}),
159
+ ...(args.code !== undefined ? { code: args.code } : {}),
160
+ ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}),
161
+ },
162
+ });
163
+ const after = await readTree(session, appCode);
164
+ const created = after.filter((row) => row.type === "Folder" && String(row.name ?? "") === args.name && !before.some((old) => old.id === row.id));
165
+ return {
166
+ performed: { errcode: performed.errcode },
167
+ readback: {
168
+ applicationCode: appCode,
169
+ present: created.length > 0,
170
+ created: created.map((row) => ({ id: row.id, path: row.path, parentId: row.parentId })),
171
+ folderCount: after.filter((row) => row.type === "Folder").length,
172
+ ...(after.filter((row) => row.type === "Folder" && String(row.name ?? "") === args.name && String(row.parentId ?? "") === String(created[0]?.parentId ?? ""))
173
+ .length > 1
174
+ ? { warning: "同层级出现了多个同名目录(allowDuplicate 生效或既有同名);后续用 id 定位,不要用 path/name" }
175
+ : {}),
176
+ hint: created.length > 0 ? "把模型放进该目录用 model.move(parentId = 这里的 id)" : "回读未见新目录:树是嵌套的,但 folder.tree 已递归展平;若确实没有,检查应用 code",
177
+ },
178
+ };
179
+ });
180
+ writeTool(tools, "folder.update", LANE, T["folder.update"].scope, "重命名目录或把目录挂到另一个目录下:`POST /api/app/folders/update`(AppFunctionController:219;`updateFolder:612` 拒绝 `parentId === id`,`parentId` 为空时保留原父级)。**只改目录节点本身,不会移动目录里的模型**——模型移动用 `model.move`。", {
181
+ applicationCode: z.string().optional(),
182
+ folderId: z.string().min(1).describe("目录 id(folder.tree 的 id)"),
183
+ name: z.string().min(1).optional().describe("新显示名"),
184
+ name_i18n: z.string().optional(),
185
+ parentId: z.string().min(1).optional().describe("新父目录 id(挂到根用应用包 id;不理解根 id 就省略此字段表示不改父级)"),
186
+ }, async (session, args) => {
187
+ const appCode = await resolveApplicationCode(session, args.applicationCode);
188
+ const before = await readTree(session, appCode);
189
+ const current = resolveFolder(before, args.folderId);
190
+ if (args.name === undefined && args.parentId === undefined)
191
+ throw new ToolRefusalError({ status: "refused", blocker: "folder-update-empty-patch", message: "没有可应用的补丁——至少给 name 或 parentId" });
192
+ if (args.parentId !== undefined && args.parentId === args.folderId)
193
+ throw new ToolRefusalError({ status: "refused", blocker: "folder-parent-self", message: "parentId 不能等于目录自身(平台 FOLDER_PARENTID_EQUALS_ID)" });
194
+ const performed = await wireCall(session, {
195
+ path: ENDPOINTS.folderUpdate,
196
+ method: "POST",
197
+ body: { id: args.folderId, name: args.name ?? current.name, ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}), ...(args.parentId !== undefined ? { parentId: args.parentId } : {}) },
198
+ });
199
+ const after = await readTree(session, appCode);
200
+ const now = after.find((row) => String(row.id ?? "") === args.folderId);
201
+ return {
202
+ performed: { errcode: performed.errcode },
203
+ readback: {
204
+ folderId: args.folderId,
205
+ present: now !== undefined,
206
+ before: { name: current.name, parentId: current.parentId, path: current.path },
207
+ after: now ? { name: now.name, parentId: now.parentId, path: now.path } : null,
208
+ note: "只改了目录节点;目录内的模型不随目录移动(模型要动请用 model.move)",
209
+ },
210
+ };
211
+ });
212
+ writeTool(tools, "folder.delete", LANE, T["folder.delete"].scope, "删除空目录:`POST /api/app/folders/delete?folderId=`(AppFunctionController:246)。**非递归**——目录里还有子目录或模型时平台回 `DIRECTORY_NOT_EMPTY`,工具在写前先查子节点并拒绝(`folder-not-empty`),避免半删。要清空请先 `model.move` 移走模型、删子目录。", {
213
+ applicationCode: z.string().optional(),
214
+ folderId: z.string().min(1),
215
+ }, async (session, args) => {
216
+ const appCode = await resolveApplicationCode(session, args.applicationCode);
217
+ const before = await readTree(session, appCode);
218
+ const current = resolveFolder(before, args.folderId);
219
+ if (String(current.type ?? "") !== "Folder")
220
+ throw new ToolRefusalError({ status: "refused", blocker: "folder-target-not-folder", message: `${args.folderId} 不是目录节点(type=${String(current.type)});删模型用 model.delete` });
221
+ const children = before.filter((row) => String(row.parentId ?? "") === String(args.folderId));
222
+ if (children.length > 0)
223
+ throw new ToolRefusalError({
224
+ status: "refused",
225
+ blocker: "folder-not-empty",
226
+ folderId: args.folderId,
227
+ children: children.map((row) => ({ id: row.id, type: row.type, path: row.path })).slice(0, 40),
228
+ message: `目录「${current.path}」还有 ${children.length} 个子节点——平台非递归删除会回 DIRECTORY_NOT_EMPTY。先 model.move 移走模型 / 删子目录`,
229
+ });
230
+ const performed = await wireCall(session, { path: ENDPOINTS.folderDelete, method: "POST", query: { folderId: args.folderId } });
231
+ const after = await readTree(session, appCode);
232
+ return {
233
+ performed: { errcode: performed.errcode },
234
+ readback: { folderId: args.folderId, deleted: !after.some((row) => String(row.id ?? "") === args.folderId), path: current.path },
235
+ };
236
+ });
237
+ }
238
+ export function registerFolderLane(tools) {
239
+ folderTools(tools);
240
+ }
241
+ //# sourceMappingURL=folder.js.map
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
+ import { decodeButtonScript } from "./list-view.js";
3
4
  import { executeFormDraftWrite, executeFormPublishWrite, executeFormRunButtonWrite, readFormButtons, readFormOrNull, refuseEmptyCallerLayout, validateHtmlRender } from "../form-support.js";
4
5
  import { capabilityArg, environmentArg } from "../args.js";
5
6
  import { governedRead } from "../read.js";
@@ -51,6 +52,7 @@ export function registerFormLane(tools) {
51
52
  .optional()
52
53
  .describe("控件定义投影:true = 返回全部控件的 draft/published 定义;字符串数组 = 只返回这些控件码的定义;省略 = 只回控件码清单"),
53
54
  full: z.boolean().optional().describe("返回全部六份布局 JSON 原文(大体积)"),
55
+ buttonScripts: z.boolean().optional().describe("附带表单按钮的脚本原文(options.customButtonJson 里 SCRIPT 按钮的 actionConfig.scriptData)及 sha/字节数"),
54
56
  capability: capabilityArg,
55
57
  },
56
58
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
@@ -120,18 +122,21 @@ export function registerFormLane(tools) {
120
122
  // Form buttons are tiny; list them plainly in the COMPACT face so a
121
123
  // readback never needs the artifact just to see which buttons exist.
122
124
  const formButtons = readFormButtons(sheet.options) ?? [];
125
+ const withScripts = args.buttonScripts === true;
123
126
  return {
124
127
  ...base,
125
128
  ...(formButtons.length > 0
126
129
  ? {
127
130
  formButtonCodes: formButtons.map((button) => String(button.code ?? "")),
128
- formButtons: formButtons.map((button) => ({
129
- code: button.code ?? null,
130
- name: button.name ?? null,
131
- bindAction: button.bindAction ?? null,
132
- targetCode: button.targetCode ?? null,
133
- useLocation: button.useLocation ?? null,
134
- })),
131
+ formButtons: formButtons.map((button) => withScripts
132
+ ? decodeButtonScript(button)
133
+ : {
134
+ code: button.code ?? null,
135
+ name: button.name ?? null,
136
+ bindAction: button.bindAction ?? null,
137
+ targetCode: button.targetCode ?? null,
138
+ useLocation: button.useLocation ?? null,
139
+ }),
135
140
  }
136
141
  : {}),
137
142
  draftControlCodes: draftControls,
@@ -157,6 +162,7 @@ export function registerFormLane(tools) {
157
162
  customButtonJson: z.string().optional().describe("buttons 的原样 JSON 数组字符串(与 buttons 互斥;用于把 form.get 读回值直接回写)"),
158
163
  buttonsMode: z.enum(["merge", "replace"]).optional().describe("默认 merge:按 code 增量 upsert(改一个按钮不必重发全表);replace:整表替换"),
159
164
  removeButtonCodes: z.array(z.string().min(1)).optional().describe("要删除的按钮 code(仅 merge 模式)"),
165
+ buttonScripts: z.array(z.object({ code: z.string().min(1), script: z.string() })).optional().describe("直接改已有表单按钮的脚本(actionConfig.scriptData),按 code 逐字替换;未知 code 拒绝。不必重发整表"),
160
166
  }, async (session, args) => executeFormDraftWrite(session, { ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) }), (args) => {
161
167
  // Zero-wire refusal (ticket 74): fires in semanticGate BEFORE the
162
168
  // governed pipeline — including the rollback before-capture read.
@@ -192,6 +198,7 @@ export function registerFormLane(tools) {
192
198
  customButtonJson: z.string().optional().describe("buttons 的原样 JSON 数组字符串(与 buttons 互斥)"),
193
199
  buttonsMode: z.enum(["merge", "replace"]).optional().describe("默认 merge:按 code 增量 upsert;replace:整表替换"),
194
200
  removeButtonCodes: z.array(z.string().min(1)).optional().describe("要删除的按钮 code(仅 merge 模式)"),
201
+ buttonScripts: z.array(z.object({ code: z.string().min(1), script: z.string() })).optional().describe("直接改已有表单按钮的脚本(actionConfig.scriptData),按 code 逐字替换;未知 code 拒绝。不必重发整表"),
195
202
  }, async (session, args) => executeFormPublishWrite(session, { ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) }));
196
203
  registerPackageTool(tools, {
197
204
  name: "configure_form",
@@ -13,6 +13,7 @@ export const LANES = Object.freeze({
13
13
  meta: "meta-governance",
14
14
  environment: "environment-governance",
15
15
  app: "app-management",
16
+ folder: "app-tree-folders",
16
17
  dictionary: "dictionary-management",
17
18
  model: "model-management",
18
19
  field: "field-families",
@@ -167,6 +167,9 @@ export function assertListViewContract() {
167
167
  if (!LIST_VIEW_WIRE.deleteBody.includes("query/delete"))
168
168
  throw new Error("list-view contract must pin the delete endpoint");
169
169
  assertDescriptionCarries(LIST_VIEW_LANE_CONTRACT, "listview.create", ["query/create", "LIST", "get_header"]);
170
+ // Button scripts are stored in h_biz_button.actionConfig.scriptData (a JSON
171
+ // STRING); listview.get/--buttons and form.get/--buttonScripts surface them
172
+ // without a round trip through the artifact.
170
173
  assertDescriptionCarries(LIST_VIEW_LANE_CONTRACT, "listview.get", ["query/get", "code", "默认视图", "present"]);
171
174
  assertDescriptionCarries(LIST_VIEW_LANE_CONTRACT, "listview.list", ["query/list", "code"]);
172
175
  assertDescriptionCarries(LIST_VIEW_LANE_CONTRACT, "listview.configure", ["query/update", "fieldCodes", "默认视图", "querySorts", "queryConditions", "queryActions", "queryActionType", "associationType", "options", "queryPresentation", "htmlJson", "schemaRelations", "customButtons", "customButtonJson", "bindAction", "useLocation", "operateType", "showPermType", "QUERY", "pagingSetting", "listViewModeSetting", "presentationType", "queryGanttModel", "GANTT", "queryLayer", "multiTableHeaderConfig", "publish=TRUE", "field.publish", "rule.enable", "actionConfig", "targetModel", "listview-button-target-unresolved"]);
@@ -232,6 +232,10 @@ export const listViewButtonArg = z
232
232
  .union([z.string(), z.record(z.string(), z.unknown())])
233
233
  .optional()
234
234
  .describe("BizButtonModel.actionConfig 原样透传(与 targetModel/targetForm/configureList 互斥);ADD_DATA/FORM 只接受 schemaCodeVal/formListData/configureList 子形状"),
235
+ script: z
236
+ .string()
237
+ .optional()
238
+ .describe("SCRIPT 按钮的脚本源码简写——等价于 actionConfig.scriptData;与 actionConfig 同传时以 script 为准(写进 actionConfig.scriptData)"),
235
239
  })
236
240
  .describe("customButtons[] entry (BizButtonModel); name/code/useLocation/bindAction/operateType are required; triggerType/triggerCode are forced to QUERY/view code and sortKey to the array position");
237
241
  /**
@@ -1004,7 +1008,29 @@ export function normalizeButtons(buttons, schemaCode, triggerCode) {
1004
1008
  // ("undefined is not iterable") blank the click. Emit an empty
1005
1009
  // configureList for those three bind actions and `{}` otherwise.
1006
1010
  const needsConfigureList = button.bindAction === "FLOW" || button.bindAction === "ADD_DATA" || button.bindAction === "FORM";
1007
- row.actionConfig = encodeJsonColumn(button.actionConfig ?? (needsConfigureList ? { configureList: [] } : {}));
1011
+ // `script` is the ergonomic spelling of actionConfig.scriptData for SCRIPT
1012
+ // buttons; when both are given the explicit script wins (it is the value the
1013
+ // caller just wrote, not a stale config blob).
1014
+ let actionConfig = button.actionConfig ?? (needsConfigureList ? { configureList: [] } : undefined);
1015
+ if (button.script !== undefined) {
1016
+ let base = {};
1017
+ if (actionConfig !== undefined) {
1018
+ if (typeof actionConfig === "string") {
1019
+ try {
1020
+ const parsed = JSON.parse(actionConfig);
1021
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
1022
+ base = parsed;
1023
+ }
1024
+ catch {
1025
+ base = {};
1026
+ }
1027
+ }
1028
+ else
1029
+ base = { ...actionConfig };
1030
+ }
1031
+ actionConfig = { ...base, scriptData: button.script };
1032
+ }
1033
+ row.actionConfig = encodeJsonColumn(actionConfig ?? {});
1008
1034
  return row;
1009
1035
  });
1010
1036
  }
@@ -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),
@@ -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,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.76",
3
+ "version": "0.4.78",
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.76",
53
+ "@stalfh233/omc-fake-platform": "0.4.78",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.76",
55
+ "omc-core": "0.4.78",
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
- - **边界 / 陷阱**:环境已带默认 `applicationCode`,建模**不必**先建应用;`app` 未进 intent 声明式面。**要把 intent 落到新应用**:`omc call app.create`(原子面)→ 把该环境的 `applicationCode` 指向新应用(`omc setup` 重配或改 `~/.config/omc/` 环境配置)→ 再 `intent plan/apply`,模型即落在新应用(用户实测摩擦:曾误以为应用能进 intent)。
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
- - **能做哪些事**:模型创建、改名、物理删除(`absent:true` 回读)、存在性、批量删除、摘要;一次建模型+字段+表单用 `configure_model`。
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 次调用。**保留 code**:字段 code 用 `name` 会被平台占用,须避开。**每个模型都必须建并发布默认表单**——只建模型不建/不发布表单则无法录入查看(`configure_form` 或 `form.draft(rebuild:true)` + `form.publish`)。**「汇总/统计/按月」类需求**:常见做法是**一张汇总表**(独立业务模型,如 `x_monthly_summary`)接住结果,用定时任务/规则把源数据汇总写进去——**但这按需求定**,不是写死的规则。需求确认时把"汇总表"作为常规选项提出来跟用户确认,别默认往报表或二开上引。
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
 
@@ -92,6 +99,7 @@ only the section for the capability you need —
92
99
 
93
100
  - **能做哪些事**:表单 draft-first 布局、发布、`publishedHtmlJson` 绑定读回;把已发布字段绑进默认表单;**表单工具栏按钮**(`options.customButtonJson` → publish 生成 FORM 按钮)与按钮执行。
94
101
  - **工具**:`form.get`、`form.draft`、`form.publish`、`configure_form`、`form.runButton`
102
+ - **按钮脚本(JS)读写**:`form.get --buttonScripts` 回 `scriptData`/sha;写用 `form.publish --buttonScripts [{code,script}]`(只改已有按钮脚本),或在 `buttons[]` 里给 `script`。**视图(列表)本身没有 JS 面**(`h_biz_query` 无脚本列);脚本只有三处:表单在线JS、按钮JS、自定义页面 JS。
95
103
  - **recipe**:`omc describe configure_form --example publish-default-form`
96
104
  - **边界 / 陷阱**:默认表单已存在,用 `configure_form`/`form.draft(rebuild)`/`form.publish(rebuild:true)` 改它,不要新建;改字段形状后刷新表单一调用 `form.publish rebuild:true`。
97
105
  - **每个模型交付前其默认表单必须已发布**:只建草稿=用户无法录入/查看。用 `configure_form` 或 `form.draft(rebuild:true)`+`form.publish` 发布。
@@ -103,6 +111,7 @@ only the section for the capability you need —
103
111
 
104
112
  - **能做哪些事**:视图表头列、排序、筛选条件生命周期(create/configure/delete)。
105
113
  - **工具**:`listview.get`、`listview.list`、`listview.create`、`listview.configure`、`listview.delete`
114
+ - **按钮脚本(JS)读写**:`listview.get --buttonCode <code>`(或 `--buttons`)直接回 `scriptData`/`scriptBytes`/`scriptSha256`,不用翻 artifact;写用 `listview.configure --buttonScripts [{code,script}]` 只改脚本(保留其它字段),或在 `buttons[]` 里用 `script` 简写(= `actionConfig.scriptData`)。
106
115
  - **recipe**:`omc describe listview.configure --example configure-default-list`
107
116
  - **边界 / 陷阱**:默认视图已存在,`listview.configure` 省略 code 即配置默认视图。**按字段分组(queryLayer)未取证**——别猜写。
108
117