openxiangda 1.0.270 → 1.0.271

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.
@@ -99,6 +99,16 @@ const only = changedOnly
99
99
  : targetPage
100
100
  ? [`pages/${targetPage}`]
101
101
  : normalizeOnly(args.only);
102
+ const stageOnlyPageRelease = process.env.OPENXIANGDA_PAGE_STAGE_ONLY === "1";
103
+ if (
104
+ stageOnlyPageRelease &&
105
+ (only.length === 0 || only.some((target) => !target.startsWith("pages/")))
106
+ ) {
107
+ console.error(
108
+ "❌ PAGE_RELEASE_STAGE_SCOPE_REQUIRED: stage-only 页面发布必须使用 --page 或只包含 pages/* 的精确 --only 范围",
109
+ );
110
+ process.exit(1);
111
+ }
102
112
 
103
113
  if (changedOnly && only.length === 0) {
104
114
  console.log("✅ 没有检测到 src/forms 或 src/pages 的 git 变更,跳过发布");
@@ -145,7 +155,10 @@ const publishLegacyFormBundle =
145
155
  Boolean(config.forms?.publishLegacyBundle);
146
156
 
147
157
  const allModules = discoverWorkspaceModules();
148
- const plan = planIncrementalPublish(allModules, { force, only });
158
+ const plan = planIncrementalPublish(allModules, {
159
+ force: force || stageOnlyPageRelease,
160
+ only,
161
+ });
149
162
  printPlan(plan, "publish-all");
150
163
  if (plan.changed.length === 0 && !force) {
151
164
  console.log("✅ 没有检测到变更,跳过发布");
@@ -177,11 +190,29 @@ for (const moduleItem of plan.changed) {
177
190
  } else if (moduleItem.kind === "pages") {
178
191
  await run("build-pages.mjs", ["--page", moduleItem.name, ...maybeForce]);
179
192
  await run("publish-oss.mjs", ["--page", moduleItem.name, ...maybeDryRun]);
180
- await run("register.mjs", ["--page", moduleItem.name, ...maybeDryRun]);
193
+ if (!stageOnlyPageRelease) {
194
+ await run("register.mjs", ["--page", moduleItem.name, ...maybeDryRun]);
195
+ }
181
196
  publishedModules.push(moduleItem);
182
197
  }
183
198
  }
184
199
 
200
+ if (stageOnlyPageRelease) {
201
+ const pageNames = publishedModules
202
+ .filter((moduleItem) => moduleItem.kind === "pages")
203
+ .map((moduleItem) => moduleItem.name);
204
+ if (pageNames.length === 0) {
205
+ throw new Error(
206
+ "PAGE_RELEASE_STAGE_SCOPE_REQUIRED: stage-only 页面发布没有可登记的精确页面",
207
+ );
208
+ }
209
+ await run("register.mjs", [
210
+ "--page-list-json",
211
+ JSON.stringify(pageNames),
212
+ ...maybeDryRun,
213
+ ]);
214
+ }
215
+
185
216
  if (!dryRun) {
186
217
  commitIncrementalPublish(plan, publishedModules);
187
218
  }
@@ -3,7 +3,9 @@
3
3
  */
4
4
 
5
5
  import fs from "node:fs";
6
+ import os from "node:os";
6
7
  import path from "node:path";
8
+ import { spawn } from "node:child_process";
7
9
  import { glob } from "glob";
8
10
  import minimist from "minimist";
9
11
  import {
@@ -31,6 +33,8 @@ const args = minimist(process.argv.slice(2).filter((arg) => arg !== "--"));
31
33
  const dryRun = Boolean(args["dry-run"]);
32
34
  const targetForm = args.form || null;
33
35
  const targetPage = args.page || null;
36
+ const targetPageList = parsePageList(args["page-list-json"]);
37
+ const stageOnlyPageRelease = process.env.OPENXIANGDA_PAGE_STAGE_ONLY === "1";
34
38
  const ensureExistingFormMenu =
35
39
  Boolean(args["ensure-menu"]) ||
36
40
  ["1", "true", "yes"].includes(
@@ -48,12 +52,36 @@ register - 注册应用工作区产物到平台
48
52
  --dry-run 只打印注册计划,不实际调用 API
49
53
  --form <name> 只注册指定表单
50
54
  --page <name> 只注册指定代码页目录
55
+ --page-list-json <JSON> 注册指定代码页目录数组(内部批量发布入口)
51
56
  --ensure-menu 已存在 formUuid 的表单也尝试补齐菜单
52
57
  --help, -h 显示帮助信息
53
58
  `);
54
59
  process.exit(0);
55
60
  }
56
61
 
62
+ if (targetPage && targetPageList.length > 0) {
63
+ console.error("❌ --page 和 --page-list-json 不能同时使用");
64
+ process.exit(1);
65
+ }
66
+
67
+ function parsePageList(value) {
68
+ if (value === undefined || value === null || value === "") return [];
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(String(value));
72
+ } catch (error) {
73
+ throw new Error(`PAGE_REGISTER_SCOPE_INVALID: page-list-json 不是合法 JSON: ${error.message}`);
74
+ }
75
+ if (!Array.isArray(parsed) || parsed.length === 0) {
76
+ throw new Error("PAGE_REGISTER_SCOPE_INVALID: page-list-json 必须是非空数组");
77
+ }
78
+ const normalized = parsed.map((item) => String(item || "").trim()).filter(Boolean);
79
+ if (normalized.length !== parsed.length || new Set(normalized).size !== normalized.length) {
80
+ throw new Error("PAGE_REGISTER_SCOPE_INVALID: page-list-json 包含空值或重复页面");
81
+ }
82
+ return normalized;
83
+ }
84
+
57
85
  const config = await loadConfig();
58
86
  const apiBase = getApiBaseUrl(config);
59
87
  let accessToken = null;
@@ -251,7 +279,7 @@ function readFormRuntimeAssets(config) {
251
279
  }
252
280
 
253
281
  async function registerPages() {
254
- const pages = await discoverPages(targetPage || "");
282
+ const pages = await discoverSelectedPages();
255
283
  if (pages.length === 0) {
256
284
  return { succeeded: 0, failed: 0 };
257
285
  }
@@ -265,6 +293,25 @@ async function registerPages() {
265
293
  return { succeeded: pages.length, failed: 0 };
266
294
  }
267
295
 
296
+ if (stageOnlyPageRelease) {
297
+ if (!isOpenXiangdaMode(config)) {
298
+ throw new Error(
299
+ "PAGE_RELEASE_STAGE_CONTEXT_REQUIRED: stage-only 页面发布只支持 OpenXiangda 模式",
300
+ );
301
+ }
302
+ if (!targetPage && targetPageList.length === 0) {
303
+ throw new Error(
304
+ "PAGE_RELEASE_STAGE_SCOPE_REQUIRED: stage-only 页面发布必须提供精确页面范围",
305
+ );
306
+ }
307
+ const data = await publishStagedPagesWithCurrentCli(payload);
308
+ data.items?.forEach((item) => {
309
+ console.log(` ✓ 代码页 ${item.code} (${item.pageId})`);
310
+ });
311
+ writePagePublishResult(payload, { data }, false);
312
+ return { succeeded: pages.length, failed: 0 };
313
+ }
314
+
268
315
  const response = await fetch(getPagePublishUrl(payload.appType), {
269
316
  method: "POST",
270
317
  headers: {
@@ -293,6 +340,119 @@ async function registerPages() {
293
340
  return { succeeded: pages.length, failed: 0 };
294
341
  }
295
342
 
343
+ async function discoverSelectedPages() {
344
+ if (targetPage) return discoverPages(targetPage);
345
+ const pages = await discoverPages("");
346
+ if (targetPageList.length === 0) return pages;
347
+ const byName = new Map();
348
+ for (const page of pages) {
349
+ byName.set(String(page.dirName || ""), page);
350
+ byName.set(String(page.config?.code || ""), page);
351
+ }
352
+ const missing = targetPageList.filter((name) => !byName.has(name));
353
+ if (missing.length > 0) {
354
+ throw new Error(`PAGE_REGISTER_SCOPE_INVALID: 找不到代码页 ${missing.join(",")}`);
355
+ }
356
+ return targetPageList.map((name) => byName.get(name));
357
+ }
358
+
359
+ async function publishStagedPagesWithCurrentCli(payload) {
360
+ const cli = String(process.env.OPENXIANGDA_CLI || "").trim();
361
+ const profile = String(config.openXiangdaProfile || process.env.OPENXIANGDA_PROFILE || "").trim();
362
+ const changeId = String(process.env.OPENXIANGDA_CHANGE_ID || "").trim();
363
+ if (!cli || !profile || !changeId) {
364
+ throw new Error(
365
+ "PAGE_RELEASE_STAGE_CONTEXT_REQUIRED: 缺少 OPENXIANGDA_CLI、OPENXIANGDA_PROFILE 或 OPENXIANGDA_CHANGE_ID",
366
+ );
367
+ }
368
+
369
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openxiangda-page-stage-"));
370
+ const definitionsFile = path.join(tempDir, "pages.json");
371
+ try {
372
+ fs.writeFileSync(
373
+ definitionsFile,
374
+ `${JSON.stringify({ pages: payload.pages }, null, 2)}\n`,
375
+ { encoding: "utf8", mode: 0o600 },
376
+ );
377
+ const cliArgs = [
378
+ "page",
379
+ "publish",
380
+ "--pages-json",
381
+ definitionsFile,
382
+ "--version",
383
+ payload.version,
384
+ "--build-id",
385
+ payload.buildId,
386
+ "--profile",
387
+ profile,
388
+ "--change",
389
+ changeId,
390
+ "--json",
391
+ ];
392
+ if (config.openXiangdaTarget) {
393
+ cliArgs.push("--environment", config.openXiangdaTarget);
394
+ }
395
+ const result = await runCurrentOpenXiangdaCli(cli, cliArgs);
396
+ const selectedPageCodes = Array.isArray(result?.stagedResource?.metadata?.selectedPageCodes)
397
+ ? [...result.stagedResource.metadata.selectedPageCodes].sort()
398
+ : [];
399
+ const expectedPageCodes = payload.pages.map((page) => page.code).sort();
400
+ if (
401
+ result?.stagedResource?.kind !== "PageRelease" ||
402
+ selectedPageCodes.length !== expectedPageCodes.length ||
403
+ !expectedPageCodes.every((code, index) => code === selectedPageCodes[index])
404
+ ) {
405
+ throw new Error(
406
+ `PAGE_RELEASE_STAGE_SCOPE_MISMATCH: staged PageRelease 范围不匹配;expected=${expectedPageCodes.join(",")} actual=${selectedPageCodes.join(",") || "-"}`,
407
+ );
408
+ }
409
+ return result;
410
+ } finally {
411
+ fs.rmSync(tempDir, { recursive: true, force: true });
412
+ }
413
+ }
414
+
415
+ function runCurrentOpenXiangdaCli(cli, args) {
416
+ return new Promise((resolve, reject) => {
417
+ const command = cli.endsWith(".js") || cli.endsWith(".mjs") ? process.execPath : cli;
418
+ const commandArgs = command === process.execPath ? [cli, ...args] : args;
419
+ const child = spawn(command, commandArgs, {
420
+ cwd: rootDir,
421
+ env: process.env,
422
+ shell: false,
423
+ stdio: ["ignore", "pipe", "pipe"],
424
+ });
425
+ let stdout = "";
426
+ let stderr = "";
427
+ child.stdout.on("data", (chunk) => {
428
+ stdout += String(chunk);
429
+ });
430
+ child.stderr.on("data", (chunk) => {
431
+ stderr += String(chunk);
432
+ });
433
+ child.on("error", reject);
434
+ child.on("close", (code, signal) => {
435
+ if (signal || code !== 0) {
436
+ reject(
437
+ new Error(
438
+ `PAGE_RELEASE_STAGE_FAILED: 当前 OpenXiangda CLI 执行失败 (${signal || code}) ${stderr.trim() || stdout.trim()}`,
439
+ ),
440
+ );
441
+ return;
442
+ }
443
+ try {
444
+ resolve(JSON.parse(stdout));
445
+ } catch (error) {
446
+ reject(
447
+ new Error(
448
+ `PAGE_RELEASE_STAGE_RESULT_INVALID: CLI JSON 输出不可解析: ${error.message}`,
449
+ ),
450
+ );
451
+ }
452
+ });
453
+ });
454
+ }
455
+
296
456
  function getPagePublishUrl(appType) {
297
457
  if (isOpenXiangdaMode(config)) {
298
458
  return `${apiBase}/openxiangda-api/v1/apps/${encodeURIComponent(appType)}/pages/publish`;
@@ -65,6 +65,8 @@ openxiangda commands --json
65
65
 
66
66
  未登记环境的旧工作区只有在平台已核验“精确非删除目标来自多次历史发布、无法对应单一 Git 基线”时,才可在同一条 `release publish` 上增加 `--adopt-online-baseline --adoption-reason "..."`。CLI 只把该意图传给精确 `resource publish --only/--code` 阶段;表单 ensure、Runtime 与 App finalize 不接收,原因不足或没有精确资源阶段会在获取租约前失败关闭。
67
67
 
68
+ Legacy PageRelease 工作区的 `release publish` 会给 Page step 注入 `OPENXIANGDA_PAGE_STAGE_ONLY=1`,并要求当前包内 CLI 写入 staged PageRelease evidence;缺少证据时禁止 Root finalize。需要完整替换 Function/Automation manifest 时可成对增加 `--replace-manifest --reason "..."`,该权限只进入唯一的精确 Backend stage,不进入 Form、Page、Workflow 或 Root。
69
+
68
70
  模板已停用无范围的 `pnpm deploy` 聚合入口。日常变更必须使用 `resource plan|publish <type> --only <codes>`(单资源可用 `--code <code>`)。Form bundle、Backend Release 和 Runtime 都先暂存;CLI 会按 `--change` 自动聚合 `.openxiangda/releases/<change>/staged-resources.json`,最后只由一次 Root App finalize 原子激活。Form Release 一旦 abort 绝不能作为幂等结果复用;重新执行相同精确表单发布时,CLI 会淘汰旧 staged 索引,平台会创建新的不可变 attempt,再由 Root App 一次事务重试。相同 change 的既有 staged FormRelease 只有在 CLI 重新核验服务端不可变状态、identity/hash、冻结 schema/formType、finalized 资源、parent/base revision 和当前 Form Head 后,才可重挂接到新的 baseline/session;`schemaSyncedAt` 只是本地缓存元数据。禁止伪造它、提前激活 Form 或用顺序激活多张表单绕过失败。不要使用 `workspace publish --form`、单独 `runtime activate`、`pnpm publish:all`、`pnpm openxiangda:publish` 或 `lowcode-workspace publish-all`。
69
71
 
70
72
  同一发布同时包含 Form 设置和表单权限组时,必须由一条 `resource publish form-setting,form-permission-group` 命令暂存,并在 `--only` 中分别使用 `form-setting:<code>`、`form-permission-group:<code>`。SDD bundle 与 prepublish 校验使用同一精确契约;缺失、夹带、拆成两个 FormRelease 或提前激活都应失败关闭。
@@ -69,7 +69,7 @@ Delivery V2 自动从期望状态按资源指纹计算精确范围,使用 CLI
69
69
  - ✅ Function/Automation 使用 Backend Release v2;正式多资源发布用精确 `--only/--code` 加 `--stage-only` 暂存,同一 child 可混合源码 create、无 `sourceFile` 的完整 v3 声明式 Automation manifest create、source-only update 和显式 manifest replacement,再由 Root App finalize 原子激活。声明式 create 自动选路;替换已有资源的整包 manifest 才需要另加 `--replace-manifest --reason "..."`。clone primary 与冻结仓库 ID 不同时,只有冻结 ID 已存在于 `repoAliases` 才会统一用于 Backend/Workflow/Root App Release;无交集继续失败关闭。
70
70
  - ✅ 相同 change 已有 staged FormRelease 时,不要直发 schema、伪造 `schemaSyncedAt` 或提前激活 Form。CLI 只在重新核验服务端不可变状态、identity/hash、冻结 schema/formType、finalized 资源、parent/base revision 与当前 Form Head 后,才将 child 重挂接到新的 baseline/session;冲突继续失败关闭。
71
71
  - ✅ 同一发布同时包含 Form 设置和表单权限组时,必须使用一条 `resource publish form-setting,form-permission-group`,并以 `form-setting:<code>`、`form-permission-group:<code>` 精确限定 `--only`;SDD bundle 与 prepublish 校验共享该契约,禁止缺失、夹带、拆分发布或提前激活。
72
- - ✅ 未登记环境的旧工作区,正式 promotion 先聚合 mainline bundle 并 commit/push,再运行 `release publish --change <id> --profile <name>`。平台核验精确非删除目标来自多次历史 lineage 后,可成对增加 `--adopt-online-baseline --adoption-reason "..."`;参数只进入精确 `resource publish --only/--code` 阶段,不进入 ensure、Runtime 或 App finalize,无精确资源范围时在获取租约前失败关闭。
72
+ - ✅ 未登记环境的旧工作区,正式 promotion 先聚合 mainline bundle 并 commit/push,再运行 `release publish --change <id> --profile <name>`。Legacy Page step 必须遵守 CLI 注入的 `OPENXIANGDA_PAGE_STAGE_ONLY=1` 并通过当前包内 CLI 登记 staged PageRelease;缺少证据时 Root finalize 会失败关闭。平台核验精确非删除目标来自多次历史 lineage 后,可成对增加 `--adopt-online-baseline --adoption-reason "..."`;参数只进入精确 `resource publish --only/--code` 阶段。需要完整替换 Function/Automation manifest 时可成对增加 `--replace-manifest --reason "..."`,只进入唯一的精确 Backend stage。上述参数不进入 ensure、Page 或 App finalize,无兼容精确范围时在获取租约前失败关闭。
73
73
  - ✅ 旧工作区已有 Root、且操作者明确授权无条件恢复时,可执行 `release app-activate <releaseId> --force-activate-without-validation --profile <name>`。该命令不读取 detail/capture,不要求 change、租约、baseline、源码 lineage、状态、parent、hash、resource head 或环境发布门禁;服务端直接在目标 tenant/appType 内以单事务切换 Root 与可识别的 staged children。
74
74
  - ✅ 已通过 `environment init` 或 `environment attach` 接入的工作区使用 `release ship`,始终按 candidate → preproduction → production 执行。日常首条命令在预发停止,后续 `--confirm-production` 晋级;用户明确授权紧急发布时,首条命令可携带 `--confirm-production` 在一个命令内顺序完成两阶段,但不跳过预发、证据、CAS 或确认。candidate 封存源码、public、构建配置/脚本、稳定环境/资源绑定和两目标 Runtime 哈希产物;部署不现场重建,Backend/Runtime/Root 子发布始终使用 candidate sourceRevision,并将两条服务端 deployment 闭环为 `succeeded`。两套环境身份仍完全隔离,人工验收建议和 swap/policy/权限门禁保持不变。
75
75
  - ✅ DataView `status` 仅是平台生命周期观察值;同一托管部署导致的 `active → draft → active` 不应让候选失效。`dataViewId`、`materializedViewName`、`storageMode`、其他资源状态、哈希、环境身份、CAS、租约和来源主线仍严格校验。