openxiangda 1.0.268 → 1.0.270

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 (42) hide show
  1. package/README.md +20 -1
  2. package/lib/cli.js +417 -24
  3. package/lib/config.js +60 -56
  4. package/lib/design-gates.js +18 -0
  5. package/lib/migration-advice.js +19 -0
  6. package/lib/release-plan.js +2 -0
  7. package/lib/workspace-init.js +11 -4
  8. package/openxiangda-skills/SKILL.md +8 -0
  9. package/openxiangda-skills/references/architecture-patterns.md +218 -218
  10. package/openxiangda-skills/references/component-guide.md +122 -122
  11. package/openxiangda-skills/references/openxiangda-api.md +12 -0
  12. package/openxiangda-skills/references/platform-data-model.md +285 -285
  13. package/openxiangda-skills/references/resource-manifest-cheatsheet.md +32 -2
  14. package/openxiangda-skills/references/troubleshooting.md +224 -224
  15. package/openxiangda-skills/references/webhooks.md +213 -0
  16. package/openxiangda-skills/references/workspace-state.md +1 -1
  17. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +1 -1
  18. package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +3 -0
  19. package/package.json +26 -23
  20. package/packages/sdk/dist/{ProcessPreview-Cyk6uv-w.d.mts → ProcessPreview-DSUIJi5V.d.mts} +2 -3
  21. package/packages/sdk/dist/{ProcessPreview-Cyk6uv-w.d.ts → ProcessPreview-DSUIJi5V.d.ts} +2 -3
  22. package/packages/sdk/dist/components/index.d.mts +41 -42
  23. package/packages/sdk/dist/components/index.d.ts +41 -42
  24. package/packages/sdk/dist/{dataManagementApi-CE8Zyj3a.d.ts → dataManagementApi-Ro9itwm8.d.ts} +2 -2
  25. package/packages/sdk/dist/{dataManagementApi-4fSaCA5t.d.mts → dataManagementApi-p_HOhQnA.d.mts} +2 -2
  26. package/packages/sdk/dist/runtime/index.d.mts +3 -4
  27. package/packages/sdk/dist/runtime/index.d.ts +3 -4
  28. package/packages/sdk/dist/runtime/react.d.mts +5 -5
  29. package/packages/sdk/dist/runtime/react.d.ts +5 -5
  30. package/packages/sdk/src/build-source/scripts/utils/mime-types.mjs +70 -70
  31. package/packages/sdk/src/build-source/scripts/utils/oss-client.mjs +128 -128
  32. package/packages/sdk/src/build-source/scripts/utils/progress.mjs +57 -57
  33. package/templates/openxiangda-react-spa/.cursor/rules/openxiangda-resources.mdc +1 -0
  34. package/templates/openxiangda-react-spa/.cursor/rules/openxiangda.mdc +6 -0
  35. package/templates/openxiangda-react-spa/.qoder/rules/openxiangda-resources.md +1 -0
  36. package/templates/openxiangda-react-spa/.qoder/rules/openxiangda.md +6 -0
  37. package/templates/openxiangda-react-spa/AGENTS.md +8 -0
  38. package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda-resources.mdc +2 -0
  39. package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda.mdc +7 -1
  40. package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda-resources.md +1 -0
  41. package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda.md +7 -1
  42. package/templates/sy-lowcode-app-workspace/AGENTS.md +9 -1
package/lib/cli.js CHANGED
@@ -7,7 +7,7 @@ const { spawn, spawnSync } = require('child_process');
7
7
  const { pathToFileURL } = require('url');
8
8
  const esbuild = require('esbuild');
