@stalfh233/omc-cli 0.4.70 → 0.4.71

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/dist/cli.js CHANGED
@@ -968,12 +968,33 @@ async function main() {
968
968
  command.command("setup").option("--list").option("--edit <environment>").option("--remove <environment>").action(async (options) => {
969
969
  commandExitCode = await cmdSetup(new ConfigStore(defaultPaths()), [...(options.list ? ["--list"] : []), ...(options.edit ? ["--edit", options.edit] : []), ...(options.remove ? ["--remove", options.remove] : [])]);
970
970
  });
971
- command.command("env [action]").option("--json").action(async (action, options) => {
971
+ command.command("env [action] [name]").option("--json").action(async (action, name, options) => {
972
972
  // Read-only inventory of configured environment names. r18: the sandbox
973
973
  // guessed names instead of listing them; doctor's wording now points here.
974
974
  const store = new ConfigStore(defaultPaths());
975
+ if (action === "allow") {
976
+ // The only sanctioned way to widen a project's environment scope. The
977
+ // list stays project-scoped until the operator names the environment —
978
+ // an agent cannot discover another project's environments from here.
979
+ const target = name;
980
+ if (!target) {
981
+ console.error("用法:omc env allow <环境名>(把该项目需要的另一环境写入 .omc-envs;执行前须向用户报告并取得同意)");
982
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
983
+ return;
984
+ }
985
+ try {
986
+ store.allowProjectEnvironment(target);
987
+ console.error(`已允许本项目使用环境 ${target}(写入 .omc-envs)`);
988
+ commandExitCode = CLI_EXIT_CODES.ok;
989
+ }
990
+ catch (error) {
991
+ console.error(error.message);
992
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
993
+ }
994
+ return;
995
+ }
975
996
  if (action !== undefined && action !== "list") {
976
- console.error("用法:omc env list [--json]");
997
+ console.error("用法:omc env list [--json] | omc env allow <环境名>");
977
998
  commandExitCode = CLI_EXIT_CODES.invalidInput;
978
999
  return;
979
1000
  }
@@ -23,6 +23,7 @@ export function defaultPaths(projectDir = process.cwd()) {
23
23
  configPath: join(configRoot, "omc.config.json"),
24
24
  credentialsPath: join(configRoot, "credentials"),
25
25
  projectEnvPath: join(resolve(projectDir), ".omc-env"),
26
+ projectEnvsPath: join(resolve(projectDir), ".omc-envs"),
26
27
  };
27
28
  }
28
29
  export class ConfigStore {
@@ -124,8 +125,55 @@ export class ConfigStore {
124
125
  this.writeConfig({ environments });
125
126
  return config;
126
127
  }
127
- listEnvironments() {
128
- return Object.entries(this.readConfig().environments).map(([name, profile]) => ({ name, profile }));
128
+ /**
129
+ * Environments visible to THIS project. Project isolation is the default:
130
+ * the caller only ever sees the project's own environments, never the rest of
131
+ * the machine's config (a co-located project's baseUrl/credentials are its
132
+ * own business). `all:true` is for the operator-facing setup command, which
133
+ * must explicitly ask before revealing another project's environment.
134
+ */
135
+ listEnvironments(options = {}) {
136
+ const all = Object.entries(this.readConfig().environments).map(([name, profile]) => ({ name, profile }));
137
+ if (options.all === true)
138
+ return all;
139
+ const own = new Set(this.ownEnvironmentNames());
140
+ return all.filter((entry) => own.has(entry.name));
141
+ }
142
+ /** Names declared by this project: `.omc-envs` lines + the `.omc-env` binding. */
143
+ ownEnvironmentNames() {
144
+ const names = new Set(this.readProjectEnvsFile());
145
+ const bound = this.readProjectEnvBinding();
146
+ if (bound)
147
+ names.add(bound);
148
+ if (names.size === 0) {
149
+ const configured = this.listEnvironments({ all: true });
150
+ const fallback = configured.find((entry) => entry.profile.isDefault) ?? (configured.length === 1 ? configured[0] : undefined);
151
+ if (fallback)
152
+ names.add(fallback.name);
153
+ }
154
+ return [...names];
155
+ }
156
+ readProjectEnvBinding() {
157
+ if (!existsSync(this.paths.projectEnvPath))
158
+ return undefined;
159
+ const name = readFileSync(this.paths.projectEnvPath, "utf8").trim();
160
+ return name || undefined;
161
+ }
162
+ readProjectEnvsFile() {
163
+ if (!existsSync(this.paths.projectEnvsPath))
164
+ return [];
165
+ return readFileSync(this.paths.projectEnvsPath, "utf8")
166
+ .split(/\r?\n/u)
167
+ .map((line) => line.replace(/#.*$/u, "").trim())
168
+ .filter((line) => line !== "");
169
+ }
170
+ /** Declare another environment as usable by this project (cross-env approval). */
171
+ allowProjectEnvironment(name) {
172
+ if (!this.readConfig().environments[name]) {
173
+ throw new ConfigurationError(`unknown environment "${name}"; available: ${this.availableEnvironmentNames()}`);
174
+ }
175
+ const names = [...new Set([...this.readProjectEnvsFile(), name])].sort();
176
+ this.atomicWrite(this.paths.projectEnvsPath, names.join("\n") + "\n", 0o644);
129
177
  }
130
178
  readCredential(name) {
131
179
  const inline = process.env.OMC_CLOUDPIVOT_CREDENTIAL;
@@ -161,20 +209,25 @@ export class ConfigStore {
161
209
  }
162
210
  /** Available environment names, for error messages — stops agents guessing or spelunking ~/.config. */
163
211
  availableEnvironmentNames() {
164
- return Object.keys(this.readConfig().environments).join("、") || "(none configured; run omc setup)";
212
+ return this.ownEnvironmentNames().join("、") || "(none configured for this project; run omc setup)";
165
213
  }
166
214
  /** Resolve the active environment for a call. */
167
215
  resolveEnvironment(explicit) {
216
+ const resolved = this.resolveCandidate(explicit);
217
+ this.assertProjectScope(resolved.name, resolved.source);
218
+ return resolved;
219
+ }
220
+ resolveCandidate(explicit) {
168
221
  if (explicit && explicit.trim()) {
169
222
  const profile = this.readConfig().environments[explicit];
170
223
  if (!profile)
171
- throw new ConfigurationError(`unknown environment "${explicit}"; available: ${this.availableEnvironmentNames()} (doctor prints the bound 环境=; do not guess or read ~/.config/omc manually)`);
224
+ throw new ConfigurationError(`unknown environment "${explicit}"; available: ${this.availableEnvironmentNames()}(仅本项目;doctor prints the bound 环境=; do not guess or read ~/.config/omc manually)`);
172
225
  return { name: explicit, profile, source: "explicit" };
173
226
  }
174
227
  if (process.env.OMC_ENV) {
175
228
  const profile = this.readConfig().environments[process.env.OMC_ENV];
176
229
  if (!profile)
177
- throw new ConfigurationError(`OMC_ENV=${process.env.OMC_ENV} is not configured; available: ${this.availableEnvironmentNames()}`);
230
+ throw new ConfigurationError(`OMC_ENV=${process.env.OMC_ENV} is not configured; available: ${this.availableEnvironmentNames()}(仅本项目)`);
178
231
  return { name: process.env.OMC_ENV, profile, source: "OMC_ENV" };
179
232
  }
180
233
  if (existsSync(this.paths.projectEnvPath)) {
@@ -182,15 +235,36 @@ export class ConfigStore {
182
235
  if (name) {
183
236
  const profile = this.readConfig().environments[name];
184
237
  if (!profile)
185
- throw new ConfigurationError(`project .omc-env binds "${name}" which is not configured in omc.config.json; available: ${this.availableEnvironmentNames()} (run omc setup)`);
238
+ throw new ConfigurationError(`project .omc-env binds "${name}" which is not configured in omc.config.json; available: ${this.availableEnvironmentNames()}(仅本项目;run omc setup)`);
186
239
  return { name, profile, source: ".omc-env" };
187
240
  }
188
241
  }
189
- const entry = this.listEnvironments().find((item) => item.profile.isDefault);
242
+ const configured = this.listEnvironments({ all: true });
243
+ const entry = configured.find((item) => item.profile.isDefault) ?? (configured.length === 1 ? configured[0] : undefined);
190
244
  if (!entry)
191
245
  throw new ConfigurationError("no default environment; run omc setup or pass environment explicitly");
192
246
  return { name: entry.name, profile: entry.profile, source: "default" };
193
247
  }
248
+ /**
249
+ * Project isolation gate. A directory may hold several environments, but only
250
+ * those it declares (`.omc-envs` + `.omc-env`). Touching any other environment
251
+ * is a cross-project read: refuse BEFORE credentials/token/network, and tell
252
+ * the caller to obtain the user's approval. `OMC_ALLOW_CROSS_ENV=1` is the
253
+ * human-approved one-off escape hatch and announces itself on stderr.
254
+ */
255
+ assertProjectScope(name, source) {
256
+ const own = this.ownEnvironmentNames();
257
+ if (own.includes(name))
258
+ return;
259
+ if (process.env.OMC_ALLOW_CROSS_ENV === "1") {
260
+ console.error(`cross-env: 环境 "${name}" 不在本项目允许列表(${own.join("、") || "空"});已按 OMC_ALLOW_CROSS_ENV=1 放行本次访问。请确认这是用户批准的临时跨环境操作。`);
261
+ return;
262
+ }
263
+ throw new ConfigurationError(`跨环境访问被拒绝:环境 "${name}" 不属于本项目(本项目允许:${own.join("、") || "(none)"})。` +
264
+ `每个项目目录只能使用自己声明的环境,不允许读取其他环境的信息。` +
265
+ `如确需跨环境,先向用户报告并取得同意,再由用户执行 \`omc env allow ${name}\`(写入项目 .omc-envs),` +
266
+ `或临时设置 OMC_ALLOW_CROSS_ENV=1 单次放行。`);
267
+ }
194
268
  bindProjectEnvironment(name) {
195
269
  const config = this.readConfig();
196
270
  if (!config.environments[name])
@@ -343,6 +343,100 @@ function childTableColumns(parentCode, children) {
343
343
  * layered back from the previous draft. Child-table (201) controls are skipped:
344
344
  * their columns are regenerated from the model's subSchema.
345
345
  */
346
+ /**
347
+ * Options that carry an expression the DESIGNER computed (formula, condition,
348
+ * default formula). A rebuild re-scaffolds controls from field metadata, which
349
+ * cannot reproduce them: the scaffold only knows the field family. Losing one
350
+ * is silent — the control still renders, the page still publishes, and the only
351
+ * signal is an empty string where an expression used to be. Live 2026-10-21
352
+ * (customer 8.2.96, 季度考评): `field.update cascadeRepublish:true` rebuilt the
353
+ * form and `totalScore`/`score`/`totalEvaluationScore` came back with
354
+ * `shortTextStitch: ""` while the pre-rebuild backup still had
355
+ * `SUM({evaluation_form.realScore})`.
356
+ */
357
+ const FORMULA_BEARING_CONTROL_KEYS = [
358
+ "shortTextStitch",
359
+ "formulaConfig",
360
+ "displayFormula",
361
+ "requiredFormula",
362
+ "readonlyFormula",
363
+ "readonlyCondition",
364
+ "verifyFormula",
365
+ "computeFormula",
366
+ ];
367
+ function nonEmptyExpression(value) {
368
+ if (typeof value === "string")
369
+ return value.trim() !== "" && value.trim() !== "{}" && value.trim() !== "[]";
370
+ if (value && typeof value === "object" && !Array.isArray(value))
371
+ return Object.keys(value).length > 0;
372
+ return false;
373
+ }
374
+ /**
375
+ * Layer two attributes faces (base = published, over = draft). Controls keep
376
+ * their own `options` merged per key, so a control edited on only one face
377
+ * still contributes its untouched options to the rebuild baseline. The draft
378
+ * wins wherever both define a key.
379
+ */
380
+ function mergeAttributeFaces(base, over) {
381
+ if (base === undefined)
382
+ return over;
383
+ if (over === undefined)
384
+ return base;
385
+ const merged = { ...base };
386
+ for (const [code, control] of Object.entries(over)) {
387
+ const prior = base[code];
388
+ const options = control?.options;
389
+ if (prior && typeof prior === "object" && options && typeof options === "object" && !Array.isArray(options)
390
+ && prior.options && typeof prior.options === "object" && !Array.isArray(prior.options)) {
391
+ merged[code] = { ...control, options: { ...prior.options, ...options } };
392
+ continue;
393
+ }
394
+ merged[code] = control;
395
+ }
396
+ return merged;
397
+ }
398
+ /** controlCode → option keys that carried a real expression on that face. */
399
+ export function formulaBearingControlOptions(face) {
400
+ const carry = new Map();
401
+ for (const [code, control] of Object.entries(face)) {
402
+ const options = control?.options;
403
+ if (!options || typeof options !== "object" || Array.isArray(options))
404
+ continue;
405
+ const keys = FORMULA_BEARING_CONTROL_KEYS.filter((key) => nonEmptyExpression(options[key]));
406
+ if (keys.length > 0)
407
+ carry.set(code, keys);
408
+ }
409
+ return carry;
410
+ }
411
+ /**
412
+ * Fail-closed guard for the rebuild path: every expression present on the
413
+ * baseline face must still be present afterwards. Refusing beats shipping a
414
+ * form whose formula silently became "". The caller can restore from a backup
415
+ * artifact or pass the layout explicitly.
416
+ */
417
+ export function assertFormulasSurvivedRebuild(next, baseline, where) {
418
+ const lost = [];
419
+ for (const [code, optionKeys] of formulaBearingControlOptions(baseline)) {
420
+ const options = next[code]?.options;
421
+ const current = options && typeof options === "object" && !Array.isArray(options) ? options : {};
422
+ for (const key of optionKeys)
423
+ if (!nonEmptyExpression(current[key]))
424
+ lost.push({ control: code, option: key });
425
+ }
426
+ if (lost.length === 0)
427
+ return;
428
+ throw new ToolRefusalError({
429
+ status: "refused",
430
+ blocker: "form-rebuild-formula-would-be-lost",
431
+ schemaCode: where.schemaCode,
432
+ sheetCode: where.sheetCode,
433
+ losing: lost,
434
+ message: `重建表单会清空 ${lost.length} 处设计器公式(${lost.map((item) => `${item.control}.${item.option}`).join("、")})——` +
435
+ `脚手架只能按字段元数据重建控件,公式/条件不在字段上。写前零 wire 拒绝,避免「回读 published:true 但公式已空」。` +
436
+ `先取备份(form.get --full 或表单版本历史 /api/api/app/bizsheet/history/list),或显式传 draftAttributesJson 原布局;` +
437
+ `只改字段时不要用 cascadeRepublish。`,
438
+ });
439
+ }
346
440
  export function preserveDesignerControlOptions(next, previous) {
347
441
  const STRUCTURAL = new Set(["name", "name_i18n", "schemaCode", "displayField", "queryCode", "relativePropertyMatchCode"]);
348
442
  const preserved = [];
@@ -692,9 +786,21 @@ export async function executeFormDraftWrite(session, args) {
692
786
  // control visibility/width, label tweaks) are not derivable from field
693
787
  // metadata. A rebuild re-scaffolds, so capture the previous draft first and
694
788
  // layer those edits back on (see preserveDesignerControlOptions).
695
- const previousAttributes = !args.rebuild || typeof pre?.draftAttributesJson !== "string"
789
+ // Baseline for the preserve/guard step. The DRAFT is the normal source, but a
790
+ // published form whose draft face is empty/stale is not a safe baseline: the
791
+ // designer's formulas then live only on publishedAttributesJson, and reading
792
+ // the draft alone makes a rebuild look like "nothing to preserve" while it
793
+ // drops every expression (live 2026-10-21). Merge draft-over-published so a
794
+ // control the designer edited only on one face still carries its options.
795
+ const draftAttributes = !args.rebuild || typeof pre?.draftAttributesJson !== "string"
696
796
  ? undefined
697
797
  : decodeJsonObjectColumn(pre.draftAttributesJson, { invalid: "undefined", array: "reject", object: "accept" });
798
+ const publishedAttributes = !args.rebuild || typeof pre?.publishedAttributesJson !== "string"
799
+ ? undefined
800
+ : decodeJsonObjectColumn(pre.publishedAttributesJson, { invalid: "undefined", array: "reject", object: "accept" });
801
+ const previousAttributes = draftAttributes === undefined && publishedAttributes === undefined
802
+ ? undefined
803
+ : mergeAttributeFaces(publishedAttributes, draftAttributes);
698
804
  // `rebuild: true` discards the existing draft layout and re-scaffolds from
699
805
  // the published fields, so fields added after the form was first built (a
700
806
  // child table, a new relation) land in the layout. The platform designer's
@@ -741,6 +847,15 @@ export async function executeFormDraftWrite(session, args) {
741
847
  // controls verbatim, so a later field.update never propagated (bench20).
742
848
  attributes = syncFieldDerivedControlOptions(attributes, publishedFields);
743
849
  }
850
+ // Fail-closed: a rebuild that would drop a designer formula must never reach
851
+ // the wire. The merge above normally carries them, but it cannot when the
852
+ // control was not re-scaffolded (field family without a control shape) or the
853
+ // baseline face was unreadable — those are exactly the silent-loss cases.
854
+ if (args.rebuild === true && previousAttributes !== undefined && typeof attributes === "string") {
855
+ const composed = decodeJsonObjectColumn(attributes, { invalid: "undefined", array: "reject", object: "accept" });
856
+ if (composed !== undefined)
857
+ assertFormulasSurvivedRebuild(composed, previousAttributes, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
858
+ }
744
859
  const payload = {
745
860
  ...(pre ?? {}),
746
861
  schemaCode: args.schemaCode,
@@ -93,7 +93,13 @@ export function registerMetaLane(tools) {
93
93
  writeRequiresConfirmation: requiresProductionConfirmation(resolved.profile),
94
94
  removedConfigKeys: removedProfileKeys(resolved.profile),
95
95
  },
96
+ // Project-scoped: never advertise another directory's environments.
96
97
  configuredEnvironments: environments.map((e) => e.name),
98
+ environmentScope: {
99
+ project: store.configPaths.projectEnvPath.replace(/[\\/][^\\/]+$/u, ""),
100
+ own: store.ownEnvironmentNames(),
101
+ policy: "本目录只能使用 .omc-envs / .omc-env 声明的环境;跨环境需先向用户报告并取得同意(omc env allow <name>)",
102
+ },
97
103
  credentialsMode: store.credentialsMode(),
98
104
  session: sessionState,
99
105
  coverage: { total: ctx.manifest.items.length, byStatus: statusCounts },
@@ -78,7 +78,7 @@ export const ONLINE_JS_LANE_CONTRACT = Object.freeze({
78
78
  }),
79
79
  "onlinejs.compose": Object.freeze({
80
80
  scope: ONLINE_JS_SCOPE,
81
- description: `Compose the Online JS source as the customScript element into the form's draftHtmlJson layout array (then publish, promoting it verbatim to publishedHtmlJson). Core contract: preserve the platform template and only extend customScript.innerHTML — the six-element skeleton includes the ${PLATFORM_LIFECYCLE_WRAPPER_MARKER} lifecycle wrapper; the style, toolbar, template, beforeCustomScript, customScript, and afterCustomScript elements are kept or synthesized with platform defaults, while every layout/control element is preserved. customScript is rebuilt canonically as {tagName:'script', id:'customScript', innerHTML:<platform lifecycle wrapper containing source>, attrs:[]} because the designer requires an ARRAY attrs (e.attrs.some) and tagName, else the Online JS editor fails with t.some is not a function. Drives bizsheet/update + bizsheet/publish; publishedHtmlJson is read back verbatim and SHA-256-fingerprinted so the caller can verify byte equality. Read the EXISTING script first with onlinejs.get (decide replace vs INCREMENTAL from what is actually stored). Never overwrites the layout with a standalone script object or a partial [customScript]-only array (regression for issue 11's root cause).`,
81
+ description: `Compose the Online JS source as the customScript element into the form's draftHtmlJson layout array (then publish, promoting it verbatim to publishedHtmlJson). Core contract: preserve the platform template and only extend customScript.innerHTML — the six-element skeleton includes the ${PLATFORM_LIFECYCLE_WRAPPER_MARKER} lifecycle wrapper; the style, toolbar, template, beforeCustomScript, customScript, and afterCustomScript elements are kept or synthesized with platform defaults, while every layout/control element is preserved. customScript is rebuilt canonically as {tagName:'script', id:'customScript', innerHTML:<platform lifecycle wrapper containing source>, attrs:[]} because the designer requires an ARRAY attrs (e.attrs.some) and tagName, else the Online JS editor fails with t.some is not a function. Drives bizsheet/update + bizsheet/publish; publishedHtmlJson is read back verbatim and SHA-256-fingerprinted so the caller can verify byte equality. Read the EXISTING script first with onlinejs.get (decide replace vs INCREMENTAL from what is actually stored). Never overwrites the layout with a standalone script object or a partial [customScript]-only array (regression for issue 11's root cause). summary:true answers "did it land?" without any HTML/JS body (sha + byte counts + draftPublishedInSync); the default readback dedupes the composed draft and the script when they equal the published face, so the same script is never returned three times.`,
82
82
  }),
83
83
  }),
84
84
  });
@@ -101,6 +101,7 @@ export function registerOnlineJsLane(tools) {
101
101
  source: z.string().min(1).describe("在线 JS 代码。默认 INCREMENTAL:并入平台 (function(form){…}) 模板的既有脚本,模板与既有注册全部保留;配 replace:true 才是整段替换。脚本不得使用 export/import;事件名必须是平台事件(onLoad/onRendered/onValidate/onPreAction/onCustomAction/onActionDone/onPrintFormDataHandle/onPrintWorkflowLogDataHandle)"),
102
102
  replace: z.boolean().optional().describe("true = 以 source 整体替换 customScript(含完整 (function(form){…}) 脚本)。默认 false = 增量并入既有脚本,保留平台模板与既有逻辑"),
103
103
  publish: z.boolean().optional(),
104
+ summary: z.boolean().optional().describe("true = 只回同步状态(sha/字节数/是否同步/渲染兼容),不回任何 HTML/JS 原文。确认「改动是否生效」用这个;要看原文用 onlinejs.get"),
104
105
  }, async (session, args) => {
105
106
  const shouldPublish = args.publish !== false;
106
107
  // 1. Read the existing draftHtmlJson.
@@ -159,17 +160,56 @@ export function registerOnlineJsLane(tools) {
159
160
  const publishedElements = parseLayoutElements(publishedRaw);
160
161
  // 8. SHA-256 of the verbatim content.
161
162
  const publishedSha256 = createHash("sha256").update(publishedRaw).digest("hex");
163
+ const draftSha256 = createHash("sha256").update(composedDraftHtml).digest("hex");
164
+ const publishedScript = readCustomScriptSource(publishedRaw).script;
165
+ const customScriptSha256 = createHash("sha256").update(composedScript, "utf8").digest("hex");
166
+ const draftPublishedInSync = publishedScript !== null && publishedScript === composedScript;
167
+ const htmlRenderCompat = { draft: validateHtmlRender(composedDraftHtml, true), published: validateHtmlRender(publishedRaw, true) };
168
+ // Live 2026-10-21 (customer): this readback shipped the same ~10KB script
169
+ // three times (composedDraftHtmlJson + publishedHtmlJson + customScript),
170
+ // ~45KB for a call whose only question was "did publish land?" — the
171
+ // session's single largest context amplification. Answer that question
172
+ // directly and keep 原文 opt-in.
173
+ if (args.summary === true) {
174
+ return {
175
+ performed: { errcode: 0, published },
176
+ readback: {
177
+ publishedSha256,
178
+ draftSha256,
179
+ draftPublishedInSync,
180
+ publishedElementCount: publishedElements.length,
181
+ scriptElementPresent: publishedElements.some((element) => element.id === "customScript"),
182
+ customScriptMerged: args.replace !== true,
183
+ customScriptSha256,
184
+ customScriptBytes: Buffer.byteLength(composedScript, "utf8"),
185
+ composedDraftBytes: Buffer.byteLength(composedDraftHtml, "utf8"),
186
+ publishedHtmlBytes: Buffer.byteLength(publishedRaw, "utf8"),
187
+ htmlRenderCompat,
188
+ hint: "summary 模式不回原文;要脚本原文用 onlinejs.get,要完整布局用 artifact 或 form.get --full",
189
+ },
190
+ };
191
+ }
162
192
  return {
163
193
  performed: { errcode: 0, published },
164
194
  readback: {
195
+ // Dedupe: after a successful publish the draft and the published
196
+ // face are the same bytes, and the script also rides inside them.
197
+ // Return the composed payload ONCE (composedDraftHtmlJson keeps the
198
+ // field every consumer already reads) and state the equality instead
199
+ // of repeating ~11KB + ~10KB.
200
+ ...(composedDraftHtml === publishedRaw
201
+ ? { draftEqualsPublished: true }
202
+ : { publishedHtmlJson: publishedRaw }),
165
203
  composedDraftHtmlJson: composedDraftHtml,
166
- publishedHtmlJson: publishedRaw,
167
204
  publishedSha256,
205
+ draftSha256,
206
+ draftPublishedInSync,
168
207
  publishedElementCount: publishedElements.length,
169
208
  scriptElementPresent: publishedElements.some((element) => element.id === "customScript"),
170
209
  customScript: composedScript,
210
+ customScriptMatchesPublished: draftPublishedInSync,
171
211
  customScriptMerged: args.replace !== true,
172
- htmlRenderCompat: { draft: validateHtmlRender(composedDraftHtml, true), published: validateHtmlRender(publishedRaw, true) },
212
+ htmlRenderCompat,
173
213
  },
174
214
  };
175
215
  },
@@ -144,7 +144,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
144
144
  }),
145
145
  "org.role.list": Object.freeze({
146
146
  scope: "workflow participant resolution",
147
- description: "列出组织角色(`GET /api/api/organization/role/list`),扁平化为 {id,name,code,roleType,corpId}。用于把审批节点的参与者解析成平台认识的引用。**真实参与者语法**(2026-09-23 生产观察):`participant` 只能是 (a) 关键字 `{Originator}`;(b) 参与者对象数组 JSON `[{\"code\":\"<roleCode>\",\"corpId\":\"<corpId|other>\",\"id\":\"<roleId>\",\"name\":\"<角色名>\",\"type\":2}]`(type 2=角色,3=用户;多个用 `+` 连接);(c) 函数表达式如 `FindUserByRole({Originator.OU},\"<roleCode>\",\"random\")`。**自由文本角色名(如 `部门负责人`)能 publish 但解析不到人、不生成待办**——先用本工具拿到 `code`/`id` 再构造对象数组。",
147
+ description: "列出组织角色(`GET /api/api/organization/role/list`),递归展开角色组,返回 {count, roleCount, groupCount, roles[{id,name,code,roleType,corpId,group}], groups[{id,name,code,path,childCount}]}。**`count` 只数角色,不含角色组**——角色组是容器(平台行标记 `group:true`),旧版把空组当角色列出,曾让调用方把「19 个角色组」误读成「目标角色未创建」;`groups[].childCount` 才说明组内是否真的没有角色。用于把审批节点的参与者解析成平台认识的引用。**真实参与者语法**(2026-09-23 生产观察):`participant` 只能是 (a) 关键字 `{Originator}`;(b) 参与者对象数组 JSON `[{\"code\":\"<roleCode>\",\"corpId\":\"<corpId|other>\",\"id\":\"<roleId>\",\"name\":\"<角色名>\",\"type\":2}]`(type 2=角色,3=用户;多个用 `+` 连接);(c) 函数表达式如 `FindUserByRole({Originator.OU},\"<roleCode>\",\"random\")`。**自由文本角色名(如 `部门负责人`)能 publish 但解析不到人、不生成待办**——先用本工具拿到 `code`/`id` 再构造对象数组。",
148
148
  }),
149
149
  "org.department.list": Object.freeze({
150
150
  scope: "workflow participant resolution",
@@ -2302,16 +2302,7 @@ export function registerWorkflowLane(tools) {
2302
2302
  inputSchema: { environment: environmentArg, capability: capabilityArg },
2303
2303
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
2304
2304
  const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList })).data;
2305
- const roles = [];
2306
- for (const group of wireRows(data)) {
2307
- const children = Array.isArray(group.children) ? group.children : [];
2308
- if (children.length === 0)
2309
- roles.push(roleOf(group, null));
2310
- else
2311
- for (const child of children)
2312
- roles.push(roleOf(child, String(group.name ?? "")));
2313
- }
2314
- return { count: roles.length, roles };
2305
+ return flattenRoleTree(wireRows(data));
2315
2306
  }),
2316
2307
  }));
2317
2308
  tools.push(defineTool({
@@ -2381,6 +2372,74 @@ export function registerWorkflowLane(tools) {
2381
2372
  }));
2382
2373
  }
2383
2374
  /** Flatten a role-group/role row into the participant-construction fields. */
2375
+ /**
2376
+ * Role groups are CONTAINERS, not roles. The platform's own row marks them
2377
+ * `group:true` (children carry `group:false`), so membership must be decided by
2378
+ * that flag — never by `children.length`. Live 2026-10-21 (customer 8.2.96,
2379
+ * 季度考评): every entry was a group with `children:[]`, so the old
2380
+ * `children.length === 0 ? role : …` shape returned 19 "roles" that were all
2381
+ * groups; the agent read `count:19` as "the 6 roles were never created" and
2382
+ * reported a false blocker to the user. Groups also nest, so flatten
2383
+ * recursively and keep them in a separate `groups` list.
2384
+ */
2385
+ const ROLE_CHILD_KEYS = ["children", "roles", "childRoles", "childList", "list"];
2386
+ function roleChildren(row) {
2387
+ for (const key of ROLE_CHILD_KEYS) {
2388
+ const value = row[key];
2389
+ if (Array.isArray(value))
2390
+ return value;
2391
+ }
2392
+ return [];
2393
+ }
2394
+ function isRoleGroup(row) {
2395
+ if (row.group === true)
2396
+ return true;
2397
+ // Older builds omit `group`; a container with children is still a container.
2398
+ // A row that carries `groupId` is a leaf role even if it has an empty array.
2399
+ if (row.groupId !== undefined && row.groupId !== null)
2400
+ return false;
2401
+ return roleChildren(row).length > 0;
2402
+ }
2403
+ export function flattenRoleTree(rows) {
2404
+ const roles = [];
2405
+ const groups = [];
2406
+ const walk = (nodes, containerPath) => {
2407
+ for (const node of nodes) {
2408
+ if (!isRoleGroup(node)) {
2409
+ roles.push(roleOf(node, containerPath.length > 0 ? containerPath[containerPath.length - 1] : null));
2410
+ continue;
2411
+ }
2412
+ const name = String(node.name ?? "");
2413
+ const path = [...containerPath, name];
2414
+ const children = roleChildren(node);
2415
+ groups.push({
2416
+ id: node.id ?? null,
2417
+ name: node.name ?? null,
2418
+ code: node.code ?? null,
2419
+ corpId: node.corpId ?? null,
2420
+ path: path.join(" / "),
2421
+ childCount: children.length,
2422
+ });
2423
+ if (children.length > 0)
2424
+ walk(children, path);
2425
+ }
2426
+ };
2427
+ walk(rows, []);
2428
+ return {
2429
+ // `count` stays role-only: it is what participant resolution consumes, and
2430
+ // counting containers as roles is exactly the misread this fixes.
2431
+ count: roles.length,
2432
+ roleCount: roles.length,
2433
+ groupCount: groups.length,
2434
+ roles,
2435
+ groups,
2436
+ ...(roles.length === 0
2437
+ ? {
2438
+ hint: "本次返回里没有任何角色行(只有角色组或无数据)。角色组不是角色:不要据此断言「角色未创建」。若预期有角色,先看 groups[].childCount —— 组内为空说明该组下确实未建角色;若要验证某个 roleCode 在运行期可用,用业务数据反证(如 data.list 读取引用该 roleCode 的记录,或 org.user.search 找到人后用其角色字段)。",
2439
+ }
2440
+ : {}),
2441
+ };
2442
+ }
2384
2443
  function roleOf(row, group) {
2385
2444
  return {
2386
2445
  id: row.id ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.70",
3
+ "version": "0.4.71",
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.70",
53
+ "@stalfh233/omc-fake-platform": "0.4.71",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.70",
55
+ "omc-core": "0.4.71",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -274,7 +274,7 @@ BIZ_ACTION:
274
274
  - 直接执行规则:`useLocation: ROW` + `operateType: WITH_OUT_VIEW`。
275
275
  - `WITH_VIEW` 是打开表单。
276
276
  - FLOW 按钮需要 workflowCode;CLI 会补安全的 actionConfig。
277
- - **在线 JS 分两条**:本 CLI 只做**读写与发布**——**读现存脚本用 `onlinejs.get`**(抽 customScript innerHTML 原文+SHA-256,不必拉 form.get --full 六份 JSON;据此决定 replace 还是默认 INCREMENTAL 并入),用 `onlinejs.compose` 把已定稿的 `source` 组合进平台 skeleton 的 `customScript` 再 publish,回读 `publishedHtmlJson`+SHA-256。**脚本内容的编写/增量合并不在本 CLI**:若当前项目自带在线 JS 编写规范(例如其 `.cpdocs`/技能或项目文档),遵循它;否则向用户/设计者索取已定稿的 `source`。不要在 omc 侧手写或臆造 JS 逻辑。
277
+ - **在线 JS 分两条**:本 CLI 只做**读写与发布**——**读现存脚本用 `onlinejs.get`**(抽 customScript innerHTML 原文+SHA-256,不必拉 form.get --full 六份 JSON;据此决定 replace 还是默认 INCREMENTAL 并入),用 `onlinejs.compose` 把已定稿的 `source` 组合进平台 skeleton 的 `customScript` 再 publish,回读 `publishedHtmlJson`+SHA-256。**确认是否落地用 `summary:true`**(只回 sha/字节数/`draftPublishedInSync`,不回原文,约 600B;默认回显也已去重,不再把同一段脚本回三次)。**脚本内容的编写/增量合并不在本 CLI**:若当前项目自带在线 JS 编写规范(例如其 `.cpdocs`/技能或项目文档),遵循它;否则向用户/设计者索取已定稿的 `source`。不要在 omc 侧手写或臆造 JS 逻辑。
278
278
 
279
279
  ## 运行验收与清理
280
280
 
@@ -164,7 +164,7 @@ you can wait for.
164
164
 
165
165
  视图与表单脚手架都从**发布面**读字段元数据——字段未发布,列/控件就生成不出来。
166
166
  `data.list` 对未配列的自定义字段投影为 null(显示面缺配置,不是数据缺失);核值用 `data.load`。
167
- 字段级变更(如关联展示字段)改完后,必须重走 `field.publish` → `form.draft` → `form.publish` 运行时才生效(表单控件快照字段 options);`field.update` 带 `"cascadeRepublish":true` 一调完成整条链(中途失败按 partial steps 如实报告),只刷新表单用 `form.publish rebuild:true`。
167
+ 字段级变更(如关联展示字段)改完后,必须重走 `field.publish` → `form.draft` → `form.publish` 运行时才生效(表单控件快照字段 options);`field.update` 带 `"cascadeRepublish":true` 一调完成整条链(中途失败按 partial steps 如实报告),只刷新表单用 `form.publish rebuild:true`。**重建会按字段元数据重生成控件,设计器公式(`shortTextStitch`/条件/默认值)不在字段上**:0.4.71 起写前校验,会丢公式即拒绝 `form-rebuild-formula-would-be-lost`;不需要重建布局时不要带此参数。
168
168
 
169
169
  ## Invariants
170
170
 
@@ -82,7 +82,20 @@ execute → readback/cleanup** (`omc-five-piece-flow`).
82
82
  There is no approval token, no `--yes`, and no self-approval switch. If a call
83
83
  refuses, work out which gate or boundary produced it from `omc-refusals`.
84
84
 
85
- ## 3. Boundaries
85
+ ## 3. Project environment isolation
86
+
87
+ Each project directory may use only the environments it declares: `.omc-env` (this
88
+ project's binding) and `.omc-envs` (its allowlist, one name per line). Any other
89
+ environment on the machine is another project's — `resolveEnvironment` refuses it
90
+ **before** credentials, token cache or network access, so nothing leaks. Lists
91
+ (`omc env list`, `get_status.configuredEnvironments`) are project-scoped too.
92
+
93
+ To use another environment you must ask the user first, then either
94
+ `omc env allow <name>` (writes `.omc-envs`, durable) or a one-off
95
+ `OMC_ALLOW_CROSS_ENV=1` (prints a `cross-env:` line to stderr). Never widen the
96
+ scope on your own initiative.
97
+
98
+ ## 4. Boundaries
86
99
 
87
100
  Out-of-coverage, blocked, or unsupported faces refuse with a manifest citation —
88
101
  that refusal is the answer, not an obstacle to route around with another tool.
@@ -34,7 +34,7 @@ omc help omc-write-hazards
34
34
  - 有映射时传非空 JSON 数组字符串,并在 draft/published 两面读回。
35
35
  - **运行时写关联值是另一种形状**(`data.save`/`data.update` 的 formData):单选关联(`propertyType 9`)传 **id 字符串或 `{ id }`**,传**数组会稳定 50000**;**多选关联(11)传分号分隔的 id 字符串 `"id1;id2"`(或 `{id:"id1;id2"}`),传数组稳定 50000**;**人员/组织(50/51/60/61)传 `[{"id","type","name"}]`,`type` 必填**(缺它稳定 50000)——`type` 是 UnitType 索引:**用户=3、部门=1**。用错形状只会拿到一个不透明的 50000。
36
36
  - **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
37
- - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 field.update 的 cascadeRepublish 刷已带自定义布局/在线JS 的表单**(它会 rebuild 脚手架)。
37
+ - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
38
38
  - **运行时写值的最小形状**(组合出错只会得到一个不透明的 `50000`/NPE,逐一最小化再合):单选关联(9) → id 字符串 `"<id>"`;**多选关联(11) → 分号串 `"<id1>;<id2>"`**;**人员/组织(50/51/60/61) → `[{"id":"<id>","type":3,"name":"<名>"}]`,`type` 必填且为整数**(缺/非整数 → `BizField.parseFromClient` 拆箱 NPE→50000;用户=3、部门=1);日期(3) → 字符串 `"2026-09-30 10:00:00"`;子表(8) → 行对象数组 `[{"<子列code>":<值>, ...}]`。先各写一条最小行通过,再组合。
39
39
  - **DATE(3) 字段的 `options.format` 必须是平台大写枚举**(`YYYY-MM-DD`、`YYYY-MM-DD HH:mm:ss`、`YYYY年MM月DD日`…),**不是 Java 的 `yyyy-MM-dd`**:平台 `DateBizField` 按字面比较,Java 写法能创建字段但**首次运行时 save 稳定 `50000`(`不支持的format`)**(2026-09-30 R5 实测)。`field.create`/`intent` 会就地拒绝非枚举值。
40
40