openxiangda 1.0.172 → 1.0.173

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
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const os = require('os');
5
5
  const { builtinModules } = require('module');
6
- const { spawnSync } = require('child_process');
6
+ const { spawn, spawnSync } = require('child_process');
7
7
  const { pathToFileURL } = require('url');
8
8
  const esbuild = require('esbuild');
9
9
  const {
@@ -20,6 +20,7 @@ const {
20
20
  } = require('./config');
21
21
  const { requestJson: rawRequestJson } = require('./http');
22
22
  const { runWorkspaceJsCodeBuildBatch } = require('./js-code-build');
23
+ const { loadLocalFormSchema } = require('./form-schema-loader');
23
24
  const {
24
25
  buildGitBaseSourceArtifacts,
25
26
  canonicalJsonSha256,
@@ -63,6 +64,12 @@ const {
63
64
  inspectReleaseSourceIntegration,
64
65
  prepareReleaseSourceRevision,
65
66
  } = require('./release-mainline');
67
+ const {
68
+ buildWorkspaceReleaseCommands,
69
+ buildWorkspaceReleaseSteps,
70
+ normalizeReleaseTargets,
71
+ releasePlanHash,
72
+ } = require('./release-plan');
66
73
  const {
67
74
  analyzeManifestSourceDependencies,
68
75
  getImpactedResourceTargets,
@@ -221,7 +228,7 @@ Usage:
221
228
  openxiangda open-api credential list|get|create|update|rotate-secret [id] [--profile name] [--show-secret] [--yes] [--json]
222
229
  openxiangda design gates|template|review [--topic code] [--json]
223
230
  openxiangda sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive [change] [--change id] [--json]
224
- openxiangda release begin|status|integration-status|renew|end [--change id] [--profile name] [--json]
231
+ openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--json]
225
232
  openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]
226
233
  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]
227
234
  openxiangda env [--profile name]
@@ -729,9 +736,11 @@ async function sdd(args) {
729
736
  assertOrClaimWorktreeOwner({ cwd: process.cwd(), changeId });
730
737
  const result = createMainlineSddBundle({
731
738
  cwd: process.cwd(),
739
+ configText: readWorkspaceConfigText(),
732
740
  changeId,
733
741
  title: flags.title || flags.name,
734
742
  changes: flags.changes,
743
+ profile: flags.profile,
735
744
  force: Boolean(flags.force),
736
745
  });
737
746
  if (flags.json) return writeJson(result);
@@ -884,15 +893,434 @@ function parseSddAffectedFlags(flags) {
884
893
  };
885
894
  }
886
895
 
