openxiangda 1.0.178 → 1.0.180

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -67,6 +67,7 @@ const {
67
67
  const {
68
68
  buildWorkspaceReleaseCommands,
69
69
  buildWorkspaceReleaseSteps,
70
+ commandFromArgs,
70
71
  normalizeReleaseTargets,
71
72
  releasePlanHash,
72
73
  } = require('./release-plan');
@@ -126,6 +127,19 @@ const {
126
127
  renderResourceExplain,
127
128
  } = require('./design-gates');
128
129
  const { buildDesignReview, renderDesignReview } = require('./design-review');
130
+ const {
131
+ bindEnvironmentTarget,
132
+ buildCandidateBundle,
133
+ normalizeEnvironmentKind,
134
+ readCandidate,
135
+ readJsonInput,
136
+ releaseExecutionPath,
137
+ resolveManagedStateBinding,
138
+ resolveEnvironmentTarget,
139
+ saveCandidate,
140
+ sha256Canonical,
141
+ } = require('./application-environments');
142
+ const { startDeveloperCenter } = require('./developer-center');
129
143
  const {
130
144
  describeOpenApiOperation,
131
145
  listOpenApiOperations,
@@ -229,7 +243,9 @@ async function mainImpl(argv) {
229
243
  if (command === 'open-api') return openApi(rest);
230
244
  if (command === 'design') return design(rest);
231
245
  if (command === 'sdd') return sdd(rest);
246
+ if (command === 'environment') return environment(rest);
232
247
  if (command === 'release') return release(rest);
248
+ if (command === 'studio') return studio(rest);
233
249
  if (command === 'task') return task(rest);
234
250
  if (command === 'workspace') return workspace(rest);
235
251
  if (command === 'app') return app(rest);
@@ -278,7 +294,9 @@ Usage:
278
294
  openxiangda open-api credential list|get|create|update|rotate-secret [id] [--profile name] [--show-secret] [--yes] [--json]
279
295
  openxiangda design gates|template|review [--topic code] [--json]
280
296
  openxiangda sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive [change] [--change id] [--json]
297
+ openxiangda environment init|attach|bind|list|status|diff|use [preproduction|production] [--logical-app code] [--environment target] [--json]
281
298
  openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--watch] [--json]
299
+ openxiangda release candidate|deploy|test|promote|rollback [--candidate id] [--environment target] [--json]
282
300
  openxiangda task status --change <id> [--profile name] [--watch] [--json]
283
301
  openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]
284
302
  openxiangda release app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort [releaseId] [--staged-resources-json <JSON|file>] [--activate-staged-children] [--profile name] [--json]
@@ -344,6 +362,7 @@ Usage:
344
362
  openxiangda skill install [--agent codex|claude|qoder|dual] [--dest <skills-dir>] [--force] [--dry-run] [--json]
345
363
  openxiangda skill status [--agent codex|claude|qoder|dual] [--dest <skills-dir>] [--json]
346
364
  openxiangda skill bootstrap [<dir>] [--force] [--dry-run] [--json]
365
+ openxiangda studio [--no-open] [--port 0]
347
366
 
348
367
  OpenXiangda 应用开发使用普通用户登录 token,不需要 AK/SK;外部后端集成使用 open-api skill 与 AK/SK。
349
368
  所有直接配置写入必须携带 --change <id>,或先执行 release begin --change <id>;dry-run、schema-plan、validate、query/invoke 等只读/数据面命令不会获取发布租约。
@@ -366,6 +385,7 @@ const SUBCOMMAND_BOOLEAN_FLAGS = new Set([
366
385
  '--legacy-form-bundle',
367
386
  '--no-activate',
368
387
  '--no-build',
388
+ '--no-open',
369
389
  '--no-runtime-aliases',
370
390
  '--prune',
371
391
  '--publish',
@@ -375,6 +395,7 @@ const SUBCOMMAND_BOOLEAN_FLAGS = new Set([
375
395
  '--resources',
376
396
  '--skip-resources',
377
397
  '--show-secret',
398
+ '--confirm-production',
378
399
  '--stage-only',
379
400
  '--strict',
380
401
  '--sdd-bypass',
@@ -1031,8 +1052,8 @@ function releaseExecutionDir(changeId) {
1031
1052
  );
1032
1053
  }
1033
1054
 
1034
- function releaseExecutionFile(changeId) {
1035
- return path.join(releaseExecutionDir(changeId), 'execution.json');
1055
+ function releaseExecutionFile(changeId, deploymentId) {
1056
+ return releaseExecutionPath(changeId, deploymentId, process.cwd());
1036
1057
  }
1037
1058
 
1038
1059
  function directPublishReceiptFile(changeId) {
@@ -1048,8 +1069,8 @@ function writePrivateJsonAtomic(file, value) {
1048
1069
  fs.renameSync(tempFile, file);
1049
1070
  }
1050
1071
 
1051
- function readReleaseExecution(changeId) {
1052
- const file = releaseExecutionFile(changeId);
1072
+ function readReleaseExecution(changeId, deploymentId) {
1073
+ const file = releaseExecutionFile(changeId, deploymentId);
1053
1074
  if (!fs.existsSync(file)) return null;
1054
1075
  try {
1055
1076
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -1141,6 +1162,7 @@ function releaseExecutionContextFromBegin(result, target, changeId) {
1141
1162
  appType: target.appType,
1142
1163
  profile: target.profileName,
1143
1164
  changeId,
1165
+ deploymentId: target.deploymentId || null,
1144
1166
  leaseId: String(value.leaseId),
1145
1167
  baselineId: value.baselineId ? String(value.baselineId) : null,
1146
1168
  clientSessionId: String(value.clientSessionId),
@@ -1406,8 +1428,42 @@ async function endReleaseWithoutLocalLease(
1406
1428
  };
1407
1429
  }
1408
1430
 
1431
+ function decorateEnvironmentReleaseSteps(steps, target, flags = {}) {
1432
+ if (!target.environmentId || !target.targetName) return steps;
1433
+ const deploymentId = readStringFlag(flags, 'deployment-id');
1434
+ for (const step of steps) {
1435
+ step.args.push('--environment', target.targetName);
1436
+ if (deploymentId) step.args.push('--deployment-id', deploymentId);
1437
+ if (step.id === 'app-finalize') {
1438
+ for (const [flag, value] of [
1439
+ ['--environment-id', target.environmentId],
1440
+ ['--candidate-id', readStringFlag(flags, 'candidate-id')],
1441
+ ]) {
1442
+ if (value) step.args.push(flag, value);
1443
+ }
1444
+ const stagedIndex = step.args.indexOf('--staged-resources-json');
1445
+ if (stagedIndex >= 0 && deploymentId) {
1446
+ step.args[stagedIndex + 1] = path
1447
+ .relative(
1448
+ process.cwd(),
1449
+ stagedResourcesFileForChange(changeIdFromStep(step), deploymentId)
1450
+ )
1451
+ .replace(/\\/g, '/');
1452
+ }
1453
+ }
1454
+ step.command = commandFromArgs(step.args);
1455
+ }
1456
+ return steps;
1457
+ }
1458
+
1459
+ function changeIdFromStep(step) {
1460
+ const index = step.args.indexOf('--change');
1461
+ return index >= 0 ? step.args[index + 1] : 'change';
1462
+ }
1463
+
1409
1464
  async function publishWorkspaceRelease(config, target, flags = {}) {
1410
1465
  const changeId = readStringFlag(flags, 'change');
1466
+ const deploymentId = readStringFlag(flags, 'deployment-id') || null;
1411
1467
  if (!changeId) {
1412
1468
  fail('用法: openxiangda release publish --change <id> --profile <name>');
1413
1469
  }
@@ -1432,6 +1488,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1432
1488
  target.profileName,
1433
1489
  changeId
1434
1490
  );
1491
+ decorateEnvironmentReleaseSteps(steps, target, flags);
1435
1492
  const releaseScope = getSddChangeScope({
1436
1493
  cwd: process.cwd(),
1437
1494
  configText: readWorkspaceConfigText(),
@@ -1503,7 +1560,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1503
1560
  const plannedSourceRevision = prepareReleaseSourceRevision({
1504
1561
  cwd: process.cwd(),
1505
1562
  });
1506
- const existing = readReleaseExecution(changeId);
1563
+ const existing = readReleaseExecution(changeId, deploymentId);
1507
1564
  if (
1508
1565
  existing &&
1509
1566
  (existing.planHash !== planHash ||
@@ -1533,6 +1590,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1533
1590
  appType: target.appType,
1534
1591
  profile: target.profileName,
1535
1592
  changeId,
1593
+ ...(deploymentId ? { deploymentId } : {}),
1536
1594
  planHash,
1537
1595
  releaseSourceRevision: plannedSourceRevision,
1538
1596
  status: 'planned',
@@ -1543,7 +1601,9 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1543
1601
  steps: [
1544
1602
  {
1545
1603
  id: 'lease-and-capture',
1546
- command: `openxiangda release begin --change ${changeId} --profile ${target.profileName} --freeze-app-capture`,
1604
+ command: `openxiangda release begin --change ${changeId} --profile ${target.profileName}${
1605
+ target.targetName ? ` --environment ${target.targetName}` : ''
1606
+ } --freeze-app-capture`,
1547
1607
  status: 'pending',
1548
1608
  },
1549
1609
  ...steps.map(step => ({
@@ -1554,7 +1614,9 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1554
1614
  })),
1555
1615
  {
1556
1616
  id: 'release-lease',
1557
- command: `openxiangda release end --change ${changeId} --profile ${target.profileName}`,
1617
+ command: `openxiangda release end --change ${changeId} --profile ${target.profileName}${
1618
+ target.targetName ? ` --environment ${target.targetName}` : ''
1619
+ }`,
1558
1620
  status: 'pending',
1559
1621
  },
1560
1622
  ],
@@ -1571,14 +1633,17 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1571
1633
  changeId,
1572
1634
  planHash,
1573
1635
  executionFile: path
1574
- .relative(process.cwd(), releaseExecutionFile(changeId))
1636
+ .relative(process.cwd(), releaseExecutionFile(changeId, deploymentId))
1575
1637
  .replace(/\\/g, '/'),
1576
1638
  execution,
1577
1639
  };
1578
1640
  }
1579
1641
  execution.status = 'running';
1580
1642
  execution.updatedAt = new Date().toISOString();
1581
- writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1643
+ writePrivateJsonAtomic(
1644
+ releaseExecutionFile(changeId, deploymentId),
1645
+ execution
1646
+ );
1582
1647
 
1583
1648
  const runnable = [
1584
1649
  {
@@ -1590,6 +1655,12 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1590
1655
  changeId,
1591
1656
  '--profile',
1592
1657
  target.profileName,
1658
+ ...(target.targetName
1659
+ ? ['--environment', target.targetName]
1660
+ : []),
1661
+ ...(deploymentId
1662
+ ? ['--deployment-id', deploymentId]
1663
+ : []),
1593
1664
  '--freeze-app-capture',
1594
1665
  ],
1595
1666
  },
@@ -1603,6 +1674,12 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1603
1674
  changeId,
1604
1675
  '--profile',
1605
1676
  target.profileName,
1677
+ ...(target.targetName
1678
+ ? ['--environment', target.targetName]
1679
+ : []),
1680
+ ...(deploymentId
1681
+ ? ['--deployment-id', deploymentId]
1682
+ : []),
1606
1683
  ],
1607
1684
  },
1608
1685
  ];
@@ -1616,7 +1693,10 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1616
1693
  journalStep.status = 'running';
1617
1694
  journalStep.startedAt = new Date().toISOString();
1618
1695
  execution.updatedAt = journalStep.startedAt;
1619
- writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1696
+ writePrivateJsonAtomic(
1697
+ releaseExecutionFile(changeId, deploymentId),
1698
+ execution
1699
+ );
1620
1700
  warn(`发布步骤开始: ${step.id}`);
1621
1701
  const result = await runOpenXiangdaInProcess(step.args);
1622
1702
  journalStep.status = 'completed';
@@ -1638,12 +1718,18 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1638
1718
  }
1639
1719
  if (step.stagedKind) execution.stagedWriteOccurred = true;
1640
1720
  execution.updatedAt = journalStep.completedAt;
1641
- writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1721
+ writePrivateJsonAtomic(
1722
+ releaseExecutionFile(changeId, deploymentId),
1723
+ execution
1724
+ );
1642
1725
  }
1643
1726
  execution.status = 'completed';
1644
1727
  execution.completedAt = new Date().toISOString();
1645
1728
  execution.updatedAt = execution.completedAt;
1646
- writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1729
+ writePrivateJsonAtomic(
1730
+ releaseExecutionFile(changeId, deploymentId),
1731
+ execution
1732
+ );
1647
1733
  return {
1648
1734
  completed: true,
1649
1735
  resumed: Boolean(existing),
@@ -1652,7 +1738,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1652
1738
  changeId,
1653
1739
  planHash,
1654
1740
  executionFile: path
1655
- .relative(process.cwd(), releaseExecutionFile(changeId))
1741
+ .relative(process.cwd(), releaseExecutionFile(changeId, deploymentId))
1656
1742
  .replace(/\\/g, '/'),
1657
1743
  execution,
1658
1744
  };
@@ -1684,7 +1770,10 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1684
1770
  writeAttempted: execution.writeAttempted,
1685
1771
  });
1686
1772
  execution.updatedAt = new Date().toISOString();
1687
- writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1773
+ writePrivateJsonAtomic(
1774
+ releaseExecutionFile(changeId, deploymentId),
1775
+ execution
1776
+ );
1688
1777
  if (!execution.writeAttempted) {
1689
1778
  try {
1690
1779
  await runOpenXiangdaInProcess(
@@ -1695,6 +1784,12 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1695
1784
  changeId,
1696
1785
  '--profile',
1697
1786
  target.profileName,
1787
+ ...(target.targetName
1788
+ ? ['--environment', target.targetName]
1789
+ : []),
1790
+ ...(deploymentId
1791
+ ? ['--deployment-id', deploymentId]
1792
+ : []),
1698
1793
  ],
1699
1794
  { quiet: true }
1700
1795
  );
@@ -1706,12 +1801,873 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1706
1801
  }
1707
1802
  }
1708
1803
 
1804
+ function environmentSetApiPath(code, suffix = '') {
1805
+ const base = '/openxiangda-api/v1/environment-sets';
1806
+ return code
1807
+ ? `${base}/${encodeURIComponent(code)}${suffix}`
1808
+ : base;
1809
+ }
1810
+
1811
+ function defaultEnvironmentSideEffectPolicy(kind) {
1812
+ return kind === 'production'
1813
+ ? {
1814
+ notifications: 'real',
1815
+ organizationWrites: 'explicit_capability_only',
1816
+ scheduledAutomations: 'enabled',
1817
+ externalWrites: 'configured',
1818
+ payments: 'configured',
1819
+ publicIndexing: 'configured',
1820
+ environmentBanner: false,
1821
+ }
1822
+ : {
1823
+ notifications: 'tester_allowlist',
1824
+ notificationAllowlist: [],
1825
+ organizationWrites: 'deny',
1826
+ scheduledAutomations: 'disabled',
1827
+ externalWrites: 'allowlist',
1828
+ payments: 'deny',
1829
+ publicIndexing: 'deny',
1830
+ environmentBanner: true,
1831
+ };
1832
+ }
1833
+
1834
+ function saveEnvironmentWorkspaceState(state, setDetail, environmentDetail, input) {
1835
+ state.logicalApp = {
1836
+ id: setDetail.id,
1837
+ code: setDetail.code,
1838
+ name: setDetail.name,
1839
+ sourceRepositoryId: setDetail.sourceRepositoryId,
1840
+ updatedAt: new Date().toISOString(),
1841
+ };
1842
+ const legacyBound = state.profiles?.[input.profile];
1843
+ const seedResources =
1844
+ legacyBound?.appType === environmentDetail.appType
1845
+ ? legacyBound.resources
1846
+ : undefined;
1847
+ const bound = bindEnvironmentTarget(state, {
1848
+ targetName: input.targetName,
1849
+ profile: input.profile,
1850
+ environmentId: environmentDetail.id,
1851
+ kind: environmentDetail.kind,
1852
+ appType: environmentDetail.appType,
1853
+ displayName: environmentDetail.displayName,
1854
+ publicOrigin: environmentDetail.publicOrigin,
1855
+ sideEffectPolicy: environmentDetail.sideEffectPolicy,
1856
+ seedResources,
1857
+ });
1858
+ ensureResourceBuckets(bound);
1859
+ saveProjectState(state);
1860
+ return bound;
1861
+ }
1862
+
1863
+ async function environment(args) {
1864
+ const { subcommand, rest } = parseSubcommandArgs(args);
1865
+ const { flags, positional } = parseArgs(rest);
1866
+ if (wantsSubcommandHelp(subcommand, flags)) {
1867
+ print([
1868
+ '用法: openxiangda environment init --logical-app <code> --name <name> --kind preproduction --app-type APP_XXX [--target name] [--profile name]',
1869
+ ' openxiangda environment attach --logical-app <code> [--preproduction-target name] [--production-target name] [--profile name]',
1870
+ ' openxiangda environment bind preproduction|production --app-type APP_XXX [--target name] [--profile name]',
1871
+ ' openxiangda environment list|status|diff [--logical-app code] [--profile name] [--json]',
1872
+ ' openxiangda environment use <target|preproduction|production>',
1873
+ ].join('\n'));
1874
+ return;
1875
+ }
1876
+ const config = loadConfig();
1877
+ const state = loadProjectState();
1878
+
1879
+ if (subcommand === 'use') {
1880
+ const selected = resolveEnvironmentTarget(
1881
+ state,
1882
+ positional[0] || flags.environment || flags.target
1883
+ );
1884
+ state.currentTarget = selected.targetName;
1885
+ saveProjectState(state);
1886
+ const result = {
1887
+ currentTarget: selected.targetName,
1888
+ kind: selected.binding.kind,
1889
+ appType: selected.binding.appType,
1890
+ };
1891
+ if (flags.json) return writeJson(result);
1892
+ print(
1893
+ `当前环境 target: ${result.currentTarget} (${result.kind}, ${result.appType})`
1894
+ );
1895
+ return;
1896
+ }
1897
+
1898
+ if (subcommand === 'list') {
1899
+ const profileName = flags.profile || config.currentProfile;
1900
+ const data = await requestWithAuth(
1901
+ config,
1902
+ profileName,
1903
+ environmentSetApiPath()
1904
+ );
1905
+ if (flags.json) return writeJson(data);
1906
+ print(JSON.stringify(data, null, 2));
1907
+ return;
1908
+ }
1909
+
1910
+ if (subcommand === 'init') {
1911
+ const code = readStringFlag(flags, 'logical-app');
1912
+ const name = readStringFlag(flags, 'name');
1913
+ const kind = normalizeEnvironmentKind(
1914
+ flags.kind || positional[0] || 'preproduction'
1915
+ );
1916
+ const appType = readStringFlag(flags, 'app-type');
1917
+ const profileName = flags.profile || config.currentProfile;
1918
+ if (!code || !name || !appType || !profileName) {
1919
+ fail(
1920
+ 'environment init 必须提供 --logical-app、--name、--app-type 和 --profile'
1921
+ );
1922
+ }
1923
+ const profile = getProfile(config, profileName);
1924
+ const sourceBase = readGitSourceBase(process.cwd(), 'HEAD');
1925
+ const sourceRepositoryId =
1926
+ readStringFlag(flags, 'source-repository-id') ||
1927
+ sourceBase.repositoryId ||
1928
+ sourceBase.repo;
1929
+ const sideEffectPolicy = flags['side-effect-policy-json']
1930
+ ? readJsonInput(
1931
+ flags['side-effect-policy-json'],
1932
+ 'side-effect-policy-json'
1933
+ )
1934
+ : defaultEnvironmentSideEffectPolicy(kind);
1935
+ const data = await requestWithAuth(
1936
+ config,
1937
+ profile.profileName,
1938
+ environmentSetApiPath(),
1939
+ {
1940
+ method: 'POST',
1941
+ body: {
1942
+ code,
1943
+ name,
1944
+ sourceRepositoryId,
1945
+ initialEnvironment: {
1946
+ kind,
1947
+ appType,
1948
+ displayName: flags['display-name'],
1949
+ publicOrigin: flags['public-origin'],
1950
+ sideEffectPolicy,
1951
+ },
1952
+ },
1953
+ }
1954
+ );
1955
+ const environmentDetail = (data.environments || []).find(
1956
+ item => item.kind === kind
1957
+ );
1958
+ if (!environmentDetail) {
1959
+ fail('平台创建环境组成功,但响应缺少初始 environment');
1960
+ }
1961
+ const bound = saveEnvironmentWorkspaceState(
1962
+ state,
1963
+ data,
1964
+ environmentDetail,
1965
+ {
1966
+ targetName: flags.target || flags.environment || kind,
1967
+ profile: profile.profileName,
1968
+ }
1969
+ );
1970
+ const result = { logicalApp: state.logicalApp, target: bound };
1971
+ if (flags.json) return writeJson(result);
1972
+ print(
1973
+ `应用环境组已创建: ${state.logicalApp.code}; ${bound.targetName}=${bound.appType}`
1974
+ );
1975
+ return;
1976
+ }
1977
+
1978
+ if (subcommand === 'attach') {
1979
+ const code = readStringFlag(flags, 'logical-app');
1980
+ const profileName = flags.profile || config.currentProfile;
1981
+ if (!code || !profileName) {
1982
+ fail('environment attach 必须提供 --logical-app 和 --profile');
1983
+ }
1984
+ const profile = getProfile(config, profileName);
1985
+ const setDetail = await requestWithAuth(
1986
+ config,
1987
+ profile.profileName,
1988
+ environmentSetApiPath(code)
1989
+ );
1990
+ const environments = setDetail.environments || [];
1991
+ if (environments.length === 0) {
1992
+ fail(`应用环境组没有可绑定环境: ${code}`);
1993
+ }
1994
+ const targets = [];
1995
+ for (const environmentDetail of environments) {
1996
+ const targetName =
1997
+ flags[`${environmentDetail.kind}-target`] ||
1998
+ environmentDetail.kind;
1999
+ targets.push(
2000
+ saveEnvironmentWorkspaceState(
2001
+ state,
2002
+ setDetail,
2003
+ environmentDetail,
2004
+ {
2005
+ targetName,
2006
+ profile: profile.profileName,
2007
+ }
2008
+ )
2009
+ );
2010
+ }
2011
+ const result = { logicalApp: state.logicalApp, targets };
2012
+ if (flags.json) return writeJson(result);
2013
+ print(
2014
+ `应用环境组已接入: ${state.logicalApp.code}; ${targets
2015
+ .map(target => `${target.targetName}=${target.appType}`)
2016
+ .join(', ')}`
2017
+ );
2018
+ return;
2019
+ }
2020
+
2021
+ const code =
2022
+ readStringFlag(flags, 'logical-app') || state.logicalApp?.code;
2023
+ if (!code) {
2024
+ fail('当前工作区未登记 logical app,请先执行 openxiangda environment init');
2025
+ }
2026
+ const selectedProfile =
2027
+ flags.profile ||
2028
+ Object.values(state.targets || {})[0]?.profile ||
2029
+ config.currentProfile;
2030
+
2031
+ if (subcommand === 'bind') {
2032
+ const kind = normalizeEnvironmentKind(positional[0] || flags.kind);
2033
+ const appType = readStringFlag(flags, 'app-type');
2034
+ if (!appType) fail('environment bind 必须提供 --app-type');
2035
+ const profile = getProfile(config, selectedProfile);
2036
+ const sideEffectPolicy = flags['side-effect-policy-json']
2037
+ ? readJsonInput(
2038
+ flags['side-effect-policy-json'],
2039
+ 'side-effect-policy-json'
2040
+ )
2041
+ : defaultEnvironmentSideEffectPolicy(kind);
2042
+ const environmentDetail = await requestWithAuth(
2043
+ config,
2044
+ profile.profileName,
2045
+ environmentSetApiPath(code, '/environments'),
2046
+ {
2047
+ method: 'POST',
2048
+ body: {
2049
+ kind,
2050
+ appType,
2051
+ displayName: flags['display-name'],
2052
+ publicOrigin: flags['public-origin'],
2053
+ sideEffectPolicy,
2054
+ },
2055
+ }
2056
+ );
2057
+ const setDetail = await requestWithAuth(
2058
+ config,
2059
+ profile.profileName,
2060
+ environmentSetApiPath(code)
2061
+ );
2062
+ const bound = saveEnvironmentWorkspaceState(
2063
+ state,
2064
+ setDetail,
2065
+ environmentDetail,
2066
+ {
2067
+ targetName: flags.target || flags.environment || kind,
2068
+ profile: profile.profileName,
2069
+ }
2070
+ );
2071
+ if (flags.json) return writeJson(bound);
2072
+ print(`应用环境已绑定: ${bound.targetName}=${bound.appType}`);
2073
+ return;
2074
+ }
2075
+
2076
+ if (subcommand === 'status' || subcommand === 'diff') {
2077
+ const data = await requestWithAuth(
2078
+ config,
2079
+ selectedProfile,
2080
+ environmentSetApiPath(code, '/status')
2081
+ );
2082
+ const result =
2083
+ subcommand === 'diff'
2084
+ ? buildEnvironmentStatusDiff(data)
2085
+ : data;
2086
+ if (flags.json) return writeJson(result);
2087
+ print(JSON.stringify(result, null, 2));
2088
+ return;
2089
+ }
2090
+
2091
+ fail('用法: openxiangda environment init|attach|bind|list|status|diff|use');
2092
+ }
2093
+
2094
+ function buildEnvironmentStatusDiff(status) {
2095
+ const environments = status?.environments || [];
2096
+ const preproduction = environments.find(
2097
+ item => item.kind === 'preproduction'
2098
+ );
2099
+ const production = environments.find(item => item.kind === 'production');
2100
+ return {
2101
+ logicalApp: {
2102
+ id: status?.id || null,
2103
+ code: status?.code || null,
2104
+ name: status?.name || null,
2105
+ },
2106
+ preproduction: preproduction || null,
2107
+ production: production || null,
2108
+ drift: {
2109
+ sameAppRelease:
2110
+ Boolean(preproduction?.heads?.appReleaseId) &&
2111
+ preproduction?.heads?.appReleaseId ===
2112
+ production?.heads?.appReleaseId,
2113
+ candidate:
2114
+ preproduction?.latestDeployment?.candidateId || null,
2115
+ promotedCandidate:
2116
+ production?.latestDeployment?.candidateId || null,
2117
+ promotionReady:
2118
+ preproduction?.latestDeployment?.status === 'succeeded' &&
2119
+ preproduction?.latestDeployment?.candidateId !== null &&
2120
+ preproduction?.latestDeployment?.candidateId !== undefined,
2121
+ },
2122
+ };
2123
+ }
2124
+
2125
+ function readStudioGit(args) {
2126
+ const result = spawnSync('git', args, {
2127
+ cwd: process.cwd(),
2128
+ encoding: 'utf8',
2129
+ });
2130
+ return result.status === 0 ? String(result.stdout || '').trim() : '';
2131
+ }
2132
+
2133
+ async function studio(args) {
2134
+ const { flags } = parseArgs(args);
2135
+ const config = loadConfig();
2136
+ const initialState = loadProjectState();
2137
+ if (!initialState.logicalApp?.code) {
2138
+ fail(
2139
+ 'Developer Center 需要环境管理工作区;请先执行 openxiangda environment init'
2140
+ );
2141
+ }
2142
+ const getStatus = async () => {
2143
+ const state = loadProjectState();
2144
+ const selected = resolveEnvironmentTarget(
2145
+ state,
2146
+ state.currentTarget || 'preproduction'
2147
+ );
2148
+ const remote = await requestWithAuth(
2149
+ config,
2150
+ selected.binding.profile,
2151
+ environmentSetApiPath(state.logicalApp.code, '/status')
2152
+ );
2153
+ const porcelain = readStudioGit([
2154
+ 'status',
2155
+ '--porcelain',
2156
+ '--untracked-files=all',
2157
+ ]);
2158
+ return {
2159
+ workspace: process.cwd(),
2160
+ logicalApp: state.logicalApp,
2161
+ currentTarget: state.currentTarget,
2162
+ targets: state.targets,
2163
+ git: {
2164
+ branch: readStudioGit(['branch', '--show-current']),
2165
+ commit: readStudioGit(['rev-parse', 'HEAD']),
2166
+ clean: !porcelain,
2167
+ changes: porcelain ? porcelain.split(/\r?\n/).slice(0, 50) : [],
2168
+ },
2169
+ openxiangdaVersion: CURRENT_VERSION,
2170
+ remote,
2171
+ };
2172
+ };
2173
+ const runAction = async body => {
2174
+ const state = loadProjectState();
2175
+ const action = String(body?.action || '').trim();
2176
+ const value = String(body?.value || '').trim();
2177
+ const details = String(body?.details || '').trim();
2178
+ const pre = resolveEnvironmentTarget(state, 'preproduction');
2179
+ const production =
2180
+ action === 'promote' || action === 'rollback'
2181
+ ? resolveEnvironmentTarget(state, 'production')
2182
+ : null;
2183
+ if (action === 'candidate') {
2184
+ if (!value) throw new Error('请输入 OpenSpec changeId');
2185
+ return await runOpenXiangdaInProcess([
2186
+ 'release',
2187
+ 'candidate',
2188
+ '--change',
2189
+ value,
2190
+ '--environment',
2191
+ pre.targetName,
2192
+ ]);
2193
+ }
2194
+ if (action === 'deploy') {
2195
+ if (!value) throw new Error('请输入 candidateId');
2196
+ return await runOpenXiangdaInProcess([
2197
+ 'release',
2198
+ 'deploy',
2199
+ '--candidate',
2200
+ value,
2201
+ '--environment',
2202
+ pre.targetName,
2203
+ ]);
2204
+ }
2205
+ if (action === 'test') {
2206
+ if (!details) throw new Error('请输入测试证据 JSON');
2207
+ return await runOpenXiangdaInProcess([
2208
+ 'release',
2209
+ 'test',
2210
+ ...(value ? ['--deployment', value] : []),
2211
+ '--evidence-json',
2212
+ details,
2213
+ '--environment',
2214
+ pre.targetName,
2215
+ ]);
2216
+ }
2217
+ if (action === 'promote') {
2218
+ if (!body?.confirmProduction) {
2219
+ throw new Error('production action requires explicit confirmation');
2220
+ }
2221
+ if (!value) throw new Error('请输入 candidateId');
2222
+ return await runOpenXiangdaInProcess([
2223
+ 'release',
2224
+ 'promote',
2225
+ '--candidate',
2226
+ value,
2227
+ '--environment',
2228
+ production.targetName,
2229
+ '--confirm-production',
2230
+ ]);
2231
+ }
2232
+ if (action === 'rollback') {
2233
+ if (!body?.confirmProduction) {
2234
+ throw new Error('production action requires explicit confirmation');
2235
+ }
2236
+ if (!value || details.length < 8) {
2237
+ throw new Error('请输入目标 AppReleaseId 和至少 8 个字符的回退原因');
2238
+ }
2239
+ return await runOpenXiangdaInProcess([
2240
+ 'release',
2241
+ 'rollback',
2242
+ '--to',
2243
+ value,
2244
+ '--reason',
2245
+ details,
2246
+ '--environment',
2247
+ production.targetName,
2248
+ '--confirm-production',
2249
+ ]);
2250
+ }
2251
+ throw new Error(`未注册的 Developer Center 操作: ${action}`);
2252
+ };
2253
+ const port = flags.port === undefined ? 0 : Number(flags.port);
2254
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
2255
+ fail('studio --port 必须是 0-65535 的整数');
2256
+ }
2257
+ const center = await startDeveloperCenter({ port, getStatus, runAction });
2258
+ if (!flags['no-open']) openBrowser(center.url);
2259
+ const publicUrl = center.url.replace(/\?.*$/, '');
2260
+ print(
2261
+ flags['no-open']
2262
+ ? `OpenXiangda Developer Center: ${center.url}`
2263
+ : `OpenXiangda Developer Center 已启动: ${publicUrl}`
2264
+ );
2265
+ }
2266
+
2267
+ function requireLogicalApplication(target) {
2268
+ const logicalApp = target.logicalApp || target.state?.logicalApp;
2269
+ if (!logicalApp?.code || !logicalApp?.id) {
2270
+ fail(
2271
+ '当前工作区尚未登记 logical app;请先执行 openxiangda environment init'
2272
+ );
2273
+ }
2274
+ return logicalApp;
2275
+ }
2276
+
2277
+ function applicationDeploymentApiPath(logicalAppCode, deploymentId, action) {
2278
+ const suffix = deploymentId
2279
+ ? `/deployments/${encodeURIComponent(deploymentId)}${
2280
+ action ? `/${action}` : ''
2281
+ }`
2282
+ : '/deployments';
2283
+ return environmentSetApiPath(logicalAppCode, suffix);
2284
+ }
2285
+
2286
+ function rememberTargetDeployment(target, deployment) {
2287
+ target.bound.lastDeploymentId = deployment.id;
2288
+ target.bound.lastCandidateId = deployment.candidateId || null;
2289
+ target.bound.lastDeploymentStatus = deployment.status;
2290
+ target.bound.updatedAt = new Date().toISOString();
2291
+ saveProjectState(target.state);
2292
+ }
2293
+
2294
+ function assertLocalCandidate(candidate) {
2295
+ if (
2296
+ !candidate?.id ||
2297
+ !candidate?.candidateHash ||
2298
+ !candidate?.sourceBundleManifest ||
2299
+ sha256Canonical(candidate.sourceBundleManifest) !== candidate.candidateHash
2300
+ ) {
2301
+ fail('LOCAL_CANDIDATE_INVALID: 本地 candidate 文件缺失或哈希不一致');
2302
+ }
2303
+ assertReleaseSourceRevisionStable(candidate.sourceRevision, {
2304
+ cwd: process.cwd(),
2305
+ });
2306
+ return candidate;
2307
+ }
2308
+
2309
+ async function createApplicationCandidate(config, target, flags, positional) {
2310
+ const logicalApp = requireLogicalApplication(target);
2311
+ const changeId =
2312
+ readStringFlag(flags, 'change') || positional[0];
2313
+ if (!changeId) {
2314
+ fail('用法: openxiangda release candidate --change <id>');
2315
+ }
2316
+ prepareReleaseSourceRevision({ cwd: process.cwd() });
2317
+ const sourceRevision = readGitSourceBase(process.cwd(), 'HEAD');
2318
+ if (
2319
+ logicalApp.sourceRepositoryId !==
2320
+ (sourceRevision.repositoryId || sourceRevision.repo)
2321
+ ) {
2322
+ fail(
2323
+ 'CANDIDATE_REPOSITORY_MISMATCH: 当前 Git 仓库与 logical app 权威仓库不一致'
2324
+ );
2325
+ }
2326
+ const plan = buildWorkspaceReleasePlan({
2327
+ changed: true,
2328
+ since: flags.since,
2329
+ profile: target.profileName,
2330
+ skipSdd: true,
2331
+ changeId,
2332
+ expandDependencies: Boolean(flags['expand-dependencies']),
2333
+ });
2334
+ if (plan.errors.length > 0) {
2335
+ fail(
2336
+ `RELEASE_PLAN_INVALID: ${plan.errors
2337
+ .map(error => error.message || error.name)
2338
+ .join('; ')}`
2339
+ );
2340
+ }
2341
+ const verification = verifySddChange({
2342
+ cwd: process.cwd(),
2343
+ changeId,
2344
+ releasePlan: plan,
2345
+ writeMetadata: false,
2346
+ stage: 'prepublish',
2347
+ });
2348
+ if (!verification.passed) {
2349
+ fail(
2350
+ `SDD_PREPUBLISH_FAILED: ${verification.errors
2351
+ .map(error => error.message)
2352
+ .join('; ')}`
2353
+ );
2354
+ }
2355
+ const testPlan = flags['test-plan-json']
2356
+ ? readJsonInput(flags['test-plan-json'], 'test-plan-json')
2357
+ : undefined;
2358
+ const bundle = buildCandidateBundle({
2359
+ cwd: process.cwd(),
2360
+ logicalAppCode: logicalApp.code,
2361
+ changeId,
2362
+ sourceRevision,
2363
+ runtimeMode: plan.workspace.runtimeMode,
2364
+ targets: plan.activationTargets,
2365
+ files: plan.changedFiles,
2366
+ testPlan,
2367
+ openxiangdaVersion: CURRENT_VERSION,
2368
+ });
2369
+ const data = await requestWithAuth(
2370
+ config,
2371
+ target.profileName,
2372
+ environmentSetApiPath(logicalApp.code, '/candidates'),
2373
+ {
2374
+ method: 'POST',
2375
+ body: {
2376
+ candidateHash: bundle.candidateHash,
2377
+ changeId,
2378
+ sourceRepositoryId: logicalApp.sourceRepositoryId,
2379
+ sourceRevision,
2380
+ sourceBundleManifest: bundle.sourceBundleManifest,
2381
+ runtimeArtifactHash: null,
2382
+ runtimeArtifactLocator: null,
2383
+ testPlanHash: bundle.testPlanHash,
2384
+ openxiangdaVersion: CURRENT_VERSION,
2385
+ },
2386
+ }
2387
+ );
2388
+ const localCandidate = {
2389
+ ...data,
2390
+ logicalAppCode: logicalApp.code,
2391
+ sourceRevision,
2392
+ sourceBundleManifest: bundle.sourceBundleManifest,
2393
+ testPlan: bundle.testPlan,
2394
+ releasePlan: plan,
2395
+ savedAt: new Date().toISOString(),
2396
+ };
2397
+ const file = saveCandidate(localCandidate);
2398
+ return {
2399
+ candidate: localCandidate,
2400
+ candidateFile: path.relative(process.cwd(), file).replace(/\\/g, '/'),
2401
+ };
2402
+ }
2403
+
2404
+ async function markApplicationDeploymentFailed(
2405
+ config,
2406
+ target,
2407
+ logicalAppCode,
2408
+ deploymentId,
2409
+ error
2410
+ ) {
2411
+ try {
2412
+ await requestWithAuth(
2413
+ config,
2414
+ target.profileName,
2415
+ applicationDeploymentApiPath(
2416
+ logicalAppCode,
2417
+ deploymentId,
2418
+ 'fail'
2419
+ ),
2420
+ {
2421
+ method: 'POST',
2422
+ body: {
2423
+ code: error?.code || 'OPENXIANGDA_DEPLOYMENT_FAILED',
2424
+ message: maskText(error?.message || String(error)).slice(0, 2000),
2425
+ details: {
2426
+ command: 'openxiangda release',
2427
+ recordedAt: new Date().toISOString(),
2428
+ },
2429
+ },
2430
+ }
2431
+ );
2432
+ } catch (recordError) {
2433
+ warn(
2434
+ `部署失败记录未写入平台: ${maskText(recordError?.message || recordError)}`
2435
+ );
2436
+ }
2437
+ }
2438
+
2439
+ async function deployApplicationCandidate(
2440
+ config,
2441
+ target,
2442
+ candidate,
2443
+ options = {}
2444
+ ) {
2445
+ const logicalApp = requireLogicalApplication(target);
2446
+ assertLocalCandidate(candidate);
2447
+ const kind = options.kind || 'deploy';
2448
+ const body = {
2449
+ kind,
2450
+ targetEnvironmentId: target.environmentId,
2451
+ ...(options.sourceEnvironmentId
2452
+ ? { sourceEnvironmentId: options.sourceEnvironmentId }
2453
+ : {}),
2454
+ ...(kind === 'rollback'
2455
+ ? { rollbackTargetReleaseId: options.rollbackTargetReleaseId }
2456
+ : { candidateId: candidate.id }),
2457
+ };
2458
+ const deployment = await requestWithAuth(
2459
+ config,
2460
+ target.profileName,
2461
+ applicationDeploymentApiPath(logicalApp.code),
2462
+ { method: 'POST', body }
2463
+ );
2464
+ rememberTargetDeployment(target, deployment);
2465
+ try {
2466
+ const published = await publishWorkspaceRelease(config, target, {
2467
+ ...options.flags,
2468
+ change: candidate.changeId,
2469
+ environment: target.targetName,
2470
+ 'candidate-id': candidate.id,
2471
+ 'deployment-id': deployment.id,
2472
+ });
2473
+ const detail = await requestWithAuth(
2474
+ config,
2475
+ target.profileName,
2476
+ applicationDeploymentApiPath(logicalApp.code, deployment.id)
2477
+ );
2478
+ rememberTargetDeployment(target, detail);
2479
+ return { deployment: detail, published };
2480
+ } catch (error) {
2481
+ await markApplicationDeploymentFailed(
2482
+ config,
2483
+ target,
2484
+ logicalApp.code,
2485
+ deployment.id,
2486
+ error
2487
+ );
2488
+ throw error;
2489
+ }
2490
+ }
2491
+
2492
+ function buildBoundDeploymentEvidence(input, target, deployment, candidate) {
2493
+ const source = input || {};
2494
+ const finishedAt = source.finishedAt || new Date().toISOString();
2495
+ const finishedAtMs = Date.parse(finishedAt);
2496
+ if (!Number.isFinite(finishedAtMs)) {
2497
+ fail('测试证据 finishedAt 无效');
2498
+ }
2499
+ const validUntil =
2500
+ source.validUntil ||
2501
+ new Date(finishedAtMs + 24 * 60 * 60 * 1000).toISOString();
2502
+ return {
2503
+ ...source,
2504
+ schemaVersion: 1,
2505
+ deploymentId: deployment.id,
2506
+ candidateId: candidate?.id || null,
2507
+ candidateHash: candidate?.candidateHash || null,
2508
+ testPlanHash: candidate?.testPlanHash || null,
2509
+ environmentId: target.environmentId,
2510
+ environmentKind: target.environmentKind,
2511
+ appType: target.appType,
2512
+ appReleaseId: deployment.targetAppReleaseId,
2513
+ outcome: source.outcome || 'passed',
2514
+ startedAt: source.startedAt || finishedAt,
2515
+ finishedAt,
2516
+ validUntil,
2517
+ };
2518
+ }
2519
+
2520
+ async function runApplicationEnvironmentReleaseCommand(
2521
+ config,
2522
+ target,
2523
+ subcommand,
2524
+ positional,
2525
+ flags
2526
+ ) {
2527
+ const logicalApp = requireLogicalApplication(target);
2528
+ if (subcommand === 'candidate') {
2529
+ return await createApplicationCandidate(
2530
+ config,
2531
+ target,
2532
+ flags,
2533
+ positional
2534
+ );
2535
+ }
2536
+
2537
+ const candidateId =
2538
+ readStringFlag(flags, 'candidate') ||
2539
+ readStringFlag(flags, 'candidate-id') ||
2540
+ positional[0] ||
2541
+ target.bound.lastCandidateId;
2542
+ if (subcommand === 'deploy') {
2543
+ if (target.environmentKind !== 'preproduction') {
2544
+ fail('release deploy 只允许目标为 preproduction');
2545
+ }
2546
+ if (!candidateId) fail('release deploy 必须提供 --candidate');
2547
+ return await deployApplicationCandidate(
2548
+ config,
2549
+ target,
2550
+ readCandidate(candidateId),
2551
+ { kind: 'deploy', flags }
2552
+ );
2553
+ }
2554
+
2555
+ if (subcommand === 'promote') {
2556
+ if (target.environmentKind !== 'production') {
2557
+ fail(
2558
+ 'release promote 必须使用 --environment production(或对应 production target)'
2559
+ );
2560
+ }
2561
+ if (!flags['confirm-production']) {
2562
+ fail('正式晋级必须显式提供 --confirm-production');
2563
+ }
2564
+ if (!candidateId) fail('release promote 必须提供 --candidate');
2565
+ const source = resolveEnvironmentTarget(target.state, 'preproduction');
2566
+ return await deployApplicationCandidate(
2567
+ config,
2568
+ target,
2569
+ readCandidate(candidateId),
2570
+ {
2571
+ kind: 'promotion',
2572
+ sourceEnvironmentId: source.binding.environmentId,
2573
+ flags,
2574
+ }
2575
+ );
2576
+ }
2577
+
2578
+ if (subcommand === 'test') {
2579
+ const deploymentId =
2580
+ readStringFlag(flags, 'deployment') ||
2581
+ readStringFlag(flags, 'deployment-id') ||
2582
+ target.bound.lastDeploymentId;
2583
+ if (!deploymentId || !flags['evidence-json']) {
2584
+ fail(
2585
+ 'release test 必须提供 --deployment <id>(首次可省略)和 --evidence-json <JSON|file>'
2586
+ );
2587
+ }
2588
+ const deployment = await requestWithAuth(
2589
+ config,
2590
+ target.profileName,
2591
+ applicationDeploymentApiPath(logicalApp.code, deploymentId)
2592
+ );
2593
+ const candidate = deployment.candidateId
2594
+ ? readCandidate(deployment.candidateId)
2595
+ : null;
2596
+ const evidence = buildBoundDeploymentEvidence(
2597
+ readJsonInput(flags['evidence-json'], 'evidence-json'),
2598
+ target,
2599
+ deployment,
2600
+ candidate
2601
+ );
2602
+ const completed = await requestWithAuth(
2603
+ config,
2604
+ target.profileName,
2605
+ applicationDeploymentApiPath(logicalApp.code, deploymentId, 'complete'),
2606
+ {
2607
+ method: 'POST',
2608
+ body: {
2609
+ evidenceSummary: evidence,
2610
+ evidenceHash: sha256Canonical(evidence),
2611
+ },
2612
+ }
2613
+ );
2614
+ rememberTargetDeployment(target, completed);
2615
+ return { deployment: completed, evidence };
2616
+ }
2617
+
2618
+ if (subcommand === 'rollback') {
2619
+ if (target.environmentKind !== 'production') {
2620
+ fail('release rollback 只允许 production target');
2621
+ }
2622
+ if (!flags['confirm-production']) {
2623
+ fail('正式回退必须显式提供 --confirm-production');
2624
+ }
2625
+ const rollbackTargetReleaseId =
2626
+ readStringFlag(flags, 'to') ||
2627
+ readStringFlag(flags, 'release-id') ||
2628
+ positional[0];
2629
+ const reason = readStringFlag(flags, 'reason');
2630
+ if (!rollbackTargetReleaseId || reason.length < 8) {
2631
+ fail(
2632
+ 'release rollback 必须提供 --to <AppReleaseId> 和至少 8 个字符的 --reason'
2633
+ );
2634
+ }
2635
+ const deployment = await requestWithAuth(
2636
+ config,
2637
+ target.profileName,
2638
+ applicationDeploymentApiPath(logicalApp.code),
2639
+ {
2640
+ method: 'POST',
2641
+ body: {
2642
+ kind: 'rollback',
2643
+ targetEnvironmentId: target.environmentId,
2644
+ rollbackTargetReleaseId,
2645
+ },
2646
+ }
2647
+ );
2648
+ rememberTargetDeployment(target, deployment);
2649
+ return {
2650
+ deployment,
2651
+ prepared: true,
2652
+ automaticActivation: false,
2653
+ reason:
2654
+ '总 AppRelease 回退记录已冻结;各历史子 Release 仍需生成新的审计式 rollback Release 后再原子激活,CLI 不会直接重激活旧行。',
2655
+ nextStep: `openxiangda release app-rollback ${rollbackTargetReleaseId} --change rollback-${deployment.id} --reason ${JSON.stringify(
2656
+ reason
2657
+ )} --environment ${target.targetName}`,
2658
+ };
2659
+ }
2660
+
2661
+ fail(`未知应用环境 Release 命令: ${subcommand}`);
2662
+ }
2663
+
1709
2664
  async function release(args) {
1710
2665
  const { subcommand, rest } = parseSubcommandArgs(args);
1711
2666
  const { flags, positional } = parseArgs(rest);
1712
2667
  if (wantsSubcommandHelp(subcommand, flags)) {
1713
2668
  print([
1714
2669
  '用法: openxiangda release publish|begin|status|explain|integration-status|renew|end [--change id] [--profile name] [--json]',
2670
+ ' openxiangda release candidate|deploy|test|promote|rollback [--candidate id] [--environment target] [--confirm-production] [--json]',
1715
2671
  ' openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]',
1716
2672
  ' openxiangda release app-capture|app-head|app-list|app-detail|app-diff|app-post-commit|app-retry|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort [releaseId] [--staged-resources-json <JSON|file>] [--activate-staged-children] [--profile name] [--json]',
1717
2673
  '常用流程:',
@@ -1748,6 +2704,22 @@ async function release(args) {
1748
2704
  flags.profile || config.currentProfile,
1749
2705
  flags
1750
2706
  );
2707
+ if (
2708
+ ['candidate', 'deploy', 'test', 'promote', 'rollback'].includes(
2709
+ subcommand
2710
+ )
2711
+ ) {
2712
+ const result = await runApplicationEnvironmentReleaseCommand(
2713
+ config,
2714
+ target,
2715
+ subcommand,
2716
+ positional,
2717
+ flags
2718
+ );
2719
+ if (flags.json) return writeJson(result);
2720
+ print(JSON.stringify(result, null, 2));
2721
+ return;
2722
+ }
1751
2723
  if (String(subcommand || '').startsWith('backend-')) {
1752
2724
  return await runBackendReleaseCommand(
1753
2725
  config,
@@ -1767,6 +2739,16 @@ async function release(args) {
1767
2739
  );
1768
2740
  }
1769
2741
  if (subcommand === 'publish') {
2742
+ if (target.environmentKind === 'production') {
2743
+ fail(
2744
+ 'PRODUCTION_PROMOTION_REQUIRED: production target 禁止 release publish;请使用 release promote --candidate <id> --environment production --confirm-production'
2745
+ );
2746
+ }
2747
+ if (target.environmentKind === 'preproduction') {
2748
+ fail(
2749
+ 'PREPRODUCTION_DEPLOYMENT_REQUIRED: 环境管理工作区请使用 release candidate + release deploy,确保候选和 deployment 可追踪'
2750
+ );
2751
+ }
1770
2752
  const result = await publishWorkspaceRelease(config, target, flags);
1771
2753
  if (flags.json) return writeJson(result);
1772
2754
  print(
@@ -1783,7 +2765,10 @@ async function release(args) {
1783
2765
  if (!changeId) {
1784
2766
  fail('用法: openxiangda release explain --change <id> [--json]');
1785
2767
  }
1786
- const execution = readReleaseExecution(changeId);
2768
+ const execution = readReleaseExecution(
2769
+ changeId,
2770
+ readStringFlag(flags, 'deployment-id')
2771
+ );
1787
2772
  const result = {
1788
2773
  changeId,
1789
2774
  execution,
@@ -1821,7 +2806,12 @@ async function release(args) {
1821
2806
  readStringFlag(flags, 'change') ||
1822
2807
  local?.changeId ||
1823
2808
  baseline?.changeId;
1824
- const execution = changeId ? readReleaseExecution(changeId) : null;
2809
+ const execution = changeId
2810
+ ? readReleaseExecution(
2811
+ changeId,
2812
+ readStringFlag(flags, 'deployment-id')
2813
+ )
2814
+ : null;
1825
2815
  const result = {
1826
2816
  remote,
1827
2817
  local,
@@ -2672,8 +3662,9 @@ async function fetchAppReleaseCapture(config, target, flags = {}) {
2672
3662
  return clonePlainJson(capture);
2673
3663
  }
2674
3664
 
2675
- function releaseSessionFile(changeId) {
2676
- return path.join(releaseExecutionDir(changeId), 'release-session.json');
3665
+ function releaseSessionFile(changeId, deploymentId) {
3666
+ const executionFile = releaseExecutionFile(changeId, deploymentId);
3667
+ return path.join(path.dirname(executionFile), 'release-session.json');
2677
3668
  }
2678
3669
 
2679
3670
  function saveFrozenAppReleaseCapture(
@@ -2683,12 +3674,13 @@ function saveFrozenAppReleaseCapture(
2683
3674
  lease,
2684
3675
  capture
2685
3676
  ) {
2686
- const file = releaseSessionFile(changeId);
3677
+ const file = releaseSessionFile(changeId, target.deploymentId);
2687
3678
  writePrivateJsonAtomic(file, {
2688
3679
  schemaVersion: 'openxiangda_release_session_v1',
2689
3680
  appType: target.appType,
2690
3681
  profile: target.profileName,
2691
3682
  changeId,
3683
+ deploymentId: target.deploymentId || null,
2692
3684
  baselineId: baseline?.baselineId || baseline?.id || null,
2693
3685
  leaseId: lease?.leaseId || lease?.id || null,
2694
3686
  sourceRevision: baseline?.releaseSourceRevision || null,
@@ -2701,7 +3693,10 @@ function saveFrozenAppReleaseCapture(
2701
3693
  function readFrozenAppReleaseCapture(target, flags = {}) {
2702
3694
  const changeId = readStringFlag(flags, 'change');
2703
3695
  if (!changeId) return null;
2704
- const file = releaseSessionFile(changeId);
3696
+ const file = releaseSessionFile(
3697
+ changeId,
3698
+ readStringFlag(flags, 'deployment-id') || target.deploymentId
3699
+ );
2705
3700
  if (!fs.existsSync(file)) return null;
2706
3701
  let session;
2707
3702
  try {
@@ -2713,7 +3708,9 @@ function readFrozenAppReleaseCapture(target, flags = {}) {
2713
3708
  session?.schemaVersion !== 'openxiangda_release_session_v1' ||
2714
3709
  session?.appType !== target.appType ||
2715
3710
  session?.profile !== target.profileName ||
2716
- session?.changeId !== changeId
3711
+ session?.changeId !== changeId ||
3712
+ (session?.deploymentId || null) !==
3713
+ (readStringFlag(flags, 'deployment-id') || target.deploymentId || null)
2717
3714
  ) {
2718
3715
  fail(
2719
3716
  'RELEASE_SESSION_CONTEXT_MISMATCH: 冻结的 App capture 与当前 app/profile/change 不一致'
@@ -2869,22 +3866,25 @@ function readStagedAppReleaseResources(flags = {}) {
2869
3866
  );
2870
3867
  }
2871
3868
 
2872
- function stagedResourcesFileForChange(changeId) {
3869
+ function stagedResourcesFileForChange(changeId, deploymentId) {
2873
3870
  const normalized = String(changeId || '').trim();
2874
3871
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
2875
3872
  fail('APP_RELEASE_STAGED_RESOURCES_CHANGE_INVALID: 自动聚合 staged resources 需要有效 --change');
2876
3873
  }
2877
- return path.join(
3874
+ const base = path.join(
2878
3875
  process.cwd(),
2879
3876
  '.openxiangda',
2880
3877
  'releases',
2881
- normalized,
3878
+ normalized
3879
+ );
3880
+ return path.join(
3881
+ deploymentId ? path.join(base, String(deploymentId)) : base,
2882
3882
  'staged-resources.json'
2883
3883
  );
2884
3884
  }
2885
3885
 
2886
- function stagedResourcesContextFileForChange(changeId) {
2887
- return stagedResourcesFileForChange(changeId).replace(
3886
+ function stagedResourcesContextFileForChange(changeId, deploymentId) {
3887
+ return stagedResourcesFileForChange(changeId, deploymentId).replace(
2888
3888
  /staged-resources\.json$/,
2889
3889
  'staged-resources.context.json'
2890
3890
  );
@@ -2902,14 +3902,23 @@ function currentStagedResourcesContext(target, changeId) {
2902
3902
  appType: target.appType,
2903
3903
  profile: target.profileName,
2904
3904
  changeId,
3905
+ deploymentId: target.deploymentId || null,
2905
3906
  baselineId: baseline.baselineId,
2906
3907
  clientSessionId: baseline.clientSessionId || null,
2907
3908
  };
2908
3909
  }
2909
3910
 
2910
- function writeStagedResourcesFiles(changeId, resources, context) {
2911
- const file = stagedResourcesFileForChange(changeId);
2912
- const contextFile = stagedResourcesContextFileForChange(changeId);
3911
+ function writeStagedResourcesFiles(
3912
+ changeId,
3913
+ resources,
3914
+ context,
3915
+ deploymentId
3916
+ ) {
3917
+ const file = stagedResourcesFileForChange(changeId, deploymentId);
3918
+ const contextFile = stagedResourcesContextFileForChange(
3919
+ changeId,
3920
+ deploymentId
3921
+ );
2913
3922
  fs.mkdirSync(path.dirname(file), { recursive: true });
2914
3923
  for (const [targetFile, value] of [
2915
3924
  [file, resources],
@@ -2926,17 +3935,29 @@ function writeStagedResourcesFiles(changeId, resources, context) {
2926
3935
 
2927
3936
  function initializeStagedAppReleaseResources(target, changeId) {
2928
3937
  const context = currentStagedResourcesContext(target, changeId);
2929
- return writeStagedResourcesFiles(changeId, [], {
2930
- ...context,
2931
- initializedAt: new Date().toISOString(),
2932
- });
3938
+ return writeStagedResourcesFiles(
3939
+ changeId,
3940
+ [],
3941
+ {
3942
+ ...context,
3943
+ initializedAt: new Date().toISOString(),
3944
+ },
3945
+ target.deploymentId
3946
+ );
2933
3947
  }
2934
3948
 
2935
3949
  function assertStagedResourcesContext(target, changeId) {
2936
3950
  const expected = currentStagedResourcesContext(target, changeId);
2937
- const contextFile = stagedResourcesContextFileForChange(changeId);
3951
+ const contextFile = stagedResourcesContextFileForChange(
3952
+ changeId,
3953
+ target.deploymentId
3954
+ );
2938
3955
  if (!fs.existsSync(contextFile)) {
2939
- if (!fs.existsSync(stagedResourcesFileForChange(changeId))) {
3956
+ if (
3957
+ !fs.existsSync(
3958
+ stagedResourcesFileForChange(changeId, target.deploymentId)
3959
+ )
3960
+ ) {
2940
3961
  initializeStagedAppReleaseResources(target, changeId);
2941
3962
  }
2942
3963
  }
@@ -2951,6 +3972,7 @@ function assertStagedResourcesContext(target, changeId) {
2951
3972
  'appType',
2952
3973
  'profile',
2953
3974
  'changeId',
3975
+ 'deploymentId',
2954
3976
  'baselineId',
2955
3977
  'clientSessionId',
2956
3978
  ]) {
@@ -2971,7 +3993,7 @@ function recordStagedAppReleaseResources(
2971
3993
  if (!changeId || !Array.isArray(resources) || resources.length === 0) {
2972
3994
  return null;
2973
3995
  }
2974
- const file = stagedResourcesFileForChange(changeId);
3996
+ const file = stagedResourcesFileForChange(changeId, target.deploymentId);
2975
3997
  const context = assertStagedResourcesContext(target, changeId);
2976
3998
  let existing = [];
2977
3999
  if (fs.existsSync(file)) {
@@ -2991,7 +4013,12 @@ function recordStagedAppReleaseResources(
2991
4013
  const rightKey = describeAppReleaseResource(right, 0, 'stagedResources').stableKey;
2992
4014
  return leftKey.localeCompare(rightKey);
2993
4015
  });
2994
- return writeStagedResourcesFiles(changeId, output, context);
4016
+ return writeStagedResourcesFiles(
4017
+ changeId,
4018
+ output,
4019
+ context,
4020
+ target.deploymentId
4021
+ );
2995
4022
  }
2996
4023
 
2997
4024
  function normalizeSatisfiedAppReleaseSelectors(values) {
@@ -3005,7 +4032,10 @@ function normalizeSatisfiedAppReleaseSelectors(values) {
3005
4032
  function recordSatisfiedAppReleaseSelectors(changeId, selectors, target) {
3006
4033
  const incoming = normalizeSatisfiedAppReleaseSelectors(selectors);
3007
4034
  if (!changeId || incoming.length === 0) return [];
3008
- const contextFile = stagedResourcesContextFileForChange(changeId);
4035
+ const contextFile = stagedResourcesContextFileForChange(
4036
+ changeId,
4037
+ target.deploymentId
4038
+ );
3009
4039
  const context = assertStagedResourcesContext(target, changeId);
3010
4040
  const satisfiedSelectors = normalizeSatisfiedAppReleaseSelectors([
3011
4041
  ...(context.satisfiedSelectors || []),
@@ -3353,12 +4383,21 @@ async function activateAppReleaseDetail(
3353
4383
  ? { activateStagedChildren: true }
3354
4384
  : {}),
3355
4385
  protocolVersion: options.protocolVersion,
4386
+ ...(options.environmentControl || {}),
3356
4387
  })
3357
4388
  ),
3358
4389
  }
3359
4390
  );
3360
4391
  }
3361
4392
 
4393
+ function appReleaseEnvironmentControl(flags = {}) {
4394
+ return stripUndefinedValues({
4395
+ environmentId: readStringFlag(flags, 'environment-id') || undefined,
4396
+ candidateId: readStringFlag(flags, 'candidate-id') || undefined,
4397
+ deploymentId: readStringFlag(flags, 'deployment-id') || undefined,
4398
+ });
4399
+ }
4400
+
3362
4401
  function summarizeAppReleaseHead(data) {
3363
4402
  const value =
3364
4403
  data && typeof data === 'object' && !Array.isArray(data) ? data : {};
@@ -3611,6 +4650,7 @@ async function runAppReleaseCommand(
3611
4650
  activateStagedChildren:
3612
4651
  activatesStagedChildren,
3613
4652
  protocolVersion: protocolVersion || undefined,
4653
+ environmentControl: appReleaseEnvironmentControl(flags),
3614
4654
  }
3615
4655
  );
3616
4656
  } else if (subcommand === 'app-finalize') {
@@ -3685,6 +4725,7 @@ async function runAppReleaseCommand(
3685
4725
  {
3686
4726
  activateStagedChildren: Boolean(stagedResources),
3687
4727
  protocolVersion,
4728
+ environmentControl: appReleaseEnvironmentControl(flags),
3688
4729
  }
3689
4730
  );
3690
4731
  const healthWait = flags.wait
@@ -12027,7 +13068,9 @@ async function commands(args) {
12027
13068
  'open-api credential list|get|create|update|rotate-secret [id]',
12028
13069
  'design gates|template|review [--topic code]',
12029
13070
  'sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive',
12030
- 'release publish|begin|status|integration-status|renew|end|backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry|app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort',
13071
+ 'environment init|attach|bind|list|status|diff|use',
13072
+ 'release candidate|deploy|test|promote|rollback|publish|begin|status|integration-status|renew|end|backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry|app-capture|app-head|app-list|app-detail|app-diff|app-prepare|app-verify|app-activate|app-finalize|app-rollback|app-abort',
13073
+ 'studio [--no-open] [--port 0]',
12031
13074
  'env',
12032
13075
  'workspace init|bind|plan|check|publish [--app-name] [--changed|--since|--form|--page|--only|--dry-run|--force|--resources|--skip-resources|--prune]',
12033
13076
  'app list|create|snapshot',
@@ -12221,8 +13264,40 @@ function printSkillBootstrapReport(result) {
12221
13264
  }
12222
13265
 
12223
13266
  function getWorkspaceTarget(config, profileName, flags = {}) {
12224
- const resolved = getProfile(config, profileName);
12225
13267
  const state = loadProjectState();
13268
+ const environmentSelector =
13269
+ flags.environment || flags.target || state.currentTarget;
13270
+ if (
13271
+ environmentSelector &&
13272
+ state.logicalApp &&
13273
+ Object.keys(state.targets || {}).length > 0
13274
+ ) {
13275
+ const selected = resolveEnvironmentTarget(state, environmentSelector);
13276
+ const targetProfileName = selected.binding.profile;
13277
+ if (profileName && profileName !== targetProfileName) {
13278
+ fail(
13279
+ `target ${selected.targetName} 固定使用 profile ${targetProfileName},不能改写为 ${profileName}`
13280
+ );
13281
+ }
13282
+ const resolved = getProfile(config, targetProfileName);
13283
+ if (!selected.binding.appType || !selected.binding.environmentId) {
13284
+ fail(`target ${selected.targetName} 缺少 appType 或 environmentId`);
13285
+ }
13286
+ ensureResourceBuckets(selected.binding);
13287
+ return {
13288
+ profileName: resolved.profileName,
13289
+ profile: resolved.profile,
13290
+ state,
13291
+ bound: selected.binding,
13292
+ appType: selected.binding.appType,
13293
+ targetName: selected.targetName,
13294
+ environmentId: selected.binding.environmentId,
13295
+ environmentKind: selected.binding.kind,
13296
+ deploymentId: readStringFlag(flags, 'deployment-id') || null,
13297
+ logicalApp: state.logicalApp,
13298
+ };
13299
+ }
13300
+ const resolved = getProfile(config, profileName);
12226
13301
  const bound = state.profiles?.[resolved.profileName] || {};
12227
13302
  const appType = flags['app-type'] || bound.appType;
12228
13303
  if (!appType) {
@@ -13130,6 +14205,12 @@ function saveNotificationTypeConfigResource(target, configCode, configId, extra
13130
14205
  }
13131
14206
 
13132
14207
  function prepareStateResourceWrite(target) {
14208
+ const managedBinding = resolveManagedStateBinding(target);
14209
+ if (managedBinding) {
14210
+ ensureResourceBuckets(managedBinding);
14211
+ target.bound = managedBinding;
14212
+ return managedBinding;
14213
+ }
13133
14214
  target.state.profiles = target.state.profiles || {};
13134
14215
  target.state.profiles[target.profileName] = {
13135
14216
  ...target.bound,
@@ -13156,15 +14237,7 @@ function saveFormPermissionGroupResource(target, groupCode, groupId, extra = {})
13156
14237
  }
13157
14238
 
13158
14239
  function saveStateResource(target, bucket, code, value = {}, keys = Object.keys(value)) {
13159
- target.state.profiles = target.state.profiles || {};
13160
- target.state.profiles[target.profileName] = {
13161
- ...target.bound,
13162
- baseUrl: target.profile.baseUrl,
13163
- appType: target.appType,
13164
- updatedAt: new Date().toISOString(),
13165
- };
13166
- const nextBound = target.state.profiles[target.profileName];
13167
- ensureResourceBuckets(nextBound);
14240
+ const nextBound = prepareStateResourceWrite(target);
13168
14241
  const previous = nextBound.resources[bucket]?.[code] || {};
13169
14242
  nextBound.resources[bucket] = nextBound.resources[bucket] || {};
13170
14243
  nextBound.resources[bucket][code] = {