@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 +23 -2
- package/dist/config-store.js +81 -7
- package/dist/form-support.js +116 -1
- package/dist/lanes/meta.js +6 -0
- package/dist/lanes/online-js-contract.js +1 -1
- package/dist/lanes/online-js.js +42 -2
- package/dist/lanes/workflow-contract.js +1 -1
- package/dist/lanes/workflow.js +69 -10
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +1 -1
- package/skills/omc-config-draft-review.md +1 -1
- package/skills/omc-start-here.md +14 -1
- package/skills/omc-write-hazards.md +1 -1
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
|
}
|
package/dist/config-store.js
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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
|
|
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()}
|
|
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()}
|
|
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
|
|
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])
|
package/dist/form-support.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/lanes/meta.js
CHANGED
|
@@ -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
|
});
|
package/dist/lanes/online-js.js
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
package/dist/lanes/workflow.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
53
|
+
"@stalfh233/omc-fake-platform": "0.4.71",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
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
|
|
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
|
|
package/skills/omc-start-here.md
CHANGED
|
@@ -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.
|
|
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` → 回读核对。**别用
|
|
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
|
|