896
+ function releaseExecutionDir(changeId) {
897
+ return path.join(
898
+ process.cwd(),
899
+ '.openxiangda',
900
+ 'releases',
901
+ String(changeId || '').trim()
902
+ );
903
+ }
904
+
905
+ function releaseExecutionFile(changeId) {
906
+ return path.join(releaseExecutionDir(changeId), 'execution.json');
907
+ }
908
+
909
+ function writePrivateJsonAtomic(file, value) {
910
+ fs.mkdirSync(path.dirname(file), { recursive: true });
911
+ const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`;
912
+ fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`, {
913
+ mode: 0o600,
914
+ });
915
+ fs.renameSync(tempFile, file);
916
+ }
917
+
918
+ function readReleaseExecution(changeId) {
919
+ const file = releaseExecutionFile(changeId);
920
+ if (!fs.existsSync(file)) return null;
921
+ try {
922
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
923
+ } catch (error) {
924
+ fail(
925
+ `RELEASE_EXECUTION_INVALID: ${path.relative(process.cwd(), file)} 无法读取: ${error.message}`
926
+ );
927
+ }
928
+ }
929
+
930
+ function summarizeReleaseStepResult(result) {
931
+ const value =
932
+ result && typeof result === 'object' && !Array.isArray(result)
933
+ ? result
934
+ : {};
935
+ const findId = candidate =>
936
+ String(
937
+ candidate?.id ||
938
+ candidate?.releaseId ||
939
+ candidate?.activeAppReleaseId ||
940
+ ''
941
+ ).trim() || null;
942
+ return {
943
+ resultHash: canonicalJsonSha256(value),
944
+ releaseId:
945
+ findId(value.activated) ||
946
+ findId(value.verified) ||
947
+ findId(value.prepared) ||
948
+ findId(value),
949
+ };
950
+ }
951
+
952
+ function parseChildJsonOutput(stdout) {
953
+ const text = String(stdout || '').trim();
954
+ if (!text) return null;
955
+ try {
956
+ return JSON.parse(text);
957
+ } catch {
958
+ const lines = text.split(/\r?\n/);
959
+ for (let index = 0; index < lines.length; index += 1) {
960
+ const candidate = lines.slice(index).join('\n').trim();
961
+ if (!candidate.startsWith('{') && !candidate.startsWith('[')) continue;
962
+ try {
963
+ return JSON.parse(candidate);
964
+ } catch {
965
+ // Continue until the first complete JSON payload is found.
966
+ }
967
+ }
968
+ return { output: maskText(text).slice(-4000) };
969
+ }
970
+ }
971
+
972
+ function runOpenXiangdaChild(args, options = {}) {
973
+ return new Promise((resolve, reject) => {
974
+ const bin = path.join(__dirname, '..', 'bin', 'openxiangda.js');
975
+ const child = spawn(process.execPath, [bin, ...args, '--json'], {
976
+ cwd: process.cwd(),
977
+ env: process.env,
978
+ stdio: ['ignore', 'pipe', 'pipe'],
979
+ });
980
+ let stdout = '';
981
+ let stderr = '';
982
+ child.stdout.on('data', chunk => {
983
+ stdout += String(chunk);
984
+ });
985
+ child.stderr.on('data', chunk => {
986
+ const text = String(chunk);
987
+ stderr += text;
988
+ if (!options.quiet) warn(maskText(text).trimEnd());
989
+ });
990
+ child.on('error', reject);
991
+ child.on('close', code => {
992
+ if (code === 0) {
993
+ resolve(parseChildJsonOutput(stdout));
994
+ return;
995
+ }
996
+ const message = maskText(
997
+ String(stderr || stdout || `openxiangda ${args.join(' ')} failed`)
998
+ ).trim();
999
+ const error = new Error(message);
1000
+ error.code =
1001
+ message.match(/\b([A-Z][A-Z0-9_]{3,})\b/)?.[1] ||
1002
+ 'RELEASE_STEP_FAILED';
1003
+ error.exitCode = code;
1004
+ reject(error);
1005
+ });
1006
+ });
1007
+ }
1008
+
1009
+ function normalizeReleaseWaitSeconds(flags) {
1010
+ const raw =
1011
+ readStringFlag(flags, 'wait-seconds') ||
1012
+ readStringFlag(flags, 'lease-wait-seconds');
1013
+ if (raw === '') return 1800;
1014
+ const value = Number(raw);
1015
+ if (!Number.isFinite(value) || value < 0 || value > 86400) {
1016
+ fail('--wait-seconds 必须是 0-86400 之间的秒数');
1017
+ }
1018
+ return Math.floor(value);
1019
+ }
1020
+
1021
+ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1022
+ if (getUsableStoredPublishLease(target)) return { waitedMs: 0 };
1023
+ const waitSeconds = normalizeReleaseWaitSeconds(flags);
1024
+ const startedAt = Date.now();
1025
+ const deadline = startedAt + waitSeconds * 1000;
1026
+ const clientSessionId = createPublishClientSessionId();
1027
+ let attempts = 0;
1028
+ while (true) {
1029
+ const remote = await fetchPublishLeaseStatus(
1030
+ config,
1031
+ target,
1032
+ clientSessionId
1033
+ );
1034
+ if (!remote?.active) {
1035
+ return { waitedMs: Date.now() - startedAt, attempts, remote };
1036
+ }
1037
+ const remoteHolder =
1038
+ (typeof remote?.holder === 'string' ? remote.holder : null) ||
1039
+ remote?.holder?.changeId ||
1040
+ remote?.changeId ||
1041
+ remote?.holder?.clientSessionId ||
1042
+ 'another release';
1043
+ if (Date.now() >= deadline) {
1044
+ fail(
1045
+ `PUBLISH_LEASE_WAIT_TIMEOUT: ${target.appType} 仍由 ${remoteHolder} 发布中;已等待 ${waitSeconds}s`
1046
+ );
1047
+ }
1048
+ attempts += 1;
1049
+ const suggestedSeconds = Number(
1050
+ remote?.retryAfterSeconds || remote?.retryAfter || 5
1051
+ );
1052
+ const delayMs = Math.min(
1053
+ Math.max(Number.isFinite(suggestedSeconds) ? suggestedSeconds * 1000 : 5000, 1000),
1054
+ 30000,
1055
+ Math.max(1000, deadline - Date.now())
1056
+ );
1057
+ warn(
1058
+ `发布队列等待中: app=${target.appType} holder=${remoteHolder} retry=${Math.ceil(delayMs / 1000)}s`
1059
+ );
1060
+ await sleep(delayMs);
1061
+ }
1062
+ }
1063
+
1064
+ async function publishWorkspaceRelease(config, target, flags = {}) {
1065
+ const changeId = readStringFlag(flags, 'change');
1066
+ if (!changeId) {
1067
+ fail('用法: openxiangda release publish --change <id> --profile <name>');
1068
+ }
1069
+ const plan = buildWorkspaceReleasePlan({
1070
+ changed: true,
1071
+ since: flags.since,
1072
+ profile: target.profileName,
1073
+ skipSdd: true,
1074
+ changeId,
1075
+ expandDependencies: Boolean(flags['expand-dependencies']),
1076
+ });
1077
+ if (plan.errors.length > 0) {
1078
+ fail(
1079
+ `RELEASE_PLAN_INVALID: ${plan.errors
1080
+ .map(error => error.message || error.name)
1081
+ .join('; ')}`
1082
+ );
1083
+ }
1084
+ const steps = buildWorkspaceReleaseSteps(
1085
+ plan.targets,
1086
+ plan.workspace.runtimeMode,
1087
+ target.profileName,
1088
+ changeId
1089
+ );
1090
+ const planHash = releasePlanHash({
1091
+ schemaVersion: 'openxiangda_release_execution_v1',
1092
+ appType: target.appType,
1093
+ profile: target.profileName,
1094
+ changeId,
1095
+ runtimeMode: plan.workspace.runtimeMode,
1096
+ targets: plan.activationTargets,
1097
+ commands: steps.map(step => step.command),
1098
+ });
1099
+ if (flags['dry-run']) {
1100
+ return {
1101
+ dryRun: true,
1102
+ appType: target.appType,
1103
+ profile: target.profileName,
1104
+ changeId,
1105
+ planHash,
1106
+ targets: plan.targets,
1107
+ activationTargets: plan.activationTargets,
1108
+ steps,
1109
+ };
1110
+ }
1111
+ const verification = verifySddChange({
1112
+ cwd: process.cwd(),
1113
+ changeId,
1114
+ releasePlan: {
1115
+ ...plan,
1116
+ commands: steps.map(step => step.command),
1117
+ },
1118
+ writeMetadata: true,
1119
+ stage: 'prepublish',
1120
+ });
1121
+ if (!verification.passed) {
1122
+ fail(
1123
+ `SDD_PREPUBLISH_FAILED: ${verification.errors
1124
+ .map(error => error.message)
1125
+ .join('; ')}`
1126
+ );
1127
+ }
1128
+ if (steps.length === 0) {
1129
+ return {
1130
+ completed: true,
1131
+ noOp: true,
1132
+ appType: target.appType,
1133
+ profile: target.profileName,
1134
+ changeId,
1135
+ planHash,
1136
+ execution: null,
1137
+ };
1138
+ }
1139
+ const existing = readReleaseExecution(changeId);
1140
+ if (
1141
+ existing &&
1142
+ (existing.planHash !== planHash ||
1143
+ existing.appType !== target.appType ||
1144
+ existing.profile !== target.profileName)
1145
+ ) {
1146
+ fail(
1147
+ 'RELEASE_EXECUTION_PLAN_CHANGED: 已存在不同计划的执行日志;请先完成或人工核对旧发布,禁止静默覆盖'
1148
+ );
1149
+ }
1150
+ if (
1151
+ existing?.status === 'write-review-required' &&
1152
+ !flags['resume-after-review']
1153
+ ) {
1154
+ fail(
1155
+ 'RELEASE_WRITE_REVIEW_REQUIRED: 上次写请求结果不确定;请先只读核对 staged release,确认可重试后追加 --resume-after-review'
1156
+ );
1157
+ }
1158
+ const now = new Date().toISOString();
1159
+ const execution = existing || {
1160
+ schemaVersion: 'openxiangda_release_execution_v1',
1161
+ appType: target.appType,
1162
+ profile: target.profileName,
1163
+ changeId,
1164
+ planHash,
1165
+ status: 'planned',
1166
+ createdAt: now,
1167
+ updatedAt: now,
1168
+ stagedWriteOccurred: false,
1169
+ writeAttempted: false,
1170
+ steps: [
1171
+ {
1172
+ id: 'lease-and-capture',
1173
+ command: `openxiangda release begin --change ${changeId} --profile ${target.profileName} --freeze-app-capture`,
1174
+ status: 'pending',
1175
+ },
1176
+ ...steps.map(step => ({
1177
+ id: step.id,
1178
+ command: step.command,
1179
+ stagedKind: step.stagedKind,
1180
+ status: 'pending',
1181
+ })),
1182
+ {
1183
+ id: 'release-lease',
1184
+ command: `openxiangda release end --change ${changeId} --profile ${target.profileName}`,
1185
+ status: 'pending',
1186
+ },
1187
+ ],
1188
+ };
1189
+ if (execution.status === 'completed') {
1190
+ return {
1191
+ completed: true,
1192
+ resumed: true,
1193
+ appType: target.appType,
1194
+ profile: target.profileName,
1195
+ changeId,
1196
+ planHash,
1197
+ executionFile: path
1198
+ .relative(process.cwd(), releaseExecutionFile(changeId))
1199
+ .replace(/\\/g, '/'),
1200
+ execution,
1201
+ };
1202
+ }
1203
+ execution.status = 'running';
1204
+ execution.updatedAt = new Date().toISOString();
1205
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1206
+
1207
+ const runnable = [
1208
+ {
1209
+ id: 'lease-and-capture',
1210
+ args: [
1211
+ 'release',
1212
+ 'begin',
1213
+ '--change',
1214
+ changeId,
1215
+ '--profile',
1216
+ target.profileName,
1217
+ '--freeze-app-capture',
1218
+ ],
1219
+ },
1220
+ ...steps,
1221
+ {
1222
+ id: 'release-lease',
1223
+ args: [
1224
+ 'release',
1225
+ 'end',
1226
+ '--change',
1227
+ changeId,
1228
+ '--profile',
1229
+ target.profileName,
1230
+ ],
1231
+ },
1232
+ ];
1233
+ try {
1234
+ for (const step of runnable) {
1235
+ const journalStep = execution.steps.find(item => item.id === step.id);
1236
+ if (journalStep?.status === 'completed') continue;
1237
+ if (step.id === 'lease-and-capture') {
1238
+ await waitForPublishLeaseAvailability(config, target, flags);
1239
+ }
1240
+ journalStep.status = 'running';
1241
+ journalStep.startedAt = new Date().toISOString();
1242
+ if (
1243
+ step.id !== 'lease-and-capture' &&
1244
+ step.id !== 'release-lease' &&
1245
+ step.writes !== false
1246
+ ) {
1247
+ execution.writeAttempted = true;
1248
+ }
1249
+ execution.updatedAt = journalStep.startedAt;
1250
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1251
+ warn(`发布步骤开始: ${step.id}`);
1252
+ const result = await runOpenXiangdaChild(step.args);
1253
+ journalStep.status = 'completed';
1254
+ journalStep.completedAt = new Date().toISOString();
1255
+ journalStep.result = summarizeReleaseStepResult(result);
1256
+ if (step.stagedKind) execution.stagedWriteOccurred = true;
1257
+ execution.updatedAt = journalStep.completedAt;
1258
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1259
+ }
1260
+ execution.status = 'completed';
1261
+ execution.completedAt = new Date().toISOString();
1262
+ execution.updatedAt = execution.completedAt;
1263
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1264
+ return {
1265
+ completed: true,
1266
+ resumed: Boolean(existing),
1267
+ appType: target.appType,
1268
+ profile: target.profileName,
1269
+ changeId,
1270
+ planHash,
1271
+ executionFile: path
1272
+ .relative(process.cwd(), releaseExecutionFile(changeId))
1273
+ .replace(/\\/g, '/'),
1274
+ execution,
1275
+ };
1276
+ } catch (error) {
1277
+ const runningStep = execution.steps.find(step => step.status === 'running');
1278
+ if (runningStep) {
1279
+ runningStep.status = 'failed';
1280
+ runningStep.failedAt = new Date().toISOString();
1281
+ runningStep.error = {
1282
+ code: error.code || 'RELEASE_STEP_FAILED',
1283
+ message: maskText(error.message).slice(0, 1000),
1284
+ };
1285
+ }
1286
+ execution.status = execution.stagedWriteOccurred
1287
+ ? 'staged-resumable'
1288
+ : execution.writeAttempted
1289
+ ? 'write-review-required'
1290
+ : 'failed';
1291
+ execution.updatedAt = new Date().toISOString();
1292
+ writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1293
+ if (!execution.writeAttempted) {
1294
+ try {
1295
+ await runOpenXiangdaChild(
1296
+ [
1297
+ 'release',
1298
+ 'end',
1299
+ '--change',
1300
+ changeId,
1301
+ '--profile',
1302
+ target.profileName,
1303
+ ],
1304
+ { quiet: true }
1305
+ );
1306
+ } catch {
1307
+ // The original error remains authoritative; lease cleanup is best effort.
1308
+ }
1309
+ }
1310
+ throw error;
1311
+ }
1312
+ }
1313
+
887
1314
  async function release(args) {
888
1315
  const { subcommand, rest } = parseSubcommandArgs(args);
889
1316
  const { flags, positional } = parseArgs(rest);
890
1317
  if (wantsSubcommandHelp(subcommand, flags)) {
891
1318
  print([
892
- '用法: openxiangda release begin|status|integration-status|renew|end [--change id] [--profile name] [--json]',
1319
+ '用法: openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--json]',
893
1320
  ' openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]',
894
1321
  ' 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]',
895
1322
  '常用流程:',
1323
+ ' openxiangda release publish --change <id> --profile <name>',
896
1324
  ' openxiangda release begin --change <id> --profile <name>',
897
1325
  ' openxiangda resource publish function --only <code> --change <id> --profile <name>',
898
1326
  ' openxiangda runtime deploy --change <id> --profile <name>',
@@ -903,6 +1331,7 @@ async function release(args) {
903
1331
  ' openxiangda release backend-rollback <releaseId> --change <id> --reason "..." --profile <name>',
904
1332
  ' openxiangda release app-finalize --change <id> --staged-resources-json <JSON|file> --profile <name>',
905
1333
  '说明:',
1334
+ ' - publish 默认等待租约并按私有执行日志恢复;上次写结果不确定时必须只读核对后显式 --resume-after-review。',
906
1335
  ' - begin 获取应用级单写者 promotion lease;不同工作区仍可并行开发和验证。',
907
1336
  ' - 带 --change 的 resource/runtime 写命令在没有本地 lease 时会自动 begin,并在后续命令复用。',
908
1337
  ' - 子 Runtime/Page/Backend/Form Release 默认先 stage;app-finalize 用 --staged-resources-json 覆盖 capture 中的 changed children,并在同一事务中原子激活。',
@@ -941,6 +1370,16 @@ async function release(args) {
941
1370
  flags
942
1371
  );
943
1372
  }
1373
+ if (subcommand === 'publish') {
1374
+ const result = await publishWorkspaceRelease(config, target, flags);
1375
+ if (flags.json) return writeJson(result);
1376
+ print(
1377
+ result.dryRun
1378
+ ? `发布计划已生成(未写入): ${result.changeId} steps=${result.steps.length}`
1379
+ : `应用原子发布完成: ${result.appType} change=${result.changeId}`
1380
+ );
1381
+ return;
1382
+ }
944
1383
  const leaseIdFromArgs = positional[0] || readStringFlag(flags, 'lease-id');
945
1384
 
946
1385
  if (subcommand === 'status') {
@@ -1040,15 +1479,56 @@ async function release(args) {
1040
1479
  throw error;
1041
1480
  }
1042
1481
  const finalizedBaseline = finalizeReleaseBaseline(target, baseline);
1043
- const stagedResourcesFile = newReleaseLifecycle
1044
- ? initializeStagedAppReleaseResources(target, changeId)
1045
- : null;
1482
+ let frozenCapture = null;
1483
+ let releaseSessionFile = null;
1484
+ let stagedResourcesFile = null;
1485
+ try {
1486
+ if (flags['freeze-app-capture']) {
1487
+ frozenCapture = await fetchAppReleaseCapture(config, target, flags);
1488
+ releaseSessionFile = saveFrozenAppReleaseCapture(
1489
+ target,
1490
+ changeId,
1491
+ finalizedBaseline,
1492
+ lease,
1493
+ frozenCapture
1494
+ );
1495
+ }
1496
+ stagedResourcesFile = newReleaseLifecycle
1497
+ ? initializeStagedAppReleaseResources(target, changeId)
1498
+ : null;
1499
+ } catch (error) {
1500
+ if (newReleaseLifecycle) {
1501
+ try {
1502
+ await requestWithAuth(
1503
+ config,
1504
+ target.profileName,
1505
+ publishLeaseApiPath(
1506
+ target,
1507
+ `/${encodeURIComponent(lease.leaseId)}/release`
1508
+ ),
1509
+ { method: 'POST', body: { completion: 'local-prepare-failed' } }
1510
+ );
1511
+ } catch {
1512
+ // Preserve the local preparation failure as the primary error.
1513
+ }
1514
+ clearPublishLease(target, lease.leaseId);
1515
+ clearChangeBaseline(target, finalizedBaseline?.baselineId);
1516
+ releaseWorktreeOwner({ cwd: process.cwd() });
1517
+ }
1518
+ throw error;
1519
+ }
1046
1520
  const result = {
1047
1521
  ...lease,
1048
1522
  baselineId: finalizedBaseline?.baselineId,
1049
1523
  baselineHeadDigest: finalizedBaseline?.headDigest,
1050
1524
  sourceBase: finalizedBaseline?.sourceBase,
1051
1525
  releaseSourceRevision: finalizedBaseline?.releaseSourceRevision,
1526
+ ...(frozenCapture
1527
+ ? {
1528
+ captureHash: frozenCapture.captureHash,
1529
+ releaseSessionFile,
1530
+ }
1531
+ : {}),
1052
1532
  ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
1053
1533
  };
1054
1534
  if (flags.json) return writeJson(result);
@@ -1191,7 +1671,7 @@ async function release(args) {
1191
1671
  }
1192
1672
  }
1193
1673
 
1194
- fail('用法: openxiangda release begin|status|integration-status|renew|end|backend-*|app-* [--change id] [--profile name]');
1674
+ fail('用法: openxiangda release publish|begin|status|integration-status|renew|end|backend-*|app-* [--change id] [--profile name]');
1195
1675
  }
1196
1676
 
1197
1677
  async function fetchPublishLeaseStatus(config, target, clientSessionId) {
@@ -1460,6 +1940,74 @@ async function fetchAppReleaseCapture(config, target, flags = {}) {
1460
1940
  return clonePlainJson(capture);
1461
1941
  }
1462
1942
 
1943
+ function releaseSessionFile(changeId) {
1944
+ return path.join(releaseExecutionDir(changeId), 'release-session.json');
1945
+ }
1946
+
1947
+ function saveFrozenAppReleaseCapture(
1948
+ target,
1949
+ changeId,
1950
+ baseline,
1951
+ lease,
1952
+ capture
1953
+ ) {
1954
+ const file = releaseSessionFile(changeId);
1955
+ writePrivateJsonAtomic(file, {
1956
+ schemaVersion: 'openxiangda_release_session_v1',
1957
+ appType: target.appType,
1958
+ profile: target.profileName,
1959
+ changeId,
1960
+ baselineId: baseline?.baselineId || baseline?.id || null,
1961
+ leaseId: lease?.leaseId || lease?.id || null,
1962
+ sourceRevision: baseline?.releaseSourceRevision || null,
1963
+ capturedAt: new Date().toISOString(),
1964
+ capture,
1965
+ });
1966
+ return path.relative(process.cwd(), file).replace(/\\/g, '/');
1967
+ }
1968
+
1969
+ function readFrozenAppReleaseCapture(target, flags = {}) {
1970
+ const changeId = readStringFlag(flags, 'change');
1971
+ if (!changeId) return null;
1972
+ const file = releaseSessionFile(changeId);
1973
+ if (!fs.existsSync(file)) return null;
1974
+ let session;
1975
+ try {
1976
+ session = JSON.parse(fs.readFileSync(file, 'utf8'));
1977
+ } catch (error) {
1978
+ fail(`RELEASE_SESSION_INVALID: ${error.message}`);
1979
+ }
1980
+ if (
1981
+ session?.schemaVersion !== 'openxiangda_release_session_v1' ||
1982
+ session?.appType !== target.appType ||
1983
+ session?.profile !== target.profileName ||
1984
+ session?.changeId !== changeId
1985
+ ) {
1986
+ fail(
1987
+ 'RELEASE_SESSION_CONTEXT_MISMATCH: 冻结的 App capture 与当前 app/profile/change 不一致'
1988
+ );
1989
+ }
1990
+ const capture = session.capture;
1991
+ if (
1992
+ !capture ||
1993
+ !Array.isArray(capture.resources) ||
1994
+ !/^[a-f0-9]{64}$/i.test(String(capture.captureHash || ''))
1995
+ ) {
1996
+ fail('RELEASE_SESSION_CAPTURE_INVALID: 冻结的 App capture 不完整');
1997
+ }
1998
+ const expectedCaptureHash = readStringFlag(flags, 'expected-capture-hash');
1999
+ if (
2000
+ expectedCaptureHash &&
2001
+ expectedCaptureHash.toLowerCase() !==
2002
+ String(capture.captureHash).toLowerCase()
2003
+ ) {
2004
+ fail(
2005
+ `APP_RELEASE_CAPTURE_MOVED: 期望 capture ${expectedCaptureHash},冻结为 ${capture.captureHash}`
2006
+ );
2007
+ }
2008
+ return clonePlainJson(capture);
2009
+ }
2010
+
1463
2011
  function appReleaseWriteBody(releaseContext, extra = {}) {
1464
2012
  return {
1465
2013
  ...releaseContext.control,
@@ -1790,7 +2338,13 @@ function assertStagedAppReleaseScope(target, flags, resources) {
1790
2338
  }
1791
2339
  throw error;
1792
2340
  }
1793
- const targets = scope.targets || {};
2341
+ const runtimeMode = getWorkspaceRuntimeModeFromConfig(
2342
+ readWorkspaceConfigText()
2343
+ );
2344
+ const { activationTargets: targets } = normalizeReleaseTargets(
2345
+ scope.targets || {},
2346
+ runtimeMode
2347
+ );
1794
2348
  const normalized = normalizeStagedAppReleaseResources(resources);
1795
2349
  const byKind = kind => normalized.filter(resource => resource.kind === kind);
1796
2350
  const expectedBackendSelectors = [
@@ -2236,7 +2790,9 @@ async function runAppReleaseCommand(
2236
2790
  flags,
2237
2791
  'App Release finalize'
2238
2792
  );
2239
- const capture = await fetchAppReleaseCapture(config, target, flags);
2793
+ const capture =
2794
+ readFrozenAppReleaseCapture(target, flags) ||
2795
+ (await fetchAppReleaseCapture(config, target, flags));
2240
2796
  const frozenResources = overlayAppReleaseResources(
2241
2797
  capture.resources,
2242
2798
  stagedResources
@@ -8761,6 +9317,9 @@ async function resource(args) {
8761
9317
  fail('resource publish --only/--code 不能与 --prune 同时使用,避免删除未选中的资源');
8762
9318
  }
8763
9319
  const manifest = loadWorkspaceResources({ typeFilters, codeFilters });
9320
+ if (subcommand !== 'typegen') {
9321
+ await attachLocalFormSchemas(manifest);
9322
+ }
8764
9323
  if (
8765
9324
  subcommand === 'plan' &&
8766
9325
  process.env.OPENXIANGDA_GIT_BASE_RESOURCE_PLAN === '1'
@@ -9560,12 +10119,23 @@ async function runtime(args) {
9560
10119
  }
9561
10120
 
9562
10121
  async function loadRuntimeHeadApp(config, target) {
9563
- const snapshot = await requestWithAuth(
9564
- config,
9565
- target.profileName,
9566
- `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/snapshot`
9567
- );
9568
- return snapshot?.app || snapshot?.application || snapshot || {};
10122
+ try {
10123
+ return (
10124
+ (await requestWithAuth(
10125
+ config,
10126
+ target.profileName,
10127
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases/head`
10128
+ )) || {}
10129
+ );
10130
+ } catch (error) {
10131
+ if (!isHttpNotFound(error)) throw error;
10132
+ const snapshot = await requestWithAuth(
10133
+ config,
10134
+ target.profileName,
10135
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/snapshot`
10136
+ );
10137
+ return snapshot?.app || snapshot?.application || snapshot || {};
10138
+ }
9569
10139
  }
9570
10140
 
9571
10141
  function assertRuntimeSourceRevisionStable(beforeBuild, afterBuild) {
@@ -10180,7 +10750,7 @@ async function commands(args) {
10180
10750
  'open-api credential list|get|create|update|rotate-secret [id]',
10181
10751
  'design gates|template|review [--topic code]',
10182
10752
  'sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive',
10183
- 'release 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',
10753
+ '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',
10184
10754
  'env',
10185
10755
  'workspace init|bind|plan|check|publish [--app-name] [--changed|--since|--form|--page|--only|--dry-run|--force|--resources|--skip-resources|--prune]',
10186
10756
  'app list|create|snapshot',
@@ -11646,6 +12216,84 @@ function loadWorkspaceResources(options = {}) {
11646
12216
  return manifest;
11647
12217
  }
11648
12218
 
12219
+ async function attachLocalFormSchemas(manifest) {
12220
+ if (
12221
+ Array.isArray(manifest.resourceTypeFilters) &&
12222
+ !manifest.resourceTypeFilters.includes('formSettings')
12223
+ ) {
12224
+ return manifest;
12225
+ }
12226
+ const selectors = Array.isArray(manifest.__resourceCodeSelectors)
12227
+ ? manifest.__resourceCodeSelectors.filter(selector =>
12228
+ resourceCodeSelectorAppliesToKey(selector, 'formSettings')
12229
+ )
12230
+ : [];
12231
+ const candidateCodes = unique([
12232
+ ...(manifest.formSettings || []).map(getResourceItemCode),
12233
+ ...selectors.map(selector => selector.code),
12234
+ ]).filter(Boolean);
12235
+ if (candidateCodes.length === 0) return manifest;
12236
+
12237
+ const loadedCodes = new Set();
12238
+ for (const code of candidateCodes) {
12239
+ let localSchema;
12240
+ try {
12241
+ localSchema = await loadLocalFormSchema(process.cwd(), code);
12242
+ } catch (error) {
12243
+ const wrapped = new Error(
12244
+ `FORM_SCHEMA_LOAD_FAILED: ${code}: ${error.message}`
12245
+ );
12246
+ wrapped.code = 'FORM_SCHEMA_LOAD_FAILED';
12247
+ throw wrapped;
12248
+ }
12249
+ if (!localSchema) continue;
12250
+ loadedCodes.add(code);
12251
+ const existing = (manifest.formSettings || []).find(
12252
+ item => getResourceItemCode(item) === code
12253
+ );
12254
+ const merged = {
12255
+ ...(existing || {}),
12256
+ code,
12257
+ formCode: existing?.formCode || code,
12258
+ name: existing?.name ?? localSchema.name,
12259
+ schema: localSchema.schema,
12260
+ packages: localSchema.packages,
12261
+ formType: existing?.formType ?? localSchema.formType,
12262
+ __source: existing?.__source || path.join(
12263
+ process.cwd(),
12264
+ localSchema.sourcePath
12265
+ ),
12266
+ __dir: existing?.__dir || path.dirname(
12267
+ path.join(process.cwd(), localSchema.sourcePath)
12268
+ ),
12269
+ __localSchema: {
12270
+ sourcePath: localSchema.sourcePath,
12271
+ fieldCount: localSchema.fieldCount,
12272
+ },
12273
+ };
12274
+ if (existing) {
12275
+ Object.assign(existing, merged);
12276
+ } else {
12277
+ manifest.formSettings = manifest.formSettings || [];
12278
+ manifest.formSettings.push(merged);
12279
+ }
12280
+ }
12281
+ manifest.formSettings = (manifest.formSettings || []).sort((left, right) =>
12282
+ getResourceItemCode(left).localeCompare(getResourceItemCode(right))
12283
+ );
12284
+ if (
12285
+ loadedCodes.size > 0 &&
12286
+ Array.isArray(manifest.unmatchedResourceCodeFilters)
12287
+ ) {
12288
+ manifest.unmatchedResourceCodeFilters =
12289
+ manifest.unmatchedResourceCodeFilters.filter(raw => {
12290
+ const code = String(raw || '').replace(/^.*[:/]/, '');
12291
+ return !loadedCodes.has(code);
12292
+ });
12293
+ }
12294
+ return manifest;
12295
+ }
12296
+
11649
12297
  function readResourceItems(baseDir, spec, options = {}) {
11650
12298
  const items = [];
11651
12299
  const selectorEntries = options.selectorEntries || null;
@@ -20593,6 +21241,16 @@ function getWorkspaceImpactedResourceTargets(files) {
20593
21241
  return getImpactedResourceTargets(getWorkspaceSourceDependencyIndex(), files);
20594
21242
  }
20595
21243
 
21244
+ function scopeNeedsBackendDependencyAnalysis(scope) {
21245
+ const targets = scope?.targets || {};
21246
+ return (
21247
+ Boolean(targets.resources) ||
21248
+ ['functions', 'automations', 'workflows', 'jsCodeNodes'].some(
21249
+ key => (targets[key] || []).length > 0
21250
+ )
21251
+ );
21252
+ }
21253
+
20596
21254
  function addCodeResourceTargetFromPath(targets, file, prefix, key, options = {}) {
20597
21255
  if (!file.startsWith(prefix)) return false;
20598
21256
  const rest = file.slice(prefix.length);
@@ -20890,8 +21548,11 @@ function fileBelongsToChangeScope(file, scope, runtimeMode) {
20890
21548
 
20891
21549
  function getChangeScopedWorkspaceFiles(changeScope, runtimeMode, options = {}) {
20892
21550
  const dirtyFiles = getWorkspaceChangedFiles({ since: options.since });
20893
- const dependencyIndex = getWorkspaceSourceDependencyIndex();
21551
+ const dependencyIndex = scopeNeedsBackendDependencyAnalysis(changeScope)
21552
+ ? getWorkspaceSourceDependencyIndex()
21553
+ : null;
20894
21554
  const dependencyBelongsToScope = file => {
21555
+ if (!dependencyIndex) return false;
20895
21556
  const impacted = getImpactedResourceTargets(dependencyIndex, [file]);
20896
21557
  return ['functions', 'automations', 'workflows', 'jsCodeNodes'].some(key =>
20897
21558
  (impacted[key] || []).some(code => scopeIncludesCode(changeScope.targets?.[key], code))
@@ -20986,87 +21647,6 @@ function scanWorkspaceReliabilityIssues(files) {
20986
21647
  return issues;
20987
21648
  }
20988
21649
 
20989
- function buildTargetedResourceReleaseCommands(targets, profileArg, changeId) {
20990
- const changeArg = changeId ? ` --change ${changeId}` : '';
20991
- const commands = [];
20992
- const functions = targets.functions || [];
20993
- const automations = targets.automations || [];
20994
- if (functions.length > 0 || automations.length > 0) {
20995
- const resourceTypes = [
20996
- ...(functions.length > 0 ? ['function'] : []),
20997
- ...(automations.length > 0 ? ['automation'] : []),
20998
- ];
20999
- const qualify = resourceTypes.length > 1;
21000
- const selectors = [
21001
- ...functions.map(code => (qualify ? `function:${code}` : code)),
21002
- ...automations.map(code => (qualify ? `automation:${code}` : code)),
21003
- ];
21004
- commands.push(
21005
- `openxiangda resource publish ${resourceTypes.join(',')} --only ${selectors.join(',')} --stage-only --profile ${profileArg}${changeArg}`
21006
- );
21007
- }
21008
- const workflows = targets.workflows || [];
21009
- if (workflows.length > 0) {
21010
- commands.push(
21011
- `openxiangda resource publish workflow --only ${workflows.join(',')} --profile ${profileArg}${changeArg}`
21012
- );
21013
- }
21014
- return commands;
21015
- }
21016
-
21017
- function buildWorkspaceReleaseCommands(targets, runtimeMode, profile, changeId) {
21018
- const profileArg = profile || '<profile>';
21019
- const commands = [];
21020
- const hasResourceTargets =
21021
- Boolean(targets.resources) ||
21022
- (targets.functions || []).length > 0 ||
21023
- (targets.automations || []).length > 0 ||
21024
- (targets.workflows || []).length > 0 ||
21025
- (targets.jsCodeNodes || []).length > 0;
21026
- if (runtimeMode === 'react-spa') {
21027
- if (targets.forms.length > 0) {
21028
- commands.push(
21029
- `openxiangda resource publish form-setting --only ${targets.forms.join(',')} --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
21030
- );
21031
- }
21032
- if (hasResourceTargets) {
21033
- commands.push(...buildTargetedResourceReleaseCommands(targets, profileArg, changeId));
21034
- }
21035
- if (targets.runtime) {
21036
- commands.push(
21037
- `openxiangda runtime deploy --no-activate --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
21038
- );
21039
- }
21040
- if (
21041
- targets.forms.length > 0 ||
21042
- (targets.functions || []).length > 0 ||
21043
- (targets.automations || []).length > 0 ||
21044
- targets.runtime
21045
- ) {
21046
- commands.push(
21047
- `openxiangda release app-finalize --staged-resources-json .openxiangda/releases/${changeId || 'change'}/staged-resources.json --profile ${profileArg}${changeId ? ` --change ${changeId}` : ''}`
21048
- );
21049
- }
21050
- return commands;
21051
- }
21052
-
21053
- const onlyTargets = [
21054
- ...targets.forms.map(code => `forms/${code}`),
21055
- ...targets.pages.map(code => `pages/${code}`),
21056
- ];
21057
- if (onlyTargets.length > 0) {
21058
- commands.push(
21059
- `openxiangda workspace publish --profile ${profileArg} --only ${onlyTargets.join(',')} --skip-resources${
21060
- changeId ? ` --change ${changeId}` : ''
21061
- }`
21062
- );
21063
- }
21064
- if (hasResourceTargets) {
21065
- commands.push(...buildTargetedResourceReleaseCommands(targets, profileArg, changeId));
21066
- }
21067
- return commands;
21068
- }
21069
-
21070
21650
  function buildWorkspaceReleasePlan(options = {}) {
21071
21651
  const configText = readWorkspaceConfigText();
21072
21652
  const runtimeMode = getWorkspaceRuntimeModeFromConfig(configText);
@@ -21095,7 +21675,7 @@ function buildWorkspaceReleasePlan(options = {}) {
21095
21675
  baseRef: options.since || 'HEAD',
21096
21676
  });
21097
21677
  let dependencyImpact = {};
21098
- if (changeScope) {
21678
+ if (changeScope && scopeNeedsBackendDependencyAnalysis(changeScope)) {
21099
21679
  const dependencyTargets = getWorkspaceImpactedResourceTargets(files);
21100
21680
  dependencyImpact = Object.fromEntries(
21101
21681
  ['functions', 'automations', 'workflows', 'jsCodeNodes'].map(key => [
@@ -21110,9 +21690,17 @@ function buildWorkspaceReleasePlan(options = {}) {
21110
21690
  // dozens of unrelated resources. Callers may opt in when the intended
21111
21691
  // change genuinely requires the whole dependency closure.
21112
21692
  if (options.expandDependencies) {
21113
- for (const key of ['functions', 'automations', 'workflows', 'jsCodeNodes']) {
21114
- targets[key] = unique([...(targets[key] || []), ...(dependencyTargets[key] || [])]).sort();
21115
- }
21693
+ for (const key of [
21694
+ 'functions',
21695
+ 'automations',
21696
+ 'workflows',
21697
+ 'jsCodeNodes',
21698
+ ]) {
21699
+ targets[key] = unique([
21700
+ ...(targets[key] || []),
21701
+ ...(dependencyTargets[key] || []),
21702
+ ]).sort();
21703
+ }
21116
21704
  }
21117
21705
  }
21118
21706
  if (options.includeResources) targets.resources = true;
@@ -21138,6 +21726,10 @@ function buildWorkspaceReleasePlan(options = {}) {
21138
21726
  name: issue.rule,
21139
21727
  message: `${issue.file}:${issue.line} ${issue.message}`,
21140
21728
  }));
21729
+ const normalizedReleaseTargets = normalizeReleaseTargets(
21730
+ targets,
21731
+ runtimeMode
21732
+ );
21141
21733
  const generatedCommands = buildWorkspaceReleaseCommands(
21142
21734
  targets,
21143
21735
  runtimeMode,
@@ -21226,6 +21818,7 @@ function buildWorkspaceReleasePlan(options = {}) {
21226
21818
  since: options.changed ? options.since || undefined : undefined,
21227
21819
  changedFiles: changeScope || options.changed ? files : undefined,
21228
21820
  targets,
21821
+ activationTargets: normalizedReleaseTargets.activationTargets,
21229
21822
  publishOrder:
21230
21823
  runtimeMode === 'react-spa'
21231
21824
  ? ['forms', 'resources', 'runtime']