openxiangda 1.0.269 → 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.
Files changed (29) hide show
  1. package/README.md +2 -0
  2. package/lib/application-environments.js +17 -0
  3. package/lib/cli.js +720 -78
  4. package/lib/design-gates.js +18 -0
  5. package/lib/release-plan.js +78 -63
  6. package/openxiangda-skills/SKILL.md +3 -1
  7. package/openxiangda-skills/references/notifications.md +28 -23
  8. package/openxiangda-skills/references/openxiangda-api.md +22 -30
  9. package/openxiangda-skills/references/pages/page-sdk.md +1 -1
  10. package/openxiangda-skills/references/resource-manifest-cheatsheet.md +37 -9
  11. package/openxiangda-skills/references/webhooks.md +213 -0
  12. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +1 -1
  13. package/openxiangda-skills/skills/openxiangda-page/SKILL.md +2 -2
  14. package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +3 -0
  15. package/package.json +24 -23
  16. package/packages/sdk/dist/runtime/index.cjs +39 -39
  17. package/packages/sdk/dist/runtime/index.mjs +39 -39
  18. package/packages/sdk/dist/runtime/react.cjs +39 -39
  19. package/packages/sdk/dist/runtime/react.d.mts +3 -0
  20. package/packages/sdk/dist/runtime/react.d.ts +3 -0
  21. package/packages/sdk/dist/runtime/react.mjs +46 -46
  22. package/packages/sdk/src/build-source/scripts/publish-all.mjs +33 -2
  23. package/packages/sdk/src/build-source/scripts/register.mjs +161 -1
  24. package/templates/openxiangda-react-spa/.cursor/rules/openxiangda-resources.mdc +1 -0
  25. package/templates/openxiangda-react-spa/.qoder/rules/openxiangda-resources.md +1 -0
  26. package/templates/openxiangda-react-spa/AGENTS.md +4 -0
  27. package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda-resources.mdc +2 -0
  28. package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda-resources.md +1 -0
  29. package/templates/sy-lowcode-app-workspace/AGENTS.md +3 -1
package/lib/cli.js CHANGED
@@ -164,6 +164,7 @@ const {
164
164
  resolveEnvironmentTarget,
165
165
  saveCandidate,
166
166
  sha256Canonical,
167
+ withLegacyReleaseForwardedFlags,
167
168
  withManagedReleaseForwardedFlags,
168
169
  withOnlineBaselineAdoptionFlags,
169
170
  withReleaseClientSessionArgs,
@@ -299,6 +300,7 @@ async function mainImpl(argv) {
299
300
  if (command === 'workflow') return workflow(rest);
300
301
  if (command === 'automation') return automation(rest);
301
302
  if (command === 'data-view') return dataView(rest);
303
+ if (command === 'webhook') return webhook(rest);
302
304
  if (command === 'scope') return scope(rest);
303
305
  if (command === 'route') return route(rest);
304
306
  if (command === 'public-access') return publicAccess(rest);
@@ -344,7 +346,7 @@ Usage:
344
346
  openxiangda status <runId> [--environment preproduction|production] [--json]
345
347
  openxiangda retry <runId> [--environment preproduction|production] [--json]
346
348
  openxiangda rollback <preproduction|production> --to <appReleaseId> [--json]
347
- openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--adopt-online-baseline --adoption-reason text] [--watch] [--json]
349
+ openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--replace-manifest --reason text] [--adopt-online-baseline --adoption-reason text] [--watch] [--json]
348
350
  openxiangda release ship|candidate|deploy|reconcile|test|fail|promote|rollback [--candidate id] [--deployment id] [--environment target] [--replace-manifest] [--allow-runtime-rollback --reason text] [--adopt-online-baseline --adoption-reason text] [--confirm-production] [--json]
349
351
  openxiangda task status --change <id> [--profile name] [--watch] [--json]
350
352
  openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]
@@ -389,6 +391,7 @@ Usage:
389
391
  openxiangda automation diagnose <automationCode|automationId> [--redact] [--json]
390
392
  openxiangda automation publish|enable|disable <automationCode|automationId>
391
393
  openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats <dataViewCode> [--profile name] [--json]
394
+ openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery [webhookCode] [deliveryId] [--json-file file] [--json]
392
395
  openxiangda scope dimension-list|dimension-upsert|grant-source-list|grant-source-upsert|policy-list|policy-upsert|sync|explain [code] [--json-file file]
393
396
  openxiangda route list|get|create|update|upsert|delete [--json-file file] [--dry-run] [--write-manifest]
394
397
  openxiangda public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check [--json-file file]