9
9
  const {
10
- CONFIG_FILE,
10
+ getConfigFile,
11
11
  GLOBAL_ENV_FILE,
12
12
  PROJECT_STATE_FILE,
13
13
  getProfile,
@@ -115,6 +115,7 @@ const {
115
115
  } = require('./resource-binding-contract');
116
116
  const { getSkillStatusReport, installSkills } = require('./skills');
117
117
  const { resolveUpdateTarget, buildGenerationUpdateCommand } = require('./generation-update');
118
+ const { migrationAdvice, printMigrationAdvice } = require('./migration-advice');
118
119
  const {
119
120
  assertOrClaimWorktreeOwner,
120
121
  claimWorktreeOwner,
@@ -233,7 +234,12 @@ const genericGitBasePlanCache = new Map();
233
234
  async function main(argv) {
234
235
  let commandError = null;
235
236
  try {
236
- return await mainImpl(argv);
237
+ const result = await mainImpl(argv);
238
+ const [command, action] = argv;
239
+ if (!argv.some(value => value === '--json' || value.startsWith('--json=')) && (!command || ['version', '--version', '-v', 'help', '--help', '-h', 'update'].includes(command) || command === 'skill' && ['install', 'bootstrap'].includes(action))) {
240
+ printMigrationAdvice();
241
+ }
242
+ return result;
237
243
  } catch (error) {
238
244
  commandError = error;
239
245
  throw error;
@@ -293,6 +299,7 @@ async function mainImpl(argv) {
293
299
  if (command === 'workflow') return workflow(rest);
294
300
  if (command === 'automation') return automation(rest);
295
301
  if (command === 'data-view') return dataView(rest);
302
+ if (command === 'webhook') return webhook(rest);
296
303
  if (command === 'scope') return scope(rest);
297
304
  if (command === 'route') return route(rest);
298
305
  if (command === 'public-access') return publicAccess(rest);
@@ -319,12 +326,12 @@ function printHelp() {
319
326
 
320
327
  Usage:
321
328
  openxiangda version [--json]
322
- openxiangda login <platform-url> [--profile name]
323
- openxiangda update check|install [--json] [--registry https://registry.npmjs.org]
329
+ openxiangda login <platform-url> [--profile name] [--cwd directory]
330
+ openxiangda update check|install [--target workspace|launcher] [--json] [--registry https://registry.npmjs.org]
324
331
  openxiangda platform add <name> <platform-url>
325
332
  openxiangda platform list
326
333
  openxiangda platform use <name>
327
- openxiangda auth status|refresh|logout [--profile name]
334
+ openxiangda auth status|refresh|logout [--profile name] [--cwd directory]
328
335
  openxiangda doctor [--profile name] [--app-type APP_XXX] [--release] [--json]
329
336
  openxiangda contract list|explain <contract-name> [--json]
330
337
  openxiangda policy show|check|render [--json]
@@ -383,6 +390,7 @@ Usage:
383
390
  openxiangda automation diagnose <automationCode|automationId> [--redact] [--json]
384
391
  openxiangda automation publish|enable|disable <automationCode|automationId>
385
392
  openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats <dataViewCode> [--profile name] [--json]
393
+ openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery [webhookCode] [deliveryId] [--json-file file] [--json]
386
394
  openxiangda scope dimension-list|dimension-upsert|grant-source-list|grant-source-upsert|policy-list|policy-upsert|sync|explain [code] [--json-file file]
387
395
  openxiangda route list|get|create|update|upsert|delete [--json-file file] [--dry-run] [--write-manifest]
388
396
  openxiangda public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check [--json-file file]
@@ -519,6 +527,7 @@ async function version(args) {
519
527
  atomicActivation: APP_FUNCTION_SECRET_CAPABILITIES.atomicActivation,
520
528
  },
521
529
  updateCheckCommand: 'openxiangda update check --json',
530
+ migrationAdvice: migrationAdvice(),
522
531
  };
523
532
  if (flags.json) return writeJson(result);
524
533
  print(CURRENT_VERSION);
@@ -529,7 +538,7 @@ async function update(args) {
529
538
  const parsedArgs = requestedSubcommand === args[0] ? args.slice(1) : args;
530
539
  const { flags } = parseArgs(parsedArgs);
531
540
  if (wantsSubcommandHelp(requestedSubcommand, flags)) {
532
- print('用法: openxiangda update check|install [--json] [--registry https://registry.npmjs.org] [--timeout-seconds 300] [--no-skills]');
541
+ print('用法: openxiangda update check|install [--target workspace|launcher] [--json] [--registry https://registry.npmjs.org] [--timeout-seconds 300] [--no-skills]');
533
542
  return;
534
543
  }
535
544
  const registry = normalizeNpmRegistry(flags.registry || OFFICIAL_NPM_REGISTRY);
@@ -557,7 +566,7 @@ async function update(args) {
557
566
  return;
558
567
  }
559
568
 
560
- fail('用法: openxiangda update check|install [--json] [--registry https://registry.npmjs.org] [--timeout-seconds 300] [--no-skills]');
569
+ fail('用法: openxiangda update check|install [--target workspace|launcher] [--json] [--registry https://registry.npmjs.org] [--timeout-seconds 300] [--no-skills]');
561
570
  }
562
571
 
563
572
  function normalizeNpmRegistry(value) {
@@ -586,6 +595,7 @@ function checkOpenXiangdaUpdate(registry, selection = resolveUpdateTarget()) {
586
595
  skillInstallCommand: 'openxiangda skill install',
587
596
  compatibilityCheckCommand: 'openxiangda commands --json',
588
597
  checkedAt: new Date().toISOString(),
598
+ migrationAdvice: migrationAdvice(),
589
599
  notes: [
590
600
  'Use the official npm registry for OpenXiangda updates; domestic mirrors may lag.',
591
601
  'After updating the package, refresh OpenXiangda skills so AI guidance matches the CLI version.',
@@ -766,6 +776,7 @@ async function installOpenXiangdaUpdate(registry, options = {}) {
766
776
  registry: plan.registry,
767
777
  installCommand: `npm ${npmArgs.join(' ')}`,
768
778
  installed: true,
779
+ migrationAdvice: migrationAdvice(),
769
780
  durationMs: installResult.durationMs,
770
781
  cacheMode: 'prefer-offline',
771
782
  timeoutSeconds: plan.timeoutSeconds,
@@ -9444,7 +9455,7 @@ async function platform(args) {
9444
9455
  print('用法: openxiangda platform add|list|use|remove');
9445
9456
  return;
9446
9457
  }
9447
- const config = loadConfig();
9458
+ const config = loadConfig(flags.cwd || process.cwd(), { exact: Boolean(flags.cwd) });
9448
9459
 
9449
9460
  if (subcommand === 'prepare') {
9450
9461
  const result = prepareDependencyCapsule({
@@ -9549,10 +9560,10 @@ async function login(args) {
9549
9560
  positional[0] === '--help' ||
9550
9561
  positional[0] === '-h'
9551
9562
  ) {
9552
- print('用法: openxiangda login <platform-url> [--profile name] [--no-open] [--json]');
9563
+ print('用法: openxiangda login <platform-url> [--profile name] [--cwd directory] [--no-open] [--json]');
9553
9564
  return;
9554
9565
  }
9555
- const config = loadConfig();
9566
+ const config = loadConfig(flags.cwd || process.cwd(), { exact: Boolean(flags.cwd) });
9556
9567
  const rawUrl = positional[0];
9557
9568
  const profileName = flags.profile || config.currentProfile || 'default';
9558
9569
  let profile = config.profiles[profileName] || { name: profileName };
@@ -9567,7 +9578,7 @@ async function login(args) {
9567
9578
  }
9568
9579
 
9569
9580
  if (!profile.baseUrl) {
9570
- fail('缺少平台地址。用法: openxiangda login <platform-url> [--profile name]');
9581
+ fail('缺少平台地址。用法: openxiangda login <platform-url> [--profile name] [--cwd directory]');
9571
9582
  }
9572
9583
 
9573
9584
  const sessionPayload = await requestJson(
@@ -9666,12 +9677,12 @@ async function auth(args) {
9666
9677
  const [subcommand, ...rest] = args;
9667
9678
  const { flags } = parseArgs(rest);
9668
9679
  if (wantsSubcommandHelp(subcommand, flags)) {
9669
- print('用法: openxiangda auth status|refresh|logout [--profile name] [--json]');
9680
+ print('用法: openxiangda auth status|refresh|logout [--profile name] [--cwd directory] [--json]');
9670
9681
  return;
9671
9682
  }
9672
9683
 
9673
9684
  if (subcommand === 'status') {
9674
- const config = loadConfig();
9685
+ const config = loadConfig(flags.cwd || process.cwd(), { exact: Boolean(flags.cwd) });
9675
9686
  const { profileName, profile } = getProfile(config, flags.profile);
9676
9687
  if (!profile.token?.accessToken) {
9677
9688
  print(`未登录: ${profileName} (${profile.baseUrl})`);
@@ -9689,7 +9700,7 @@ async function auth(args) {
9689
9700
  }
9690
9701
 
9691
9702
  if (subcommand === 'refresh') {
9692
- const config = loadConfig();
9703
+ const config = loadConfig(flags.cwd || process.cwd(), { exact: Boolean(flags.cwd) });
9693
9704
  const profileName = flags.profile || config.currentProfile;
9694
9705
  if (!profileName) fail('未选择平台 profile');
9695
9706
  const data = await refreshProfile(config, profileName);
@@ -9700,7 +9711,7 @@ async function auth(args) {
9700
9711
  }
9701
9712
 
9702
9713
  if (subcommand === 'logout') {
9703
- const config = loadConfig();
9714
+ const config = loadConfig(flags.cwd || process.cwd(), { exact: Boolean(flags.cwd) });
9704
9715
  const { profileName, profile } = getProfile(config, flags.profile);
9705
9716
  if (profile.token?.accessToken) {
9706
9717
  try {
@@ -9718,7 +9729,7 @@ async function auth(args) {
9718
9729
  return;
9719
9730
  }
9720
9731
 
9721
- fail('用法: openxiangda auth status|refresh|logout [--profile name]');
9732
+ fail('用法: openxiangda auth status|refresh|logout [--profile name] [--cwd directory]');
9722
9733
  }
9723
9734
 
9724
9735
  async function env(args) {
@@ -9735,7 +9746,8 @@ async function env(args) {
9735
9746
  const currentState = profileName ? state.profiles?.[profileName] : null;
9736
9747
  const oss = summarizeOssEnv(globalEnv);
9737
9748
  const data = {
9738
- configFile: CONFIG_FILE,
9749
+ configFile: getConfigFile(config),
9750
+ sessionScope: 'workspace',
9739
9751
  globalEnvFile: GLOBAL_ENV_FILE,
9740
9752
  globalEnvExists: fs.existsSync(GLOBAL_ENV_FILE),
9741
9753
  projectStateFile: path.join(process.cwd(), PROJECT_STATE_FILE),
@@ -10198,7 +10210,9 @@ async function workspace(args) {
10198
10210
  printWorkspaceHelp();
10199
10211
  return;
10200
10212
  }
10201
- const config = loadConfig();
10213
+ const config = subcommand === 'init'
10214
+ ? loadConfig(path.resolve(positional[0] || process.cwd()), { exact: true })
10215
+ : loadConfig();
10202
10216
 
10203
10217
  if (subcommand === 'ownership' || subcommand === 'owner') {
10204
10218
  const action = positional[0] || 'status';
@@ -10345,6 +10359,7 @@ async function workspace(args) {
10345
10359
  connectors: {},
10346
10360
  dataViews: {},
10347
10361
  storageConfigs: {},
10362
+ webhooks: {},
10348
10363
  scopeDimensions: {},
10349
10364
  scopeGrantSources: {},
10350
10365
  dataScopePolicies: {},
@@ -12531,6 +12546,93 @@ async function dataView(args) {
12531
12546
  fail('用法: openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats');
12532
12547
  }
12533
12548
 
12549
+ async function webhook(args) {
12550
+ const { subcommand, rest } = parseSubcommandArgs(args);
12551
+ const { flags, positional } = parseArgs(rest);
12552
+ if (wantsSubcommandHelp(subcommand, flags)) {
12553
+ print('用法: openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery [webhookCode] [deliveryId] [--json-file file] [--json]');
12554
+ return;
12555
+ }
12556
+ const config = loadConfig();
12557
+ const target = getWorkspaceTarget(
12558
+ config,
12559
+ flags.profile || config.currentProfile,
12560
+ flags
12561
+ );
12562
+ const basePath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`;
12563
+
12564
+ if (['list', 'get', 'create', 'update', 'upsert'].includes(subcommand)) {
12565
+ return directResourceCrud(config, target, {
12566
+ commandName: 'webhook',
12567
+ subcommand,
12568
+ flags,
12569
+ positional,
12570
+ resourceType: 'webhook',
12571
+ basePath,
12572
+ manifestDir: path.join('src', 'resources', 'webhooks'),
12573
+ normalizeBody: normalizeWebhookManifest,
12574
+ codeOf: body => body.code || body.resourceCode,
12575
+ saveState: (code, data) => saveWebhookResource(target, code, data),
12576
+ });
12577
+ }
12578
+
12579
+ if (subcommand === 'disable') {
12580
+ const code = positional[0] || flags.code;
12581
+ if (!code) fail('用法: openxiangda webhook disable <webhookCode>');
12582
+ const body = readDirectJsonBody(flags, 'webhook disable', {
12583
+ optional: true,
12584
+ });
12585
+ if (flags['expected-revision'] !== undefined) {
12586
+ body.expectedRevision = Number(flags['expected-revision']);
12587
+ }
12588
+ const data = await runDirectRequest(
12589
+ config,
12590
+ target,
12591
+ flags,
12592
+ {
12593
+ method: 'POST',
12594
+ path: apiPathWithPostDeleteAction(
12595
+ `${basePath}/${encodeURIComponent(code)}`
12596
+ ),
12597
+ body,
12598
+ },
12599
+ { returnData: true }
12600
+ );
12601
+ if (!flags['dry-run']) saveWebhookResource(target, code, data);
12602
+ return outputDirectResult({ action: 'disable', data }, flags);
12603
+ }
12604
+
12605
+ if (subcommand === 'deliveries') {
12606
+ const code = positional[0] || flags.code;
12607
+ if (!code) fail('用法: openxiangda webhook deliveries <webhookCode>');
12608
+ return runDirectRequest(config, target, flags, {
12609
+ method: 'GET',
12610
+ path: apiPathWithQuery(
12611
+ `${basePath}/${encodeURIComponent(code)}/deliveries`,
12612
+ {
12613
+ page: flags.page,
12614
+ pageSize: flags['page-size'] || flags.limit,
12615
+ status: flags.status,
12616
+ }
12617
+ ),
12618
+ });
12619
+ }
12620
+
12621
+ if (subcommand === 'delivery') {
12622
+ const code = positional[0] || flags.code;
12623
+ const deliveryId = positional[1] || flags['delivery-id'];
12624
+ if (!code || !deliveryId) {
12625
+ fail('用法: openxiangda webhook delivery <webhookCode> <deliveryId>');
12626
+ }
12627
+ return runDirectRequest(config, target, flags, {
12628
+ method: 'GET',
12629
+ path: `${basePath}/${encodeURIComponent(code)}/deliveries/${encodeURIComponent(deliveryId)}`,
12630
+ });
12631
+ }
12632
+
12633
+ fail('用法: openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery');
12634
+ }
12635
+
12534
12636
  async function scope(args) {
12535
12637
  const { subcommand, rest } = parseSubcommandArgs(args);
12536
12638
  const { flags, positional } = parseArgs(rest);
@@ -17064,7 +17166,7 @@ async function commands(args) {
17064
17166
  commands: [
17065
17167
  'version [--json]',
17066
17168
  'login <platform-url> [--profile name]',
17067
- 'update check|install',
17169
+ 'update check|install [--target workspace|launcher] [--json]',
17068
17170
  'platform add|list|use|remove',
17069
17171
  'auth status|refresh|logout',
17070
17172
  'doctor [--profile name] [--app-type APP_XXX] [--release]',
@@ -17085,6 +17187,7 @@ async function commands(args) {
17085
17187
  'workflow compile|list|create|bind|pull|publish|delete|validate',
17086
17188
  'automation list|create|bind|pull|publish|unpublish|enable|disable|delete|validate|cron-validate',
17087
17189
  'data-view list|get|create|update|upsert|delete|status|refresh|query|stats',
17190
+ 'webhook list|get|create|update|upsert|disable|deliveries|delivery',
17088
17191
  'route list|get|create|update|upsert|delete',
17089
17192
  'public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check',
17090
17193
  'auth-config list|get|create|update|upsert|delete|methods',
@@ -17107,6 +17210,7 @@ async function commands(args) {
17107
17210
  resourceNotes: [
17108
17211
  'For formal multi-resource development, prefer src/resources/** + openxiangda resource validate|plan|publish.',
17109
17212
  '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.',
17213
+ '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.',
17110
17214
  '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.',
17111
17215
  'settings public-access is legacy form public-access compatibility/repair and should not be used for new React SPA apps.',
17112
17216
  'Direct live mutation commands should use --dry-run first and --write-manifest when the repository should remain source of truth.',
@@ -17160,6 +17264,7 @@ async function skill(args) {
17160
17264
 
17161
17265
  if (subcommand === 'install') {
17162
17266
  const result = installSkills(options);
17267
+ result.migrationAdvice = migrationAdvice();
17163
17268
  if (flags.json) return writeJson(result);
17164
17269
  printSkillInstallReport(result);
17165
17270
  return;
@@ -17178,6 +17283,7 @@ async function skill(args) {
17178
17283
  force: Boolean(flags.force),
17179
17284
  dryRun: Boolean(flags['dry-run']),
17180
17285
  });
17286
+ result.migrationAdvice = migrationAdvice();
17181
17287
  if (flags.json) return writeJson(result);
17182
17288
  printSkillBootstrapReport(result);
17183
17289
  return;
@@ -17499,6 +17605,7 @@ function directManifestDir(resourceType) {
17499
17605
  connector: path.join('src', 'resources', 'connectors'),
17500
17606
  notification: path.join('src', 'resources', 'notifications'),
17501
17607
  'data-view': path.join('src', 'resources', 'data-views'),
17608
+ webhook: path.join('src', 'resources', 'webhooks'),
17502
17609
  menu: path.join('src', 'resources', 'menus'),
17503
17610
  role: path.join('src', 'resources', 'roles'),
17504
17611
  'page-permission-group': path.join('src', 'resources', 'permissions', 'page-groups'),
@@ -17518,6 +17625,7 @@ function removeDirectStateResource(target, resourceType, code) {
17518
17625
  function: 'functions',
17519
17626
  connector: 'connectors',
17520
17627
  'data-view': 'dataViews',
17628
+ webhook: 'webhooks',
17521
17629
  };
17522
17630
  const bucket = buckets[resourceType];
17523
17631
  if (!bucket || !target.bound.resources?.[bucket]?.[code]) return;
@@ -17840,6 +17948,7 @@ function ensureResourceBuckets(bound) {
17840
17948
  bound.resources.connectors = bound.resources.connectors || {};
17841
17949
  bound.resources.dataViews = bound.resources.dataViews || {};
17842
17950
  bound.resources.storageConfigs = bound.resources.storageConfigs || {};
17951
+ bound.resources.webhooks = bound.resources.webhooks || {};
17843
17952
  bound.resources.authConfigs = bound.resources.authConfigs || {};
17844
17953
  bound.resources.routes = bound.resources.routes || {};
17845
17954
  bound.resources.publicAccessPolicies = bound.resources.publicAccessPolicies || {};
@@ -18185,6 +18294,29 @@ function saveStorageConfigResource(target, storageCode, storageConfigId, extra =
18185
18294
  }, keys);
18186
18295
  }
18187
18296
 
18297
+ function saveWebhookResource(target, webhookCode, data = {}) {
18298
+ const keys = [
18299
+ 'webhookId',
18300
+ 'endpointId',
18301
+ 'callbackPath',
18302
+ 'callbackUrl',
18303
+ 'targetFunctionCode',
18304
+ 'idempotencyQueryParam',
18305
+ 'maxBodyBytes',
18306
+ 'status',
18307
+ 'revision',
18308
+ ];
18309
+ const callbackPath = String(data?.callbackPath || '').trim();
18310
+ const callbackUrl = callbackPath
18311
+ ? `${String(target.profile?.baseUrl || '').replace(/\/+$/, '')}${callbackPath}`
18312
+ : data?.callbackUrl;
18313
+ saveStateResource(target, 'webhooks', webhookCode, {
18314
+ ...pickStateFields(data, keys),
18315
+ webhookId: data?.id || data?.webhookId,
18316
+ callbackUrl,
18317
+ }, keys);
18318
+ }
18319
+
18188
18320
  function saveScopeResource(target, bucket, code, data = {}) {
18189
18321
  if (!bucket || !code) return;
18190
18322
  const keys = ['id', 'resourceCode', 'name', 'updatedAt'];
@@ -18333,6 +18465,7 @@ const RESOURCE_SPECS = [
18333
18465
  { key: 'functions', dir: 'functions', topFiles: ['functions.json'], pluralKeys: ['functions'] },
18334
18466
  { key: 'dataViews', dir: 'data-views', topFiles: ['data-views.json'], pluralKeys: ['dataViews', 'data-views'] },
18335
18467
  { key: 'storageConfigs', dir: 'storage', topFiles: ['storage.json'], pluralKeys: ['storageConfigs', 'storage'] },
18468
+ { key: 'webhooks', dir: 'webhooks', topFiles: ['webhooks.json'], pluralKeys: ['webhooks'] },
18336
18469
  { key: 'authConfigs', dir: 'auth', topFiles: ['auth.json'], pluralKeys: ['authConfigs', 'auth'] },
18337
18470
  { key: 'routes', dir: 'routes', topFiles: ['routes.json'], pluralKeys: ['routes'] },
18338
18471
  {
@@ -18412,6 +18545,8 @@ const RESOURCE_TYPE_ALIASES = new Map([
18412
18545
  ['oss', 'storageConfigs'],
18413
18546
  ['oss-config', 'storageConfigs'],
18414
18547
  ['oss-configs', 'storageConfigs'],
18548
+ ['webhook', 'webhooks'],
18549
+ ['webhooks', 'webhooks'],
18415
18550
  ['auth', 'authConfigs'],
18416
18551
  ['auth-config', 'authConfigs'],
18417
18552
  ['auth-configs', 'authConfigs'],
@@ -19124,6 +19259,7 @@ function generateResourceTypes(manifest, outputFile) {
19124
19259
  const dataViewCodes = unique((manifest.dataViews || []).map(item => item.code).filter(Boolean));
19125
19260
  const functionCodes = unique((manifest.functions || []).map(item => item.code).filter(Boolean));
19126
19261
  const storageConfigCodes = unique((manifest.storageConfigs || []).map(item => item.code).filter(Boolean));
19262
+ const webhookCodes = unique((manifest.webhooks || []).map(item => item.code).filter(Boolean));
19127
19263
  const authConfigCodes = unique((manifest.authConfigs || []).map(item => item.code).filter(Boolean));
19128
19264
  const publicAccessPolicyCodes = unique((manifest.publicAccessPolicies || []).map(item => item.code).filter(Boolean));
19129
19265
  const scopeDimensionCodes = unique((manifest.scopeDimensions || []).map(item => item.code).filter(Boolean));
@@ -19148,6 +19284,9 @@ function generateResourceTypes(manifest, outputFile) {
19148
19284
  `export const storageConfigCodes = ${JSON.stringify(storageConfigCodes, null, 2)} as const`,
19149
19285
  'export type StorageConfigCode = typeof storageConfigCodes[number]',
19150
19286
  '',
19287
+ `export const webhookCodes = ${JSON.stringify(webhookCodes, null, 2)} as const`,
19288
+ 'export type WebhookCode = typeof webhookCodes[number]',
19289
+ '',
19151
19290
  `export const functionCodes = ${JSON.stringify(functionCodes, null, 2)} as const`,
19152
19291
  'export type FunctionCode = typeof functionCodes[number]',
19153
19292
  '',
@@ -19182,6 +19321,7 @@ function generateResourceTypes(manifest, outputFile) {
19182
19321
  pagePermissionGroups: pagePermissionGroupCodes.length,
19183
19322
  dataViews: dataViewCodes.length,
19184
19323
  storageConfigs: storageConfigCodes.length,
19324
+ webhooks: webhookCodes.length,
19185
19325
  functions: functionCodes.length,
19186
19326
  authConfigs: authConfigCodes.length,
19187
19327
  publicAccessPolicies: publicAccessPolicyCodes.length,
@@ -19259,6 +19399,50 @@ function validateResourceItem(kind, item, errors, warnings) {
19259
19399
  validateFunctionRuntimeInvoke(label, item, errors, warnings);
19260
19400
  validateFunctionPerformancePolicies(label, item, errors);
19261
19401
  }
19402
+ if (kind === 'webhooks') {
19403
+ const allowed = new Set([
19404
+ 'code',
19405
+ 'resourceCode',
19406
+ 'name',
19407
+ 'description',
19408
+ 'targetFunctionCode',
19409
+ 'idempotencyQueryParam',
19410
+ 'maxBodyBytes',
19411
+ 'status',
19412
+ ]);
19413
+ const unsupported = Object.keys(item).filter(
19414
+ key => !key.startsWith('__') && !allowed.has(key)
19415
+ );
19416
+ if (unsupported.length > 0) {
19417
+ errors.push(`${label}: 包含不支持的字段 ${unsupported.join(', ')}`);
19418
+ }
19419
+ if (!item.name) errors.push(`${label}: 缺少 name`);
19420
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(String(item.targetFunctionCode || ''))) {
19421
+ errors.push(`${label}: targetFunctionCode 格式不正确`);
19422
+ }
19423
+ if (
19424
+ item.idempotencyQueryParam !== undefined &&
19425
+ !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(
19426
+ String(item.idempotencyQueryParam)
19427
+ )
19428
+ ) {
19429
+ errors.push(`${label}: idempotencyQueryParam 格式不正确`);
19430
+ }
19431
+ const maxBodyBytes = Number(item.maxBodyBytes || 262144);
19432
+ if (
19433
+ !Number.isInteger(maxBodyBytes) ||
19434
+ maxBodyBytes < 1024 ||
19435
+ maxBodyBytes > 1048576
19436
+ ) {
19437
+ errors.push(`${label}: maxBodyBytes 必须在 1024 到 1048576 之间`);
19438
+ }
19439
+ if (!['active', 'disabled'].includes(String(item.status || 'active'))) {
19440
+ errors.push(`${label}: status 只能是 active 或 disabled`);
19441
+ }
19442
+ if (Object.keys(item).some(key => /secret|signature|hmac/i.test(key))) {
19443
+ errors.push(`${label}: Webhook manifest 不能包含 Secret 或供应商验签配置`);
19444
+ }
19445
+ }
19262
19446
  if (kind === 'dataViews') {
19263
19447
  const definition = item.definition || item;
19264
19448
  const viewType = String(definition.viewType || 'row').toLowerCase();
@@ -20370,6 +20554,7 @@ async function buildResourcePlan(
20370
20554
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
20371
20555
  addPlanActions(actions, 'dataView', manifest.dataViews, existing.dataViews, (item, current) => dataViewEquals(target.bound, item, current));
20372
20556
  addPlanActions(actions, 'storageConfig', manifest.storageConfigs, existing.storageConfigs, (item, current) => storageConfigEquals(item, current));
20557
+ addPlanActions(actions, 'webhook', manifest.webhooks, existing.webhooks, webhookEquals);
20373
20558
  addPlanActions(actions, 'scopeDimension', manifest.scopeDimensions, existing.scopeDimensions, (item, current) => scopeDimensionEquals(target.bound, item, current));
20374
20559
  addPlanActions(actions, 'scopeGrantSource', manifest.scopeGrantSources, existing.scopeGrantSources, (item, current) => scopeGrantSourceEquals(target.bound, item, current));
20375
20560
  addPlanActions(actions, 'dataScopePolicy', manifest.dataScopePolicies, existing.dataScopePolicies, dataScopePolicyEquals);
@@ -22230,6 +22415,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
22230
22415
  await publishDataScopePolicyResources(config, target, manifest.dataScopePolicies || [], result, publishOptions);
22231
22416
  await publishDataViewResources(config, target, manifest.dataViews || [], result, publishOptions);
22232
22417
  await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
22418
+ await publishWebhookResources(config, target, manifest.webhooks || [], result, publishOptions);
22233
22419
  await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
22234
22420
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
22235
22421
  await publishFormPermissionGroupResources(
@@ -22376,6 +22562,7 @@ const GENERIC_RESOURCE_KEY_BY_PLAN_KIND = Object.freeze({
22376
22562
  publicAccessPolicy: 'publicAccessPolicies',
22377
22563
  dataView: 'dataViews',
22378
22564
  storageConfig: 'storageConfigs',
22565
+ webhook: 'webhooks',
22379
22566
  scopeDimension: 'scopeDimensions',
22380
22567
  scopeGrantSource: 'scopeGrantSources',
22381
22568
  dataScopePolicy: 'dataScopePolicies',
@@ -22492,7 +22679,7 @@ function runGenericResourceGitBasePreflight({
22492
22679
  copyProjectState: true,
22493
22680
  },
22494
22681
  ({ buildRoot, buildEnv, tempRoot }) => {
22495
- const isolatedHome = prepareReadonlyGitBasePlanHome(tempRoot);
22682
+ const isolatedHome = prepareReadonlyGitBasePlanHome(tempRoot, buildRoot);
22496
22683
  const childEnv = {
22497
22684
  ...buildEnv,
22498
22685
  HOME: isolatedHome,
@@ -22628,8 +22815,9 @@ function runGenericResourceGitBasePreflight({
22628
22815
  };
22629
22816
  }
22630
22817
 
22631
- function prepareReadonlyGitBasePlanHome(tempRoot) {
22632
- if (!fs.existsSync(CONFIG_FILE)) {
22818
+ function prepareReadonlyGitBasePlanHome(tempRoot, buildRoot) {
22819
+ const configSource = getConfigFile();
22820
+ if (!fs.existsSync(configSource)) {
22633
22821
  const error = new Error(
22634
22822
  'SOURCE_BASE_PLAN_FAILED: 当前 profile 配置不存在,无法执行隔离 Git 基线 plan'
22635
22823
  );
@@ -22637,10 +22825,11 @@ function prepareReadonlyGitBasePlanHome(tempRoot) {
22637
22825
  throw error;
22638
22826
  }
22639
22827
  const home = path.join(tempRoot, 'plan-home');
22640
- const configDir = path.join(home, '.openxiangda');
22828
+ const configDir = path.join(buildRoot, '.openxiangda');
22829
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
22641
22830
  const configFile = path.join(configDir, 'profiles.json');
22642
22831
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
22643
- fs.copyFileSync(CONFIG_FILE, configFile);
22832
+ fs.copyFileSync(configSource, configFile);
22644
22833
  try {
22645
22834
  fs.chmodSync(configFile, 0o400);
22646
22835
  } catch {
@@ -22882,6 +23071,7 @@ async function fetchExistingResourceMaps(config, target, manifest) {
22882
23071
  automations: new Map(),
22883
23072
  dataViews: new Map(),
22884
23073
  storageConfigs: new Map(),
23074
+ webhooks: new Map(),
22885
23075
  scopeDimensions: new Map(),
22886
23076
  scopeGrantSources: new Map(),
22887
23077
  dataScopePolicies: new Map(),
@@ -23108,6 +23298,21 @@ async function fetchExistingResourceMaps(config, target, manifest) {
23108
23298
  });
23109
23299
  }
23110
23300
  }
23301
+ if ((manifest.webhooks || []).length > 0) {
23302
+ const data = await requestWithAuth(
23303
+ config,
23304
+ target.profileName,
23305
+ apiPathWithQuery(
23306
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
23307
+ { page: 1, pageSize: 1000 }
23308
+ )
23309
+ );
23310
+ indexByCode(
23311
+ maps.webhooks,
23312
+ normalizeItems(data),
23313
+ item => item.code || item.resourceCode
23314
+ );
23315
+ }
23111
23316
  if ((manifest.scopeDimensions || []).length > 0) {
23112
23317
  const data = await requestWithAuth(
23113
23318
  config,
@@ -25099,6 +25304,45 @@ async function publishStorageConfigResources(config, target, storageConfigs, res
25099
25304
  }
25100
25305
  }
25101
25306
 
25307
+ async function publishWebhookResources(config, target, webhooks, result, options = {}) {
25308
+ for (const webhookItem of webhooks) {
25309
+ const code = webhookItem.code || webhookItem.resourceCode;
25310
+ if (shouldSkipNoopResource(options, 'webhook', code)) {
25311
+ recordNoopResource(result, 'webhook', code, options);
25312
+ continue;
25313
+ }
25314
+ const existing = await findExistingWebhook(config, target, code);
25315
+ const existingRevision = Number(existing?.revision);
25316
+ const body = {
25317
+ ...normalizeWebhookManifest(webhookItem),
25318
+ expectedRevision:
25319
+ existing && Number.isInteger(existingRevision) && existingRevision > 0
25320
+ ? existingRevision
25321
+ : existing
25322
+ ? undefined
25323
+ : 0,
25324
+ };
25325
+ const data = await requestWithAuth(
25326
+ config,
25327
+ target.profileName,
25328
+ existing
25329
+ ? `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks/${encodeURIComponent(code)}`
25330
+ : `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
25331
+ { method: 'POST', body }
25332
+ );
25333
+ saveWebhookResource(target, code, data);
25334
+ result.published.push({
25335
+ kind: 'webhook',
25336
+ code,
25337
+ action: existing ? 'update' : 'create',
25338
+ id: data?.id,
25339
+ endpointId: data?.endpointId,
25340
+ callbackPath: data?.callbackPath,
25341
+ status: data?.status,
25342
+ });
25343
+ }
25344
+ }
25345
+
25102
25346
  async function publishDataViewPermissionGroups(config, target, dataViewCode, permissionGroups) {
25103
25347
  await requestWithAuth(
25104
25348
  config,
@@ -25263,6 +25507,25 @@ async function findExistingStorageConfig(config, target, code) {
25263
25507
  return null;
25264
25508
  }
25265
25509
 
25510
+ async function findExistingWebhook(config, target, code) {
25511
+ const state = target.bound.resources?.webhooks?.[code];
25512
+ const byCode = await requestOptionalWithAuth(
25513
+ config,
25514
+ target.profileName,
25515
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks/${encodeURIComponent(code)}`
25516
+ );
25517
+ if (byCode?.id) return byCode;
25518
+ if (state?.webhookId) {
25519
+ return {
25520
+ id: state.webhookId,
25521
+ code,
25522
+ revision: state.revision,
25523
+ endpointId: state.endpointId,
25524
+ };
25525
+ }
25526
+ return null;
25527
+ }
25528
+
25266
25529
  async function findExistingAutomation(config, target, code) {
25267
25530
  const stateId = target.bound.resources?.automations?.[code]?.automationId;
25268
25531
  if (stateId) {
@@ -25541,6 +25804,22 @@ async function collectPruneResourceCandidates(config, target, manifest) {
25541
25804
  item => item.code || item.resourceCode
25542
25805
  );
25543
25806
  }
25807
+ if (enabled('webhooks')) {
25808
+ const data = await requestWithAuth(
25809
+ config,
25810
+ target.profileName,
25811
+ apiPathWithQuery(`${appPath}/webhooks`, {
25812
+ page: 1,
25813
+ pageSize: 1000,
25814
+ })
25815
+ );
25816
+ append(
25817
+ 'webhook',
25818
+ desiredCodes(manifest.webhooks),
25819
+ normalizeItems(data),
25820
+ item => item.code || item.resourceCode
25821
+ );
25822
+ }
25544
25823
  if (enabled('publicAccessPolicies')) {
25545
25824
  const data = await requestWithAuth(
25546
25825
  config,
@@ -25731,6 +26010,21 @@ async function pruneFrozenResourceCandidate(config, target, result, candidate) {
25731
26010
  if (kind === 'storageConfig') {
25732
26011
  return postDelete(`${appPath}/storage-configs/${encodeURIComponent(code)}`);
25733
26012
  }
26013
+ if (kind === 'webhook') {
26014
+ return requestWithAuth(
26015
+ config,
26016
+ target.profileName,
26017
+ apiPathWithPostDeleteAction(
26018
+ `${appPath}/webhooks/${encodeURIComponent(code)}`
26019
+ ),
26020
+ {
26021
+ method: 'POST',
26022
+ body: candidate.frozenRevision
26023
+ ? { expectedRevision: candidate.frozenRevision }
26024
+ : {},
26025
+ }
26026
+ );
26027
+ }
25734
26028
  if (kind === 'publicAccessPolicy') {
25735
26029
  return postDelete(`${appPath}/public-access/policies/${encodeURIComponent(code)}`);
25736
26030
  }
@@ -26131,6 +26425,7 @@ function removeStateResource(target, kind, code) {
26131
26425
  publicAccessPolicy: 'publicAccessPolicies',
26132
26426
  dataView: 'dataViews',
26133
26427
  storageConfig: 'storageConfigs',
26428
+ webhook: 'webhooks',
26134
26429
  pagePermissionGroup: 'pagePermissionGroups',
26135
26430
  formPermissionGroup: 'formPermissionGroups',
26136
26431
  };
@@ -26433,6 +26728,22 @@ async function pullResources(config, target) {
26433
26728
  written.push(path.relative(process.cwd(), filePath));
26434
26729
  }
26435
26730
 
26731
+ const webhooks = await requestWithAuth(
26732
+ config,
26733
+ target.profileName,
26734
+ apiPathWithQuery(
26735
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
26736
+ { page: 1, pageSize: 1000 }
26737
+ )
26738
+ );
26739
+ for (const webhookItem of normalizeItems(webhooks)) {
26740
+ const code = webhookItem.code || webhookItem.resourceCode || webhookItem.id;
26741
+ const filePath = path.join(baseDir, 'webhooks', `${code}.json`);
26742
+ writeResourceJsonFile(filePath, toPulledWebhook(webhookItem));
26743
+ written.push(path.relative(process.cwd(), filePath));
26744
+ saveWebhookResource(target, code, webhookItem);
26745
+ }
26746
+
26436
26747
  const pageGroups = await requestWithAuth(
26437
26748
  config,
26438
26749
  target.profileName,
@@ -26706,6 +27017,18 @@ function toPulledStorageConfig(storageConfig) {
26706
27017
  });
26707
27018
  }
26708
27019
 
27020
+ function toPulledWebhook(webhookItem) {
27021
+ return stripUndefinedValues({
27022
+ code: webhookItem.code || webhookItem.resourceCode,
27023
+ name: webhookItem.name,
27024
+ description: webhookItem.description || '',
27025
+ targetFunctionCode: webhookItem.targetFunctionCode,
27026
+ idempotencyQueryParam: webhookItem.idempotencyQueryParam || 'nonce',
27027
+ maxBodyBytes: webhookItem.maxBodyBytes || 262144,
27028
+ status: webhookItem.status || 'active',
27029
+ });
27030
+ }
27031
+
26709
27032
  function rewriteDataViewDefinitionForManifest(definition, lookups) {
26710
27033
  rewriteDataViewSourceForManifest(definition.base, lookups);
26711
27034
  for (const join of definition.joins || []) {
@@ -27158,6 +27481,61 @@ function normalizeStorageConfigManifest(storageConfig) {
27158
27481
  });
27159
27482
  }
27160
27483
 
27484
+ function normalizeWebhookManifest(webhook) {
27485
+ const code = String(webhook.code || webhook.resourceCode || '').trim();
27486
+ const allowed = new Set([
27487
+ 'code',
27488
+ 'resourceCode',
27489
+ 'name',
27490
+ 'description',
27491
+ 'targetFunctionCode',
27492
+ 'idempotencyQueryParam',
27493
+ 'maxBodyBytes',
27494
+ 'status',
27495
+ ]);
27496
+ const unsupported = Object.keys(webhook).filter(
27497
+ key => !key.startsWith('__') && !allowed.has(key)
27498
+ );
27499
+ if (unsupported.length > 0) {
27500
+ fail(`webhook:${code || '<unknown>'} 包含不支持的字段 ${unsupported.join(', ')}`);
27501
+ }
27502
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(code)) {
27503
+ fail('Webhook code 格式不正确');
27504
+ }
27505
+ const targetFunctionCode = String(webhook.targetFunctionCode || '').trim();
27506
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(targetFunctionCode)) {
27507
+ fail(`webhook:${code} targetFunctionCode 格式不正确`);
27508
+ }
27509
+ const idempotencyQueryParam = String(
27510
+ webhook.idempotencyQueryParam || 'nonce'
27511
+ ).trim();
27512
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(idempotencyQueryParam)) {
27513
+ fail(`webhook:${code} idempotencyQueryParam 格式不正确`);
27514
+ }
27515
+ const maxBodyBytes = Number(webhook.maxBodyBytes || 262144);
27516
+ if (
27517
+ !Number.isInteger(maxBodyBytes) ||
27518
+ maxBodyBytes < 1024 ||
27519
+ maxBodyBytes > 1048576
27520
+ ) {
27521
+ fail(`webhook:${code} maxBodyBytes 必须在 1024 到 1048576 之间`);
27522
+ }
27523
+ const status = String(webhook.status || 'active');
27524
+ if (!['active', 'disabled'].includes(status)) {
27525
+ fail(`webhook:${code} status 只能是 active 或 disabled`);
27526
+ }
27527
+ return stripUndefinedValues({
27528
+ code,
27529
+ name: String(webhook.name || code).trim(),
27530
+ description:
27531
+ webhook.description === undefined ? '' : String(webhook.description),
27532
+ targetFunctionCode,
27533
+ idempotencyQueryParam,
27534
+ maxBodyBytes,
27535
+ status,
27536
+ });
27537
+ }
27538
+
27161
27539
  function normalizeStorageCorsForPublish(cors) {
27162
27540
  if (cors === undefined || cors === null || cors === '') return undefined;
27163
27541
  const managed =
@@ -28305,6 +28683,21 @@ function storageConfigEquals(desired, existing) {
28305
28683
  );
28306
28684
  }
28307
28685
 
28686
+ function webhookEquals(desired, existing) {
28687
+ if (!existing) return false;
28688
+ const expected = normalizeWebhookManifest(desired);
28689
+ return (
28690
+ String(existing.code || existing.resourceCode || '') === expected.code &&
28691
+ String(existing.name || '') === expected.name &&
28692
+ String(existing.description || '') === expected.description &&
28693
+ String(existing.targetFunctionCode || '') === expected.targetFunctionCode &&
28694
+ String(existing.idempotencyQueryParam || 'nonce') ===
28695
+ expected.idempotencyQueryParam &&
28696
+ Number(existing.maxBodyBytes || 262144) === expected.maxBodyBytes &&
28697
+ String(existing.status || 'active') === expected.status
28698
+ );
28699
+ }
28700
+
28308
28701
  function routeEquals(desired, existing) {
28309
28702
  if (!existing) return false;
28310
28703
  const expected = normalizeRouteManifest(desired);