openxiangda 1.0.164 → 1.0.166

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 (33) hide show
  1. package/README.md +9 -7
  2. package/lib/change-baseline.js +74 -6
  3. package/lib/cli.js +2508 -167
  4. package/lib/sdd.js +320 -6
  5. package/lib/utils.js +76 -2
  6. package/openxiangda-skills/SKILL.md +13 -6
  7. package/openxiangda-skills/references/pages/publish-flow.md +15 -16
  8. package/openxiangda-skills/references/resource-manifest-cheatsheet.md +24 -2
  9. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +14 -6
  10. package/openxiangda-skills/skills/openxiangda-form/SKILL.md +10 -9
  11. package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +2 -0
  12. package/package.json +2 -1
  13. package/packages/sdk/dist/runtime/index.cjs +164 -48
  14. package/packages/sdk/dist/runtime/index.cjs.map +1 -1
  15. package/packages/sdk/dist/runtime/index.d.mts +1 -1
  16. package/packages/sdk/dist/runtime/index.d.ts +1 -1
  17. package/packages/sdk/dist/runtime/index.mjs +164 -48
  18. package/packages/sdk/dist/runtime/index.mjs.map +1 -1
  19. package/packages/sdk/dist/runtime/react.cjs +164 -48
  20. package/packages/sdk/dist/runtime/react.cjs.map +1 -1
  21. package/packages/sdk/dist/runtime/react.d.mts +79 -1
  22. package/packages/sdk/dist/runtime/react.d.ts +79 -1
  23. package/packages/sdk/dist/runtime/react.mjs +164 -48
  24. package/packages/sdk/dist/runtime/react.mjs.map +1 -1
  25. package/templates/openxiangda-react-spa/.cursor/rules/openxiangda-resources.mdc +1 -0
  26. package/templates/openxiangda-react-spa/.qoder/rules/openxiangda-resources.md +1 -0
  27. package/templates/openxiangda-react-spa/AGENTS.md +12 -10
  28. package/templates/openxiangda-react-spa/scripts/build-js-code.mjs +20 -2
  29. package/templates/openxiangda-react-spa/scripts/deploy.mjs +2 -2
  30. package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda-resources.mdc +2 -0
  31. package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda-resources.md +1 -0
  32. package/templates/sy-lowcode-app-workspace/AGENTS.md +5 -3
  33. package/templates/sy-lowcode-app-workspace/scripts/build-js-code.mjs +20 -2
package/lib/cli.js CHANGED
@@ -114,12 +114,14 @@ const {
114
114
  verifySddChange,
115
115
  } = require('./sdd');