@@ -1633,6 +1636,16 @@ async function runOpenXiangdaInProcess(args, options = {}) {
1633
1636
  let stdout = '';
1634
1637
  const originalWrite = process.stdout.write;
1635
1638
  const previousExitCode = process.exitCode;
1639
+ const environment = Object.entries(options.env || {}).map(([key, value]) => ({
1640
+ key,
1641
+ existed: Object.prototype.hasOwnProperty.call(process.env, key),
1642
+ value: process.env[key],
1643
+ next: value,
1644
+ }));
1645
+ for (const entry of environment) {
1646
+ if (entry.next === undefined || entry.next === null) delete process.env[entry.key];
1647
+ else process.env[entry.key] = String(entry.next);
1648
+ }
1636
1649
  process.exitCode = undefined;
1637
1650
  process.stdout.write = function capture(chunk, encoding, callback) {
1638
1651
  stdout += Buffer.isBuffer(chunk) ? chunk.toString(encoding) : String(chunk);
@@ -1660,6 +1673,10 @@ async function runOpenXiangdaInProcess(args, options = {}) {
1660
1673
  }
1661
1674
  throw error;
1662
1675
  } finally {
1676
+ for (const entry of environment) {
1677
+ if (entry.existed) process.env[entry.key] = entry.value;
1678
+ else delete process.env[entry.key];
1679
+ }
1663
1680
  process.stdout.write = originalWrite;
1664
1681
  process.exitCode = previousExitCode;
1665
1682
  }
@@ -2109,7 +2126,7 @@ function decorateEnvironmentReleaseSteps(steps, target, flags = {}) {
2109
2126
  for (const step of steps) {
2110
2127
  step.args = managedEnvironment
2111
2128
  ? withManagedReleaseForwardedFlags(step.id, step.args, flags)
2112
- : withOnlineBaselineAdoptionFlags(step.args, flags);
2129
+ : withLegacyReleaseForwardedFlags(step.id, step.args, flags);
2113
2130
  if (!managedEnvironment) {
2114
2131
  step.command = commandFromArgs(step.args);
2115
2132
  continue;
@@ -2200,11 +2217,104 @@ function assertReleasePublishAdoptionScope(steps = [], flags = {}) {
2200
2217
  }
2201
2218
  }
2202
2219
 
2220
+ function assertReleasePublishManifestReplacementFlags(flags = {}) {
2221
+ const replacementRequested = Boolean(flags['replace-manifest']);
2222
+ const reasonSupplied = Object.prototype.hasOwnProperty.call(flags, 'reason');
2223
+ const reason = String(flags.reason || '').trim();
2224
+ if (!replacementRequested && reasonSupplied && !flags['allow-runtime-rollback']) {
2225
+ const error = new Error(
2226
+ 'RELEASE_PUBLISH_REPLACEMENT_FLAG_REQUIRED: --reason 必须与 --replace-manifest 一起使用;命令已在任何发布租约或资源写入前终止'
2227
+ );
2228
+ error.code = 'RELEASE_PUBLISH_REPLACEMENT_FLAG_REQUIRED';
2229
+ throw error;
2230
+ }
2231
+ if (replacementRequested && reason.length < 8) {
2232
+ const error = new Error(
2233
+ 'RELEASE_PUBLISH_REPLACEMENT_REASON_REQUIRED: --replace-manifest 必须提供至少 8 个字符的 --reason;命令已在任何发布租约或资源写入前终止'
2234
+ );
2235
+ error.code = 'RELEASE_PUBLISH_REPLACEMENT_REASON_REQUIRED';
2236
+ throw error;
2237
+ }
2238
+ }
2239
+
2240
+ function assertReleasePublishManifestReplacementScope(steps = [], flags = {}) {
2241
+ if (!flags['replace-manifest']) return;
2242
+ const replacementSteps = steps.filter(
2243
+ step =>
2244
+ step.id === 'backend-stage' &&
2245
+ step.args?.includes('--replace-manifest') &&
2246
+ (step.args.includes('--only') || step.args.includes('--code'))
2247
+ );
2248
+ if (replacementSteps.length !== 1) {
2249
+ const error = new Error(
2250
+ 'RELEASE_PUBLISH_REPLACEMENT_SCOPE_REQUIRED: 当前发布计划没有唯一的精确 Backend stage 可接收 --replace-manifest;命令已在任何发布租约或资源写入前终止'
2251
+ );
2252
+ error.code = 'RELEASE_PUBLISH_REPLACEMENT_SCOPE_REQUIRED';
2253
+ throw error;
2254
+ }
2255
+ }
2256
+
2203
2257
  function changeIdFromStep(step) {
2204
2258
  const index = step.args.indexOf('--change');
2205
2259
  return index >= 0 ? step.args[index + 1] : 'change';
2206
2260
  }
2207
2261
 
2262
+ function assertLegacyPageReleaseStaged(step, changeId, target) {
2263
+ if (step.stagedKind !== 'PageRelease') return;
2264
+ const file = stagedResourcesFileForChange(changeId, target.deploymentId);
2265
+ const resources = fs.existsSync(file)
2266
+ ? normalizeStagedAppReleaseResources(
2267
+ JSON.parse(fs.readFileSync(file, 'utf8'))
2268
+ )
2269
+ : [];
2270
+ const pageRelease = resources.find(resource => resource.kind === 'PageRelease');
2271
+ if (pageRelease) {
2272
+ assertLegacyPageReleaseCoverage(step, pageRelease);
2273
+ return;
2274
+ }
2275
+ const error = new Error(
2276
+ 'PAGE_RELEASE_STAGE_EVIDENCE_REQUIRED: legacy workspace publish 未记录 staged PageRelease;已阻止 Root finalize,工作区发布脚本必须遵守 OPENXIANGDA_PAGE_STAGE_ONLY=1 并通过当前 OpenXiangda CLI 登记 staged child'
2277
+ );
2278
+ error.code = 'PAGE_RELEASE_STAGE_EVIDENCE_REQUIRED';
2279
+ throw error;
2280
+ }
2281
+
2282
+ function legacyWorkspaceStepPageCodes(step = {}) {
2283
+ const onlyIndex = Array.isArray(step.args) ? step.args.indexOf('--only') : -1;
2284
+ if (onlyIndex < 0) return [];
2285
+ return unique(
2286
+ String(step.args[onlyIndex + 1] || '')
2287
+ .split(',')
2288
+ .map(value => value.trim())
2289
+ .filter(value => value.startsWith('pages/'))
2290
+ .map(value => value.slice('pages/'.length))
2291
+ .filter(Boolean)
2292
+ ).sort();
2293
+ }
2294
+
2295
+ function assertLegacyPageReleaseCoverage(step, pageRelease) {
2296
+ const expected = legacyWorkspaceStepPageCodes(step);
2297
+ const actual = unique(
2298
+ Array.isArray(pageRelease?.metadata?.selectedPageCodes)
2299
+ ? pageRelease.metadata.selectedPageCodes
2300
+ .map(value => String(value || '').trim())
2301
+ .filter(Boolean)
2302
+ : []
2303
+ ).sort();
2304
+ if (
2305
+ expected.length > 0 &&
2306
+ expected.length === actual.length &&
2307
+ expected.every((code, index) => code === actual[index])
2308
+ ) {
2309
+ return;
2310
+ }
2311
+ const error = new Error(
2312
+ `PAGE_RELEASE_STAGE_SCOPE_MISMATCH: staged PageRelease selectedPageCodes 与 workspace 计划不一致;expected=${expected.join(',') || '-'} actual=${actual.join(',') || '-'}`
2313
+ );
2314
+ error.code = 'PAGE_RELEASE_STAGE_SCOPE_MISMATCH';
2315
+ throw error;
2316
+ }
2317
+
2208
2318
  async function publishWorkspaceRelease(config, target, flags = {}) {
2209
2319
  const changeId = readStringFlag(flags, 'change');
2210
2320
  const deploymentId = readStringFlag(flags, 'deployment-id') || null;
@@ -2214,6 +2324,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
2214
2324
  fail('用法: openxiangda release publish --change <id> --profile <name>');
2215
2325
  }
2216
2326
  assertReleasePublishAdoptionFlags(flags);
2327
+ assertReleasePublishManifestReplacementFlags(flags);
2217
2328
  assertManagedRuntimeRollbackFlags(flags);
2218
2329
  const releaseCandidate = flags.__verifiedCandidate
2219
2330
  ? assertLocalCandidate(flags.__verifiedCandidate)
@@ -2241,6 +2352,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
2241
2352
  );
2242
2353
  decorateEnvironmentReleaseSteps(steps, target, flags);
2243
2354
  assertReleasePublishAdoptionScope(steps, flags);
2355
+ assertReleasePublishManifestReplacementScope(steps, flags);
2244
2356
  const releaseScope = getSddChangeScope({
2245
2357
  cwd: process.cwd(),
2246
2358
  configText: readWorkspaceConfigText(),
@@ -2262,6 +2374,12 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
2262
2374
  runtimeMode: plan.workspace.runtimeMode,
2263
2375
  targets: plan.activationTargets,
2264
2376
  commands: steps.map(step => step.command),
2377
+ executionSteps: steps.map(step => ({
2378
+ id: step.id,
2379
+ command: step.command,
2380
+ env: step.env || {},
2381
+ stagedKind: step.stagedKind || null,
2382
+ })),
2265
2383
  candidateHash: releaseCandidate?.candidateHash || null,
2266
2384
  });
2267
2385
  if (flags['dry-run']) {
@@ -2377,6 +2495,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
2377
2495
  ...steps.map(step => ({
2378
2496
  id: step.id,
2379
2497
  command: step.command,
2498
+ env: step.env || {},
2380
2499
  stagedKind: step.stagedKind,
2381
2500
  status: 'pending',
2382
2501
  })),
@@ -2476,7 +2595,10 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
2476
2595
  execution
2477
2596
  );
2478
2597
  warn(`发布步骤开始: ${step.id}`);
2479
- const result = await runOpenXiangdaInProcess(step.args);
2598
+ const result = await runOpenXiangdaInProcess(step.args, {
2599
+ env: step.env,
2600
+ });
2601
+ assertLegacyPageReleaseStaged(step, changeId, target);
2480
2602
  journalStep.status = 'completed';
2481
2603
  journalStep.completedAt = new Date().toISOString();
2482
2604
  journalStep.result = summarizeReleaseStepResult(result);
@@ -5378,7 +5500,7 @@ async function release(args) {
5378
5500
  const { flags, positional } = parseArgs(rest);
5379
5501
  if (wantsSubcommandHelp(subcommand, flags)) {
5380
5502
  print([
5381
- '用法: openxiangda release publish|begin|status|explain|integration-status|renew|end [--change id] [--profile name] [--adopt-online-baseline --adoption-reason text] [--json]',
5503
+ '用法: openxiangda release publish|begin|status|explain|integration-status|renew|end [--change id] [--profile name] [--replace-manifest --reason text] [--adopt-online-baseline --adoption-reason text] [--json]',
5382
5504
  ' openxiangda release ship|candidate|deploy|reconcile|test|fail|promote|rollback [--candidate id] [--deployment id] [--environment target] [--replace-manifest] [--allow-runtime-rollback --reason text] [--adopt-online-baseline --adoption-reason text] [--confirm-production] [--json]',
5383
5505
  ' openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]',
5384
5506
  ' openxiangda release app-capture|app-head|app-list|app-detail|app-diff|app-post-commit|app-retry|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort [releaseId] [--staged-resources-json <JSON|file>] [--activate-staged-children] [--break-glass-adopt-verified-root --reason text] [--force-activate-without-validation] [--profile name] [--json]',
@@ -5391,6 +5513,7 @@ async function release(args) {
5391
5513
  ' openxiangda release ship --change <id> --profile <name> --confirm-production',
5392
5514
  ' openxiangda release fail --deployment <id> --message "UAT 未通过" --code UAT_FAILED --details-json <JSON|file> --environment preproduction --profile <name>',
5393
5515
  ' openxiangda release publish --change <id> --profile <name>',
5516
+ ' openxiangda release publish --change <id> --profile <name> --replace-manifest --reason "已审查精确 Function manifest"',
5394
5517
  ' openxiangda release publish --change <id> --profile <name> --adopt-online-baseline --adoption-reason "已审计目标来自多次历史发布"',
5395
5518
  ' openxiangda release begin --change <id> --profile <name>',
5396
5519
  ' openxiangda resource publish function --only <code> --change <id> --profile <name>',
@@ -5411,6 +5534,7 @@ async function release(args) {
5411
5534
  ' - ship 当前不支持 --dry-run;任何未声明参数都会在 candidate/deployment 写入前 fail-closed。需要只读检查时使用 environment status/diff 与 release status。',
5412
5535
  ' - publish 默认等待租约并按私有执行日志恢复;上次写结果不确定时必须只读核对后显式 --resume-after-review。',
5413
5536
  ' - 旧式非环境托管 publish 也会把线上基线采纳意图仅透传给精确的 resource publish --only/--code 阶段;无精确资源阶段、原因不足或单独提供 reason 均在获取租约前 fail-closed。',
5537
+ ' - 旧式非环境托管 publish 的 --replace-manifest 与 --reason 必须成对,只透传给唯一的精确 Backend Function/Automation stage,不进入 Form、Page、Workflow 或 App finalize。',
5414
5538
  ' - app-head 默认只输出紧凑 head 摘要;需要完整 manifest 时显式追加 --full。',
5415
5539
  ' - begin 获取应用级单写者 promotion lease;不同工作区仍可并行开发和验证。',
5416
5540
  ' - 带 --change 的 resource/runtime 写命令在没有本地 lease 时会自动 begin,并在后续命令复用。',
@@ -10357,6 +10481,7 @@ async function workspace(args) {
10357
10481
  connectors: {},
10358
10482
  dataViews: {},
10359
10483
  storageConfigs: {},
10484
+ webhooks: {},
10360
10485
  scopeDimensions: {},
10361
10486
  scopeGrantSources: {},
10362
10487
  dataScopePolicies: {},
@@ -11405,6 +11530,105 @@ function outputPageResult(data, flags = {}) {
11405
11530
  print(JSON.stringify(data, null, 2));
11406
11531
  }
11407
11532
 
11533
+ const PAGE_PUBLISH_MAX_DEFINITIONS = 200;
11534
+ const PAGE_PUBLISH_MAX_JSON_BYTES = 2 * 1024 * 1024;
11535
+
11536
+ function normalizePagePublishDefinitions(positional, flags = {}) {
11537
+ const pageCode = String(positional?.[0] || '').trim();
11538
+ const hasPagesJson = Object.prototype.hasOwnProperty.call(flags, 'pages-json');
11539
+ let definitions;
11540
+
11541
+ if (hasPagesJson) {
11542
+ if (pageCode) {
11543
+ fail('page publish 使用 --pages-json 时不能再提供单页 pageCode');
11544
+ }
11545
+ const input = readJsonArg(readStringFlag(flags, 'pages-json'), 'pages-json');
11546
+ definitions = Array.isArray(input) ? input : input?.pages;
11547
+ } else {
11548
+ if (!pageCode || !flags['entry-url']) {
11549
+ fail(
11550
+ '用法: openxiangda page publish <pageCode> --entry-url <url> --version <v> --build-id <id> [--activate]\n' +
11551
+ ' openxiangda page publish --pages-json <JSON|file> --version <v> --build-id <id> [--activate]'
11552
+ );
11553
+ }
11554
+ definitions = [
11555
+ {
11556
+ code: pageCode,
11557
+ name: flags.name || pageCode,
11558
+ description: flags.description || '',
11559
+ route: flags.route ? JSON.parse(flags.route) : {},
11560
+ props: flags.props ? JSON.parse(flags.props) : {},
11561
+ runtime: {
11562
+ entryUrl: flags['entry-url'],
11563
+ cssUrls: splitList(flags['css-urls']),
11564
+ jsUrls: splitList(flags['js-urls']),
11565
+ framework: flags.framework || 'react',
11566
+ frameworkVersion: flags['framework-version'] || '',
11567
+ cssIsolation: flags['css-isolation'] || 'none',
11568
+ format: flags.format || 'esm',
11569
+ },
11570
+ menu: flags.menu
11571
+ ? {
11572
+ enabled: true,
11573
+ name: flags['menu-name'] || flags.name || pageCode,
11574
+ parentId: flags['menu-parent-id'] || null,
11575
+ icon: flags['menu-icon'] || null,
11576
+ }
11577
+ : { enabled: false },
11578
+ },
11579
+ ];
11580
+ }
11581
+
11582
+ if (!Array.isArray(definitions) || definitions.length === 0) {
11583
+ fail('PAGE_PUBLISH_DEFINITIONS_REQUIRED: pages-json 必须包含非空页面定义数组');
11584
+ }
11585
+ if (definitions.length > PAGE_PUBLISH_MAX_DEFINITIONS) {
11586
+ fail(
11587
+ `PAGE_PUBLISH_BATCH_TOO_LARGE: 单次最多发布 ${PAGE_PUBLISH_MAX_DEFINITIONS} 个页面`
11588
+ );
11589
+ }
11590
+ const jsonBytes = Buffer.byteLength(JSON.stringify(definitions), 'utf8');
11591
+ if (jsonBytes > PAGE_PUBLISH_MAX_JSON_BYTES) {
11592
+ fail(
11593
+ `PAGE_PUBLISH_BATCH_TOO_LARGE: 页面定义 JSON 不得超过 ${PAGE_PUBLISH_MAX_JSON_BYTES} 字节`
11594
+ );
11595
+ }
11596
+
11597
+ const seen = new Set();
11598
+ return definitions.map((definition, index) => {
11599
+ if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
11600
+ fail(`PAGE_PUBLISH_DEFINITION_INVALID: pages-json[${index}] 必须是对象`);
11601
+ }
11602
+ const code = String(definition.code || '').trim();
11603
+ const runtime = definition.runtime;
11604
+ if (!code || code.length > 128) {
11605
+ fail(
11606
+ `PAGE_PUBLISH_DEFINITION_INVALID: pages-json[${index}].code 必须是 1-128 个字符`
11607
+ );
11608
+ }
11609
+ const stableCode = code.toLowerCase();
11610
+ if (seen.has(stableCode)) {
11611
+ fail(`PAGE_PUBLISH_DEFINITION_DUPLICATE: 页面 code 重复: ${code}`);
11612
+ }
11613
+ seen.add(stableCode);
11614
+ if (
11615
+ !runtime ||
11616
+ typeof runtime !== 'object' ||
11617
+ Array.isArray(runtime) ||
11618
+ !String(runtime.entryUrl || '').trim()
11619
+ ) {
11620
+ fail(
11621
+ `PAGE_PUBLISH_DEFINITION_INVALID: 页面 ${code} 缺少 runtime.entryUrl`
11622
+ );
11623
+ }
11624
+ return {
11625
+ ...clonePlainJson(definition),
11626
+ code,
11627
+ name: String(definition.name || code),
11628
+ };
11629
+ });
11630
+ }
11631
+
11408
11632
  async function page(args) {
11409
11633
  const [subcommand, ...rest] = args;
11410
11634
  const { flags, positional } = parseArgs(rest);
@@ -11526,78 +11750,128 @@ async function page(args) {
11526
11750
  }
11527
11751
 
11528
11752
  if (subcommand === 'publish') {
11529
- const [pageCode] = positional;
11530
- if (!pageCode || !flags['entry-url'] || !flags.version || !flags['build-id']) {
11753
+ if (!flags.version || !flags['build-id']) {
11531
11754
  fail(
11532
- '用法: openxiangda page publish <pageCode> --entry-url <url> --version <v> --build-id <id> [--activate]'
11755
+ '用法: openxiangda page publish <pageCode> --entry-url <url> --version <v> --build-id <id> [--activate]\n' +
11756
+ ' openxiangda page publish --pages-json <JSON|file> --version <v> --build-id <id> [--activate]'
11533
11757
  );
11534
11758
  }
11759
+ const pageDefinitions = normalizePagePublishDefinitions(positional, flags);
11760
+ const pageCodes = pageDefinitions.map(definition => definition.code);
11535
11761
  await ensureDirectMutationPublishContext(config, target, flags, 'page publish');
11536
11762
  const frozen = await fetchFrozenPageSnapshot(config, target);
11537
- const currentPage = frozen.pageByCode.get(pageCode) || null;
11538
- const expectedRevision = currentPage ? Number(currentPage.revision) : 0;
11539
11763
  const activate = flags.activate === true;
11540
- const definition = {
11541
- code: pageCode,
11542
- name: flags.name || pageCode,
11543
- description: flags.description || '',
11544
- route: flags.route ? JSON.parse(flags.route) : {},
11545
- props: flags.props ? JSON.parse(flags.props) : {},
11546
- runtime: {
11547
- entryUrl: flags['entry-url'],
11548
- cssUrls: splitList(flags['css-urls']),
11549
- jsUrls: splitList(flags['js-urls']),
11550
- framework: flags.framework || 'react',
11551
- frameworkVersion: flags['framework-version'] || '',
11552
- cssIsolation: flags['css-isolation'] || 'none',
11553
- format: flags.format || 'esm',
11554
- },
11555
- menu: flags.menu
11556
- ? {
11557
- enabled: true,
11558
- name: flags['menu-name'] || flags.name || pageCode,
11559
- parentId: flags['menu-parent-id'] || null,
11560
- icon: flags['menu-icon'] || null,
11561
- }
11562
- : { enabled: false },
11563
- expectedRevision,
11564
- };
11764
+ const definitions = pageDefinitions.map(definition => {
11765
+ const currentPage = frozen.pageByCode.get(definition.code) || null;
11766
+ return {
11767
+ ...definition,
11768
+ expectedRevision: currentPage ? Number(currentPage.revision) : 0,
11769
+ };
11770
+ });
11771
+ const expectedRevisions = { ...frozen.expectedRevisions };
11772
+ const expectedActiveAssets = { ...frozen.expectedActiveAssets };
11773
+ for (const definition of definitions) {
11774
+ expectedRevisions[definition.code] = definition.expectedRevision;
11775
+ if (!Object.prototype.hasOwnProperty.call(expectedActiveAssets, definition.code)) {
11776
+ expectedActiveAssets[definition.code] = null;
11777
+ }
11778
+ }
11779
+ const singleCurrentPage =
11780
+ definitions.length === 1
11781
+ ? frozen.pageByCode.get(definitions[0].code) || null
11782
+ : null;
11565
11783
  const data = await requestWithAuth(
11566
11784
  config,
11567
11785
  target.profileName,
11568
11786
  pageApiPath(target, '/publish'),
11569
11787
  {
11570
11788
  method: 'POST',
11571
- headers: { 'if-match': currentPage?.etag || '0' },
11789
+ headers:
11790
+ definitions.length === 1
11791
+ ? { 'if-match': singleCurrentPage?.etag || '0' }
11792
+ : {},
11572
11793
  body: {
11573
11794
  version: flags.version,
11574
11795
  buildId: flags['build-id'],
11575
- pages: [definition],
11576
- expectedRevision,
11577
- expectedRevisions: {
11578
- ...frozen.expectedRevisions,
11579
- [pageCode]: expectedRevision,
11580
- },
11581
- expectedActiveAssets: {
11582
- ...frozen.expectedActiveAssets,
11583
- [pageCode]: currentPage
11584
- ? frozen.expectedActiveAssets[pageCode]
11585
- : null,
11586
- },
11796
+ pages: definitions,
11797
+ ...(definitions.length === 1
11798
+ ? { expectedRevision: definitions[0].expectedRevision }
11799
+ : {}),
11800
+ expectedRevisions,
11801
+ expectedActiveAssets,
11587
11802
  expectedParent: frozen.expectedParent,
11588
11803
  stage: !activate,
11589
11804
  activate,
11590
11805
  },
11591
11806
  }
11592
11807
  );
11593
- const item = Array.isArray(data?.items) ? data.items[0] : null;
11594
- if (item?.pageId) {
11595
- savePageResource(target, pageCode, item.pageId, {
11596
- routeKey: item.routeKey,
11597
- legacyFormUuid: item.legacyFormUuid,
11598
- });
11808
+ const items = Array.isArray(data?.items) ? data.items : [];
11809
+ const itemByCode = new Map(
11810
+ items.map(item => [String(item?.code || '').trim(), item])
11811
+ );
11812
+ const missingPageCodes = pageCodes.filter(code => !itemByCode.has(code));
11813
+ if (missingPageCodes.length > 0) {
11814
+ fail(
11815
+ `PAGE_RELEASE_STAGE_SCOPE_MISMATCH: Page Release 响应缺少页面 ${missingPageCodes.join(',')}`
11816
+ );
11817
+ }
11818
+ for (const pageCode of pageCodes) {
11819
+ const item = itemByCode.get(pageCode);
11820
+ if (item?.pageId) {
11821
+ savePageResource(target, pageCode, item.pageId, {
11822
+ routeKey: item.routeKey,
11823
+ legacyFormUuid: item.legacyFormUuid,
11824
+ });
11825
+ }
11599
11826
  }
11600
11827
  if (!activate) {
11828
+ const releaseId = String(data?.release?.id || '').trim();
11829
+ const releaseHash = String(data?.release?.releaseHash || '').toLowerCase();
11830
+ if (
11831
+ !data?.staged ||
11832
+ data?.activation ||
11833
+ !releaseId ||
11834
+ !/^[a-f0-9]{64}$/.test(releaseHash) ||
11835
+ data?.release?.immutable !== true ||
11836
+ String(data?.release?.version || '') !== String(flags.version) ||
11837
+ String(data?.release?.buildId || '') !== String(flags['build-id'])
11838
+ ) {
11839
+ fail(
11840
+ `PAGE_RELEASE_STAGE_REQUIRED: ${pageCodes.join(',')} 的非激活发布必须返回匹配 version/buildId 的 immutable staged releaseId 和 64 位 releaseHash`
11841
+ );
11842
+ }
11843
+ const stagedChangeId =
11844
+ readStringFlag(flags, 'change') ||
11845
+ getStoredChangeBaseline(target)?.changeId;
11846
+ const stagedResource = {
11847
+ kind: 'PageRelease',
11848
+ identity: { releaseId },
11849
+ action: 'update',
11850
+ hash: releaseHash,
11851
+ revision: {
11852
+ releaseId,
11853
+ version: data.release.version,
11854
+ buildId: data.release.buildId,
11855
+ },
11856
+ metadata: {
11857
+ state: 'staged',
11858
+ immutable: true,
11859
+ parentReleaseId: data.release.parentReleaseId || null,
11860
+ pageCount: items.length,
11861
+ selectedPageCodes: [...pageCodes].sort(),
11862
+ releasePageCodes: unique(
11863
+ items
11864
+ .map(item => String(item?.code || '').trim())
11865
+ .filter(Boolean)
11866
+ ).sort(),
11867
+ },
11868
+ };
11869
+ data.stagedResource = stagedResource;
11870
+ data.stagedResourcesFile = recordStagedAppReleaseResources(
11871
+ stagedChangeId,
11872
+ [stagedResource],
11873
+ target
11874
+ );
11601
11875
  retainCommandScopedPublishLease('staged-page');
11602
11876
  }
11603
11877
  const leaseCompletion = await finalizeCommandScopedPublishLease({
@@ -12543,6 +12817,93 @@ async function dataView(args) {
12543
12817
  fail('用法: openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats');
12544
12818
  }
12545
12819
 
12820
+ async function webhook(args) {
12821
+ const { subcommand, rest } = parseSubcommandArgs(args);
12822
+ const { flags, positional } = parseArgs(rest);
12823
+ if (wantsSubcommandHelp(subcommand, flags)) {
12824
+ print('用法: openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery [webhookCode] [deliveryId] [--json-file file] [--json]');
12825
+ return;
12826
+ }
12827
+ const config = loadConfig();
12828
+ const target = getWorkspaceTarget(
12829
+ config,
12830
+ flags.profile || config.currentProfile,
12831
+ flags
12832
+ );
12833
+ const basePath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`;
12834
+
12835
+ if (['list', 'get', 'create', 'update', 'upsert'].includes(subcommand)) {
12836
+ return directResourceCrud(config, target, {
12837
+ commandName: 'webhook',
12838
+ subcommand,
12839
+ flags,
12840
+ positional,
12841
+ resourceType: 'webhook',
12842
+ basePath,
12843
+ manifestDir: path.join('src', 'resources', 'webhooks'),
12844
+ normalizeBody: normalizeWebhookManifest,
12845
+ codeOf: body => body.code || body.resourceCode,
12846
+ saveState: (code, data) => saveWebhookResource(target, code, data),
12847
+ });
12848
+ }
12849
+
12850
+ if (subcommand === 'disable') {
12851
+ const code = positional[0] || flags.code;
12852
+ if (!code) fail('用法: openxiangda webhook disable <webhookCode>');
12853
+ const body = readDirectJsonBody(flags, 'webhook disable', {
12854
+ optional: true,
12855
+ });
12856
+ if (flags['expected-revision'] !== undefined) {
12857
+ body.expectedRevision = Number(flags['expected-revision']);
12858
+ }
12859
+ const data = await runDirectRequest(
12860
+ config,
12861
+ target,
12862
+ flags,
12863
+ {
12864
+ method: 'POST',
12865
+ path: apiPathWithPostDeleteAction(
12866
+ `${basePath}/${encodeURIComponent(code)}`
12867
+ ),
12868
+ body,
12869
+ },
12870
+ { returnData: true }
12871
+ );
12872
+ if (!flags['dry-run']) saveWebhookResource(target, code, data);
12873
+ return outputDirectResult({ action: 'disable', data }, flags);
12874
+ }
12875
+
12876
+ if (subcommand === 'deliveries') {
12877
+ const code = positional[0] || flags.code;
12878
+ if (!code) fail('用法: openxiangda webhook deliveries <webhookCode>');
12879
+ return runDirectRequest(config, target, flags, {
12880
+ method: 'GET',
12881
+ path: apiPathWithQuery(
12882
+ `${basePath}/${encodeURIComponent(code)}/deliveries`,
12883
+ {
12884
+ page: flags.page,
12885
+ pageSize: flags['page-size'] || flags.limit,
12886
+ status: flags.status,
12887
+ }
12888
+ ),
12889
+ });
12890
+ }
12891
+
12892
+ if (subcommand === 'delivery') {
12893
+ const code = positional[0] || flags.code;
12894
+ const deliveryId = positional[1] || flags['delivery-id'];
12895
+ if (!code || !deliveryId) {
12896
+ fail('用法: openxiangda webhook delivery <webhookCode> <deliveryId>');
12897
+ }
12898
+ return runDirectRequest(config, target, flags, {
12899
+ method: 'GET',
12900
+ path: `${basePath}/${encodeURIComponent(code)}/deliveries/${encodeURIComponent(deliveryId)}`,
12901
+ });
12902
+ }
12903
+
12904
+ fail('用法: openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery');
12905
+ }
12906
+
12546
12907
  async function scope(args) {
12547
12908
  const { subcommand, rest } = parseSubcommandArgs(args);
12548
12909
  const { flags, positional } = parseArgs(rest);
@@ -13962,16 +14323,19 @@ async function notification(args) {
13962
14323
  const { flags, positional } = parseArgs(rest);
13963
14324
  if (wantsSubcommandHelp(subcommand, flags)) {
13964
14325
  print([
13965
- '用法: openxiangda notification template-list|template-get|template-upsert|template-delete|type-list|type-get|type-upsert|type-delete|preview|send|batch-send|capabilities|dingding-preview|dingding-send',
14326
+ '用法: openxiangda notification template-list|template-get|template-upsert|template-delete|type-list|type-get|type-upsert|type-delete|preview|capabilities|dingding-preview',
13966
14327
  ' preview <notificationType|templateCode> [--form-code code|--form-uuid FORM_XXX] --body-json \'{"payload":{"title":"测试"}}\'',
13967
- ' send <notificationType> --body-json \'{"recipientId":"USER_ID","payload":{"title":"测试"}}\' --force',
13968
- ' batch-send <notificationType> --body-json \'{"recipients":[{"recipientId":"USER_ID","payload":{}}]}\' --force',
13969
14328
  ' capabilities --json',
13970
14329
  ' dingding-preview <notificationType> [--template-code code|--template-id id] --body-json \'{"payload":{"title":"测试"}}\'',
13971
- ' dingding-send <notificationType> --body-json \'{"recipientId":"USER_ID","payload":{"title":"测试"}}\' --force',
14330
+ ' 页面和 CLI 不再直接发送通知;请使用 openxiangda function invoke <functionCode> --body-json \'{"input":{"businessId":"..."}}\'',
13972
14331
  ].join('\n'));
13973
14332
  return;
13974
14333
  }
14334
+ if (['send', 'batch-send', 'dingding-send'].includes(subcommand)) {
14335
+ fail(
14336
+ 'DIRECT_NOTIFICATION_SEND_REMOVED: CLI 不再直接发送通知,请使用具名 App Function,并通过 openxiangda function invoke <functionCode> 调用'
14337
+ );
14338
+ }
13975
14339
  const config = loadConfig();
13976
14340
  const target = getWorkspaceTarget(config, flags.profile || config.currentProfile, flags);
13977
14341
  const appPrefix = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications`;
@@ -14070,45 +14434,30 @@ async function notification(args) {
14070
14434
  strictEnvelope: true,
14071
14435
  });
14072
14436
  }
14073
- if (subcommand === 'dingding-preview' || subcommand === 'dingding-send') {
14074
- if (subcommand === 'dingding-send' && !flags.force) {
14075
- fail('openxiangda notification dingding-send 是发送动作,必须加 --force');
14076
- }
14437
+ if (subcommand === 'dingding-preview') {
14077
14438
  const code = positional[0] || flags.code;
14078
14439
  const rawBody = readDirectJsonBody(flags, `notification ${subcommand}`, { optional: true });
14079
14440
  const body = buildDingTalkNotificationActionBody(target, code, rawBody, flags);
14080
14441
  return runDirectRequest(config, target, flags, {
14081
14442
  method: 'POST',
14082
- path: `${appPrefix}/dingtalk/${subcommand === 'dingding-send' ? 'send' : 'preview'}`,
14443
+ path: `${appPrefix}/dingtalk/preview`,
14083
14444
  body,
14084
14445
  strictEnvelope: true,
14085
14446
  }, { controlPlaneMutation: false });
14086
14447
  }
14087
- if (['preview', 'send', 'batch-send'].includes(subcommand)) {
14088
- if ((subcommand === 'send' || subcommand === 'batch-send') && !flags.force) {
14089
- fail(`openxiangda notification ${subcommand} 是发送动作,必须加 --force`);
14090
- }
14448
+ if (subcommand === 'preview') {
14091
14449
  const code = positional[0] || flags.code;
14092
14450
  const rawBody = readDirectJsonBody(flags, `notification ${subcommand}`, { optional: true });
14093
- const body =
14094
- subcommand === 'preview'
14095
- ? await normalizeNotificationPreviewBody(config, target, code, rawBody, flags)
14096
- : normalizeNotificationActionBody(subcommand, code, rawBody);
14097
- const actionPath =
14098
- subcommand === 'preview'
14099
- ? 'templates/preview'
14100
- : subcommand === 'batch-send'
14101
- ? 'batch-send-by-type'
14102
- : 'send-by-type';
14451
+ const body = await normalizeNotificationPreviewBody(config, target, code, rawBody, flags);
14103
14452
  return runDirectRequest(config, target, flags, {
14104
14453
  method: 'POST',
14105
- path: `${appPrefix}/${actionPath}`,
14454
+ path: `${appPrefix}/templates/preview`,
14106
14455
  body,
14107
14456
  strictEnvelope: true,
14108
14457
  }, { controlPlaneMutation: false });
14109
14458
  }
14110
14459
 
14111
- fail('用法: openxiangda notification template-list|template-get|template-upsert|template-delete|type-list|type-get|type-upsert|type-delete|preview|send|batch-send|capabilities|dingding-preview|dingding-send');
14460
+ fail('用法: openxiangda notification template-list|template-get|template-upsert|template-delete|type-list|type-get|type-upsert|type-delete|preview|capabilities|dingding-preview');
14112
14461
  }
14113
14462
 
14114
14463
  async function organization(args) {
@@ -17097,6 +17446,7 @@ async function commands(args) {
17097
17446
  'workflow compile|list|create|bind|pull|publish|delete|validate',
17098
17447
  'automation list|create|bind|pull|publish|unpublish|enable|disable|delete|validate|cron-validate',
17099
17448
  'data-view list|get|create|update|upsert|delete|status|refresh|query|stats',
17449
+ 'webhook list|get|create|update|upsert|disable|deliveries|delivery',
17100
17450
  'route list|get|create|update|upsert|delete',
17101
17451
  'public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check',
17102
17452
  'auth-config list|get|create|update|upsert|delete|methods',
@@ -17119,6 +17469,7 @@ async function commands(args) {
17119
17469
  resourceNotes: [
17120
17470
  'For formal multi-resource development, prefer src/resources/** + openxiangda resource validate|plan|publish.',
17121
17471
  'Use first-class route/public-access/auth-config/function/connector/notification/data-view/menu/permission commands for discovery, diagnosis, dry-run, and small live fixes.',
17472
+ 'Inbound Webhooks are declared in src/resources/webhooks and route only to a fixed App Function. Provider secrets belong in top-level Function secretRefs; signature verification must use input.rawBody before any data access.',
17122
17473
  'public-access is the new React SPA public policy resource for /view/:appType/public/* routes; page code should use PublicAccessGate, and Page SDK hooks must stay inside OpenXiangdaProvider + OpenXiangdaPageProvider.',
17123
17474
  'settings public-access is legacy form public-access compatibility/repair and should not be used for new React SPA apps.',
17124
17475
  'Direct live mutation commands should use --dry-run first and --write-manifest when the repository should remain source of truth.',
@@ -17513,6 +17864,7 @@ function directManifestDir(resourceType) {
17513
17864
  connector: path.join('src', 'resources', 'connectors'),
17514
17865
  notification: path.join('src', 'resources', 'notifications'),
17515
17866
  'data-view': path.join('src', 'resources', 'data-views'),
17867
+ webhook: path.join('src', 'resources', 'webhooks'),
17516
17868
  menu: path.join('src', 'resources', 'menus'),
17517
17869
  role: path.join('src', 'resources', 'roles'),
17518
17870
  'page-permission-group': path.join('src', 'resources', 'permissions', 'page-groups'),
@@ -17532,6 +17884,7 @@ function removeDirectStateResource(target, resourceType, code) {
17532
17884
  function: 'functions',
17533
17885
  connector: 'connectors',
17534
17886
  'data-view': 'dataViews',
17887
+ webhook: 'webhooks',
17535
17888
  };
17536
17889
  const bucket = buckets[resourceType];
17537
17890
  if (!bucket || !target.bound.resources?.[bucket]?.[code]) return;
@@ -17854,6 +18207,7 @@ function ensureResourceBuckets(bound) {
17854
18207
  bound.resources.connectors = bound.resources.connectors || {};
17855
18208
  bound.resources.dataViews = bound.resources.dataViews || {};
17856
18209
  bound.resources.storageConfigs = bound.resources.storageConfigs || {};
18210
+ bound.resources.webhooks = bound.resources.webhooks || {};
17857
18211
  bound.resources.authConfigs = bound.resources.authConfigs || {};
17858
18212
  bound.resources.routes = bound.resources.routes || {};
17859
18213
  bound.resources.publicAccessPolicies = bound.resources.publicAccessPolicies || {};
@@ -18199,6 +18553,29 @@ function saveStorageConfigResource(target, storageCode, storageConfigId, extra =
18199
18553
  }, keys);
18200
18554
  }
18201
18555
 
18556
+ function saveWebhookResource(target, webhookCode, data = {}) {
18557
+ const keys = [
18558
+ 'webhookId',
18559
+ 'endpointId',
18560
+ 'callbackPath',
18561
+ 'callbackUrl',
18562
+ 'targetFunctionCode',
18563
+ 'idempotencyQueryParam',
18564
+ 'maxBodyBytes',
18565
+ 'status',
18566
+ 'revision',
18567
+ ];
18568
+ const callbackPath = String(data?.callbackPath || '').trim();
18569
+ const callbackUrl = callbackPath
18570
+ ? `${String(target.profile?.baseUrl || '').replace(/\/+$/, '')}${callbackPath}`
18571
+ : data?.callbackUrl;
18572
+ saveStateResource(target, 'webhooks', webhookCode, {
18573
+ ...pickStateFields(data, keys),
18574
+ webhookId: data?.id || data?.webhookId,
18575
+ callbackUrl,
18576
+ }, keys);
18577
+ }
18578
+
18202
18579
  function saveScopeResource(target, bucket, code, data = {}) {
18203
18580
  if (!bucket || !code) return;
18204
18581
  const keys = ['id', 'resourceCode', 'name', 'updatedAt'];
@@ -18347,6 +18724,7 @@ const RESOURCE_SPECS = [
18347
18724
  { key: 'functions', dir: 'functions', topFiles: ['functions.json'], pluralKeys: ['functions'] },
18348
18725
  { key: 'dataViews', dir: 'data-views', topFiles: ['data-views.json'], pluralKeys: ['dataViews', 'data-views'] },
18349
18726
  { key: 'storageConfigs', dir: 'storage', topFiles: ['storage.json'], pluralKeys: ['storageConfigs', 'storage'] },
18727
+ { key: 'webhooks', dir: 'webhooks', topFiles: ['webhooks.json'], pluralKeys: ['webhooks'] },
18350
18728
  { key: 'authConfigs', dir: 'auth', topFiles: ['auth.json'], pluralKeys: ['authConfigs', 'auth'] },
18351
18729
  { key: 'routes', dir: 'routes', topFiles: ['routes.json'], pluralKeys: ['routes'] },
18352
18730
  {
@@ -18426,6 +18804,8 @@ const RESOURCE_TYPE_ALIASES = new Map([
18426
18804
  ['oss', 'storageConfigs'],
18427
18805
  ['oss-config', 'storageConfigs'],
18428
18806
  ['oss-configs', 'storageConfigs'],
18807
+ ['webhook', 'webhooks'],
18808
+ ['webhooks', 'webhooks'],
18429
18809
  ['auth', 'authConfigs'],
18430
18810
  ['auth-config', 'authConfigs'],
18431
18811
  ['auth-configs', 'authConfigs'],
@@ -19138,6 +19518,7 @@ function generateResourceTypes(manifest, outputFile) {
19138
19518
  const dataViewCodes = unique((manifest.dataViews || []).map(item => item.code).filter(Boolean));
19139
19519
  const functionCodes = unique((manifest.functions || []).map(item => item.code).filter(Boolean));
19140
19520
  const storageConfigCodes = unique((manifest.storageConfigs || []).map(item => item.code).filter(Boolean));
19521
+ const webhookCodes = unique((manifest.webhooks || []).map(item => item.code).filter(Boolean));
19141
19522
  const authConfigCodes = unique((manifest.authConfigs || []).map(item => item.code).filter(Boolean));
19142
19523
  const publicAccessPolicyCodes = unique((manifest.publicAccessPolicies || []).map(item => item.code).filter(Boolean));
19143
19524
  const scopeDimensionCodes = unique((manifest.scopeDimensions || []).map(item => item.code).filter(Boolean));
@@ -19162,6 +19543,9 @@ function generateResourceTypes(manifest, outputFile) {
19162
19543
  `export const storageConfigCodes = ${JSON.stringify(storageConfigCodes, null, 2)} as const`,
19163
19544
  'export type StorageConfigCode = typeof storageConfigCodes[number]',
19164
19545
  '',
19546
+ `export const webhookCodes = ${JSON.stringify(webhookCodes, null, 2)} as const`,
19547
+ 'export type WebhookCode = typeof webhookCodes[number]',
19548
+ '',
19165
19549
  `export const functionCodes = ${JSON.stringify(functionCodes, null, 2)} as const`,
19166
19550
  'export type FunctionCode = typeof functionCodes[number]',
19167
19551
  '',
@@ -19196,6 +19580,7 @@ function generateResourceTypes(manifest, outputFile) {
19196
19580
  pagePermissionGroups: pagePermissionGroupCodes.length,
19197
19581
  dataViews: dataViewCodes.length,
19198
19582
  storageConfigs: storageConfigCodes.length,
19583
+ webhooks: webhookCodes.length,
19199
19584
  functions: functionCodes.length,
19200
19585
  authConfigs: authConfigCodes.length,
19201
19586
  publicAccessPolicies: publicAccessPolicyCodes.length,
@@ -19273,6 +19658,50 @@ function validateResourceItem(kind, item, errors, warnings) {
19273
19658
  validateFunctionRuntimeInvoke(label, item, errors, warnings);
19274
19659
  validateFunctionPerformancePolicies(label, item, errors);
19275
19660
  }
19661
+ if (kind === 'webhooks') {
19662
+ const allowed = new Set([
19663
+ 'code',
19664
+ 'resourceCode',
19665
+ 'name',
19666
+ 'description',
19667
+ 'targetFunctionCode',
19668
+ 'idempotencyQueryParam',
19669
+ 'maxBodyBytes',
19670
+ 'status',
19671
+ ]);
19672
+ const unsupported = Object.keys(item).filter(
19673
+ key => !key.startsWith('__') && !allowed.has(key)
19674
+ );
19675
+ if (unsupported.length > 0) {
19676
+ errors.push(`${label}: 包含不支持的字段 ${unsupported.join(', ')}`);
19677
+ }
19678
+ if (!item.name) errors.push(`${label}: 缺少 name`);
19679
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(String(item.targetFunctionCode || ''))) {
19680
+ errors.push(`${label}: targetFunctionCode 格式不正确`);
19681
+ }
19682
+ if (
19683
+ item.idempotencyQueryParam !== undefined &&
19684
+ !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(
19685
+ String(item.idempotencyQueryParam)
19686
+ )
19687
+ ) {
19688
+ errors.push(`${label}: idempotencyQueryParam 格式不正确`);
19689
+ }
19690
+ const maxBodyBytes = Number(item.maxBodyBytes || 262144);
19691
+ if (
19692
+ !Number.isInteger(maxBodyBytes) ||
19693
+ maxBodyBytes < 1024 ||
19694
+ maxBodyBytes > 1048576
19695
+ ) {
19696
+ errors.push(`${label}: maxBodyBytes 必须在 1024 到 1048576 之间`);
19697
+ }
19698
+ if (!['active', 'disabled'].includes(String(item.status || 'active'))) {
19699
+ errors.push(`${label}: status 只能是 active 或 disabled`);
19700
+ }
19701
+ if (Object.keys(item).some(key => /secret|signature|hmac/i.test(key))) {
19702
+ errors.push(`${label}: Webhook manifest 不能包含 Secret 或供应商验签配置`);
19703
+ }
19704
+ }
19276
19705
  if (kind === 'dataViews') {
19277
19706
  const definition = item.definition || item;
19278
19707
  const viewType = String(definition.viewType || 'row').toLowerCase();
@@ -20384,6 +20813,7 @@ async function buildResourcePlan(
20384
20813
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
20385
20814
  addPlanActions(actions, 'dataView', manifest.dataViews, existing.dataViews, (item, current) => dataViewEquals(target.bound, item, current));
20386
20815
  addPlanActions(actions, 'storageConfig', manifest.storageConfigs, existing.storageConfigs, (item, current) => storageConfigEquals(item, current));
20816
+ addPlanActions(actions, 'webhook', manifest.webhooks, existing.webhooks, webhookEquals);
20387
20817
  addPlanActions(actions, 'scopeDimension', manifest.scopeDimensions, existing.scopeDimensions, (item, current) => scopeDimensionEquals(target.bound, item, current));
20388
20818
  addPlanActions(actions, 'scopeGrantSource', manifest.scopeGrantSources, existing.scopeGrantSources, (item, current) => scopeGrantSourceEquals(target.bound, item, current));
20389
20819
  addPlanActions(actions, 'dataScopePolicy', manifest.dataScopePolicies, existing.dataScopePolicies, dataScopePolicyEquals);
@@ -22244,6 +22674,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
22244
22674
  await publishDataScopePolicyResources(config, target, manifest.dataScopePolicies || [], result, publishOptions);
22245
22675
  await publishDataViewResources(config, target, manifest.dataViews || [], result, publishOptions);
22246
22676
  await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
22677
+ await publishWebhookResources(config, target, manifest.webhooks || [], result, publishOptions);
22247
22678
  await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
22248
22679
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
22249
22680
  await publishFormPermissionGroupResources(
@@ -22390,6 +22821,7 @@ const GENERIC_RESOURCE_KEY_BY_PLAN_KIND = Object.freeze({
22390
22821
  publicAccessPolicy: 'publicAccessPolicies',
22391
22822
  dataView: 'dataViews',
22392
22823
  storageConfig: 'storageConfigs',
22824
+ webhook: 'webhooks',
22393
22825
  scopeDimension: 'scopeDimensions',
22394
22826
  scopeGrantSource: 'scopeGrantSources',
22395
22827
  dataScopePolicy: 'dataScopePolicies',
@@ -22898,6 +23330,7 @@ async function fetchExistingResourceMaps(config, target, manifest) {
22898
23330
  automations: new Map(),
22899
23331
  dataViews: new Map(),
22900
23332
  storageConfigs: new Map(),
23333
+ webhooks: new Map(),
22901
23334
  scopeDimensions: new Map(),
22902
23335
  scopeGrantSources: new Map(),
22903
23336
  dataScopePolicies: new Map(),
@@ -23124,6 +23557,21 @@ async function fetchExistingResourceMaps(config, target, manifest) {
23124
23557
  });
23125
23558
  }
23126
23559
  }
23560
+ if ((manifest.webhooks || []).length > 0) {
23561
+ const data = await requestWithAuth(
23562
+ config,
23563
+ target.profileName,
23564
+ apiPathWithQuery(
23565
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
23566
+ { page: 1, pageSize: 1000 }
23567
+ )
23568
+ );
23569
+ indexByCode(
23570
+ maps.webhooks,
23571
+ normalizeItems(data),
23572
+ item => item.code || item.resourceCode
23573
+ );
23574
+ }
23127
23575
  if ((manifest.scopeDimensions || []).length > 0) {
23128
23576
  const data = await requestWithAuth(
23129
23577
  config,
@@ -25115,6 +25563,45 @@ async function publishStorageConfigResources(config, target, storageConfigs, res
25115
25563
  }
25116
25564
  }
25117
25565
 
25566
+ async function publishWebhookResources(config, target, webhooks, result, options = {}) {
25567
+ for (const webhookItem of webhooks) {
25568
+ const code = webhookItem.code || webhookItem.resourceCode;
25569
+ if (shouldSkipNoopResource(options, 'webhook', code)) {
25570
+ recordNoopResource(result, 'webhook', code, options);
25571
+ continue;
25572
+ }
25573
+ const existing = await findExistingWebhook(config, target, code);
25574
+ const existingRevision = Number(existing?.revision);
25575
+ const body = {
25576
+ ...normalizeWebhookManifest(webhookItem),
25577
+ expectedRevision:
25578
+ existing && Number.isInteger(existingRevision) && existingRevision > 0
25579
+ ? existingRevision
25580
+ : existing
25581
+ ? undefined
25582
+ : 0,
25583
+ };
25584
+ const data = await requestWithAuth(
25585
+ config,
25586
+ target.profileName,
25587
+ existing
25588
+ ? `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks/${encodeURIComponent(code)}`
25589
+ : `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
25590
+ { method: 'POST', body }
25591
+ );
25592
+ saveWebhookResource(target, code, data);
25593
+ result.published.push({
25594
+ kind: 'webhook',
25595
+ code,
25596
+ action: existing ? 'update' : 'create',
25597
+ id: data?.id,
25598
+ endpointId: data?.endpointId,
25599
+ callbackPath: data?.callbackPath,
25600
+ status: data?.status,
25601
+ });
25602
+ }
25603
+ }
25604
+
25118
25605
  async function publishDataViewPermissionGroups(config, target, dataViewCode, permissionGroups) {
25119
25606
  await requestWithAuth(
25120
25607
  config,
@@ -25279,6 +25766,25 @@ async function findExistingStorageConfig(config, target, code) {
25279
25766
  return null;
25280
25767
  }
25281
25768
 
25769
+ async function findExistingWebhook(config, target, code) {
25770
+ const state = target.bound.resources?.webhooks?.[code];
25771
+ const byCode = await requestOptionalWithAuth(
25772
+ config,
25773
+ target.profileName,
25774
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks/${encodeURIComponent(code)}`
25775
+ );
25776
+ if (byCode?.id) return byCode;
25777
+ if (state?.webhookId) {
25778
+ return {
25779
+ id: state.webhookId,
25780
+ code,
25781
+ revision: state.revision,
25782
+ endpointId: state.endpointId,
25783
+ };
25784
+ }
25785
+ return null;
25786
+ }
25787
+
25282
25788
  async function findExistingAutomation(config, target, code) {
25283
25789
  const stateId = target.bound.resources?.automations?.[code]?.automationId;
25284
25790
  if (stateId) {
@@ -25557,6 +26063,22 @@ async function collectPruneResourceCandidates(config, target, manifest) {
25557
26063
  item => item.code || item.resourceCode
25558
26064
  );
25559
26065
  }
26066
+ if (enabled('webhooks')) {
26067
+ const data = await requestWithAuth(
26068
+ config,
26069
+ target.profileName,
26070
+ apiPathWithQuery(`${appPath}/webhooks`, {
26071
+ page: 1,
26072
+ pageSize: 1000,
26073
+ })
26074
+ );
26075
+ append(
26076
+ 'webhook',
26077
+ desiredCodes(manifest.webhooks),
26078
+ normalizeItems(data),
26079
+ item => item.code || item.resourceCode
26080
+ );
26081
+ }
25560
26082
  if (enabled('publicAccessPolicies')) {
25561
26083
  const data = await requestWithAuth(
25562
26084
  config,
@@ -25747,6 +26269,21 @@ async function pruneFrozenResourceCandidate(config, target, result, candidate) {
25747
26269
  if (kind === 'storageConfig') {
25748
26270
  return postDelete(`${appPath}/storage-configs/${encodeURIComponent(code)}`);
25749
26271
  }
26272
+ if (kind === 'webhook') {
26273
+ return requestWithAuth(
26274
+ config,
26275
+ target.profileName,
26276
+ apiPathWithPostDeleteAction(
26277
+ `${appPath}/webhooks/${encodeURIComponent(code)}`
26278
+ ),
26279
+ {
26280
+ method: 'POST',
26281
+ body: candidate.frozenRevision
26282
+ ? { expectedRevision: candidate.frozenRevision }
26283
+ : {},
26284
+ }
26285
+ );
26286
+ }
25750
26287
  if (kind === 'publicAccessPolicy') {
25751
26288
  return postDelete(`${appPath}/public-access/policies/${encodeURIComponent(code)}`);
25752
26289
  }
@@ -26147,6 +26684,7 @@ function removeStateResource(target, kind, code) {
26147
26684
  publicAccessPolicy: 'publicAccessPolicies',
26148
26685
  dataView: 'dataViews',
26149
26686
  storageConfig: 'storageConfigs',
26687
+ webhook: 'webhooks',
26150
26688
  pagePermissionGroup: 'pagePermissionGroups',
26151
26689
  formPermissionGroup: 'formPermissionGroups',
26152
26690
  };
@@ -26449,6 +26987,22 @@ async function pullResources(config, target) {
26449
26987
  written.push(path.relative(process.cwd(), filePath));
26450
26988
  }
26451
26989
 
26990
+ const webhooks = await requestWithAuth(
26991
+ config,
26992
+ target.profileName,
26993
+ apiPathWithQuery(
26994
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
26995
+ { page: 1, pageSize: 1000 }
26996
+ )
26997
+ );
26998
+ for (const webhookItem of normalizeItems(webhooks)) {
26999
+ const code = webhookItem.code || webhookItem.resourceCode || webhookItem.id;
27000
+ const filePath = path.join(baseDir, 'webhooks', `${code}.json`);
27001
+ writeResourceJsonFile(filePath, toPulledWebhook(webhookItem));
27002
+ written.push(path.relative(process.cwd(), filePath));
27003
+ saveWebhookResource(target, code, webhookItem);
27004
+ }
27005
+
26452
27006
  const pageGroups = await requestWithAuth(
26453
27007
  config,
26454
27008
  target.profileName,
@@ -26722,6 +27276,18 @@ function toPulledStorageConfig(storageConfig) {
26722
27276
  });
26723
27277
  }
26724
27278
 
27279
+ function toPulledWebhook(webhookItem) {
27280
+ return stripUndefinedValues({
27281
+ code: webhookItem.code || webhookItem.resourceCode,
27282
+ name: webhookItem.name,
27283
+ description: webhookItem.description || '',
27284
+ targetFunctionCode: webhookItem.targetFunctionCode,
27285
+ idempotencyQueryParam: webhookItem.idempotencyQueryParam || 'nonce',
27286
+ maxBodyBytes: webhookItem.maxBodyBytes || 262144,
27287
+ status: webhookItem.status || 'active',
27288
+ });
27289
+ }
27290
+
26725
27291
  function rewriteDataViewDefinitionForManifest(definition, lookups) {
26726
27292
  rewriteDataViewSourceForManifest(definition.base, lookups);
26727
27293
  for (const join of definition.joins || []) {
@@ -27174,6 +27740,61 @@ function normalizeStorageConfigManifest(storageConfig) {
27174
27740
  });
27175
27741
  }
27176
27742
 
27743
+ function normalizeWebhookManifest(webhook) {
27744
+ const code = String(webhook.code || webhook.resourceCode || '').trim();
27745
+ const allowed = new Set([
27746
+ 'code',
27747
+ 'resourceCode',
27748
+ 'name',
27749
+ 'description',
27750
+ 'targetFunctionCode',
27751
+ 'idempotencyQueryParam',
27752
+ 'maxBodyBytes',
27753
+ 'status',
27754
+ ]);
27755
+ const unsupported = Object.keys(webhook).filter(
27756
+ key => !key.startsWith('__') && !allowed.has(key)
27757
+ );
27758
+ if (unsupported.length > 0) {
27759
+ fail(`webhook:${code || '<unknown>'} 包含不支持的字段 ${unsupported.join(', ')}`);
27760
+ }
27761
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(code)) {
27762
+ fail('Webhook code 格式不正确');
27763
+ }
27764
+ const targetFunctionCode = String(webhook.targetFunctionCode || '').trim();
27765
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(targetFunctionCode)) {
27766
+ fail(`webhook:${code} targetFunctionCode 格式不正确`);
27767
+ }
27768
+ const idempotencyQueryParam = String(
27769
+ webhook.idempotencyQueryParam || 'nonce'
27770
+ ).trim();
27771
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(idempotencyQueryParam)) {
27772
+ fail(`webhook:${code} idempotencyQueryParam 格式不正确`);
27773
+ }
27774
+ const maxBodyBytes = Number(webhook.maxBodyBytes || 262144);
27775
+ if (
27776
+ !Number.isInteger(maxBodyBytes) ||
27777
+ maxBodyBytes < 1024 ||
27778
+ maxBodyBytes > 1048576
27779
+ ) {
27780
+ fail(`webhook:${code} maxBodyBytes 必须在 1024 到 1048576 之间`);
27781
+ }
27782
+ const status = String(webhook.status || 'active');
27783
+ if (!['active', 'disabled'].includes(status)) {
27784
+ fail(`webhook:${code} status 只能是 active 或 disabled`);
27785
+ }
27786
+ return stripUndefinedValues({
27787
+ code,
27788
+ name: String(webhook.name || code).trim(),
27789
+ description:
27790
+ webhook.description === undefined ? '' : String(webhook.description),
27791
+ targetFunctionCode,
27792
+ idempotencyQueryParam,
27793
+ maxBodyBytes,
27794
+ status,
27795
+ });
27796
+ }
27797
+
27177
27798
  function normalizeStorageCorsForPublish(cors) {
27178
27799
  if (cors === undefined || cors === null || cors === '') return undefined;
27179
27800
  const managed =
@@ -28321,6 +28942,21 @@ function storageConfigEquals(desired, existing) {
28321
28942
  );
28322
28943
  }
28323
28944
 
28945
+ function webhookEquals(desired, existing) {
28946
+ if (!existing) return false;
28947
+ const expected = normalizeWebhookManifest(desired);
28948
+ return (
28949
+ String(existing.code || existing.resourceCode || '') === expected.code &&
28950
+ String(existing.name || '') === expected.name &&
28951
+ String(existing.description || '') === expected.description &&
28952
+ String(existing.targetFunctionCode || '') === expected.targetFunctionCode &&
28953
+ String(existing.idempotencyQueryParam || 'nonce') ===
28954
+ expected.idempotencyQueryParam &&
28955
+ Number(existing.maxBodyBytes || 262144) === expected.maxBodyBytes &&
28956
+ String(existing.status || 'active') === expected.status
28957
+ );
28958
+ }
28959
+
28324
28960
  function routeEquals(desired, existing) {
28325
28961
  if (!existing) return false;
28326
28962
  const expected = normalizeRouteManifest(desired);
@@ -31135,6 +31771,9 @@ function buildWorkspacePublishEnv(
31135
31771
  env.OPENXIANGDA_BASE_URL = profile.baseUrl;
31136
31772
  env.OPENXIANGDA_ACCESS_TOKEN = profile.token.accessToken;
31137
31773
  env.OPENXIANGDA_APP_TYPE = appType;
31774
+ if (!env.OPENXIANGDA_CLI) {
31775
+ env.OPENXIANGDA_CLI = path.resolve(__dirname, '..', 'bin', 'openxiangda.js');
31776
+ }
31138
31777
  if (targetName) env.OPENXIANGDA_TARGET = targetName;
31139
31778
  else delete env.OPENXIANGDA_TARGET;
31140
31779
  env.OPENXIANGDA_GLOBAL_ENV_KEYS = injectedGlobalKeys.join(',');
@@ -31144,8 +31783,11 @@ function buildWorkspacePublishEnv(
31144
31783
  }
31145
31784
 
31146
31785
  module.exports = {
31786
+ assertLegacyPageReleaseCoverage,
31147
31787
  assertReleasePublishAdoptionFlags,
31148
31788
  assertReleasePublishAdoptionScope,
31789
+ assertReleasePublishManifestReplacementFlags,
31790
+ assertReleasePublishManifestReplacementScope,
31149
31791
  assertRuntimeReleaseReusable,
31150
31792
  buildOpenXiangdaUpdateInstallPlan,
31151
31793
  buildResourceManifestSddTargets,