116
116
  const {
117
+ clearSensitiveText,
117
118
  fail,
118
119
  formatFetchError,
119
120
  maskText,
120
121
  openBrowser,
121
122
  parseArgs,
122
123
  print,
124
+ registerSensitiveText,
123
125
  sleep,
124
126
  warn,
125
127
  writeJson,
@@ -130,6 +132,12 @@ const NPM_PACKAGE_NAME = 'openxiangda';
130
132
  const OFFICIAL_NPM_REGISTRY = 'https://registry.npmjs.org';
131
133
  const RUNTIME_UPLOAD_DEFAULT_TIMEOUT_MS = 120000;
132
134
  const RUNTIME_UPLOAD_CONCURRENCY = 3;
135
+ const APP_FUNCTION_SECRET_CAPABILITIES = Object.freeze({
136
+ contractVersion: 'app_function_secrets_v1',
137
+ runtimeContractVersion: 'trusted_node_v2',
138
+ backendRelease: 'backend_release_v2',
139
+ atomicActivation: 'atomic_staged_children_v2',
140
+ });
133
141
  let readOnlyHttpGuardDepth = 0;
134
142
  let readOnlyHttpGuardLabel = null;
135
143
  let activePublishRequestContext = null;
@@ -179,6 +187,7 @@ async function mainImpl(argv) {
179
187
  if (command === 'public-access') return publicAccess(rest);
180
188
  if (command === 'auth-config') return authConfig(rest);
181
189
  if (command === 'function') return appFunction(rest);
190
+ if (command === 'secret') return appSecret(rest);
182
191
  if (command === 'connector') return connector(rest);
183
192
  if (command === 'notification') return notification(rest);
184
193
  if (command === 'organization') return organization(rest);
@@ -256,7 +265,9 @@ Usage:
256
265
  openxiangda route list|get|create|update|upsert|delete [--json-file file] [--dry-run] [--write-manifest]
257
266
  openxiangda public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check [--json-file file]
258
267
  openxiangda auth-config list|get|create|update|upsert|delete|methods [--json-file file]
259
- openxiangda function list|get|create|update|upsert|delete|invoke [--json-file file]
268
+ openxiangda function list|get|create|update|upsert|delete|invoke|test [--json-file file]
269
+ openxiangda secret list|metadata|references|audit|unused [name] --profile <name>
270
+ openxiangda secret create|update|rotate|delete <name> --profile <name> --change <id> [--expected-revision n] [--value-stdin] [--dry-run]
260
271
  openxiangda connector list|get|create|update|upsert|delete|invoke|download-test [--json-file file]
261
272
  openxiangda notification template-list|template-get|template-upsert|template-delete|type-list|type-get|type-upsert|type-delete|preview|send|batch-send
262
273
  openxiangda organization capabilities|department-list|department-get|department-create|department-update|account-list|account-get|account-create|account-update|account-reset-password
@@ -304,10 +315,12 @@ const SUBCOMMAND_BOOLEAN_FLAGS = new Set([
304
315
  '--resources',
305
316
  '--skip-resources',
306
317
  '--show-secret',
318
+ '--stage-only',
307
319
  '--strict',
308
320
  '--sdd-bypass',
309
321
  '--summary',
310
322
  '--unpublish',
323
+ '--value-stdin',
311
324
  '--write-manifest',
312
325
  '--yes',
313
326
  '-h',
@@ -370,6 +383,12 @@ async function version(args) {
370
383
  const result = {
371
384
  name: NPM_PACKAGE_NAME,
372
385
  version: CURRENT_VERSION,
386
+ contracts: {
387
+ appFunctionSecrets: APP_FUNCTION_SECRET_CAPABILITIES.contractVersion,
388
+ trustedNode: APP_FUNCTION_SECRET_CAPABILITIES.runtimeContractVersion,
389
+ backendRelease: APP_FUNCTION_SECRET_CAPABILITIES.backendRelease,
390
+ atomicActivation: APP_FUNCTION_SECRET_CAPABILITIES.atomicActivation,
391
+ },
373
392
  updateCheckCommand: 'openxiangda update check --json',
374
393
  };
375
394
  if (flags.json) return writeJson(result);
@@ -971,6 +990,7 @@ async function release(args) {
971
990
  );
972
991
  }
973
992
  const pendingBaseline = getStoredChangeBaseline(target);
993
+ const newReleaseLifecycle = !stored && !pendingBaseline;
974
994
  const clientSessionId =
975
995
  existing?.clientSessionId ||
976
996
  (pendingBaseline?.changeId === changeId &&
@@ -999,12 +1019,16 @@ async function release(args) {
999
1019
  throw error;
1000
1020
  }
1001
1021
  const finalizedBaseline = finalizeReleaseBaseline(target, baseline);
1022
+ const stagedResourcesFile = newReleaseLifecycle
1023
+ ? initializeStagedAppReleaseResources(target, changeId)
1024
+ : null;
1002
1025
  const result = {
1003
1026
  ...lease,
1004
1027
  baselineId: finalizedBaseline?.baselineId,
1005
1028
  baselineHeadDigest: finalizedBaseline?.headDigest,
1006
1029
  sourceBase: finalizedBaseline?.sourceBase,
1007
1030
  releaseSourceRevision: finalizedBaseline?.releaseSourceRevision,
1031
+ ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
1008
1032
  };
1009
1033
  if (flags.json) return writeJson(result);
1010
1034
  print(
@@ -1543,6 +1567,285 @@ function readStagedAppReleaseResources(flags = {}) {
1543
1567
  );
1544
1568
  }
1545
1569
 
1570
+ function stagedResourcesFileForChange(changeId) {
1571
+ const normalized = String(changeId || '').trim();
1572
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
1573
+ fail('APP_RELEASE_STAGED_RESOURCES_CHANGE_INVALID: 自动聚合 staged resources 需要有效 --change');
1574
+ }
1575
+ return path.join(
1576
+ process.cwd(),
1577
+ '.openxiangda',
1578
+ 'releases',
1579
+ normalized,
1580
+ 'staged-resources.json'
1581
+ );
1582
+ }
1583
+
1584
+ function stagedResourcesContextFileForChange(changeId) {
1585
+ return stagedResourcesFileForChange(changeId).replace(
1586
+ /staged-resources\.json$/,
1587
+ 'staged-resources.context.json'
1588
+ );
1589
+ }
1590
+
1591
+ function currentStagedResourcesContext(target, changeId) {
1592
+ const baseline = getStoredChangeBaseline(target);
1593
+ if (!baseline?.baselineId || baseline.changeId !== changeId) {
1594
+ fail(
1595
+ `APP_RELEASE_STAGED_CONTEXT_REQUIRED: change ${changeId} 缺少当前发布基线`
1596
+ );
1597
+ }
1598
+ return {
1599
+ contractVersion: 'staged_resources_context_v1',
1600
+ appType: target.appType,
1601
+ profile: target.profileName,
1602
+ changeId,
1603
+ baselineId: baseline.baselineId,
1604
+ clientSessionId: baseline.clientSessionId || null,
1605
+ };
1606
+ }
1607
+
1608
+ function writeStagedResourcesFiles(changeId, resources, context) {
1609
+ const file = stagedResourcesFileForChange(changeId);
1610
+ const contextFile = stagedResourcesContextFileForChange(changeId);
1611
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1612
+ for (const [targetFile, value] of [
1613
+ [file, resources],
1614
+ [contextFile, context],
1615
+ ]) {
1616
+ const temporary = `${targetFile}.${process.pid}.tmp`;
1617
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
1618
+ mode: 0o600,
1619
+ });
1620
+ fs.renameSync(temporary, targetFile);
1621
+ }
1622
+ return path.relative(process.cwd(), file).replace(/\\/g, '/');
1623
+ }
1624
+
1625
+ function initializeStagedAppReleaseResources(target, changeId) {
1626
+ const context = currentStagedResourcesContext(target, changeId);
1627
+ return writeStagedResourcesFiles(changeId, [], {
1628
+ ...context,
1629
+ initializedAt: new Date().toISOString(),
1630
+ });
1631
+ }
1632
+
1633
+ function assertStagedResourcesContext(target, changeId) {
1634
+ const expected = currentStagedResourcesContext(target, changeId);
1635
+ const contextFile = stagedResourcesContextFileForChange(changeId);
1636
+ if (!fs.existsSync(contextFile)) {
1637
+ if (!fs.existsSync(stagedResourcesFileForChange(changeId))) {
1638
+ initializeStagedAppReleaseResources(target, changeId);
1639
+ }
1640
+ }
1641
+ if (!fs.existsSync(contextFile)) {
1642
+ fail(
1643
+ 'APP_RELEASE_STAGED_CONTEXT_REQUIRED: staged resources 缺少当前 release begin 生成的 context;请重新暂存本次 change 的 child releases'
1644
+ );
1645
+ }
1646
+ const actual = JSON.parse(fs.readFileSync(contextFile, 'utf8'));
1647
+ for (const field of [
1648
+ 'contractVersion',
1649
+ 'appType',
1650
+ 'profile',
1651
+ 'changeId',
1652
+ 'baselineId',
1653
+ 'clientSessionId',
1654
+ ]) {
1655
+ if ((actual?.[field] ?? null) !== (expected[field] ?? null)) {
1656
+ fail(
1657
+ `APP_RELEASE_STAGED_CONTEXT_MISMATCH: ${field} 不属于当前 change/baseline/session,请重新暂存 child releases`
1658
+ );
1659
+ }
1660
+ }
1661
+ return actual;
1662
+ }
1663
+
1664
+ function recordStagedAppReleaseResources(
1665
+ changeId,
1666
+ resources,
1667
+ target
1668
+ ) {
1669
+ if (!changeId || !Array.isArray(resources) || resources.length === 0) {
1670
+ return null;
1671
+ }
1672
+ const file = stagedResourcesFileForChange(changeId);
1673
+ const context = assertStagedResourcesContext(target, changeId);
1674
+ let existing = [];
1675
+ if (fs.existsSync(file)) {
1676
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1677
+ existing = Array.isArray(parsed) && parsed.length === 0
1678
+ ? []
1679
+ : normalizeStagedAppReleaseResources(parsed);
1680
+ }
1681
+ const incoming = normalizeStagedAppReleaseResources(resources);
1682
+ const merged = new Map();
1683
+ for (const resource of [...existing, ...incoming]) {
1684
+ const described = describeAppReleaseResource(resource, 0, 'stagedResources');
1685
+ merged.set(described.stableKey, resource);
1686
+ }
1687
+ const output = Array.from(merged.values()).sort((left, right) => {
1688
+ const leftKey = describeAppReleaseResource(left, 0, 'stagedResources').stableKey;
1689
+ const rightKey = describeAppReleaseResource(right, 0, 'stagedResources').stableKey;
1690
+ return leftKey.localeCompare(rightKey);
1691
+ });
1692
+ return writeStagedResourcesFiles(changeId, output, context);
1693
+ }
1694
+
1695
+ function exactStringSet(left, right) {
1696
+ const normalize = values =>
1697
+ Array.from(
1698
+ new Set((values || []).map(value => String(value || '').trim()).filter(Boolean))
1699
+ ).sort();
1700
+ const a = normalize(left);
1701
+ const b = normalize(right);
1702
+ return a.length === b.length && a.every((value, index) => value === b[index]);
1703
+ }
1704
+
1705
+ function stagedFormCode(target, resource) {
1706
+ const explicit = String(resource?.metadata?.formCode || '').trim();
1707
+ if (explicit) return explicit;
1708
+ const formUuid = String(resource?.identity?.formUuid || '').trim();
1709
+ for (const bucketName of ['formSettings', 'forms']) {
1710
+ for (const [code, entry] of Object.entries(
1711
+ target.bound.resources?.[bucketName] || {}
1712
+ )) {
1713
+ if (String(entry?.formUuid || '').trim() === formUuid) return code;
1714
+ }
1715
+ }
1716
+ return formUuid;
1717
+ }
1718
+
1719
+ function assertStagedAppReleaseScope(target, flags, resources) {
1720
+ const changeId = readStringFlag(flags, 'change');
1721
+ if (!changeId) {
1722
+ fail(
1723
+ 'APP_RELEASE_STAGED_SCOPE_REQUIRED: staged child activation 必须显式提供 --change'
1724
+ );
1725
+ }
1726
+ assertStagedResourcesContext(target, changeId);
1727
+ let scope;
1728
+ try {
1729
+ scope = getSddChangeScope({
1730
+ cwd: process.cwd(),
1731
+ configText: readWorkspaceConfigText(),
1732
+ changeId,
1733
+ });
1734
+ } catch (error) {
1735
+ if (/SDD 未初始化|change 不存在|ENOENT/i.test(String(error?.message || ''))) {
1736
+ return {
1737
+ changeId,
1738
+ scope: null,
1739
+ resources: normalizeStagedAppReleaseResources(resources),
1740
+ compatibility: 'unmanaged_sdd_scope',
1741
+ };
1742
+ }
1743
+ throw error;
1744
+ }
1745
+ const targets = scope.targets || {};
1746
+ const normalized = normalizeStagedAppReleaseResources(resources);
1747
+ const byKind = kind => normalized.filter(resource => resource.kind === kind);
1748
+ const expectedBackendSelectors = [
1749
+ ...(targets.functions || []).map(code => `function:${code}`),
1750
+ ...(targets.automations || []).map(code => `automation:${code}`),
1751
+ ].sort();
1752
+ const backend = byKind('BackendRelease');
1753
+ if (expectedBackendSelectors.length > 0) {
1754
+ if (
1755
+ backend.length !== 1 ||
1756
+ !exactStringSet(
1757
+ backend[0]?.metadata?.handledSelectors,
1758
+ expectedBackendSelectors
1759
+ )
1760
+ ) {
1761
+ fail(
1762
+ `APP_RELEASE_STAGED_SCOPE_MISMATCH: Backend handledSelectors 必须精确为 ${expectedBackendSelectors.join(',')}`
1763
+ );
1764
+ }
1765
+ } else if (backend.length > 0) {
1766
+ fail('APP_RELEASE_STAGED_SCOPE_MISMATCH: SDD 未授权 BackendRelease child');
1767
+ }
1768
+
1769
+ const expectedForms = (targets.forms || []).map(String).sort();
1770
+ const actualForms = byKind('FormRelease')
1771
+ .map(resource => stagedFormCode(target, resource))
1772
+ .sort();
1773
+ if (!exactStringSet(actualForms, expectedForms)) {
1774
+ fail(
1775
+ `APP_RELEASE_STAGED_SCOPE_MISMATCH: FormRelease 必须精确覆盖 ${expectedForms.join(',') || '(none)'}`
1776
+ );
1777
+ }
1778
+
1779
+ const runtimeCount = byKind('RuntimeRelease').length;
1780
+ if (runtimeCount !== (targets.runtime ? 1 : 0)) {
1781
+ fail(
1782
+ `APP_RELEASE_STAGED_SCOPE_MISMATCH: RuntimeRelease 期望 ${targets.runtime ? 1 : 0} 个,实际 ${runtimeCount} 个`
1783
+ );
1784
+ }
1785
+ const pageCount = byKind('PageRelease').length;
1786
+ const expectsPageRelease = (targets.pages || []).length > 0;
1787
+ if (pageCount !== (expectsPageRelease ? 1 : 0)) {
1788
+ fail(
1789
+ `APP_RELEASE_STAGED_SCOPE_MISMATCH: PageRelease 期望 ${expectsPageRelease ? 1 : 0} 个,实际 ${pageCount} 个`
1790
+ );
1791
+ }
1792
+ return { changeId, scope, resources: normalized };
1793
+ }
1794
+
1795
+ function changedAppReleaseResources(detailResources, captureResources) {
1796
+ const captured = new Map(
1797
+ (captureResources || []).map((resource, index) => {
1798
+ const described = describeAppReleaseResource(
1799
+ resource,
1800
+ index,
1801
+ 'capture.resources'
1802
+ );
1803
+ return [described.stableKey, described];
1804
+ })
1805
+ );
1806
+ return (detailResources || []).filter((resource, index) => {
1807
+ const described = describeAppReleaseResource(
1808
+ resource,
1809
+ index,
1810
+ 'release.resources'
1811
+ );
1812
+ const active = captured.get(described.stableKey);
1813
+ return (
1814
+ !active ||
1815
+ active.releaseId !== described.releaseId ||
1816
+ active.hash !== described.hash
1817
+ );
1818
+ });
1819
+ }
1820
+
1821
+ function assertAppReleaseActualGate(target, flags, scope, command) {
1822
+ assertSddReleaseGate(
1823
+ buildExplicitSddReleasePlan(scope.targets, {
1824
+ files: scope.changedFiles || [],
1825
+ commands: [command],
1826
+ changeId: scope.changeId,
1827
+ actualCommands: true,
1828
+ }),
1829
+ flags,
1830
+ command
1831
+ );
1832
+ }
1833
+
1834
+ function stagedAppReleaseProtocolVersion(stagedResources) {
1835
+ const hasBackendReleaseV2 = (stagedResources || []).some(
1836
+ resource =>
1837
+ resource.kind === 'BackendRelease' &&
1838
+ String(
1839
+ resource.metadata?.protocolVersion ||
1840
+ resource.revision?.protocolVersion ||
1841
+ ''
1842
+ ) === APP_FUNCTION_SECRET_CAPABILITIES.backendRelease
1843
+ );
1844
+ return hasBackendReleaseV2
1845
+ ? APP_FUNCTION_SECRET_CAPABILITIES.atomicActivation
1846
+ : null;
1847
+ }
1848
+
1546
1849
  function overlayAppReleaseResources(capturedResources, stagedResources) {
1547
1850
  if (!Array.isArray(capturedResources)) {
1548
1851
  fail('APP_RELEASE_CAPTURE_INVALID: capture.resources 必须是数组');
@@ -1614,6 +1917,9 @@ async function prepareAppReleaseFromCapture(
1614
1917
  {
1615
1918
  method: 'POST',
1616
1919
  body: appReleaseWriteBody(releaseContext, {
1920
+ ...(flags.__appReleaseProtocolVersion
1921
+ ? { protocolVersion: flags.__appReleaseProtocolVersion }
1922
+ : {}),
1617
1923
  parentReleaseId: capture.parentReleaseId || null,
1618
1924
  resources: clonePlainJson(capture.resources),
1619
1925
  ...(expectedManifestHash ? { expectedManifestHash } : {}),
@@ -1627,7 +1933,8 @@ async function verifyAppReleaseDetail(
1627
1933
  target,
1628
1934
  releaseContext,
1629
1935
  detail,
1630
- resources
1936
+ resources,
1937
+ options = {}
1631
1938
  ) {
1632
1939
  if (!detail?.id || !/^[a-f0-9]{64}$/i.test(String(detail.manifestHash || ''))) {
1633
1940
  fail('APP_RELEASE_MANIFEST_INVALID: App Release 缺少 id 或 manifestHash');
@@ -1642,6 +1949,9 @@ async function verifyAppReleaseDetail(
1642
1949
  {
1643
1950
  method: 'POST',
1644
1951
  body: appReleaseWriteBody(releaseContext, {
1952
+ ...(options.protocolVersion
1953
+ ? { protocolVersion: options.protocolVersion }
1954
+ : {}),
1645
1955
  manifestHash: detail.manifestHash,
1646
1956
  resources: clonePlainJson(resources),
1647
1957
  }),
@@ -1667,9 +1977,12 @@ async function activateAppReleaseDetail(
1667
1977
  method: 'POST',
1668
1978
  body: appReleaseWriteBody(
1669
1979
  releaseContext,
1670
- options.activateStagedChildren === true
1671
- ? { activateStagedChildren: true }
1672
- : {}
1980
+ stripUndefinedValues({
1981
+ ...(options.activateStagedChildren === true
1982
+ ? { activateStagedChildren: true }
1983
+ : {}),
1984
+ protocolVersion: options.protocolVersion,
1985
+ })
1673
1986
  ),
1674
1987
  }
1675
1988
  );
@@ -1723,6 +2036,15 @@ async function runAppReleaseCommand(
1723
2036
  );
1724
2037
  } else if (subcommand === 'app-prepare') {
1725
2038
  const stagedResources = readStagedAppReleaseResources(flags);
2039
+ if (stagedResources) {
2040
+ assertStagedAppReleaseScope(target, flags, stagedResources);
2041
+ }
2042
+ const protocolVersion = stagedAppReleaseProtocolVersion(stagedResources);
2043
+ if (protocolVersion) {
2044
+ assertAppFunctionSecretCapabilities(
2045
+ await fetchAppFunctionSecretCapabilities(config, target)
2046
+ );
2047
+ }
1726
2048
  const releaseContext = await ensureAppReleasePublishContext(
1727
2049
  config,
1728
2050
  target,
@@ -1739,34 +2061,78 @@ async function runAppReleaseCommand(
1739
2061
  target,
1740
2062
  releaseContext,
1741
2063
  { ...capture, resources },
1742
- flags
2064
+ {
2065
+ ...flags,
2066
+ __appReleaseProtocolVersion: protocolVersion,
2067
+ }
1743
2068
  );
1744
2069
  } else if (subcommand === 'app-verify') {
1745
2070
  if (!releaseId) {
1746
2071
  fail('用法: openxiangda release app-verify <releaseId> --change <id>');
1747
2072
  }
2073
+ const detail = await requestWithAuth(
2074
+ config,
2075
+ target.profileName,
2076
+ appReleaseApiPath(target, `/${encodeURIComponent(releaseId)}`)
2077
+ );
2078
+ const detailResources = appReleaseManifestResources(detail);
2079
+ const capture = await fetchAppReleaseCapture(config, target, flags);
2080
+ const changedResources = changedAppReleaseResources(
2081
+ detailResources,
2082
+ capture.resources
2083
+ );
2084
+ if (changedResources.length > 0) {
2085
+ assertStagedAppReleaseScope(target, flags, changedResources);
2086
+ }
2087
+ const protocolVersion = String(detail?.protocolVersion || '');
2088
+ if (protocolVersion === APP_FUNCTION_SECRET_CAPABILITIES.atomicActivation) {
2089
+ assertAppFunctionSecretCapabilities(
2090
+ await fetchAppFunctionSecretCapabilities(config, target)
2091
+ );
2092
+ }
1748
2093
  const releaseContext = await ensureAppReleasePublishContext(
1749
2094
  config,
1750
2095
  target,
1751
2096
  flags,
1752
2097
  'App Release verify'
1753
2098
  );
1754
- const detail = await requestWithAuth(
1755
- config,
1756
- target.profileName,
1757
- appReleaseApiPath(target, `/${encodeURIComponent(releaseId)}`)
1758
- );
1759
2099
  data = await verifyAppReleaseDetail(
1760
2100
  config,
1761
2101
  target,
1762
2102
  releaseContext,
1763
2103
  detail,
1764
- appReleaseManifestResources(detail)
2104
+ detailResources,
2105
+ { protocolVersion: protocolVersion || undefined }
1765
2106
  );
1766
2107
  } else if (subcommand === 'app-activate') {
1767
2108
  if (!releaseId) {
1768
2109
  fail('用法: openxiangda release app-activate <releaseId> --change <id>');
1769
2110
  }
2111
+ const detail = await requestWithAuth(
2112
+ config,
2113
+ target.profileName,
2114
+ appReleaseApiPath(target, `/${encodeURIComponent(releaseId)}`)
2115
+ );
2116
+ const protocolVersion = String(detail?.protocolVersion || '');
2117
+ const atomicV2 =
2118
+ protocolVersion === APP_FUNCTION_SECRET_CAPABILITIES.atomicActivation;
2119
+ const activatesStagedChildren =
2120
+ atomicV2 ||
2121
+ flags['activate-staged-children'] === true ||
2122
+ String(flags['activate-staged-children'] || '').toLowerCase() === 'true';
2123
+ if (activatesStagedChildren) {
2124
+ const capture = await fetchAppReleaseCapture(config, target, flags);
2125
+ const changedResources = changedAppReleaseResources(
2126
+ appReleaseManifestResources(detail),
2127
+ capture.resources
2128
+ );
2129
+ assertStagedAppReleaseScope(target, flags, changedResources);
2130
+ }
2131
+ if (atomicV2) {
2132
+ assertAppFunctionSecretCapabilities(
2133
+ await fetchAppFunctionSecretCapabilities(config, target)
2134
+ );
2135
+ }
1770
2136
  const releaseContext = await ensureAppReleasePublishContext(
1771
2137
  config,
1772
2138
  target,
@@ -1780,13 +2146,36 @@ async function runAppReleaseCommand(
1780
2146
  releaseId,
1781
2147
  {
1782
2148
  activateStagedChildren:
1783
- flags['activate-staged-children'] === true ||
1784
- String(flags['activate-staged-children'] || '').toLowerCase() ===
1785
- 'true',
2149
+ activatesStagedChildren,
2150
+ protocolVersion: protocolVersion || undefined,
1786
2151
  }
1787
2152
  );
1788
2153
  } else if (subcommand === 'app-finalize') {
1789
2154
  const stagedResources = readStagedAppReleaseResources(flags);
2155
+ let stagedScope = null;
2156
+ if (stagedResources) {
2157
+ stagedScope = assertStagedAppReleaseScope(
2158
+ target,
2159
+ flags,
2160
+ stagedResources
2161
+ );
2162
+ if (stagedScope.scope) {
2163
+ assertAppReleaseActualGate(
2164
+ target,
2165
+ flags,
2166
+ stagedScope.scope,
2167
+ `openxiangda release app-finalize --staged-resources-json ${String(
2168
+ flags['staged-resources-json']
2169
+ )} --profile ${target.profileName} --change ${stagedScope.changeId}`
2170
+ );
2171
+ }
2172
+ }
2173
+ const protocolVersion = stagedAppReleaseProtocolVersion(stagedResources);
2174
+ if (protocolVersion) {
2175
+ assertAppFunctionSecretCapabilities(
2176
+ await fetchAppFunctionSecretCapabilities(config, target)
2177
+ );
2178
+ }
1790
2179
  const releaseContext = await ensureAppReleasePublishContext(
1791
2180
  config,
1792
2181
  target,
@@ -1803,37 +2192,56 @@ async function runAppReleaseCommand(
1803
2192
  sourceRepositoryId: releaseContext.sourceRepositoryId,
1804
2193
  sourceRevision: releaseContext.sourceRevision,
1805
2194
  resources: frozenResources,
2195
+ ...(protocolVersion ? { protocolVersion } : {}),
1806
2196
  });
1807
2197
  const prepared = await prepareAppReleaseFromCapture(
1808
2198
  config,
1809
2199
  target,
1810
2200
  releaseContext,
1811
2201
  { ...capture, resources: frozenResources },
1812
- flags
2202
+ {
2203
+ ...flags,
2204
+ __appReleaseProtocolVersion: protocolVersion,
2205
+ }
1813
2206
  );
1814
2207
  const verified = await verifyAppReleaseDetail(
1815
2208
  config,
1816
2209
  target,
1817
2210
  releaseContext,
1818
2211
  prepared,
1819
- frozenResources
2212
+ frozenResources,
2213
+ { protocolVersion }
1820
2214
  );
1821
2215
  const activated = await activateAppReleaseDetail(
1822
2216
  config,
1823
2217
  target,
1824
2218
  releaseContext,
1825
2219
  verified?.id || prepared.id,
1826
- { activateStagedChildren: Boolean(stagedResources) }
2220
+ {
2221
+ activateStagedChildren: Boolean(stagedResources),
2222
+ protocolVersion,
2223
+ }
1827
2224
  );
2225
+ let stateReconcileWarning = null;
2226
+ try {
2227
+ const appHead = await loadRuntimeHeadApp(config, target);
2228
+ saveRuntimeHeadState(target, appHead);
2229
+ } catch (error) {
2230
+ stateReconcileWarning = {
2231
+ component: 'RuntimeStateReadback',
2232
+ message: error?.message || String(error),
2233
+ };
2234
+ }
1828
2235
  data = {
1829
2236
  activationModel: stagedResources
1830
- ? 'atomic_staged_children_v1'
2237
+ ? protocolVersion || 'atomic_staged_children_v1'
1831
2238
  : 'retrospective_active_children',
1832
2239
  captureHash: capture.captureHash,
1833
2240
  payloadHash,
1834
2241
  prepared,
1835
2242
  verified,
1836
2243
  activated,
2244
+ ...(stateReconcileWarning ? { stateReconcileWarning } : {}),
1837
2245
  };
1838
2246
  } else if (subcommand === 'app-rollback') {
1839
2247
  if (!releaseId) {
@@ -3873,7 +4281,19 @@ async function workspace(args) {
3873
4281
  if (publishOptions.dryRun) {
3874
4282
  printWorkspaceReleasePlan(sddPlan);
3875
4283
  } else {
3876
- assertSddReleaseGate(sddPlan, flags, 'openxiangda workspace publish');
4284
+ const actualSddPlan = buildExplicitSddReleasePlan(sddPlan.targets, {
4285
+ files: sddPlan.changedFiles || [],
4286
+ commands: [
4287
+ buildWorkspacePublishCommand(flags, profileName),
4288
+ ],
4289
+ changeId: publishOptions.changeId,
4290
+ actualCommands: true,
4291
+ });
4292
+ assertSddReleaseGate(
4293
+ actualSddPlan,
4294
+ flags,
4295
+ 'openxiangda workspace publish'
4296
+ );
3877
4297
  }
3878
4298
  await requestWithAuth(
3879
4299
  config,
@@ -6042,9 +6462,17 @@ async function appFunction(args) {
6042
6462
  const { subcommand, rest } = parseSubcommandArgs(args);
6043
6463
  const { flags, positional } = parseArgs(rest);
6044
6464
  if (wantsSubcommandHelp(subcommand, flags)) {
6045
- print('用法: openxiangda function list|get|create|update|upsert|delete|invoke [functionCode] [--json-file file]');
6465
+ print([
6466
+ '用法: openxiangda function list|get|create|update|upsert|delete|invoke [functionCode] [--json-file file]',
6467
+ ' openxiangda function test <functionCode> [--input-json JSON|file] [--secret-from-env logical=ENV] [--json]',
6468
+ '说明: function test 只把父进程环境变量中的值通过 stdin 交给隔离测试子进程;不会写入 workspace/cache/state。',
6469
+ ].join('\n'));
6046
6470
  return;
6047
6471
  }
6472
+ if (subcommand === 'test') {
6473
+ return await testAppFunctionLocally(positional, flags);
6474
+ }
6475
+
6048
6476
  const config = loadConfig();
6049
6477
  const target = getWorkspaceTarget(config, flags.profile || config.currentProfile, flags);
6050
6478
 
@@ -6093,6 +6521,11 @@ async function appFunction(args) {
6093
6521
  basePath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions`,
6094
6522
  manifestDir: path.join('src', 'resources', 'functions'),
6095
6523
  prepareBody: async body => {
6524
+ if (functionManifestDeclaresSecretRefs(body)) {
6525
+ fail(
6526
+ 'APP_FUNCTION_SECRET_STAGED_RELEASE_REQUIRED: 带 secretRefs 的 Function 必须使用 resource publish + Backend Release v2,禁止 direct CRUD 绕过原子绑定'
6527
+ );
6528
+ }
6096
6529
  const definitionJson = await resolveManifestJson(
6097
6530
  config,
6098
6531
  target.profileName,
@@ -6100,6 +6533,11 @@ async function appFunction(args) {
6100
6533
  'definitionJson',
6101
6534
  'definitionFile'
6102
6535
  );
6536
+ if (definitionJson?.secretRefs !== undefined) {
6537
+ fail(
6538
+ 'APP_FUNCTION_SECRET_STAGED_RELEASE_REQUIRED: secretRefs 必须位于 Function manifest 顶层并通过 Backend Release v2 发布,禁止写入 definitionJson 或 direct CRUD'
6539
+ );
6540
+ }
6103
6541
  applyResourceBindingsToRuntimeDefinition(target.bound, body, definitionJson);
6104
6542
  const code = body.code || body.functionCode || body.resourceCode;
6105
6543
  return stripUndefinedValues({
@@ -6120,67 +6558,1042 @@ async function appFunction(args) {
6120
6558
  });
6121
6559
  }
6122
6560
 
6123
- async function connector(args) {
6124
- const { subcommand, rest } = parseSubcommandArgs(args);
6125
- const { flags, positional } = parseArgs(rest);
6126
- if (wantsSubcommandHelp(subcommand, flags)) {
6127
- print('用法: openxiangda connector list|get|create|update|upsert|delete|invoke|download-test [connectorCode[.apiCode]] [--json-file file]');
6128
- return;
6129
- }
6130
- const config = loadConfig();
6131
- const target = getWorkspaceTarget(config, flags.profile || config.currentProfile, flags);
6132
-
6133
- if (subcommand === 'invoke' || subcommand === 'download-test') {
6134
- const targetName = positional[0] || flags.connector;
6135
- if (!targetName) fail(`用法: openxiangda connector ${subcommand} <connectorCode.apiCode> [--body-json file|json]`);
6136
- const parsed = parseConnectorApiName(targetName, flags);
6137
- const body = {
6138
- connector: parsed.connector,
6139
- api: parsed.api,
6140
- ...readDirectJsonBody(flags, 'connector invoke', { optional: true }),
6141
- ...(flags['path-params-json'] ? { pathParams: readJsonArg(flags['path-params-json'], 'path-params-json') } : {}),
6142
- ...(flags['query-json'] ? { query: readJsonArg(flags['query-json'], 'query-json') } : {}),
6143
- ...(flags['headers-json'] ? { headers: readJsonArg(flags['headers-json'], 'headers-json') } : {}),
6144
- ...(flags['request-body-type'] ? { requestBodyType: flags['request-body-type'] } : {}),
6145
- ...(flags['response-type'] ? { responseType: flags['response-type'] } : {}),
6146
- };
6147
- if (subcommand === 'download-test') body.responseType = 'binary';
6148
- return runDirectRequest(config, target, flags, {
6149
- method: 'POST',
6150
- path: `/${encodeURIComponent(target.appType)}/v1/connectors/actions/${subcommand === 'download-test' ? 'download' : 'invoke'}`,
6151
- body,
6152
- strictEnvelope: subcommand !== 'download-test',
6153
- }, { controlPlaneMutation: false });
6154
- }
6561
+ function appSecretApiPath(target, suffix = '') {
6562
+ return `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/secrets${suffix}`;
6563
+ }
6155
6564
 
6156
- return directResourceCrud(config, target, {
6157
- commandName: 'connector',
6158
- subcommand,
6159
- flags,
6160
- positional,
6161
- resourceType: 'connector',
6162
- basePath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/connectors`,
6163
- manifestDir: path.join('src', 'resources', 'connectors'),
6164
- createMethod: 'POST',
6165
- createPath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/connectors/actions/sync`,
6166
- createBody: body => ({ connectors: [normalizeConnectorManifest(body)] }),
6167
- updateMethod: 'POST',
6168
- updatePath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/connectors/actions/sync`,
6169
- updateBody: body => ({ connectors: [normalizeConnectorManifest(body)] }),
6170
- normalizeBody: body => normalizeConnectorManifest(body),
6171
- codeOf: body => body.code || body.methodName,
6172
- responseData: data => Array.isArray(data?.data) ? data.data[0] : data,
6173
- saveState: (code, data) => saveConnectorResource(target, code, data?.connector?.id || data?.id, {
6174
- apis: Object.fromEntries(
6175
- (data?.apis || []).map(api => [api.code || api.methodName, { apiId: api.id, name: api.name }])
6176
- ),
6177
- }),
6178
- });
6565
+ function manifestUsesAppFunctionSecrets(manifest) {
6566
+ return (manifest?.functions || []).some(
6567
+ item => functionManifestDeclaresSecretRefs(item)
6568
+ );
6179
6569
  }
6180
6570
 
6181
- async function notification(args) {
6182
- const { subcommand, rest } = parseSubcommandArgs(args);
6183
- const { flags, positional } = parseArgs(rest);
6571
+ function collectCapabilityNames(value) {
6572
+ const names = new Set();
6573
+ const visit = item => {
6574
+ if (!item) return;
6575
+ if (typeof item === 'string') {
6576
+ names.add(item.trim().toLowerCase());
6577
+ return;
6578
+ }
6579
+ if (Array.isArray(item)) {
6580
+ item.forEach(visit);
6581
+ return;
6582
+ }
6583
+ if (typeof item === 'object') {
6584
+ for (const [key, enabled] of Object.entries(item)) {
6585
+ if (enabled === true) names.add(String(key).trim().toLowerCase());
6586
+ }
6587
+ }
6588
+ };
6589
+ visit(value?.capabilities);
6590
+ visit(value?.features);
6591
+ visit(value?.contracts);
6592
+ return names;
6593
+ }
6594
+
6595
+ function appFunctionSecretCapabilityEnabled(capabilities, name) {
6596
+ const normalized = String(name || '').toLowerCase();
6597
+ const camel = normalized.replace(/_([a-z])/g, (_match, char) => char.toUpperCase());
6598
+ return (
6599
+ capabilities?.[normalized] === true ||
6600
+ capabilities?.[camel] === true ||
6601
+ capabilities?.capabilities?.[normalized] === true ||
6602
+ capabilities?.features?.[normalized] === true ||
6603
+ collectCapabilityNames(capabilities).has(normalized)
6604
+ );
6605
+ }
6606
+
6607
+ async function fetchAppFunctionSecretCapabilities(config, target) {
6608
+ return await requestWithAuth(
6609
+ config,
6610
+ target.profileName,
6611
+ appSecretApiPath(target, '/capabilities')
6612
+ );
6613
+ }
6614
+
6615
+ function assertAppFunctionSecretCapabilities(capabilities) {
6616
+ const managementReady =
6617
+ capabilities?.enabled === true &&
6618
+ String(capabilities?.contractVersion || '') ===
6619
+ APP_FUNCTION_SECRET_CAPABILITIES.contractVersion &&
6620
+ String(capabilities?.runtimeContractVersion || '') ===
6621
+ APP_FUNCTION_SECRET_CAPABILITIES.runtimeContractVersion;
6622
+ const missing = [];
6623
+ if (!managementReady) missing.push('app_function_secrets_v1/trusted_node_v2');
6624
+ for (const feature of [
6625
+ 'app_function_secrets_v1',
6626
+ 'trusted_node_v2',
6627
+ APP_FUNCTION_SECRET_CAPABILITIES.backendRelease,
6628
+ APP_FUNCTION_SECRET_CAPABILITIES.atomicActivation,
6629
+ ]) {
6630
+ if (!appFunctionSecretCapabilityEnabled(capabilities, feature)) {
6631
+ missing.push(feature);
6632
+ }
6633
+ }
6634
+ if (missing.length > 0) {
6635
+ const error = new Error(
6636
+ `APP_FUNCTION_SECRET_CAPABILITY_REQUIRED: 平台尚未完整启用 ${unique(missing).join(', ')};禁止忽略 secretRefs 或降级发布`
6637
+ );
6638
+ error.code = 'APP_FUNCTION_SECRET_CAPABILITY_REQUIRED';
6639
+ error.missingCapabilities = unique(missing);
6640
+ throw error;
6641
+ }
6642
+ return capabilities;
6643
+ }
6644
+
6645
+ async function assertManifestAppFunctionSecretCapabilities(
6646
+ config,
6647
+ target,
6648
+ manifest
6649
+ ) {
6650
+ if (!manifestUsesAppFunctionSecrets(manifest)) return null;
6651
+ try {
6652
+ return assertAppFunctionSecretCapabilities(
6653
+ await fetchAppFunctionSecretCapabilities(config, target)
6654
+ );
6655
+ } catch (error) {
6656
+ if (isHttpNotFound(error)) {
6657
+ const missing = new Error(
6658
+ 'APP_FUNCTION_SECRET_CAPABILITY_REQUIRED: 当前平台没有 App Function Secret capability endpoint;禁止忽略 secretRefs 或按旧 Function 协议发布'
6659
+ );
6660
+ missing.code = 'APP_FUNCTION_SECRET_CAPABILITY_REQUIRED';
6661
+ throw missing;
6662
+ }
6663
+ throw error;
6664
+ }
6665
+ }
6666
+
6667
+ const SECRET_RESPONSE_FORBIDDEN_KEYS = new Set([
6668
+ 'value',
6669
+ 'secretvalue',
6670
+ 'plaintext',
6671
+ 'ciphertext',
6672
+ 'cipher',
6673
+ 'encryptedvalue',
6674
+ 'encrypteddek',
6675
+ 'wrappeddek',
6676
+ 'valuehash',
6677
+ 'cipherhash',
6678
+ 'valuedigest',
6679
+ 'cipherdigest',
6680
+ 'valuelength',
6681
+ 'cipherlength',
6682
+ 'plaintextlength',
6683
+ 'iv',
6684
+ 'nonce',
6685
+ 'authtag',
6686
+ 'tag',
6687
+ ]);
6688
+
6689
+ function assertSecretMetadataOnly(value, pathParts = []) {
6690
+ if (!value || typeof value !== 'object') return;
6691
+ if (Array.isArray(value)) {
6692
+ value.forEach((item, index) =>
6693
+ assertSecretMetadataOnly(item, [...pathParts, String(index)])
6694
+ );
6695
+ return;
6696
+ }
6697
+ for (const [key, child] of Object.entries(value)) {
6698
+ const normalizedKey = key.replace(/[_-]/g, '').toLowerCase();
6699
+ if (SECRET_RESPONSE_FORBIDDEN_KEYS.has(normalizedKey)) {
6700
+ const error = new Error(
6701
+ `APP_SECRET_RESPONSE_CONTRACT_VIOLATION: Secret API 返回了禁止的明文/密文派生字段 ${[...pathParts, key].join('.')}`
6702
+ );
6703
+ error.code = 'APP_SECRET_RESPONSE_CONTRACT_VIOLATION';
6704
+ throw error;
6705
+ }
6706
+ assertSecretMetadataOnly(child, [...pathParts, key]);
6707
+ }
6708
+ }
6709
+
6710
+ function outputSecretMetadataOnly(value, flags = {}) {
6711
+ assertSecretMetadataOnly(value);
6712
+ if (flags.json) return writeJson(value);
6713
+ print(JSON.stringify(value, null, 2));
6714
+ }
6715
+
6716
+ function requireExplicitProfile(flags, label) {
6717
+ const profile = String(flags.profile || '').trim();
6718
+ if (!profile) fail(`${label} 必须显式提供 --profile <name>`);
6719
+ return profile;
6720
+ }
6721
+
6722
+ function resolveExactSecretName(positional, flags, label) {
6723
+ const selectors = [
6724
+ ...(positional || []),
6725
+ ...splitList(flags.only),
6726
+ ...splitList(flags.code),
6727
+ ]
6728
+ .map(value => String(value || '').trim())
6729
+ .filter(Boolean);
6730
+ const names = unique(selectors);
6731
+ if (names.length !== 1) {
6732
+ fail(`${label} 必须通过位置参数、--only 或 --code 精确选择一个 Secret name`);
6733
+ }
6734
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(names[0])) {
6735
+ fail(`${label}: Secret name 只能包含字母、数字、点、下划线和中划线,并以字母开头`);
6736
+ }
6737
+ return names[0];
6738
+ }
6739
+
6740
+ function rejectUnsafeSecretValueFlags(flags) {
6741
+ const forbidden = [
6742
+ 'value',
6743
+ 'value-file',
6744
+ 'secret',
6745
+ 'secret-file',
6746
+ 'json-file',
6747
+ 'body-json',
6748
+ ].filter(key => flags[key] !== undefined);
6749
+ if (forbidden.length > 0) {
6750
+ fail(
6751
+ `Secret 明文禁止出现在命令行或文件参数中: ${forbidden
6752
+ .map(key => `--${key}`)
6753
+ .join(', ')};请使用隐藏 TTY 输入或 --value-stdin`
6754
+ );
6755
+ }
6756
+ }
6757
+
6758
+ function readSecretValueFromStdin() {
6759
+ const input = fs.readFileSync(0, 'utf8');
6760
+ return input.replace(/\r?\n$/, '');
6761
+ }
6762
+
6763
+ async function readHiddenSecretValue(promptText = 'Secret value: ') {
6764
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
6765
+ fail('非交互环境必须使用 --value-stdin;不会从参数或 workspace 文件读取 Secret');
6766
+ }
6767
+ return await new Promise((resolve, reject) => {
6768
+ const input = process.stdin;
6769
+ const previousRaw = Boolean(input.isRaw);
6770
+ let value = '';
6771
+ const cleanup = () => {
6772
+ input.off('data', onData);
6773
+ try {
6774
+ input.setRawMode(previousRaw);
6775
+ } catch {
6776
+ // The TTY may have closed while handling Ctrl-C.
6777
+ }
6778
+ input.pause();
6779
+ };
6780
+ const onData = chunk => {
6781
+ const textValue = String(chunk);
6782
+ for (const char of textValue) {
6783
+ if (char === '\u0003') {
6784
+ cleanup();
6785
+ process.stderr.write('\n');
6786
+ reject(new Error('Secret 输入已取消'));
6787
+ return;
6788
+ }
6789
+ if (char === '\r' || char === '\n') {
6790
+ cleanup();
6791
+ process.stderr.write('\n');
6792
+ resolve(value);
6793
+ return;
6794
+ }
6795
+ if (char === '\u007f' || char === '\b') {
6796
+ value = value.slice(0, -1);
6797
+ continue;
6798
+ }
6799
+ value += char;
6800
+ }
6801
+ };
6802
+ process.stderr.write(promptText);
6803
+ input.setEncoding('utf8');
6804
+ input.setRawMode(true);
6805
+ input.resume();
6806
+ input.on('data', onData);
6807
+ });
6808
+ }
6809
+
6810
+ async function readSecretValue(flags, label) {
6811
+ rejectUnsafeSecretValueFlags(flags);
6812
+ const value = flags['value-stdin']
6813
+ ? readSecretValueFromStdin()
6814
+ : await readHiddenSecretValue(`${label} value: `);
6815
+ if (!value) fail(`${label}: Secret value 不能为空`);
6816
+ if (Buffer.byteLength(value, 'utf8') > 64 * 1024) {
6817
+ fail(`${label}: Secret value 不能超过 64 KiB`);
6818
+ }
6819
+ registerSensitiveText(value);
6820
+ return value;
6821
+ }
6822
+
6823
+ function readSecretExpectedRevision(flags, label, options = {}) {
6824
+ const raw = readStringFlag(flags, 'expected-revision');
6825
+ if (options.create && (raw === '' || raw === undefined)) return 0;
6826
+ if (raw === '' || raw === undefined || !/^\d+$/.test(String(raw))) {
6827
+ fail(`${label} 必须提供 --expected-revision <非负整数>`);
6828
+ }
6829
+ const revision = Number(raw);
6830
+ if (!Number.isSafeInteger(revision) || revision < 0) {
6831
+ fail(`${label}: --expected-revision 必须是非负安全整数`);
6832
+ }
6833
+ if (options.create && revision !== 0) {
6834
+ fail(`${label}: create 的 --expected-revision 必须是 0`);
6835
+ }
6836
+ return revision;
6837
+ }
6838
+
6839
+ function secretMutationControl(context) {
6840
+ return {
6841
+ publishLeaseId: context.publishLeaseId,
6842
+ baselineId: context.changeBaselineId,
6843
+ changeId: context.changeId,
6844
+ clientSessionId: context.clientSessionId,
6845
+ };
6846
+ }
6847
+
6848
+ function secretClientOperationId(
6849
+ target,
6850
+ control,
6851
+ name,
6852
+ action,
6853
+ expectedRevision,
6854
+ flags = {}
6855
+ ) {
6856
+ const explicit = String(
6857
+ flags['client-operation-id'] || flags['idempotency-key'] || ''
6858
+ ).trim();
6859
+ if (explicit) return explicit;
6860
+ return `openxiangda-secret-${canonicalJsonSha256({
6861
+ profile: target.profileName,
6862
+ appType: target.appType,
6863
+ name,
6864
+ action,
6865
+ changeId: control.changeId,
6866
+ clientSessionId: control.clientSessionId,
6867
+ expectedRevision,
6868
+ })}`;
6869
+ }
6870
+
6871
+ async function buildSecretDryRun(config, target, subcommand, name, flags) {
6872
+ return await withReadOnlyHttpGuard('secret --dry-run', async () => {
6873
+ const capabilities = assertAppFunctionSecretCapabilities(
6874
+ await fetchAppFunctionSecretCapabilities(config, target)
6875
+ );
6876
+ const current = name
6877
+ ? await requestOptionalWithAuth(
6878
+ config,
6879
+ target.profileName,
6880
+ appSecretApiPath(target, `/${encodeURIComponent(name)}`)
6881
+ )
6882
+ : null;
6883
+ const result = {
6884
+ dryRun: true,
6885
+ command: `secret ${subcommand}`,
6886
+ profile: target.profileName,
6887
+ appType: target.appType,
6888
+ name,
6889
+ changeId: readStringFlag(flags, 'change'),
6890
+ expectedRevision:
6891
+ subcommand === 'create'
6892
+ ? readSecretExpectedRevision(flags, 'secret create', { create: true })
6893
+ : readSecretExpectedRevision(flags, `secret ${subcommand}`),
6894
+ current,
6895
+ valueRead: false,
6896
+ writes: 0,
6897
+ capabilities: {
6898
+ contractVersion: capabilities.contractVersion,
6899
+ runtimeContractVersion: capabilities.runtimeContractVersion,
6900
+ backend_release_v2: true,
6901
+ atomic_staged_children_v2: true,
6902
+ },
6903
+ };
6904
+ assertSecretMetadataOnly(result);
6905
+ return result;
6906
+ });
6907
+ }
6908
+
6909
+ async function appSecret(args) {
6910
+ const { subcommand, rest } = parseSubcommandArgs(args);
6911
+ const { flags, positional } = parseArgs(rest);
6912
+ if (wantsSubcommandHelp(subcommand, flags)) {
6913
+ print([
6914
+ '用法: openxiangda secret list|unused --profile <name> [--json]',
6915
+ ' openxiangda secret metadata|references|audit <name> --profile <name> [--json]',
6916
+ ' openxiangda secret create <name> --profile <name> --change <id> [--value-stdin] [--description text] [--expires-at ISO] [--dry-run]',
6917
+ ' openxiangda secret update <name> --profile <name> --change <id> --expected-revision <n> [--description text] [--status active|disabled] [--expires-at ISO] [--dry-run]',
6918
+ ' openxiangda secret rotate <name> --profile <name> --change <id> --expected-revision <n> [--value-stdin] [--reason text] [--dry-run]',
6919
+ ' openxiangda secret delete <name> --profile <name> --change <id> --expected-revision <n> --reason text [--dry-run]',
6920
+ 'Secret 明文只接受隐藏 TTY 或 stdin;禁止 --value、文件、workspace/state 和明文回读。',
6921
+ ].join('\n'));
6922
+ return;
6923
+ }
6924
+
6925
+ const profileName = requireExplicitProfile(flags, `secret ${subcommand || ''}`);
6926
+ const config = loadConfig();
6927
+ const target = getWorkspaceTarget(config, profileName, flags);
6928
+ const readOnlyCommands = new Set([
6929
+ 'list',
6930
+ 'metadata',
6931
+ 'get',
6932
+ 'references',
6933
+ 'audit',
6934
+ 'unused',
6935
+ ]);
6936
+ if (readOnlyCommands.has(subcommand)) {
6937
+ assertAppFunctionSecretCapabilities(
6938
+ await fetchAppFunctionSecretCapabilities(config, target)
6939
+ );
6940
+ let pathSuffix = '';
6941
+ if (subcommand === 'unused') pathSuffix = '/unused';
6942
+ else if (subcommand !== 'list') {
6943
+ const name = resolveExactSecretName(positional, flags, `secret ${subcommand}`);
6944
+ pathSuffix = `/${encodeURIComponent(name)}`;
6945
+ if (subcommand === 'references' || subcommand === 'audit') {
6946
+ pathSuffix += `/${subcommand}`;
6947
+ }
6948
+ }
6949
+ const data = await requestWithAuth(
6950
+ config,
6951
+ target.profileName,
6952
+ appSecretApiPath(target, pathSuffix)
6953
+ );
6954
+ return outputSecretMetadataOnly(data, flags);
6955
+ }
6956
+
6957
+ if (!['create', 'update', 'rotate', 'delete'].includes(subcommand)) {
6958
+ fail('用法: openxiangda secret list|metadata|references|audit|unused|create|update|rotate|delete');
6959
+ }
6960
+ requireExplicitProfile(flags, `secret ${subcommand}`);
6961
+ const changeId = String(readStringFlag(flags, 'change') || '').trim();
6962
+ if (!changeId) fail(`secret ${subcommand} 必须提供 --change <id>`);
6963
+ const name = resolveExactSecretName(positional, flags, `secret ${subcommand}`);
6964
+ rejectUnsafeSecretValueFlags(flags);
6965
+ if (flags['dry-run']) {
6966
+ const result = await buildSecretDryRun(
6967
+ config,
6968
+ target,
6969
+ subcommand,
6970
+ name,
6971
+ flags
6972
+ );
6973
+ return outputSecretMetadataOnly(result, flags);
6974
+ }
6975
+
6976
+ assertAppFunctionSecretCapabilities(
6977
+ await fetchAppFunctionSecretCapabilities(config, target)
6978
+ );
6979
+ const context = await ensureDirectMutationPublishContext(
6980
+ config,
6981
+ target,
6982
+ flags,
6983
+ `secret ${subcommand}`
6984
+ );
6985
+ const control = secretMutationControl(context);
6986
+ try {
6987
+ let method;
6988
+ let pathSuffix;
6989
+ let body;
6990
+ if (subcommand === 'create') {
6991
+ const expectedRevision = readSecretExpectedRevision(
6992
+ flags,
6993
+ 'secret create',
6994
+ { create: true }
6995
+ );
6996
+ const value = await readSecretValue(flags, 'secret create');
6997
+ method = 'POST';
6998
+ pathSuffix = '';
6999
+ body = stripUndefinedValues({
7000
+ ...control,
7001
+ name,
7002
+ value,
7003
+ description: flags.description,
7004
+ expiresAt: flags['expires-at'],
7005
+ expectedRevision,
7006
+ idempotencyKey: secretClientOperationId(
7007
+ target,
7008
+ control,
7009
+ name,
7010
+ 'create',
7011
+ expectedRevision,
7012
+ flags
7013
+ ),
7014
+ });
7015
+ } else if (subcommand === 'update') {
7016
+ method = 'PATCH';
7017
+ pathSuffix = `/${encodeURIComponent(name)}`;
7018
+ body = stripUndefinedValues({
7019
+ ...control,
7020
+ description: flags.description,
7021
+ status: flags.status,
7022
+ expiresAt: flags['expires-at'],
7023
+ expectedRevision: readSecretExpectedRevision(flags, 'secret update'),
7024
+ });
7025
+ if (
7026
+ body.description === undefined &&
7027
+ body.status === undefined &&
7028
+ body.expiresAt === undefined
7029
+ ) {
7030
+ fail('secret update 至少需要 --description、--status 或 --expires-at');
7031
+ }
7032
+ } else if (subcommand === 'rotate') {
7033
+ const expectedRevision = readSecretExpectedRevision(
7034
+ flags,
7035
+ 'secret rotate'
7036
+ );
7037
+ const value = await readSecretValue(flags, 'secret rotate');
7038
+ method = 'POST';
7039
+ pathSuffix = `/${encodeURIComponent(name)}/rotate`;
7040
+ body = stripUndefinedValues({
7041
+ ...control,
7042
+ value,
7043
+ expectedRevision,
7044
+ idempotencyKey: secretClientOperationId(
7045
+ target,
7046
+ control,
7047
+ name,
7048
+ 'rotate',
7049
+ expectedRevision,
7050
+ flags
7051
+ ),
7052
+ reason: flags.reason,
7053
+ });
7054
+ } else {
7055
+ const reason = String(flags.reason || '').trim();
7056
+ if (reason.length < 8) {
7057
+ fail('secret delete 必须提供至少 8 个字符的 --reason');
7058
+ }
7059
+ method = 'POST';
7060
+ pathSuffix = `/${encodeURIComponent(name)}/delete`;
7061
+ body = {
7062
+ ...control,
7063
+ expectedRevision: readSecretExpectedRevision(flags, 'secret delete'),
7064
+ reason,
7065
+ };
7066
+ }
7067
+ const data = await requestWithAuth(
7068
+ config,
7069
+ target.profileName,
7070
+ appSecretApiPath(target, pathSuffix),
7071
+ { method, body }
7072
+ );
7073
+ return outputSecretMetadataOnly(data, flags);
7074
+ } catch (error) {
7075
+ const safeMessage = maskText(error?.message || String(error));
7076
+ if (error && typeof error === 'object') {
7077
+ error.message = safeMessage;
7078
+ if (typeof error.stack === 'string') error.stack = maskText(error.stack);
7079
+ throw error;
7080
+ }
7081
+ throw new Error(safeMessage);
7082
+ } finally {
7083
+ clearSensitiveText();
7084
+ }
7085
+ }
7086
+
7087
+ function parseFunctionTestSecretMappings(flags, declaredRefs) {
7088
+ const raw = flags['secret-from-env'];
7089
+ const declared = new Map(declaredRefs.map(ref => [ref.name, ref]));
7090
+ const mappings = new Map();
7091
+ for (const entry of splitList(raw)) {
7092
+ const separator = entry.indexOf('=');
7093
+ const name = separator > 0 ? entry.slice(0, separator).trim() : '';
7094
+ const envName = separator > 0 ? entry.slice(separator + 1).trim() : '';
7095
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) {
7096
+ fail(
7097
+ `function test --secret-from-env 格式必须为 logical=ENV_NAME: ${entry}`
7098
+ );
7099
+ }
7100
+ if (!declared.has(name)) {
7101
+ const error = new Error(
7102
+ `FUNCTION_SECRET_REF_UNDECLARED: Function manifest 未声明 secretRef ${name}`
7103
+ );
7104
+ error.code = 'FUNCTION_SECRET_REF_UNDECLARED';
7105
+ throw error;
7106
+ }
7107
+ if (mappings.has(name)) {
7108
+ fail(`function test 重复声明 --secret-from-env ${name}`);
7109
+ }
7110
+ mappings.set(name, envName);
7111
+ }
7112
+ for (const ref of declaredRefs) {
7113
+ const envName = mappings.get(ref.name);
7114
+ if (!envName) {
7115
+ if (ref.required) {
7116
+ const error = new Error(
7117
+ `FUNCTION_SECRET_NOT_CONFIGURED: required secretRef ${ref.name} 缺少 --secret-from-env ${ref.name}=ENV_NAME`
7118
+ );
7119
+ error.code = 'FUNCTION_SECRET_NOT_CONFIGURED';
7120
+ throw error;
7121
+ }
7122
+ continue;
7123
+ }
7124
+ }
7125
+ return mappings;
7126
+ }
7127
+
7128
+ function readFunctionTestSecretsFromEnv(mappings, declaredRefs) {
7129
+ const secrets = {};
7130
+ for (const ref of declaredRefs) {
7131
+ const envName = mappings.get(ref.name);
7132
+ if (!envName) continue;
7133
+ if (!Object.prototype.hasOwnProperty.call(process.env, envName)) {
7134
+ const error = new Error(
7135
+ `FUNCTION_SECRET_NOT_CONFIGURED: 测试父进程未设置环境变量 ${envName}(secretRef ${ref.name})`
7136
+ );
7137
+ error.code = 'FUNCTION_SECRET_NOT_CONFIGURED';
7138
+ throw error;
7139
+ }
7140
+ secrets[ref.name] = String(process.env[envName]);
7141
+ }
7142
+ return secrets;
7143
+ }
7144
+
7145
+ function resolveFunctionTestSource(functionItem, functionCode) {
7146
+ const definition = resolveManifestPlainJson(
7147
+ functionItem,
7148
+ 'definitionJson',
7149
+ 'definitionFile'
7150
+ );
7151
+ const localPath = definition?.sourceFile?.localPath;
7152
+ if (!localPath) {
7153
+ fail(`Function:${functionCode} 缺少 definitionJson.sourceFile.localPath`);
7154
+ }
7155
+ const fromManifest = path.resolve(functionItem.__dir || process.cwd(), localPath);
7156
+ const fromWorkspace = path.resolve(process.cwd(), localPath);
7157
+ const sourcePath = fs.existsSync(fromManifest) ? fromManifest : fromWorkspace;
7158
+ if (!fs.existsSync(sourcePath)) {
7159
+ fail(`Function:${functionCode} sourceFile 不存在: ${localPath}`);
7160
+ }
7161
+ return { definition, sourcePath };
7162
+ }
7163
+
7164
+ function isolatedAppFunctionTestBuildRunner() {
7165
+ const { builtinModules } = require('module');
7166
+ const fs = require('fs');
7167
+ const path = require('path');
7168
+ const esbuild = require(process.argv[1]);
7169
+ const sourcePath = process.argv[2];
7170
+ const outfile = process.argv[3];
7171
+ const workspaceRoot = process.argv[4];
7172
+ const resolveWorkspaceAlias = importPath => {
7173
+ const raw = path.join(workspaceRoot, 'src', importPath.replace(/^@\//, ''));
7174
+ const candidates = [
7175
+ raw,
7176
+ `${raw}.ts`,
7177
+ `${raw}.tsx`,
7178
+ `${raw}.js`,
7179
+ `${raw}.mjs`,
7180
+ `${raw}.cjs`,
7181
+ path.join(raw, 'index.ts'),
7182
+ path.join(raw, 'index.tsx'),
7183
+ path.join(raw, 'index.js'),
7184
+ ];
7185
+ return candidates.find(candidate => fs.existsSync(candidate)) || raw;
7186
+ };
7187
+ const external = Array.from(
7188
+ new Set([...builtinModules, ...builtinModules.map(name => `node:${name}`)])
7189
+ );
7190
+ esbuild
7191
+ .build({
7192
+ entryPoints: [sourcePath],
7193
+ outfile,
7194
+ bundle: true,
7195
+ platform: 'node',
7196
+ format: 'cjs',
7197
+ target: ['node20'],
7198
+ sourcemap: false,
7199
+ logLevel: 'silent',
7200
+ external,
7201
+ plugins: [
7202
+ {
7203
+ name: 'openxiangda-function-test-workspace-alias',
7204
+ setup(build) {
7205
+ build.onResolve({ filter: /^@\// }, args => ({
7206
+ path: resolveWorkspaceAlias(args.path),
7207
+ }));
7208
+ },
7209
+ },
7210
+ ],
7211
+ })
7212
+ .catch(error => {
7213
+ process.stderr.write(String(error?.message || error));
7214
+ process.exitCode = 1;
7215
+ });
7216
+ }
7217
+
7218
+ async function buildIsolatedFunctionTestBundle(
7219
+ sourcePath,
7220
+ workspaceRoot,
7221
+ functionCode,
7222
+ tempRoot
7223
+ ) {
7224
+ const outfile = path.join(tempRoot, 'bundle', `${functionCode}.cjs`);
7225
+ fs.mkdirSync(path.dirname(outfile), { recursive: true });
7226
+ const build = spawnSync(
7227
+ process.execPath,
7228
+ [
7229
+ '-e',
7230
+ `(${isolatedAppFunctionTestBuildRunner.toString()})()`,
7231
+ require.resolve('esbuild'),
7232
+ sourcePath,
7233
+ outfile,
7234
+ workspaceRoot,
7235
+ ],
7236
+ {
7237
+ cwd: tempRoot,
7238
+ env: stripUndefinedValues({
7239
+ PATH: process.env.PATH,
7240
+ NODE_ENV: 'test-build',
7241
+ HOME: tempRoot,
7242
+ USERPROFILE: tempRoot,
7243
+ TMPDIR: tempRoot,
7244
+ }),
7245
+ encoding: 'utf8',
7246
+ maxBuffer: 8 * 1024 * 1024,
7247
+ timeout: 30000,
7248
+ killSignal: 'SIGKILL',
7249
+ }
7250
+ );
7251
+ if (build.error || build.status !== 0 || !fs.existsSync(outfile)) {
7252
+ fail(
7253
+ `App Function 隔离测试构建失败: ${functionCode}: ${
7254
+ build.error?.message || build.stderr || build.stdout || 'unknown error'
7255
+ }`
7256
+ );
7257
+ }
7258
+ return outfile;
7259
+ }
7260
+
7261
+ function isolatedAppFunctionTestRunner() {
7262
+ const fs = require('fs');
7263
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
7264
+ const secretValues = Object.values(payload.secrets || {}).map(String);
7265
+ const secretVariants = Array.from(
7266
+ new Set(
7267
+ secretValues.flatMap(secret => {
7268
+ const bytes = Buffer.from(secret, 'utf8');
7269
+ const jsonEscaped = JSON.stringify(secret).slice(1, -1);
7270
+ const base64 = bytes.toString('base64');
7271
+ return [
7272
+ secret,
7273
+ jsonEscaped,
7274
+ encodeURIComponent(secret),
7275
+ base64,
7276
+ base64.replace(/=+$/g, ''),
7277
+ bytes.toString('base64url'),
7278
+ bytes.toString('hex'),
7279
+ ].filter(Boolean);
7280
+ })
7281
+ )
7282
+ ).sort((left, right) => right.length - left.length);
7283
+ let exfiltrationDetected = false;
7284
+ const containsSecretVariant = (value, seen = new WeakSet()) => {
7285
+ const renderings = [];
7286
+ if (Buffer.isBuffer(value)) {
7287
+ renderings.push(value.toString('utf8'), value.toString('base64'), value.toString('hex'));
7288
+ } else if (typeof value === 'string') {
7289
+ renderings.push(value);
7290
+ } else if (ArrayBuffer.isView(value)) {
7291
+ const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
7292
+ renderings.push(bytes.toString('utf8'), bytes.toString('base64'), bytes.toString('hex'));
7293
+ } else if (!value || typeof value !== 'object') {
7294
+ renderings.push(String(value));
7295
+ }
7296
+ if (
7297
+ renderings.some(rendered =>
7298
+ secretVariants.some(variant => variant && String(rendered).includes(variant))
7299
+ )
7300
+ ) {
7301
+ return true;
7302
+ }
7303
+ if (!value || typeof value !== 'object' || seen.has(value)) return false;
7304
+ seen.add(value);
7305
+ const children = Array.isArray(value) ? value : Object.values(value);
7306
+ if (value instanceof Error) {
7307
+ children.push(value.name, value.message, value.stack, value.code, value.cause);
7308
+ }
7309
+ return children.some(child => containsSecretVariant(child, seen));
7310
+ };
7311
+ const redactString = value => {
7312
+ let result = String(value);
7313
+ for (const variant of secretVariants) {
7314
+ if (variant) result = result.split(variant).join('***secret***');
7315
+ }
7316
+ return result;
7317
+ };
7318
+ const sanitize = (value, seen = new WeakSet()) => {
7319
+ if (typeof value === 'string') return redactString(value);
7320
+ if (!value || typeof value !== 'object') return value;
7321
+ if (seen.has(value)) return '[Circular]';
7322
+ seen.add(value);
7323
+ if (Array.isArray(value)) return value.map(item => sanitize(item, seen));
7324
+ return Object.fromEntries(
7325
+ Object.entries(value).map(([key, child]) => [key, sanitize(child, seen)])
7326
+ );
7327
+ };
7328
+ const renderLog = values => {
7329
+ if (values.some(containsSecretVariant)) exfiltrationDetected = true;
7330
+ return redactString(
7331
+ values
7332
+ .map(value =>
7333
+ typeof value === 'string' ? value : JSON.stringify(sanitize(value))
7334
+ )
7335
+ .join(' ')
7336
+ );
7337
+ };
7338
+ const safeConsole = Object.freeze({
7339
+ log: (...values) => process.stderr.write(`${renderLog(values)}\n`),
7340
+ info: (...values) => process.stderr.write(`${renderLog(values)}\n`),
7341
+ warn: (...values) => process.stderr.write(`${renderLog(values)}\n`),
7342
+ error: (...values) => process.stderr.write(`${renderLog(values)}\n`),
7343
+ debug: (...values) => process.stderr.write(`${renderLog(values)}\n`),
7344
+ });
7345
+ global.console = safeConsole;
7346
+ const declaredNames = new Set(
7347
+ (payload.secretRefs || []).map(item => String(item.name || ''))
7348
+ );
7349
+ const secrets = Object.freeze({
7350
+ get: async name => {
7351
+ const normalized = String(name || '');
7352
+ if (!declaredNames.has(normalized)) {
7353
+ const error = new Error(`Function secretRef is not declared: ${normalized}`);
7354
+ error.code = 'FUNCTION_SECRET_REF_UNDECLARED';
7355
+ error.status = 403;
7356
+ throw error;
7357
+ }
7358
+ if (!Object.prototype.hasOwnProperty.call(payload.secrets || {}, normalized)) {
7359
+ const error = new Error(`Function secret is not configured: ${normalized}`);
7360
+ error.code = 'FUNCTION_SECRET_NOT_CONFIGURED';
7361
+ error.status = 424;
7362
+ throw error;
7363
+ }
7364
+ return String(payload.secrets[normalized]);
7365
+ },
7366
+ });
7367
+ const logger = Object.freeze({
7368
+ debug: safeConsole.debug,
7369
+ info: safeConsole.info,
7370
+ warn: safeConsole.warn,
7371
+ error: safeConsole.error,
7372
+ });
7373
+ Promise.resolve()
7374
+ .then(() => {
7375
+ const loaded = require(process.argv[1]);
7376
+ const execute = loaded?.default || loaded;
7377
+ if (typeof execute !== 'function') {
7378
+ const error = new Error('App Function module must export a function');
7379
+ error.code = 'APP_FUNCTION_TEST_EXPORT_INVALID';
7380
+ throw error;
7381
+ }
7382
+ const context = Object.freeze({
7383
+ input: payload.input,
7384
+ secrets,
7385
+ logger,
7386
+ console: safeConsole,
7387
+ });
7388
+ return execute(context, payload.input);
7389
+ })
7390
+ .then(result => {
7391
+ if (containsSecretVariant(result) || exfiltrationDetected) {
7392
+ const error = new Error('App Function attempted to expose a secret');
7393
+ error.code = 'APP_SECRET_EXFILTRATION_BLOCKED';
7394
+ error.status = 500;
7395
+ throw error;
7396
+ }
7397
+ process.stdout.write(
7398
+ JSON.stringify({ ok: true, result: sanitize(result) })
7399
+ );
7400
+ })
7401
+ .catch(error => {
7402
+ const errorCode = String(error?.code || '');
7403
+ const blocked =
7404
+ error?.code === 'APP_SECRET_EXFILTRATION_BLOCKED' ||
7405
+ exfiltrationDetected ||
7406
+ containsSecretVariant(error);
7407
+ const safeErrorCode = /^[A-Z][A-Z0-9_]{0,127}$/.test(errorCode)
7408
+ ? errorCode
7409
+ : 'APP_FUNCTION_TEST_FAILED';
7410
+ process.stdout.write(
7411
+ JSON.stringify({
7412
+ ok: false,
7413
+ error: {
7414
+ code: blocked
7415
+ ? 'APP_SECRET_EXFILTRATION_BLOCKED'
7416
+ : safeErrorCode,
7417
+ status: blocked ? 500 : error?.status,
7418
+ message: blocked
7419
+ ? 'App Function secret exfiltration was blocked'
7420
+ : redactString(error?.message || String(error)),
7421
+ },
7422
+ })
7423
+ );
7424
+ process.exitCode = 1;
7425
+ });
7426
+ }
7427
+
7428
+ async function testAppFunctionLocally(positional, flags = {}) {
7429
+ rejectUnsafeSecretValueFlags(flags);
7430
+ const functionCode = resolveExactSecretName(
7431
+ positional,
7432
+ flags,
7433
+ 'function test'
7434
+ );
7435
+ const typeFilters = new Set(['functions']);
7436
+ const codeFilters = [{ key: 'functions', code: functionCode, raw: functionCode }];
7437
+ const manifest = loadWorkspaceResources({ typeFilters, codeFilters });
7438
+ if (
7439
+ flags['stage-only'] &&
7440
+ (manifest.functions || []).length + (manifest.automations || []).length === 0
7441
+ ) {
7442
+ fail('--stage-only 没有匹配到可冻结的 Function/Automation 资源');
7443
+ }
7444
+ const validation = validateWorkspaceResources(manifest);
7445
+ if (validation.errors.length > 0) {
7446
+ fail(`Function:${functionCode} 本地 manifest 校验失败: ${validation.errors[0]}`);
7447
+ }
7448
+ const functionItem = manifest.functions?.[0];
7449
+ if (!functionItem) fail(`未找到 Function manifest: ${functionCode}`);
7450
+ const secretRefs = functionManifestDeclaresSecretRefs(functionItem)
7451
+ ? normalizeFunctionSecretRefs(functionItem.secretRefs)
7452
+ : [];
7453
+ const secretMappings = parseFunctionTestSecretMappings(flags, secretRefs);
7454
+ const input = flags['input-json']
7455
+ ? readJsonArg(flags['input-json'], 'input-json')
7456
+ : {};
7457
+ const { sourcePath } = resolveFunctionTestSource(
7458
+ functionItem,
7459
+ functionCode
7460
+ );
7461
+ const workspaceRoot = findWorkspaceRoot(sourcePath);
7462
+ const tempRoot = fs.mkdtempSync(
7463
+ path.join(os.tmpdir(), 'openxiangda-function-test-')
7464
+ );
7465
+ try {
7466
+ const bundlePath = await buildIsolatedFunctionTestBundle(
7467
+ sourcePath,
7468
+ workspaceRoot,
7469
+ functionCode,
7470
+ tempRoot
7471
+ );
7472
+ const secrets = readFunctionTestSecretsFromEnv(
7473
+ secretMappings,
7474
+ secretRefs
7475
+ );
7476
+ for (const value of Object.values(secrets)) registerSensitiveText(value);
7477
+ const child = spawnSync(
7478
+ process.execPath,
7479
+ ['-e', `(${isolatedAppFunctionTestRunner.toString()})()`, bundlePath],
7480
+ {
7481
+ cwd: tempRoot,
7482
+ env: stripUndefinedValues({
7483
+ PATH: process.env.PATH,
7484
+ NODE_ENV: 'test',
7485
+ HOME: tempRoot,
7486
+ USERPROFILE: tempRoot,
7487
+ TMPDIR: tempRoot,
7488
+ }),
7489
+ input: JSON.stringify({ input, secretRefs, secrets }),
7490
+ encoding: 'utf8',
7491
+ maxBuffer: 8 * 1024 * 1024,
7492
+ timeout: Number(flags.timeout || 30000),
7493
+ killSignal: 'SIGKILL',
7494
+ }
7495
+ );
7496
+ if (child.error) {
7497
+ const error = new Error(
7498
+ `APP_FUNCTION_SECRET_UNAVAILABLE: 隔离测试子进程失败: ${child.error.message}`
7499
+ );
7500
+ error.code = 'APP_FUNCTION_SECRET_UNAVAILABLE';
7501
+ throw error;
7502
+ }
7503
+ if (child.stderr) process.stderr.write(maskText(child.stderr));
7504
+ let output;
7505
+ try {
7506
+ output = JSON.parse(String(child.stdout || '{}'));
7507
+ } catch (error) {
7508
+ fail(`App Function 隔离测试输出无法解析: ${error.message}`);
7509
+ }
7510
+ const result = {
7511
+ ...output,
7512
+ functionCode,
7513
+ runtimeContractVersion: secretRefs.length
7514
+ ? APP_FUNCTION_SECRET_CAPABILITIES.runtimeContractVersion
7515
+ : 'trusted_node_v1_compat',
7516
+ isolated: true,
7517
+ workspaceWrites: 0,
7518
+ secretValuesPersisted: false,
7519
+ };
7520
+ if (flags.json) writeJson(result);
7521
+ else print(JSON.stringify(result, null, 2));
7522
+ if (child.status !== 0 || output.ok === false) {
7523
+ const error = new Error(
7524
+ `${output.error?.code || 'APP_FUNCTION_TEST_FAILED'}: ${output.error?.message || 'App Function local test failed'}`
7525
+ );
7526
+ error.code = output.error?.code || 'APP_FUNCTION_TEST_FAILED';
7527
+ throw error;
7528
+ }
7529
+ return result;
7530
+ } finally {
7531
+ fs.rmSync(tempRoot, { recursive: true, force: true });
7532
+ clearSensitiveText();
7533
+ }
7534
+ }
7535
+
7536
+ async function connector(args) {
7537
+ const { subcommand, rest } = parseSubcommandArgs(args);
7538
+ const { flags, positional } = parseArgs(rest);
7539
+ if (wantsSubcommandHelp(subcommand, flags)) {
7540
+ print('用法: openxiangda connector list|get|create|update|upsert|delete|invoke|download-test [connectorCode[.apiCode]] [--json-file file]');
7541
+ return;
7542
+ }
7543
+ const config = loadConfig();
7544
+ const target = getWorkspaceTarget(config, flags.profile || config.currentProfile, flags);
7545
+
7546
+ if (subcommand === 'invoke' || subcommand === 'download-test') {
7547
+ const targetName = positional[0] || flags.connector;
7548
+ if (!targetName) fail(`用法: openxiangda connector ${subcommand} <connectorCode.apiCode> [--body-json file|json]`);
7549
+ const parsed = parseConnectorApiName(targetName, flags);
7550
+ const body = {
7551
+ connector: parsed.connector,
7552
+ api: parsed.api,
7553
+ ...readDirectJsonBody(flags, 'connector invoke', { optional: true }),
7554
+ ...(flags['path-params-json'] ? { pathParams: readJsonArg(flags['path-params-json'], 'path-params-json') } : {}),
7555
+ ...(flags['query-json'] ? { query: readJsonArg(flags['query-json'], 'query-json') } : {}),
7556
+ ...(flags['headers-json'] ? { headers: readJsonArg(flags['headers-json'], 'headers-json') } : {}),
7557
+ ...(flags['request-body-type'] ? { requestBodyType: flags['request-body-type'] } : {}),
7558
+ ...(flags['response-type'] ? { responseType: flags['response-type'] } : {}),
7559
+ };
7560
+ if (subcommand === 'download-test') body.responseType = 'binary';
7561
+ return runDirectRequest(config, target, flags, {
7562
+ method: 'POST',
7563
+ path: `/${encodeURIComponent(target.appType)}/v1/connectors/actions/${subcommand === 'download-test' ? 'download' : 'invoke'}`,
7564
+ body,
7565
+ strictEnvelope: subcommand !== 'download-test',
7566
+ }, { controlPlaneMutation: false });
7567
+ }
7568
+
7569
+ return directResourceCrud(config, target, {
7570
+ commandName: 'connector',
7571
+ subcommand,
7572
+ flags,
7573
+ positional,
7574
+ resourceType: 'connector',
7575
+ basePath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/connectors`,
7576
+ manifestDir: path.join('src', 'resources', 'connectors'),
7577
+ createMethod: 'POST',
7578
+ createPath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/connectors/actions/sync`,
7579
+ createBody: body => ({ connectors: [normalizeConnectorManifest(body)] }),
7580
+ updateMethod: 'POST',
7581
+ updatePath: `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/connectors/actions/sync`,
7582
+ updateBody: body => ({ connectors: [normalizeConnectorManifest(body)] }),
7583
+ normalizeBody: body => normalizeConnectorManifest(body),
7584
+ codeOf: body => body.code || body.methodName,
7585
+ responseData: data => Array.isArray(data?.data) ? data.data[0] : data,
7586
+ saveState: (code, data) => saveConnectorResource(target, code, data?.connector?.id || data?.id, {
7587
+ apis: Object.fromEntries(
7588
+ (data?.apis || []).map(api => [api.code || api.methodName, { apiId: api.id, name: api.name }])
7589
+ ),
7590
+ }),
7591
+ });
7592
+ }
7593
+
7594
+ async function notification(args) {
7595
+ const { subcommand, rest } = parseSubcommandArgs(args);
7596
+ const { flags, positional } = parseArgs(rest);
6184
7597
  if (wantsSubcommandHelp(subcommand, flags)) {
6185
7598
  print([
6186
7599
  '用法: 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',
@@ -7242,12 +8655,14 @@ async function resource(args) {
7242
8655
  ' openxiangda resource validate --profile <name>',
7243
8656
  ' openxiangda resource plan [type] --only <code1,code2> --profile <name> --json',
7244
8657
  ' openxiangda resource publish [type] --only <code1,code2> --profile <name>',
8658
+ ' openxiangda resource publish function --only <code> --stage-only --profile <name>',
7245
8659
  ' openxiangda resource publish form-settings --only <code> --activate --profile <name>',
7246
8660
  ' openxiangda resource publish [type] --all --reason "approved app-wide closure" --profile <name>',
7247
8661
  '说明:',
7248
8662
  ' - 读取 src/resources/**;workflowFile 会先走非写入编译校验。',
7249
8663
  ' - publish 默认跳过 plan 判定为 noop 的资源,避免无意义 upsert;需要强制写入时加 --force。',
7250
8664
  ' - Form settings bundle 默认只 stage,不推进线上 Form head;只有显式 --activate 才 CAS 激活。',
8665
+ ' - Function/Automation 可用 --stage-only 只 prepare + verify Backend Release;随后把返回的 stagedResource 交给 release app-finalize 原子激活。',
7251
8666
  ' - publish --dry-run 等价于发布前计划,不会写平台资源。',
7252
8667
  ' - 在线 publish 必须用 --only/--code 精确选择;真正全量发布必须显式 --all --reason,SDD bypass 不能绕过此范围门。',
7253
8668
  ' - 源码依赖触发的 Function/Automation 更新默认只替换 source snapshot,保留线上 bindings/schema/metadata;显式替换 manifest 必须加 --replace-manifest --reason。',
@@ -7284,6 +8699,7 @@ async function resource(args) {
7284
8699
 
7285
8700
  const typeFilters = getResourceTypeFilters(positional, flags);
7286
8701
  const codeFilters = getResourceCodeFilters(flags);
8702
+ assertBackendStageOnlyScope(subcommand, flags, typeFilters);
7287
8703
  assertExplicitResourcePublishScope(subcommand, flags, codeFilters);
7288
8704
  assertExplicitManifestReplacement(flags);
7289
8705
  if (codeFilters && flags.prune) {
@@ -7353,6 +8769,19 @@ async function resource(args) {
7353
8769
  const publishPlan = await telemetry.runPhase('plan', async () =>
7354
8770
  buildResourcePlan(config, target, manifest)
7355
8771
  );
8772
+ if (publishPlan.functionSecretRefs?.valid === false) {
8773
+ const missing = publishPlan.functionSecretRefs.functions
8774
+ .flatMap(item =>
8775
+ item.missingRequired.map(ref => `${item.code}:${ref.name}`)
8776
+ )
8777
+ .sort();
8778
+ const error = new Error(
8779
+ `FUNCTION_SECRET_NOT_CONFIGURED: 以下 Function required secretRefs 尚未配置或未激活: ${missing.join(', ')}`
8780
+ );
8781
+ error.code = 'FUNCTION_SECRET_NOT_CONFIGURED';
8782
+ error.missingSecretRefs = missing;
8783
+ throw error;
8784
+ }
7356
8785
  const pruneCandidates = flags.prune
7357
8786
  ? await telemetry.runPhase('prune-plan', async () =>
7358
8787
  collectPruneResourceCandidates(config, target, manifest)
@@ -7381,8 +8810,16 @@ async function resource(args) {
7381
8810
  resourceTargets,
7382
8811
  {
7383
8812
  files: scopedFiles,
7384
- commands: [buildResourcePublishCommand(typeFilters, codeFilters, profileName)],
8813
+ commands: [
8814
+ buildResourcePublishCommand(
8815
+ typeFilters,
8816
+ codeFilters,
8817
+ profileName,
8818
+ flags
8819
+ ),
8820
+ ],
7385
8821
  changeId: flags.change,
8822
+ actualCommands: true,
7386
8823
  }
7387
8824
  ),
7388
8825
  flags,
@@ -7426,11 +8863,27 @@ async function resource(args) {
7426
8863
  baseRef: flags.since || 'HEAD',
7427
8864
  replaceManifest: Boolean(flags['replace-manifest']),
7428
8865
  activateFormBundles: Boolean(flags.activate),
8866
+ stageBackendReleaseOnly: Boolean(flags['stage-only']),
7429
8867
  })
7430
8868
  );
7431
8869
  if (changeBaselinePreflight) {
7432
8870
  result.changeBaselinePreflight = changeBaselinePreflight;
7433
8871
  }
8872
+ const stagedResources =
8873
+ Array.isArray(result.stagedResources) && result.stagedResources.length > 0
8874
+ ? result.stagedResources
8875
+ : result.stagedResource
8876
+ ? [result.stagedResource]
8877
+ : [];
8878
+ const stagedChangeId =
8879
+ flags.change || getStoredChangeBaseline(target)?.changeId;
8880
+ if (stagedResources.length > 0 && stagedChangeId) {
8881
+ result.stagedResourcesFile = recordStagedAppReleaseResources(
8882
+ stagedChangeId,
8883
+ stagedResources,
8884
+ target
8885
+ );
8886
+ }
7434
8887
  result.timings = telemetry.stop();
7435
8888
  if (flags.json) return writeJson(result);
7436
8889
  printResourceResult(result);
@@ -7439,6 +8892,23 @@ async function resource(args) {
7439
8892
  }
7440
8893
  }
7441
8894
 
8895
+ function assertBackendStageOnlyScope(subcommand, flags, typeFilters) {
8896
+ if (!flags['stage-only']) return;
8897
+ if (subcommand !== 'publish' || flags['dry-run']) {
8898
+ fail('--stage-only 仅适用于在线 resource publish');
8899
+ }
8900
+ if (flags.prune || flags.activate) {
8901
+ fail('--stage-only 不能与 --prune 或 --activate 同时使用');
8902
+ }
8903
+ const normalized = Array.from(typeFilters || []);
8904
+ if (
8905
+ normalized.length === 0 ||
8906
+ normalized.some(type => !['functions', 'automations'].includes(type))
8907
+ ) {
8908
+ fail('--stage-only 必须显式选择 function 或 automation 资源类型');
8909
+ }
8910
+ }
8911
+
7442
8912
  function assertExplicitResourcePublishScope(subcommand, flags, codeFilters) {
7443
8913
  if (subcommand !== 'publish' || flags['dry-run']) return;
7444
8914
  if (Array.isArray(codeFilters) && codeFilters.length > 0) return;
@@ -7470,7 +8940,12 @@ function buildResourceManifestSddTargets(manifest, planActions = []) {
7470
8940
  .map(action => action.code)
7471
8941
  .filter(Boolean)
7472
8942
  ).sort();
7473
- const codeResourceKeys = new Set(['functions', 'automations', 'workflows']);
8943
+ const codeResourceKeys = new Set([
8944
+ 'functions',
8945
+ 'automations',
8946
+ 'workflows',
8947
+ 'formSettings',
8948
+ ]);
7474
8949
  const hasGenericResources = RESOURCE_SPECS.some(
7475
8950
  spec => !codeResourceKeys.has(spec.key) && (manifest[spec.key] || []).length > 0
7476
8951
  ) || (planActions || []).some(
@@ -7479,7 +8954,7 @@ function buildResourceManifestSddTargets(manifest, planActions = []) {
7479
8954
  !['function', 'automation', 'workflow'].includes(action.kind)
7480
8955
  );
7481
8956
  return {
7482
- forms: [],
8957
+ forms: codes('formSettings'),
7483
8958
  pages: [],
7484
8959
  functions: unique([...codes('functions'), ...deletedCodes('function')]).sort(),
7485
8960
  automations: unique([
@@ -7494,20 +8969,51 @@ function buildResourceManifestSddTargets(manifest, planActions = []) {
7494
8969
  };
7495
8970
  }
7496
8971
 
7497
- function buildResourcePublishCommand(typeFilters, codeFilters, profileName) {
8972
+ function buildResourcePublishCommand(
8973
+ typeFilters,
8974
+ codeFilters,
8975
+ profileName,
8976
+ flags = {}
8977
+ ) {
7498
8978
  const args = ['openxiangda resource publish'];
7499
- if (typeFilters?.size === 1) {
7500
- args.push(Array.from(typeFilters)[0]);
8979
+ if (typeFilters?.size > 0) {
8980
+ args.push(Array.from(typeFilters).sort().join(','));
7501
8981
  }
7502
8982
  if (codeFilters?.length) {
8983
+ const qualifierAliases = {
8984
+ functions: 'function',
8985
+ automations: 'automation',
8986
+ workflows: 'workflow',
8987
+ formSettings: 'form-setting',
8988
+ };
7503
8989
  const selectors = codeFilters.map(selector =>
7504
8990
  (selector.key || selector.qualifier) && (!typeFilters || typeFilters.size !== 1)
7505
- ? `${selector.key || selector.qualifier}:${selector.code}`
8991
+ ? `${
8992
+ qualifierAliases[selector.key || selector.qualifier] ||
8993
+ selector.key ||
8994
+ selector.qualifier
8995
+ }:${selector.code}`
7506
8996
  : selector.code
7507
8997
  );
7508
8998
  args.push(`--only ${Array.from(new Set(selectors)).join(',')}`);
7509
8999
  }
7510
9000
  args.push(`--profile ${profileName}`);
9001
+ if (flags.change) args.push(`--change ${flags.change}`);
9002
+ if (flags['stage-only']) args.push('--stage-only');
9003
+ if (flags.activate) args.push('--activate');
9004
+ if (flags['replace-manifest']) args.push('--replace-manifest');
9005
+ if (flags.reason) args.push(`--reason ${JSON.stringify(String(flags.reason))}`);
9006
+ return args.join(' ');
9007
+ }
9008
+
9009
+ function buildWorkspacePublishCommand(flags = {}, profileName) {
9010
+ const args = ['openxiangda workspace publish', `--profile ${profileName}`];
9011
+ if (flags.change) args.push(`--change ${flags.change}`);
9012
+ if (flags.form) args.push(`--form ${flags.form}`);
9013
+ if (flags.page) args.push(`--page ${flags.page}`);
9014
+ if (flags.only) args.push(`--only ${flags.only}`);
9015
+ if (flags.changed) args.push('--changed');
9016
+ if (flags['include-resources']) args.push('--include-resources');
7511
9017
  return args.join(' ');
7512
9018
  }
7513
9019
 
@@ -7615,9 +9121,10 @@ async function runtime(args) {
7615
9121
  commands: [
7616
9122
  `openxiangda runtime deploy --profile ${target.profileName}${
7617
9123
  flags.change ? ` --change ${flags.change}` : ''
7618
- }`,
9124
+ }${flags['no-activate'] ? ' --no-activate' : ''}`,
7619
9125
  ],
7620
9126
  changeId: flags.change,
9127
+ actualCommands: true,
7621
9128
  }
7622
9129
  ),
7623
9130
  flags,
@@ -7854,7 +9361,46 @@ async function runtime(args) {
7854
9361
  }
7855
9362
  )
7856
9363
  );
7857
- saveRuntimeReleaseState(target, data);
9364
+ const runtimeContentHash = String(data?.contentHash || '')
9365
+ .trim()
9366
+ .toLowerCase();
9367
+ const stagedResource = flags['no-activate']
9368
+ ? stripUndefinedValues({
9369
+ kind: 'RuntimeRelease',
9370
+ identity: { releaseId: data?.id },
9371
+ action: 'update',
9372
+ hash: runtimeContentHash,
9373
+ revision: {
9374
+ parentReleaseId:
9375
+ data?.parentReleaseId || runtimeLineage.parentReleaseId || null,
9376
+ },
9377
+ metadata: {
9378
+ buildId: data?.buildId || buildId,
9379
+ releaseStatus: data?.status || 'staged',
9380
+ },
9381
+ })
9382
+ : null;
9383
+ if (
9384
+ flags['no-activate'] &&
9385
+ (!data?.id || !/^[a-f0-9]{64}$/.test(runtimeContentHash))
9386
+ ) {
9387
+ fail(
9388
+ 'RUNTIME_RELEASE_STAGED_RESOURCE_INVALID: --no-activate 响应缺少 releaseId 或 64 位 contentHash'
9389
+ );
9390
+ }
9391
+ if (!flags['no-activate']) {
9392
+ saveRuntimeReleaseState(target, data);
9393
+ }
9394
+ const stagedChangeId =
9395
+ flags.change || getStoredChangeBaseline(target)?.changeId;
9396
+ const stagedResourcesFile =
9397
+ stagedResource && stagedChangeId
9398
+ ? recordStagedAppReleaseResources(
9399
+ stagedChangeId,
9400
+ [stagedResource],
9401
+ target
9402
+ )
9403
+ : null;
7858
9404
  const releaseAssetBaseUrl = data?.assetBaseUrl || assetBaseUrl;
7859
9405
  const result = {
7860
9406
  appType: target.appType,
@@ -7876,6 +9422,8 @@ async function runtime(args) {
7876
9422
  releaseFiles.find(file => file.storageProvider)?.storageProvider ||
7877
9423
  null,
7878
9424
  traceId,
9425
+ ...(stagedResource ? { stagedResource } : {}),
9426
+ ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
7879
9427
  timings: telemetry.stop(),
7880
9428
  };
7881
9429
  if (flags.json) return writeJson(result);
@@ -7888,6 +9436,9 @@ async function runtime(args) {
7888
9436
  `traceId: ${traceId}`,
7889
9437
  `assetBase: ${releaseAssetBaseUrl}`,
7890
9438
  `active: ${result.activated ? 'yes' : 'no'}`,
9439
+ ...(stagedResourcesFile
9440
+ ? [`staged resources: ${stagedResourcesFile}`]
9441
+ : []),
7891
9442
  ].join('\n')
7892
9443
  );
7893
9444
  return;
@@ -8368,6 +9919,21 @@ function saveRuntimeReleaseState(target, release) {
8368
9919
  saveProjectState(target.state);
8369
9920
  }
8370
9921
 
9922
+ function saveRuntimeHeadState(target, app) {
9923
+ const activeReleaseId =
9924
+ app?.activeRuntimeReleaseId || app?.activeRuntime?.releaseId || null;
9925
+ const activeBuildId =
9926
+ app?.activeRuntimeBuildId || app?.activeRuntime?.buildId || null;
9927
+ if (!activeReleaseId && !activeBuildId) return false;
9928
+ saveRuntimeReleaseState(target, {
9929
+ id: activeReleaseId,
9930
+ buildId: activeBuildId,
9931
+ assetBaseUrl:
9932
+ app?.runtimeAssetBaseUrl || app?.activeRuntime?.assetBaseUrl || null,
9933
+ });
9934
+ return true;
9935
+ }
9936
+
8371
9937
  function formatBytes(value) {
8372
9938
  const bytes = Number(value || 0);
8373
9939
  if (bytes < 1024) return `${bytes}B`;
@@ -8493,7 +10059,8 @@ async function commands(args) {
8493
10059
  'route list|get|create|update|upsert|delete',
8494
10060
  'public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check',
8495
10061
  'auth-config list|get|create|update|upsert|delete|methods',
8496
- 'function list|get|create|update|upsert|delete|invoke',
10062
+ 'function list|get|create|update|upsert|delete|invoke|test',
10063
+ 'secret list|metadata|references|audit|unused|create|update|rotate|delete',
8497
10064
  'connector list|get|create|update|upsert|delete|invoke|download-test',
8498
10065
  'notification template-list|template-get|template-upsert|template-delete|type-list|type-get|type-upsert|type-delete|preview|send|batch-send',
8499
10066
  'organization capabilities|department-list|department-get|department-create|department-update|account-list|account-get|account-create|account-update|account-reset-password',
@@ -8514,6 +10081,7 @@ async function commands(args) {
8514
10081
  '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.',
8515
10082
  'settings public-access is legacy form public-access compatibility/repair and should not be used for new React SPA apps.',
8516
10083
  'Direct live mutation commands should use --dry-run first and --write-manifest when the repository should remain source of truth.',
10084
+ 'App Function Secret values never belong in a manifest or workspace file. Use top-level function secretRefs, ctx.secrets.get(name), and secret create/rotate via hidden TTY or --value-stdin.',
8517
10085
  'OpenXiangda native SDD uses openspec/ records; high-risk publishes require an approved change with coverage.json/release.json verification unless --sdd-bypass --reason is used.',
8518
10086
  'A formal release freezes the clean publish HEAD separately from the change base; after activation merge/fast-forward and push that exact SHA to the authoritative default branch, verify release integration-status, then run release end.',
8519
10087
  ],
@@ -10430,7 +11998,9 @@ function validateResourceItem(kind, item, errors, warnings) {
10430
11998
  if (!item.definitionJson && !item.definitionFile) {
10431
11999
  errors.push(`${label}: 缺少 definitionJson 或 definitionFile`);
10432
12000
  }
12001
+ validateFunctionSecretRefs(label, item, errors);
10433
12002
  validateFunctionRuntimeInvoke(label, item, errors, warnings);
12003
+ validateFunctionPerformancePolicies(label, item, errors);
10434
12004
  }
10435
12005
  if (kind === 'dataViews') {
10436
12006
  const definition = item.definition || item;
@@ -10743,6 +12313,62 @@ function validateFunctionRuntimeInvoke(label, item, errors, warnings) {
10743
12313
  }
10744
12314
  }
10745
12315
 
12316
+ function validateFunctionPerformancePolicies(label, item, errors) {
12317
+ const definition = item.definitionJson || {};
12318
+ const responseCache = definition.responseCache;
12319
+ if (responseCache !== undefined) {
12320
+ if (!responseCache || typeof responseCache !== 'object' || Array.isArray(responseCache)) {
12321
+ errors.push(`${label}: responseCache 必须是对象`);
12322
+ } else {
12323
+ if (responseCache.enabled !== undefined && typeof responseCache.enabled !== 'boolean') {
12324
+ errors.push(`${label}: responseCache.enabled 必须是 boolean`);
12325
+ }
12326
+ const ttlSeconds = Number(responseCache.ttlSeconds);
12327
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 86400) {
12328
+ errors.push(`${label}: responseCache.ttlSeconds 必须是 1-86400 的整数`);
12329
+ }
12330
+ if (!Array.isArray(responseCache.actions) || responseCache.actions.length === 0) {
12331
+ errors.push(`${label}: responseCache.actions 必须是非空数组`);
12332
+ }
12333
+ if (responseCache.scope !== 'public_policy') {
12334
+ errors.push(`${label}: responseCache.scope 当前仅支持 public_policy`);
12335
+ }
12336
+ if (
12337
+ responseCache.invalidateOnForms !== undefined &&
12338
+ !Array.isArray(responseCache.invalidateOnForms)
12339
+ ) {
12340
+ errors.push(`${label}: responseCache.invalidateOnForms 必须是数组`);
12341
+ }
12342
+ if (responseCache.maxValueBytes !== undefined) {
12343
+ const maxValueBytes = Number(responseCache.maxValueBytes);
12344
+ if (!Number.isInteger(maxValueBytes) || maxValueBytes < 1024 || maxValueBytes > 4194304) {
12345
+ errors.push(`${label}: responseCache.maxValueBytes 必须是 1024-4194304 的整数`);
12346
+ }
12347
+ }
12348
+ }
12349
+ }
12350
+
12351
+ const invocationAudit = definition.invocationAudit;
12352
+ if (invocationAudit !== undefined) {
12353
+ if (!invocationAudit || typeof invocationAudit !== 'object' || Array.isArray(invocationAudit)) {
12354
+ errors.push(`${label}: invocationAudit 必须是对象`);
12355
+ } else {
12356
+ if (
12357
+ invocationAudit.successOutput !== undefined &&
12358
+ !['full', 'summary'].includes(String(invocationAudit.successOutput))
12359
+ ) {
12360
+ errors.push(`${label}: invocationAudit.successOutput 只能是 full 或 summary`);
12361
+ }
12362
+ if (invocationAudit.maxLogBytes !== undefined) {
12363
+ const maxLogBytes = Number(invocationAudit.maxLogBytes);
12364
+ if (!Number.isInteger(maxLogBytes) || maxLogBytes < 1024 || maxLogBytes > 65536) {
12365
+ errors.push(`${label}: invocationAudit.maxLogBytes 必须是 1024-65536 的整数`);
12366
+ }
12367
+ }
12368
+ }
12369
+ }
12370
+ }
12371
+
10746
12372
  function validateFunctionRuntimeInvokeAudience(label, value, errors) {
10747
12373
  if (Array.isArray(value)) {
10748
12374
  value.forEach(item => validateFunctionRuntimeInvokeAudience(label, item, errors));
@@ -11412,6 +13038,11 @@ async function publishResourcesForWorkspace(config, profileName, options = {}) {
11412
13038
 
11413
13039
  async function buildResourcePlan(config, target, manifest) {
11414
13040
  return await withReadOnlyHttpGuard('resource plan', async () => {
13041
+ const secretCapabilities = await assertManifestAppFunctionSecretCapabilities(
13042
+ config,
13043
+ target,
13044
+ manifest
13045
+ );
11415
13046
  await prepareManifestJsCodeBundlesForPlan(manifest);
11416
13047
  const existing = await fetchExistingResourceMaps(config, target, manifest);
11417
13048
  const actions = [];
@@ -11438,6 +13069,14 @@ async function buildResourcePlan(config, target, manifest) {
11438
13069
  actions,
11439
13070
  manifest.formSettings || []
11440
13071
  );
13072
+ const functionSecretRefs = secretCapabilities
13073
+ ? await buildAppFunctionSecretRefPlan(
13074
+ config,
13075
+ target,
13076
+ manifest.functions || [],
13077
+ existing.functions
13078
+ )
13079
+ : null;
11441
13080
  return {
11442
13081
  appType: target.appType,
11443
13082
  profile: target.profileName,
@@ -11445,10 +13084,99 @@ async function buildResourcePlan(config, target, manifest) {
11445
13084
  ...(manifest.resourceCodeFilters ? { resourceCodeFilters: manifest.resourceCodeFilters } : {}),
11446
13085
  actions,
11447
13086
  summary: summarizeActions(actions),
13087
+ ...(functionSecretRefs
13088
+ ? {
13089
+ capabilities: {
13090
+ app_function_secrets_v1: true,
13091
+ trusted_node_v2: true,
13092
+ backend_release_v2: true,
13093
+ atomic_staged_children_v2: true,
13094
+ },
13095
+ functionSecretRefs,
13096
+ }
13097
+ : {}),
11448
13098
  };
11449
13099
  });
11450
13100
  }
11451
13101
 
13102
+ async function buildAppFunctionSecretRefPlan(
13103
+ config,
13104
+ target,
13105
+ functions,
13106
+ existingFunctions
13107
+ ) {
13108
+ const declaredFunctions = (functions || []).filter(
13109
+ functionManifestDeclaresSecretRefs
13110
+ );
13111
+ if (declaredFunctions.length === 0) return null;
13112
+ const metadataResponse = await requestWithAuth(
13113
+ config,
13114
+ target.profileName,
13115
+ appSecretApiPath(target)
13116
+ );
13117
+ assertSecretMetadataOnly(metadataResponse);
13118
+ const metadataByName = new Map(
13119
+ normalizeItems(metadataResponse)
13120
+ .filter(item => item?.name)
13121
+ .map(item => [String(item.name), item])
13122
+ );
13123
+ const plans = declaredFunctions.map(item => {
13124
+ const code = item.code || item.functionCode || item.resourceCode;
13125
+ const desired = normalizeFunctionSecretRefs(item.secretRefs);
13126
+ const current = normalizeFunctionSecretRefs(
13127
+ existingFunctions?.get(code)?.secretRefs ??
13128
+ existingFunctions?.get(code)?.secretReferences ??
13129
+ []
13130
+ );
13131
+ const desiredNames = new Set(desired.map(ref => ref.name));
13132
+ const currentNames = new Set(current.map(ref => ref.name));
13133
+ const added = desired
13134
+ .map(ref => ref.name)
13135
+ .filter(name => !currentNames.has(name));
13136
+ const removed = current
13137
+ .map(ref => ref.name)
13138
+ .filter(name => !desiredNames.has(name));
13139
+ const missingRequired = [];
13140
+ const unavailableOptional = [];
13141
+ for (const ref of desired) {
13142
+ const metadata = metadataByName.get(ref.name);
13143
+ let reason = null;
13144
+ if (!metadata) reason = 'missing';
13145
+ else if (String(metadata.status || 'active') !== 'active') {
13146
+ reason = `status:${metadata.status || 'unknown'}`;
13147
+ } else if (metadata.hasValue !== true) {
13148
+ reason = 'value-not-configured';
13149
+ }
13150
+ if (!reason) continue;
13151
+ (ref.required ? missingRequired : unavailableOptional).push({
13152
+ name: ref.name,
13153
+ reason,
13154
+ });
13155
+ }
13156
+ const unusedAfterActivation = removed.filter(name => {
13157
+ const referenceCount = Number(metadataByName.get(name)?.referenceCount);
13158
+ return Number.isFinite(referenceCount) && referenceCount <= 1;
13159
+ });
13160
+ return {
13161
+ code,
13162
+ desired,
13163
+ current,
13164
+ added,
13165
+ removed,
13166
+ missingRequired,
13167
+ unavailableOptional,
13168
+ unusedAfterActivation,
13169
+ };
13170
+ });
13171
+ return {
13172
+ contractVersion: APP_FUNCTION_SECRET_CAPABILITIES.contractVersion,
13173
+ runtimeContractVersion:
13174
+ APP_FUNCTION_SECRET_CAPABILITIES.runtimeContractVersion,
13175
+ functions: plans,
13176
+ valid: plans.every(item => item.missingRequired.length === 0),
13177
+ };
13178
+ }
13179
+
11452
13180
  async function prepareManifestJsCodeBundlesForPlan(manifest) {
11453
13181
  const targetsByWorkspace = new Map();
11454
13182
  const seenObjects = new Set();
@@ -11649,19 +13377,19 @@ function collectBackendReleaseCandidates(manifest, options = {}) {
11649
13377
  for (const item of items || []) {
11650
13378
  const code = item.code || item.functionCode || item.resourceCode;
11651
13379
  const action = getPlannedResourceAction(options, planKind, code);
11652
- if (!action || !['update', 'noop'].includes(action.action)) continue;
13380
+ if (!action || !['create', 'update', 'noop'].includes(action.action)) continue;
11653
13381
  if (action.action === 'noop' && options.skipNoop !== false) continue;
11654
- if (
11655
- shouldReplaceResourceManifestItem(
11656
- item,
11657
- resourceKey,
11658
- code,
11659
- options
11660
- )
11661
- ) {
11662
- continue;
11663
- }
11664
- candidates.push({ kind, code, item, plannedAction: action.action });
13382
+ const replaceManifest =
13383
+ action.action !== 'create' &&
13384
+ shouldReplaceResourceManifestItem(item, resourceKey, code, options);
13385
+ candidates.push({
13386
+ kind,
13387
+ code,
13388
+ item,
13389
+ plannedAction: action.action,
13390
+ operation: action.action === 'create' ? 'create' : 'update',
13391
+ mode: replaceManifest ? 'manifest_replacement' : 'source_only',
13392
+ });
11665
13393
  }
11666
13394
  };
11667
13395
  append('Function', 'functions', manifest.functions);
@@ -11833,25 +13561,206 @@ async function buildBackendReleaseResourceInput(
11833
13561
  candidate.item,
11834
13562
  definitionJson
11835
13563
  );
11836
- return {
13564
+ const secretRefsDeclared = functionManifestDeclaresSecretRefs(
13565
+ candidate.item
13566
+ );
13567
+ const common = {
11837
13568
  kind: 'Function',
11838
13569
  code: candidate.code,
11839
- source: backendSourcePlane(
13570
+ mode: candidate.mode,
13571
+ operation: candidate.operation,
13572
+ ...(secretRefsDeclared
13573
+ ? {
13574
+ secretRefs: normalizeFunctionSecretRefs(
13575
+ candidate.item.secretRefs
13576
+ ),
13577
+ }
13578
+ : {}),
13579
+ };
13580
+ if (candidate.mode === 'manifest_replacement') {
13581
+ return {
13582
+ ...common,
13583
+ replacement: buildBackendReleaseFunctionReplacementInput(
13584
+ candidate.item,
13585
+ definitionJson
13586
+ ),
13587
+ };
13588
+ }
13589
+ const source = backendSourcePlane(
13590
+ definitionJson,
13591
+ `Function:${candidate.code}`
13592
+ );
13593
+ if (secretRefsDeclared) {
13594
+ source.version = definitionJson.version;
13595
+ source.runtimeContractVersion = definitionJson.runtimeContractVersion;
13596
+ }
13597
+ return {
13598
+ ...common,
13599
+ ...(candidate.operation === 'create'
13600
+ ? {
13601
+ create: buildBackendReleaseFunctionCreateInput(
13602
+ candidate.item,
13603
+ definitionJson
13604
+ ),
13605
+ }
13606
+ : {}),
13607
+ source,
13608
+ };
13609
+ }
13610
+ const viewJson =
13611
+ (await resolveManifestJson(
13612
+ config,
13613
+ target.profileName,
13614
+ candidate.item,
13615
+ 'viewJson',
13616
+ 'viewFile',
13617
+ true,
13618
+ uploadCache
13619
+ )) ??
13620
+ (await resolveManifestJson(
13621
+ config,
13622
+ target.profileName,
13623
+ candidate.item,
13624
+ 'previewJson',
13625
+ 'previewFile',
13626
+ true,
13627
+ uploadCache
13628
+ ));
13629
+ const common = {
13630
+ kind: 'Automation',
13631
+ code: candidate.code,
13632
+ mode: candidate.mode,
13633
+ operation: candidate.operation,
13634
+ };
13635
+ if (candidate.mode === 'manifest_replacement') {
13636
+ return {
13637
+ ...common,
13638
+ replacement: buildBackendReleaseAutomationMetadata(
13639
+ target,
13640
+ candidate.item,
11840
13641
  definitionJson,
11841
- `Function:${candidate.code}`
13642
+ viewJson
11842
13643
  ),
11843
13644
  };
11844
13645
  }
11845
13646
  return {
11846
- kind: 'Automation',
11847
- code: candidate.code,
13647
+ ...common,
11848
13648
  sources: buildAutomationBackendReleaseSources(
11849
13649
  definitionJson,
11850
13650
  `Automation:${candidate.code}`
11851
13651
  ),
13652
+ ...(candidate.operation === 'create'
13653
+ ? {
13654
+ create: buildBackendReleaseAutomationMetadata(
13655
+ target,
13656
+ candidate.item,
13657
+ withoutBackendSourcePlanes(definitionJson),
13658
+ viewJson
13659
+ ),
13660
+ }
13661
+ : {}),
11852
13662
  };
11853
13663
  }
11854
13664
 
13665
+ function buildBackendReleaseFunctionCreateInput(functionItem, definitionJson) {
13666
+ const baseDefinition = clonePlainJson(definitionJson || {});
13667
+ for (const key of [
13668
+ 'sourceFile',
13669
+ 'sourceType',
13670
+ 'sourceProvenance',
13671
+ 'secretRefs',
13672
+ ]) {
13673
+ delete baseDefinition[key];
13674
+ }
13675
+ const code =
13676
+ functionItem.code || functionItem.functionCode || functionItem.resourceCode;
13677
+ return stripUndefinedValues({
13678
+ name: functionItem.name || definitionJson.name || code,
13679
+ description:
13680
+ functionItem.description !== undefined
13681
+ ? functionItem.description
13682
+ : definitionJson.description || '',
13683
+ definitionJson: baseDefinition,
13684
+ resourceBindings: clonePlainJson(definitionJson.resourceBindings || {}),
13685
+ inputSchema: clonePlainJson(
13686
+ functionItem.inputSchema || definitionJson.inputSchema
13687
+ ),
13688
+ outputSchema: clonePlainJson(
13689
+ functionItem.outputSchema || definitionJson.outputSchema
13690
+ ),
13691
+ status: functionItem.status || 'active',
13692
+ });
13693
+ }
13694
+
13695
+ function buildBackendReleaseFunctionReplacementInput(
13696
+ functionItem,
13697
+ definitionJson
13698
+ ) {
13699
+ const code =
13700
+ functionItem.code || functionItem.functionCode || functionItem.resourceCode;
13701
+ return stripUndefinedValues({
13702
+ name: functionItem.name || definitionJson.name || code,
13703
+ description:
13704
+ functionItem.description !== undefined
13705
+ ? functionItem.description
13706
+ : definitionJson.description || '',
13707
+ definitionJson: clonePlainJson(definitionJson),
13708
+ resourceBindings: clonePlainJson(definitionJson.resourceBindings || {}),
13709
+ inputSchema: clonePlainJson(
13710
+ functionItem.inputSchema || definitionJson.inputSchema
13711
+ ),
13712
+ outputSchema: clonePlainJson(
13713
+ functionItem.outputSchema || definitionJson.outputSchema
13714
+ ),
13715
+ status: functionItem.status || 'active',
13716
+ });
13717
+ }
13718
+
13719
+ function buildBackendReleaseAutomationMetadata(
13720
+ target,
13721
+ automationItem,
13722
+ definitionJson,
13723
+ viewJson
13724
+ ) {
13725
+ const payload = resolveAutomationManifestPayload(target, automationItem);
13726
+ const enabled =
13727
+ automationItem.enable !== undefined
13728
+ ? Boolean(automationItem.enable)
13729
+ : automationItem.isEnabled !== undefined
13730
+ ? Boolean(automationItem.isEnabled)
13731
+ : true;
13732
+ return stripUndefinedValues({
13733
+ name: automationItem.name || automationItem.code,
13734
+ description: automationItem.description || '',
13735
+ formUuid: payload.formUuid,
13736
+ triggerConfig: clonePlainJson(payload.triggerConfig),
13737
+ definitionJson: clonePlainJson(definitionJson),
13738
+ ...(viewJson !== undefined ? { viewJson: clonePlainJson(viewJson) } : {}),
13739
+ isPublished: Boolean(automationItem.publish || automationItem.isPublished),
13740
+ isEnabled: enabled,
13741
+ ...(automationItem.tags !== undefined
13742
+ ? {
13743
+ tags: Array.isArray(automationItem.tags)
13744
+ ? automationItem.tags.join(',')
13745
+ : automationItem.tags,
13746
+ }
13747
+ : {}),
13748
+ });
13749
+ }
13750
+
13751
+ function withoutBackendSourcePlanes(value) {
13752
+ if (Array.isArray(value)) return value.map(withoutBackendSourcePlanes);
13753
+ if (!value || typeof value !== 'object') return value;
13754
+ return Object.fromEntries(
13755
+ Object.entries(value)
13756
+ .filter(
13757
+ ([key]) =>
13758
+ !['sourceFile', 'sourceType', 'sourceProvenance'].includes(key)
13759
+ )
13760
+ .map(([key, item]) => [key, withoutBackendSourcePlanes(item)])
13761
+ );
13762
+ }
13763
+
11855
13764
  function indexCurrentAutomationStates(items, codes) {
11856
13765
  const codeSet = new Set(codes);
11857
13766
  const groups = new Map();
@@ -11938,6 +13847,9 @@ async function applyBackendAutomationStateTransitions(
11938
13847
  if (!current?.id) {
11939
13848
  fail(`Backend Release 激活后缺少 Automation:${candidate.code} 当前版本`);
11940
13849
  }
13850
+ if (candidate.mode === 'manifest_replacement') {
13851
+ return { code: candidate.code, transitions: [] };
13852
+ }
11941
13853
  const plan = getPlannedResourceAction(
11942
13854
  options,
11943
13855
  'automation',
@@ -12051,14 +13963,44 @@ function saveBackendReleaseResourceStates(
12051
13963
  target.bound = nextBound;
12052
13964
  }
12053
13965
 
12054
- function backendReleaseResultSummary(release, stateTransitions = new Map()) {
13966
+ function backendReleaseResultSummary(
13967
+ release,
13968
+ stateTransitions = new Map(),
13969
+ protocolVersion = null,
13970
+ handledSelectors = []
13971
+ ) {
12055
13972
  const resources = release.resources || [];
13973
+ const resolvedProtocolVersion =
13974
+ release.protocolVersion || protocolVersion || undefined;
13975
+ const releaseResource = {
13976
+ kind: 'BackendRelease',
13977
+ identity: { releaseId: release.id },
13978
+ action: 'update',
13979
+ hash: release.verificationHash || release.manifestHash,
13980
+ metadata: resolvedProtocolVersion
13981
+ ? {
13982
+ protocolVersion: resolvedProtocolVersion,
13983
+ mode: release.mode,
13984
+ parentReleaseId: release.parentReleaseId || null,
13985
+ handledSelectors,
13986
+ }
13987
+ : {
13988
+ mode: release.mode,
13989
+ parentReleaseId: release.parentReleaseId || null,
13990
+ handledSelectors,
13991
+ },
13992
+ };
13993
+ const isStaged = ['prepared', 'verified'].includes(
13994
+ String(release.status || '').toLowerCase()
13995
+ );
12056
13996
  return {
12057
13997
  id: release.id,
12058
13998
  status: release.status,
12059
13999
  manifestHash: release.manifestHash,
12060
14000
  verificationHash: release.verificationHash,
12061
14001
  parentReleaseId: release.parentReleaseId || null,
14002
+ mode: release.mode,
14003
+ handledSelectors,
12062
14004
  resourceCount: resources.length,
12063
14005
  updateCount: resources.filter(resource => resource.action === 'update').length,
12064
14006
  noopCount: resources.filter(resource => resource.action === 'noop').length,
@@ -12068,6 +14010,12 @@ function backendReleaseResultSummary(release, stateTransitions = new Map()) {
12068
14010
  ),
12069
14011
  postCommitStatus: release.postCommitStatus,
12070
14012
  postCommit: release.postCommit,
14013
+ ...(resolvedProtocolVersion
14014
+ ? { protocolVersion: resolvedProtocolVersion }
14015
+ : {}),
14016
+ ...(isStaged
14017
+ ? { stagedResource: releaseResource }
14018
+ : { activeResource: releaseResource }),
12071
14019
  };
12072
14020
  }
12073
14021
 
@@ -12079,7 +14027,26 @@ async function publishBackendReleaseResources(
12079
14027
  options = {}
12080
14028
  ) {
12081
14029
  const candidates = collectBackendReleaseCandidates(manifest, options);
12082
- if (candidates.length === 0) return;
14030
+ if (candidates.length === 0) {
14031
+ if (options.stageBackendReleaseOnly) {
14032
+ fail('--stage-only 没有匹配到可冻结的 Function/Automation 资源');
14033
+ }
14034
+ return;
14035
+ }
14036
+ const protocolVersion = APP_FUNCTION_SECRET_CAPABILITIES.backendRelease;
14037
+ const handledSelectors = candidates
14038
+ .map(candidate =>
14039
+ `${candidate.kind === 'Function' ? 'function' : 'automation'}:${candidate.code}`
14040
+ )
14041
+ .sort();
14042
+ const resourceModes = unique(candidates.map(candidate => candidate.mode));
14043
+ const releaseMode =
14044
+ resourceModes.length > 1 ? 'mixed' : resourceModes[0] || 'source_only';
14045
+ const replacementReason = String(
14046
+ options.publishLeaseFlags?.reason ||
14047
+ options.publishLeaseFlags?.['replace-reason'] ||
14048
+ ''
14049
+ ).trim();
12083
14050
 
12084
14051
  let head;
12085
14052
  try {
@@ -12089,12 +14056,26 @@ async function publishBackendReleaseResources(
12089
14056
  backendReleaseApiPath(target, '/head')
12090
14057
  );
12091
14058
  } catch (error) {
12092
- if (!isBackendReleaseFeatureAbsent(error)) throw error;
12093
- const message =
12094
- '当前平台未提供 Backend Release API(head=404);本次兼容降级为逐项 source PATCH。升级平台后会自动启用 Function + Automation 单事务发布。';
12095
- result.warnings.push(message);
12096
- warn(message);
12097
- return;
14059
+ if (isBackendReleaseFeatureAbsent(error)) {
14060
+ const secretRefsRequired = candidates.some(
14061
+ candidate =>
14062
+ candidate.kind === 'Function' &&
14063
+ functionManifestDeclaresSecretRefs(candidate.item)
14064
+ );
14065
+ if (!options.stageBackendReleaseOnly && !secretRefsRequired) {
14066
+ const message =
14067
+ '当前平台未提供 Backend Release API(head=404);非 stage-only 发布兼容降级为逐项写入。升级平台后会自动使用 Backend Release v2。';
14068
+ result.warnings.push(message);
14069
+ warn(message);
14070
+ return;
14071
+ }
14072
+ const missing = new Error(
14073
+ 'BACKEND_RELEASE_V2_REQUIRED: 本次 Function/Automation 发布包含 v2 operation/mode,平台必须支持 Backend Release v2,禁止降级为逐项写入'
14074
+ );
14075
+ missing.code = 'BACKEND_RELEASE_V2_REQUIRED';
14076
+ throw missing;
14077
+ }
14078
+ throw error;
12098
14079
  }
12099
14080
 
12100
14081
  const releaseContext = await ensureBackendReleasePublishContext(
@@ -12126,8 +14107,12 @@ async function publishBackendReleaseResources(
12126
14107
  method: 'POST',
12127
14108
  body: {
12128
14109
  ...releaseContext.control,
14110
+ ...(protocolVersion ? { protocolVersion } : {}),
12129
14111
  parentReleaseId: head?.activeBackendReleaseId || null,
12130
- mode: 'source_only',
14112
+ mode: releaseMode,
14113
+ ...(releaseMode !== 'source_only'
14114
+ ? { replacementReason }
14115
+ : {}),
12131
14116
  sourceRepositoryId: releaseContext.sourceRepositoryId,
12132
14117
  sourceRevision: releaseContext.sourceRevision,
12133
14118
  resources,
@@ -12157,10 +14142,55 @@ async function publishBackendReleaseResources(
12157
14142
  method: 'POST',
12158
14143
  body: {
12159
14144
  ...releaseContext.control,
14145
+ ...(protocolVersion ? { protocolVersion } : {}),
12160
14146
  manifestHash: prepared.manifestHash,
12161
14147
  },
12162
14148
  }
12163
14149
  );
14150
+ if (options.stageBackendReleaseOnly) {
14151
+ const stagedRelease = {
14152
+ ...prepared,
14153
+ ...verified,
14154
+ resources:
14155
+ Array.isArray(verified.resources) && verified.resources.length > 0
14156
+ ? verified.resources
14157
+ : prepared.resources || [],
14158
+ };
14159
+ if (String(stagedRelease.status || '').toLowerCase() !== 'verified') {
14160
+ fail('Backend Release stage-only 未获得 verified 状态,拒绝生成 stagedResource');
14161
+ }
14162
+ const handledKeys = new Set(
14163
+ candidates.map(candidate => `${candidate.kind}:${candidate.code}`)
14164
+ );
14165
+ options.backendReleaseSourceHandledKeys = handledKeys;
14166
+ options.backendReleaseHandledKeys = handledKeys;
14167
+ for (const resource of stagedRelease.resources) {
14168
+ result.published.push({
14169
+ kind: resource.kind === 'Function' ? 'function' : 'automation',
14170
+ code: resource.code,
14171
+ action: resource.action === 'noop' ? 'noop' : 'stage',
14172
+ writeMode: 'backend-release-stage',
14173
+ backendReleaseId: stagedRelease.id,
14174
+ activation: 'staged',
14175
+ id:
14176
+ resource.kind === 'Function'
14177
+ ? resource.desired?.id || resource.resourceId
14178
+ : resource.desired?.id || resource.resourceId,
14179
+ });
14180
+ }
14181
+ result.backendRelease = backendReleaseResultSummary(
14182
+ stagedRelease,
14183
+ new Map(),
14184
+ protocolVersion,
14185
+ handledSelectors
14186
+ );
14187
+ if (!result.backendRelease.stagedResource) {
14188
+ fail('Backend Release stage-only 响应缺少 verified stagedResource');
14189
+ }
14190
+ result.stagedResource = result.backendRelease.stagedResource;
14191
+ result.handledSelectors = handledSelectors;
14192
+ return;
14193
+ }
12164
14194
  const activated = await requestWithAuth(
12165
14195
  config,
12166
14196
  target.profileName,
@@ -12168,7 +14198,13 @@ async function publishBackendReleaseResources(
12168
14198
  target,
12169
14199
  `/${encodeURIComponent(verified.id || prepared.id)}/activate`
12170
14200
  ),
12171
- { method: 'POST', body: releaseContext.control }
14201
+ {
14202
+ method: 'POST',
14203
+ body: {
14204
+ ...releaseContext.control,
14205
+ ...(protocolVersion ? { protocolVersion } : {}),
14206
+ },
14207
+ }
12172
14208
  );
12173
14209
  options.backendReleaseSourceHandledKeys = new Set(
12174
14210
  candidates.map(candidate => `${candidate.kind}:${candidate.code}`)
@@ -12232,8 +14268,11 @@ async function publishBackendReleaseResources(
12232
14268
  }
12233
14269
  result.backendRelease = backendReleaseResultSummary(
12234
14270
  activated,
12235
- stateTransitions
14271
+ stateTransitions,
14272
+ protocolVersion,
14273
+ handledSelectors
12236
14274
  );
14275
+ result.handledSelectors = handledSelectors;
12237
14276
  }
12238
14277
 
12239
14278
  async function publishResourceManifest(config, target, manifest, options = {}) {
@@ -12683,9 +14722,15 @@ async function runResourceChangeBaselinePreflight(
12683
14722
  const artifactCache = { ...(baseline.localBaseArtifactHashes || {}) };
12684
14723
  const missingArtifactTargets = targets.filter(targetItem => {
12685
14724
  const frozen = baseline.resourceHeads?.[targetItem.kind]?.[targetItem.code];
14725
+ const cached = artifactCache[
14726
+ resourceBaselineTargetKey(targetItem.kind, targetItem.code)
14727
+ ];
14728
+ const hasStableLineage =
14729
+ frozen?.fields?.sourceLineageHash &&
14730
+ frozen.fields.sourceLineageHash !== canonicalJsonSha256(null);
12686
14731
  return (
12687
14732
  frozen?.fields?.sourceArtifactHash &&
12688
- !artifactCache[resourceBaselineTargetKey(targetItem.kind, targetItem.code)]
14733
+ (!cached || (hasStableLineage && !cached.sourceHash))
12689
14734
  );
12690
14735
  });
12691
14736
  if (missingArtifactTargets.length > 0) {
@@ -12716,7 +14761,26 @@ async function runResourceChangeBaselinePreflight(
12716
14761
  const artifact = artifactCache[
12717
14762
  resourceBaselineTargetKey(targetItem.kind, targetItem.code)
12718
14763
  ];
12719
- if (artifact?.artifactHash && fields.sourceArtifactHash) {
14764
+ const hasStableLineage =
14765
+ fields.sourceLineageHash &&
14766
+ fields.sourceLineageHash !== canonicalJsonSha256(null);
14767
+ if (hasStableLineage) {
14768
+ if (!artifact?.sourceHash) {
14769
+ throwSourceBaseDiverged(
14770
+ `Git 基线构建未生成稳定源码血缘: ${targetItem.kind}:${targetItem.code}`,
14771
+ [targetItem]
14772
+ );
14773
+ }
14774
+ fields.sourceLineageHash =
14775
+ targetItem.kind === 'Automation'
14776
+ ? canonicalJsonSha256([
14777
+ {
14778
+ nodeId: '$definition',
14779
+ sha256: String(artifact.sourceHash).toLowerCase(),
14780
+ },
14781
+ ])
14782
+ : String(artifact.sourceHash).toLowerCase();
14783
+ } else if (artifact?.artifactHash && fields.sourceArtifactHash) {
12720
14784
  fields.sourceArtifactHash =
12721
14785
  targetItem.kind === 'Automation'
12722
14786
  ? canonicalJsonSha256([
@@ -13218,10 +15282,20 @@ function addPlanActions(actions, kind, desiredItems = [], existingMap, equals) {
13218
15282
 
13219
15283
  function sourceAnalysisPlanFields(item) {
13220
15284
  const analysis = item?.__sourceAnalysis;
13221
- if (!analysis) return {};
15285
+ const definition = item?.definitionJson || {};
13222
15286
  return {
13223
- sourceDependencies: analysis.sourceDependencies,
13224
- resourceReferences: analysis.resourceReferences,
15287
+ ...(analysis
15288
+ ? {
15289
+ sourceDependencies: analysis.sourceDependencies,
15290
+ resourceReferences: analysis.resourceReferences,
15291
+ }
15292
+ : {}),
15293
+ ...(definition.responseCache !== undefined
15294
+ ? { responseCache: clonePlainJson(definition.responseCache) }
15295
+ : {}),
15296
+ ...(definition.invocationAudit !== undefined
15297
+ ? { invocationAudit: clonePlainJson(definition.invocationAudit) }
15298
+ : {}),
13225
15299
  };
13226
15300
  }
13227
15301
 
@@ -13592,6 +15666,54 @@ async function publishFormSettingsResources(
13592
15666
  { frozen, artifactId }
13593
15667
  );
13594
15668
  saveResourceEntry(target, 'formSettings', code, { formUuid });
15669
+ const releaseId = data?.release?.id;
15670
+ const contentHash = String(
15671
+ data?.release?.contentHash || data?.contentHash || ''
15672
+ ).toLowerCase();
15673
+ const stagedResource =
15674
+ data?.staged && !data?.active && releaseId
15675
+ ? {
15676
+ kind: 'FormRelease',
15677
+ identity: { releaseId, formUuid },
15678
+ action: 'update',
15679
+ hash: contentHash,
15680
+ revision: stripUndefinedValues({
15681
+ parentReleaseId:
15682
+ data?.release?.parentReleaseId ||
15683
+ frozen.expectedParent?.releaseId ||
15684
+ null,
15685
+ baseRevision:
15686
+ data?.release?.baseRevision ?? frozen.expectedParent?.revision,
15687
+ }),
15688
+ metadata: {
15689
+ releaseStatus: data?.releaseStatus || 'staged',
15690
+ formCode: code,
15691
+ },
15692
+ }
15693
+ : null;
15694
+ if (
15695
+ !activate &&
15696
+ !data?.noop &&
15697
+ (!data?.staged || data?.active || !releaseId || !stagedResource)
15698
+ ) {
15699
+ fail(
15700
+ `FORM_RELEASE_STAGE_REQUIRED: ${code} 的非激活 bundle 响应必须返回 staged=true、active=false、releaseId 和 contentHash`
15701
+ );
15702
+ }
15703
+ if (stagedResource && !/^[a-f0-9]{64}$/.test(contentHash)) {
15704
+ fail(
15705
+ `FORM_RELEASE_STAGED_RESOURCE_INVALID: ${code} 的 staged FormRelease 缺少 64 位 contentHash`
15706
+ );
15707
+ }
15708
+ if (stagedResource) {
15709
+ result.stagedResources = result.stagedResources || [];
15710
+ result.stagedResources.push(stagedResource);
15711
+ if (result.stagedResources.length === 1) {
15712
+ result.stagedResource = stagedResource;
15713
+ } else {
15714
+ delete result.stagedResource;
15715
+ }
15716
+ }
13595
15717
  result.published.push({
13596
15718
  kind: 'formSetting',
13597
15719
  code,
@@ -13602,7 +15724,9 @@ async function publishFormSettingsResources(
13602
15724
  staged: Boolean(data?.staged),
13603
15725
  active: Boolean(data?.active),
13604
15726
  releaseStatus: data?.releaseStatus,
13605
- releaseId: data?.release?.id,
15727
+ releaseId,
15728
+ contentHash: contentHash || undefined,
15729
+ ...(stagedResource ? { stagedResource } : {}),
13606
15730
  });
13607
15731
  }
13608
15732
  }
@@ -14117,6 +16241,13 @@ async function publishFunctionResources(config, target, functions, result, optio
14117
16241
  ) {
14118
16242
  continue;
14119
16243
  }
16244
+ if (functionManifestDeclaresSecretRefs(functionItem)) {
16245
+ const error = new Error(
16246
+ `APP_FUNCTION_SECRET_STAGED_RELEASE_REQUIRED: Function:${code} secretRefs 必须由 Backend Release v2 原子发布,禁止 direct/source PATCH 降级`
16247
+ );
16248
+ error.code = 'APP_FUNCTION_SECRET_STAGED_RELEASE_REQUIRED';
16249
+ throw error;
16250
+ }
14120
16251
  if (shouldSkipNoopResource(options, 'function', code)) {
14121
16252
  recordNoopResource(result, 'function', code, options);
14122
16253
  continue;
@@ -15707,6 +17838,9 @@ async function pullResources(config, target) {
15707
17838
  code,
15708
17839
  name: detail.name,
15709
17840
  description: detail.description || '',
17841
+ ...(Array.isArray(detail.secretRefs)
17842
+ ? { secretRefs: normalizeFunctionSecretRefs(detail.secretRefs) }
17843
+ : {}),
15710
17844
  definitionJson: detail.definitionJson,
15711
17845
  resourceBindings: detail.resourceBindings || undefined,
15712
17846
  inputSchema: detail.inputSchema || undefined,
@@ -17262,6 +19396,15 @@ function functionEquals(target, desired, existing) {
17262
19396
  existing.resourceBindingsJson ??
17263
19397
  existing.definitionJson?.resourceBindings ??
17264
19398
  {};
19399
+ const secretRefsEqual = functionManifestDeclaresSecretRefs(desired)
19400
+ ? jsonEqualsForPlan(
19401
+ normalizeFunctionSecretRefs(
19402
+ existing.secretRefs ?? existing.secretReferences ?? []
19403
+ ),
19404
+ normalizeFunctionSecretRefs(desired.secretRefs),
19405
+ desired.__dir
19406
+ )
19407
+ : true;
17265
19408
  return (
17266
19409
  String(existing.code || existing.functionCode || existing.resourceCode || '') === String(desired.code || desired.functionCode || desired.resourceCode || '') &&
17267
19410
  String(existing.name || '') === String(desired.name || desiredDefinition.name || desired.code || '') &&
@@ -17270,10 +19413,94 @@ function functionEquals(target, desired, existing) {
17270
19413
  ) &&
17271
19414
  jsonEqualsForPlan(existing.definitionJson, desiredDefinition, desired.__dir) &&
17272
19415
  jsonEqualsForPlan(existingResourceBindings, desiredDefinition.resourceBindings || {}, desired.__dir) &&
17273
- String(existing.status || 'active') === desiredStatus
19416
+ String(existing.status || 'active') === desiredStatus &&
19417
+ secretRefsEqual
19418
+ );
19419
+ }
19420
+
19421
+ function functionManifestDeclaresSecretRefs(functionItem) {
19422
+ return Boolean(
19423
+ functionItem &&
19424
+ Object.prototype.hasOwnProperty.call(functionItem, 'secretRefs')
17274
19425
  );
17275
19426
  }
17276
19427
 
19428
+ function normalizeFunctionSecretRefs(value) {
19429
+ if (!Array.isArray(value)) return [];
19430
+ return value
19431
+ .filter(item => item && typeof item === 'object' && !Array.isArray(item))
19432
+ .map(item => ({
19433
+ name: String(item.name || '').trim(),
19434
+ required: item.required !== false,
19435
+ }))
19436
+ .filter(item => item.name)
19437
+ .sort((left, right) => left.name.localeCompare(right.name));
19438
+ }
19439
+
19440
+ function validateFunctionSecretRefs(label, item, errors) {
19441
+ if (!functionManifestDeclaresSecretRefs(item)) return;
19442
+ if (!Array.isArray(item.secretRefs)) {
19443
+ errors.push(`${label}: secretRefs 必须是数组`);
19444
+ return;
19445
+ }
19446
+ const names = new Set();
19447
+ for (const [index, ref] of item.secretRefs.entries()) {
19448
+ const refLabel = `${label}.secretRefs[${index}]`;
19449
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) {
19450
+ errors.push(`${refLabel}: 必须是 {name, required} 对象`);
19451
+ continue;
19452
+ }
19453
+ const extraKeys = Object.keys(ref).filter(
19454
+ key => !['name', 'required'].includes(key)
19455
+ );
19456
+ if (extraKeys.length > 0) {
19457
+ errors.push(
19458
+ `${refLabel}: 仅允许 name/required metadata,禁止 value/file/env 等字段: ${extraKeys.join(', ')}`
19459
+ );
19460
+ }
19461
+ const name = String(ref.name || '').trim();
19462
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(name)) {
19463
+ errors.push(`${refLabel}.name: 格式无效`);
19464
+ } else if (names.has(name)) {
19465
+ errors.push(`${refLabel}.name: 重复声明 ${name}`);
19466
+ } else {
19467
+ names.add(name);
19468
+ }
19469
+ if (typeof ref.required !== 'boolean') {
19470
+ errors.push(`${refLabel}.required: 必须显式为 boolean`);
19471
+ }
19472
+ }
19473
+ let definition = item.definitionJson;
19474
+ if (!definition && item.definitionFile) {
19475
+ try {
19476
+ definition = resolveManifestPlainJson(
19477
+ item,
19478
+ 'definitionJson',
19479
+ 'definitionFile'
19480
+ );
19481
+ } catch (error) {
19482
+ errors.push(`${label}: 无法校验 secretRefs runtime contract: ${error.message}`);
19483
+ return;
19484
+ }
19485
+ }
19486
+ if (definition?.secretRefs !== undefined) {
19487
+ errors.push(`${label}: secretRefs 必须位于 Function resource 顶层,不能进入 definitionJson/source snapshot`);
19488
+ }
19489
+ if (String(definition?.version || '') !== 'function_v2') {
19490
+ errors.push(`${label}: 声明 secretRefs 时 definitionJson.version 必须是 function_v2`);
19491
+ }
19492
+ if (String(definition?.runtimeMode || '') !== 'trusted_node') {
19493
+ errors.push(`${label}: 声明 secretRefs 时 runtimeMode 必须是 trusted_node`);
19494
+ }
19495
+ if (
19496
+ String(definition?.runtimeContractVersion || '') !== 'trusted_node_v2'
19497
+ ) {
19498
+ errors.push(
19499
+ `${label}: 声明 secretRefs 时 runtimeContractVersion 必须是 trusted_node_v2`
19500
+ );
19501
+ }
19502
+ }
19503
+
17277
19504
  function normalizeFunctionDefinitionJson(functionItem, definitionJson) {
17278
19505
  const definition = clonePlainJson(definitionJson || {});
17279
19506
  const functionCode =
@@ -17472,11 +19699,37 @@ function normalizeJsonForPlan(value, baseDir, keyName) {
17472
19699
  result.code = result.code === undefined ? '' : result.code;
17473
19700
  result.runtimeMode = result.runtimeMode || 'trusted_node';
17474
19701
  result.sourceType = result.sourceType || 'file_snapshot';
19702
+ const sourceProvenance = resolveLocalSourceProvenanceForPlan(
19703
+ value.sourceFile.localPath,
19704
+ baseDir
19705
+ );
19706
+ if (sourceProvenance) {
19707
+ result.sourceProvenance = {
19708
+ ...(result.sourceProvenance || {}),
19709
+ ...sourceProvenance,
19710
+ };
19711
+ }
17475
19712
  }
17476
19713
  if (result.code === '') delete result.code;
17477
19714
  return sortObjectForStableJson(result);
17478
19715
  }
17479
19716
 
19717
+ function resolveLocalSourceProvenanceForPlan(localPath, baseDir) {
19718
+ const rawLocalPath = String(localPath);
19719
+ const baseResolvedPath = path.resolve(baseDir || process.cwd(), rawLocalPath);
19720
+ const cwdResolvedPath = path.resolve(process.cwd(), rawLocalPath);
19721
+ const resolvedLocalPath = fs.existsSync(baseResolvedPath)
19722
+ ? baseResolvedPath
19723
+ : cwdResolvedPath;
19724
+ const sourceInfo = getJsCodeSourceInfoForPlan(resolvedLocalPath);
19725
+ if (!sourceInfo) return null;
19726
+ return readJsCodeSourceProvenance(
19727
+ sourceInfo.workspaceRoot,
19728
+ sourceInfo.sourceKind,
19729
+ sourceInfo.scriptCode
19730
+ );
19731
+ }
19732
+
17480
19733
  function normalizeLocalSourceFileForPlan(localPath, baseDir) {
17481
19734
  const rawLocalPath = String(localPath);
17482
19735
  const baseResolvedPath = path.resolve(baseDir || process.cwd(), rawLocalPath);
@@ -17698,7 +19951,7 @@ async function resolveJsCodeSnapshotFiles(config, profileName, value, baseDir, u
17698
19951
  typeof value.sourceFile === 'object' &&
17699
19952
  value.sourceFile.localPath
17700
19953
  ) {
17701
- const sourceFile = await uploadJsCodeSnapshotFile(
19954
+ const snapshot = await uploadJsCodeSnapshotFile(
17702
19955
  config,
17703
19956
  profileName,
17704
19957
  value.sourceFile.localPath,
@@ -17708,7 +19961,13 @@ async function resolveJsCodeSnapshotFiles(config, profileName, value, baseDir, u
17708
19961
  );
17709
19962
  value.runtimeMode = value.runtimeMode || 'trusted_node';
17710
19963
  value.sourceType = 'file_snapshot';
17711
- value.sourceFile = sourceFile;
19964
+ value.sourceFile = snapshot.sourceFile;
19965
+ if (snapshot.sourceProvenance) {
19966
+ value.sourceProvenance = {
19967
+ ...(value.sourceProvenance || {}),
19968
+ ...snapshot.sourceProvenance,
19969
+ };
19970
+ }
17712
19971
  value.code = value.code || '';
17713
19972
  }
17714
19973
 
@@ -17743,6 +20002,14 @@ async function uploadJsCodeSnapshotFile(
17743
20002
  if (uploadCache.has(cacheKey)) return await uploadCache.get(cacheKey);
17744
20003
 
17745
20004
  const buffer = fs.readFileSync(bundlePath);
20005
+ const sourceInfo = getJsCodeSourceInfoForPlan(resolvedLocalPath);
20006
+ const sourceProvenance = sourceInfo
20007
+ ? readJsCodeSourceProvenance(
20008
+ sourceInfo.workspaceRoot,
20009
+ sourceInfo.sourceKind,
20010
+ sourceInfo.scriptCode
20011
+ )
20012
+ : null;
17746
20013
  const pending = requestFormWithAuth(
17747
20014
  config,
17748
20015
  profileName,
@@ -17762,10 +20029,14 @@ async function uploadJsCodeSnapshotFile(
17762
20029
  originalName: uploaded.originalName,
17763
20030
  contentType: uploaded.contentType,
17764
20031
  };
17765
- uploadCache.set(cacheKey, sourceFile);
20032
+ const snapshot = {
20033
+ sourceFile,
20034
+ ...(sourceProvenance ? { sourceProvenance } : {}),
20035
+ };
20036
+ uploadCache.set(cacheKey, snapshot);
17766
20037
  // Progress belongs on stderr so `--json` keeps stdout machine-readable.
17767
20038
  warn(`已上传JS_CODE快照: ${path.relative(process.cwd(), bundlePath)} (${sourceFile.sha256})`);
17768
- return sourceFile;
20039
+ return snapshot;
17769
20040
  });
17770
20041
  uploadCache.set(cacheKey, pending);
17771
20042
  try {
@@ -17776,6 +20047,38 @@ async function uploadJsCodeSnapshotFile(
17776
20047
  }
17777
20048
  }
17778
20049
 
20050
+ function readJsCodeSourceProvenance(workspaceRoot, sourceKind, scriptCode) {
20051
+ const key = `${sourceKind}/${scriptCode}`;
20052
+ for (const filename of [
20053
+ 'build-cache.json',
20054
+ 'build-cache.cli-v4.json',
20055
+ 'build-cache.cli-v3.json',
20056
+ ]) {
20057
+ try {
20058
+ const cache = JSON.parse(
20059
+ fs.readFileSync(
20060
+ path.join(workspaceRoot, '.openxiangda', filename),
20061
+ 'utf8'
20062
+ )
20063
+ );
20064
+ const metadata = cache?.targets?.[key];
20065
+ const sourceHash = String(metadata?.sourceHash || '').toLowerCase();
20066
+ if (!/^[a-f0-9]{64}$/.test(sourceHash)) continue;
20067
+ return {
20068
+ contractVersion: 'source_lineage_v1',
20069
+ sourceHash,
20070
+ entryPath: `src/${sourceKind}/${scriptCode}/index.ts`,
20071
+ dependencies: Array.isArray(metadata.dependencies)
20072
+ ? metadata.dependencies.slice().sort()
20073
+ : [],
20074
+ };
20075
+ } catch {
20076
+ // Custom workspace builders are compatible but may not emit lineage.
20077
+ }
20078
+ }
20079
+ return null;
20080
+ }
20081
+
17779
20082
  async function resolveJsCodeBundlePath(localPath, scriptCode) {
17780
20083
  const extension = path.extname(localPath).toLowerCase();
17781
20084
  if (['.js', '.cjs', '.mjs'].includes(extension)) {
@@ -17837,10 +20140,10 @@ async function resolveJsCodeBundlePath(localPath, scriptCode) {
17837
20140
  }
17838
20141
 
17839
20142
  function runWorkspaceJsCodeBuild(workspaceRoot, resolvedScriptCode, sourceKind) {
17840
- return spawnSync('pnpm', ['build-js-code', '--script', resolvedScriptCode, '--source', sourceKind], {
17841
- cwd: workspaceRoot,
17842
- encoding: 'utf8',
17843
- });
20143
+ return runWorkspaceJsCodeBuildBatch(
20144
+ workspaceRoot,
20145
+ [{ sourceKind, scriptCode: resolvedScriptCode }]
20146
+ );
17844
20147
  }
17845
20148
 
17846
20149
  function isUnsupportedFunctionsBuildScript(result) {
@@ -18154,26 +20457,7 @@ function getChangedManifestResourceCodes(file, resourceKey, baseRef = 'HEAD') {
18154
20457
  }
18155
20458
 
18156
20459
  function shouldReplaceResourceManifestItem(item, resourceKey, code, options = {}) {
18157
- if (options.replaceManifest) return true;
18158
- if (!item?.__source) return false;
18159
- const relativeFile = normalizeWorkspaceRelativePath(
18160
- path.relative(process.cwd(), path.resolve(item.__source))
18161
- );
18162
- const scopedFiles = new Set(
18163
- (options.scopedFiles || []).map(normalizeWorkspaceRelativePath)
18164
- );
18165
- if (!scopedFiles.has(relativeFile)) return false;
18166
- const changedCodes = getChangedManifestResourceCodes(
18167
- relativeFile,
18168
- resourceKey,
18169
- options.baseRef || 'HEAD'
18170
- );
18171
- if (changedCodes.length === 0) {
18172
- // The SDD/change scope may refer to a committed manifest change that no
18173
- // longer differs from HEAD inside the release worktree.
18174
- return true;
18175
- }
18176
- return changedCodes.includes(String(code));
20460
+ return options.replaceManifest === true;
18177
20461
  }
18178
20462
 
18179
20463
  function buildAutomationSourcePatchSources(existingDefinition, desiredDefinition) {
@@ -18527,19 +20811,27 @@ function scanWorkspaceReliabilityIssues(files) {
18527
20811
 
18528
20812
  function buildTargetedResourceReleaseCommands(targets, profileArg, changeId) {
18529
20813
  const changeArg = changeId ? ` --change ${changeId}` : '';
18530
- if (targets.resources || (targets.jsCodeNodes || []).length > 0) {
18531
- return [`openxiangda resource publish --profile ${profileArg}${changeArg}`];
18532
- }
18533
20814
  const commands = [];
18534
- for (const [targetKey, resourceType] of [
18535
- ['functions', 'function'],
18536
- ['automations', 'automation'],
18537
- ['workflows', 'workflow'],
18538
- ]) {
18539
- const codes = targets[targetKey] || [];
18540
- if (codes.length === 0) continue;
20815
+ const functions = targets.functions || [];
20816
+ const automations = targets.automations || [];
20817
+ if (functions.length > 0 || automations.length > 0) {
20818
+ const resourceTypes = [
20819
+ ...(functions.length > 0 ? ['function'] : []),
20820
+ ...(automations.length > 0 ? ['automation'] : []),
20821
+ ];
20822
+ const qualify = resourceTypes.length > 1;
20823
+ const selectors = [
20824
+ ...functions.map(code => (qualify ? `function:${code}` : code)),
20825
+ ...automations.map(code => (qualify ? `automation:${code}` : code)),
20826
+ ];
20827
+ commands.push(
20828
+ `openxiangda resource publish ${resourceTypes.join(',')} --only ${selectors.join(',')} --stage-only --profile ${profileArg}${changeArg}`
20829
+ );
20830
+ }
20831
+ const workflows = targets.workflows || [];
20832
+ if (workflows.length > 0) {
18541
20833
  commands.push(
18542
- `openxiangda resource publish ${resourceType} --only ${codes.join(',')} --profile ${profileArg}${changeArg}`
20834
+ `openxiangda resource publish workflow --only ${workflows.join(',')} --profile ${profileArg}${changeArg}`
18543
20835
  );
18544
20836
  }
18545
20837
  return commands;
@@ -18555,9 +20847,9 @@ function buildWorkspaceReleaseCommands(targets, runtimeMode, profile, changeId)
18555
20847
  (targets.workflows || []).length > 0 ||
18556
20848
  (targets.jsCodeNodes || []).length > 0;
18557
20849
  if (runtimeMode === 'react-spa') {
18558
- for (const formCode of targets.forms) {
20850
+ if (targets.forms.length > 0) {
18559
20851
  commands.push(
18560
- `openxiangda workspace publish --profile ${profileArg} --form ${formCode}${changeId ? ` --change ${changeId}` : ''}`
20852
+ `openxiangda resource publish form-setting --only ${targets.forms.join(',')} --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
18561
20853
  );
18562
20854
  }
18563
20855
  if (hasResourceTargets) {
@@ -18565,7 +20857,17 @@ function buildWorkspaceReleaseCommands(targets, runtimeMode, profile, changeId)
18565
20857
  }
18566
20858
  if (targets.runtime) {
18567
20859
  commands.push(
18568
- `openxiangda runtime deploy --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
20860
+ `openxiangda runtime deploy --no-activate --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
20861
+ );
20862
+ }
20863
+ if (
20864
+ targets.forms.length > 0 ||
20865
+ (targets.functions || []).length > 0 ||
20866
+ (targets.automations || []).length > 0 ||
20867
+ targets.runtime
20868
+ ) {
20869
+ commands.push(
20870
+ `openxiangda release app-finalize --staged-resources-json .openxiangda/releases/${changeId || 'change'}/staged-resources.json --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
18569
20871
  );
18570
20872
  }
18571
20873
  return commands;
@@ -18629,12 +20931,35 @@ function buildWorkspaceReleasePlan(options = {}) {
18629
20931
  name: issue.rule,
18630
20932
  message: `${issue.file}:${issue.line} ${issue.message}`,
18631
20933
  }));
18632
- const commands = buildWorkspaceReleaseCommands(
20934
+ const generatedCommands = buildWorkspaceReleaseCommands(
18633
20935
  targets,
18634
20936
  runtimeMode,
18635
20937
  options.profile,
18636
20938
  changeScope?.changeId
18637
20939
  );
20940
+ const commands =
20941
+ changeScope?.commands?.length > 0
20942
+ ? [...changeScope.commands]
20943
+ : generatedCommands;
20944
+ if (!changeScope?.commands?.length && targets.resources) {
20945
+ errors.push({
20946
+ name: 'sdd-release-generic-resource-selector-required',
20947
+ message:
20948
+ '变更包含尚未归一化为 code 的通用资源;已保留可确定的 Function/Automation/Workflow 命令,请在 release.json.commands 中补一条精确 --only/--code 命令覆盖剩余资源。',
20949
+ });
20950
+ }
20951
+ if (
20952
+ !changeScope?.commands?.length &&
20953
+ (targets.jsCodeNodes || []).length > 0 &&
20954
+ (targets.functions || []).length === 0 &&
20955
+ (targets.automations || []).length === 0 &&
20956
+ (targets.workflows || []).length === 0
20957
+ ) {
20958
+ errors.push({
20959
+ name: 'sdd-release-js-code-owner-required',
20960
+ message: `JS_CODE 源码尚未映射到可发布资源: ${targets.jsCodeNodes.join(', ')};请在 coverage/release 中声明所属 Function、Automation 或 Workflow。`,
20961
+ });
20962
+ }
18638
20963
  const sdd = options.skipSdd
18639
20964
  ? null
18640
20965
  : buildSddGovernanceReport({
@@ -18749,6 +21074,7 @@ function buildExplicitSddReleasePlan(targets, options = {}) {
18749
21074
  commands,
18750
21075
  changeId: options.changeId,
18751
21076
  verifyStage: options.verifyStage || 'prepublish',
21077
+ actualCommands: options.actualCommands === true,
18752
21078
  });
18753
21079
  return {
18754
21080
  targets,
@@ -18760,6 +21086,21 @@ function buildExplicitSddReleasePlan(targets, options = {}) {
18760
21086
 
18761
21087
  function assertSddReleaseGate(plan, flags, command) {
18762
21088
  if (!plan?.sdd || plan.sdd.errors.length === 0) return;
21089
+ const nonBypassable = new Set([
21090
+ 'sdd-release-direct-form-schema-forbidden',
21091
+ 'sdd-release-direct-form-activation-forbidden',
21092
+ 'sdd-release-resource-scope-missing',
21093
+ 'sdd-release-runtime-direct-activation-forbidden',
21094
+ 'sdd-release-backend-direct-activation-forbidden',
21095
+ 'sdd-release-backend-scope-inexact',
21096
+ 'sdd-release-form-scope-inexact',
21097
+ ]);
21098
+ const hardFailure = plan.sdd.errors.find(error =>
21099
+ nonBypassable.has(error.name)
21100
+ );
21101
+ if (hardFailure) {
21102
+ fail(`${hardFailure.message}\n该原子发布安全门禁不可通过 --sdd-bypass 绕过。`);
21103
+ }
18763
21104
  if (flags['sdd-bypass']) {
18764
21105
  const bypass = appendBypassLog({
18765
21106
  cwd: process.cwd(),