openxiangda 1.0.197 → 1.0.199

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
@@ -130,6 +130,7 @@ const { buildDesignReview, renderDesignReview } = require('./design-review');
130
130
  const {
131
131
  bindEnvironmentTarget,
132
132
  buildCandidateBundle,
133
+ buildSideEffectPolicyDiff,
133
134
  isRecoverablePostActivationDeploymentError,
134
135
  normalizeEnvironmentKind,
135
136
  normalizeManagedChangeSourceBase,
@@ -299,9 +300,9 @@ Usage:
299
300
  openxiangda open-api credential list|get|create|update|rotate-secret [id] [--profile name] [--show-secret] [--yes] [--json]
300
301
  openxiangda design gates|template|review [--topic code] [--json]
301
302
  openxiangda sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive [change] [--change id] [--json]
302
- openxiangda environment init|attach|bind|swap|list|status|diff|use [preproduction|production] [--logical-app code] [--environment target] [--json]
303
+ openxiangda environment init|attach|bind|swap|policy update|list|status|diff|use [preproduction|production] [--logical-app code] [--environment target] [--json]
303
304
  openxiangda release publish|begin|status|integration-status|renew|end [--change id] [--profile name] [--watch] [--json]
304
- openxiangda release candidate|deploy|test|promote|rollback [--candidate id] [--environment target] [--json]
305
+ openxiangda release ship|candidate|deploy|test|promote|rollback [--candidate id] [--environment target] [--json]
305
306
  openxiangda task status --change <id> [--profile name] [--watch] [--json]
306
307
  openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]
307
308
  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]
@@ -384,6 +385,7 @@ const SUBCOMMAND_BOOLEAN_FLAGS = new Set([
384
385
  '--dry-run',
385
386
  '--enabled',
386
387
  '--force',
388
+ '--full-replace',
387
389
  '--help',
388
390
  '--include-sourcemaps',
389
391
  '--json',
@@ -1065,6 +1067,41 @@ function directPublishReceiptFile(changeId) {
1065
1067
  return path.join(releaseExecutionDir(changeId), 'direct-publish.json');
1066
1068
  }
1067
1069
 
1070
+ function managedShipFile(changeId) {
1071
+ return path.join(releaseExecutionDir(changeId), 'ship.json');
1072
+ }
1073
+
1074
+ function readManagedShip(changeId) {
1075
+ const file = managedShipFile(changeId);
1076
+ if (!fs.existsSync(file)) return null;
1077
+ try {
1078
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1079
+ } catch (error) {
1080
+ fail(
1081
+ `MANAGED_SHIP_INVALID: ${path.relative(process.cwd(), file)} 无法读取: ${error.message}`
1082
+ );
1083
+ }
1084
+ }
1085
+
1086
+ function writeManagedShip(changeId, value) {
1087
+ const existing = readManagedShip(changeId);
1088
+ const now = new Date().toISOString();
1089
+ const record = {
1090
+ schemaVersion: 'openxiangda_managed_ship_v1',
1091
+ changeId,
1092
+ createdAt: existing?.createdAt || now,
1093
+ ...(existing || {}),
1094
+ ...value,
1095
+ updatedAt: now,
1096
+ };
1097
+ const file = managedShipFile(changeId);
1098
+ writePrivateJsonAtomic(file, record);
1099
+ return {
1100
+ record,
1101
+ file: path.relative(process.cwd(), file).replace(/\\/g, '/'),
1102
+ };
1103
+ }
1104
+
1068
1105
  function writePrivateJsonAtomic(file, value) {
1069
1106
  fs.mkdirSync(path.dirname(file), { recursive: true });
1070
1107
  const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`;
@@ -1542,22 +1579,30 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1542
1579
  cwd: process.cwd(),
1543
1580
  });
1544
1581
  }
1545
- const verification = verifySddChange({
1546
- cwd: process.cwd(),
1547
- changeId,
1548
- releasePlan: {
1549
- ...plan,
1550
- commands: steps.map(step => step.command),
1551
- },
1552
- writeMetadata: false,
1553
- stage: 'prepublish',
1554
- });
1555
- if (!verification.passed) {
1556
- fail(
1557
- `SDD_PREPUBLISH_FAILED: ${verification.errors
1558
- .map(error => error.message)
1559
- .join('; ')}`
1582
+ const verifiedByCandidate =
1583
+ flags.__verifiedCandidate &&
1584
+ assertCandidatePrepublishVerification(
1585
+ flags.__verifiedCandidate,
1586
+ plan
1560
1587
  );
1588
+ if (!verifiedByCandidate) {
1589
+ const verification = verifySddChange({
1590
+ cwd: process.cwd(),
1591
+ changeId,
1592
+ releasePlan: {
1593
+ ...plan,
1594
+ commands: steps.map(step => step.command),
1595
+ },
1596
+ writeMetadata: false,
1597
+ stage: 'prepublish',
1598
+ });
1599
+ if (!verification.passed) {
1600
+ fail(
1601
+ `SDD_PREPUBLISH_FAILED: ${verification.errors
1602
+ .map(error => error.message)
1603
+ .join('; ')}`
1604
+ );
1605
+ }
1561
1606
  }
1562
1607
  if (steps.length === 0) {
1563
1608
  return {
@@ -1877,6 +1922,7 @@ function saveEnvironmentWorkspaceState(state, setDetail, environmentDetail, inpu
1877
1922
  displayName: environmentDetail.displayName,
1878
1923
  publicOrigin: environmentDetail.publicOrigin,
1879
1924
  sideEffectPolicy: environmentDetail.sideEffectPolicy,
1925
+ revision: environmentDetail.revision,
1880
1926
  seedResources,
1881
1927
  replaceResources: input.replaceResources,
1882
1928
  seedAppScopedState: input.seedAppScopedState,
@@ -1896,6 +1942,7 @@ async function environment(args) {
1896
1942
  ' openxiangda environment attach --logical-app <code> [--preproduction-target name] [--production-target name] [--profile name]',
1897
1943
  ' openxiangda environment bind preproduction|production --app-type APP_XXX [--target name] [--profile name]',
1898
1944
  ' openxiangda environment swap --reason <text> --confirm-production [--preproduction-side-effect-policy-json <JSON|file>] [--production-side-effect-policy-json <JSON|file>] [--profile name]',
1945
+ ' openxiangda environment policy update preproduction|production --side-effect-policy-json <JSON|file> --reason <text> [--dry-run] [--full-replace] [--confirm-production] [--profile name]',
1899
1946
  ' openxiangda environment list|status|diff [--logical-app code] [--profile name] [--json]',
1900
1947
  ' openxiangda environment use <target|preproduction|production>',
1901
1948
  ].join('\n'));
@@ -2056,6 +2103,133 @@ async function environment(args) {
2056
2103
  Object.values(state.targets || {})[0]?.profile ||
2057
2104
  config.currentProfile;
2058
2105
 
2106
+ if (subcommand === 'policy') {
2107
+ if (positional[0] !== 'update') {
2108
+ fail(
2109
+ '用法: openxiangda environment policy update preproduction|production --side-effect-policy-json <JSON|file> --reason <text> [--dry-run]',
2110
+ );
2111
+ }
2112
+ const selector = positional[1] || flags.environment || flags.target;
2113
+ if (!selector) {
2114
+ fail('environment policy update 必须指定环境 kind、id 或本地 target');
2115
+ }
2116
+ const reason = readStringFlag(flags, 'reason');
2117
+ if (!reason || reason.length < 10) {
2118
+ fail('environment policy update 必须提供至少 10 个字符的 --reason');
2119
+ }
2120
+ const policySource = readStringFlag(flags, 'side-effect-policy-json');
2121
+ if (!policySource) {
2122
+ fail('environment policy update 必须提供 --side-effect-policy-json');
2123
+ }
2124
+ const profile = getProfile(config, selectedProfile);
2125
+ const setDetail = await requestWithAuth(
2126
+ config,
2127
+ profile.profileName,
2128
+ environmentSetApiPath(code),
2129
+ );
2130
+ const localTarget = state.targets?.[selector];
2131
+ const environmentSelector = localTarget?.environmentId || selector;
2132
+ const matches = (setDetail.environments || []).filter(
2133
+ item =>
2134
+ item.id === environmentSelector || item.kind === environmentSelector,
2135
+ );
2136
+ if (matches.length !== 1) {
2137
+ fail(`应用环境不存在或不唯一: ${selector}`);
2138
+ }
2139
+ const environmentDetail = matches[0];
2140
+ const explicitExpectedRevision = readStringFlag(
2141
+ flags,
2142
+ 'expected-revision',
2143
+ );
2144
+ const expectedRevision = explicitExpectedRevision
2145
+ ? Number(explicitExpectedRevision)
2146
+ : Number(environmentDetail.revision);
2147
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
2148
+ fail('environment policy update 缺少有效的环境 revision');
2149
+ }
2150
+ const policyUpdate = readJsonInput(
2151
+ policySource,
2152
+ 'side-effect-policy-json',
2153
+ );
2154
+ const diff = buildSideEffectPolicyDiff(
2155
+ environmentDetail.sideEffectPolicy || {},
2156
+ policyUpdate,
2157
+ { fullReplace: Boolean(flags['full-replace']) },
2158
+ );
2159
+ const preview = {
2160
+ logicalApp: {
2161
+ id: setDetail.id,
2162
+ code: setDetail.code,
2163
+ },
2164
+ environment: {
2165
+ id: environmentDetail.id,
2166
+ kind: environmentDetail.kind,
2167
+ appType: environmentDetail.appType,
2168
+ revision: environmentDetail.revision,
2169
+ },
2170
+ expectedRevision,
2171
+ reason,
2172
+ dryRun: Boolean(flags['dry-run']),
2173
+ ...diff,
2174
+ };
2175
+ if (flags['dry-run'] || !diff.changed) {
2176
+ if (flags.json) return writeJson(preview);
2177
+ print(JSON.stringify(preview, null, 2));
2178
+ return;
2179
+ }
2180
+ if (
2181
+ environmentDetail.kind === 'production' &&
2182
+ !flags['confirm-production']
2183
+ ) {
2184
+ fail(
2185
+ 'environment policy update 正式环境写入必须提供 --confirm-production;--dry-run 不需要',
2186
+ );
2187
+ }
2188
+ const data = await requestWithAuth(
2189
+ config,
2190
+ profile.profileName,
2191
+ environmentSetApiPath(
2192
+ code,
2193
+ `/environments/${encodeURIComponent(environmentDetail.id)}/policy`,
2194
+ ),
2195
+ {
2196
+ method: 'POST',
2197
+ body: {
2198
+ expectedRevision,
2199
+ sideEffectPolicy: policyUpdate,
2200
+ reason,
2201
+ fullReplace: Boolean(flags['full-replace']),
2202
+ confirmProduction: Boolean(flags['confirm-production']),
2203
+ },
2204
+ },
2205
+ );
2206
+ const previousTarget = Object.values(state.targets || {}).find(
2207
+ target => target.environmentId === environmentDetail.id,
2208
+ );
2209
+ const updatedTarget = saveEnvironmentWorkspaceState(
2210
+ state,
2211
+ setDetail,
2212
+ data.environment,
2213
+ {
2214
+ targetName:
2215
+ previousTarget?.targetName ||
2216
+ localTarget?.targetName ||
2217
+ data.environment.kind,
2218
+ profile: previousTarget?.profile || profile.profileName,
2219
+ },
2220
+ );
2221
+ const result = {
2222
+ ...preview,
2223
+ dryRun: false,
2224
+ environment: data.environment,
2225
+ audit: data.audit,
2226
+ target: updatedTarget,
2227
+ };
2228
+ if (flags.json) return writeJson(result);
2229
+ print(JSON.stringify(result, null, 2));
2230
+ return;
2231
+ }
2232
+
2059
2233
  if (subcommand === 'swap') {
2060
2234
  if (!flags['confirm-production']) {
2061
2235
  fail(
@@ -2197,7 +2371,7 @@ async function environment(args) {
2197
2371
  return;
2198
2372
  }
2199
2373
 
2200
- fail('用法: openxiangda environment init|attach|bind|swap|list|status|diff|use');
2374
+ fail('用法: openxiangda environment init|attach|bind|swap|policy update|list|status|diff|use');
2201
2375
  }
2202
2376
 
2203
2377
  function buildEnvironmentStatusDiff(status) {
@@ -2564,6 +2738,65 @@ function assertLocalCandidate(candidate) {
2564
2738
  return candidate;
2565
2739
  }
2566
2740
 
2741
+ function candidateReleasePlanFingerprint(plan) {
2742
+ return sha256Canonical({
2743
+ schemaVersion: 'openxiangda_candidate_release_plan_v1',
2744
+ runtimeMode: plan.workspace.runtimeMode,
2745
+ targets: plan.activationTargets,
2746
+ files: unique(plan.changedFiles || []).sort(),
2747
+ commands: plan.commands || [],
2748
+ });
2749
+ }
2750
+
2751
+ function buildCandidatePrepublishVerification(
2752
+ changeId,
2753
+ sourceRevision,
2754
+ plan
2755
+ ) {
2756
+ const payload = {
2757
+ schemaVersion: 'openxiangda_candidate_prepublish_v1',
2758
+ stage: 'prepublish',
2759
+ passed: true,
2760
+ changeId,
2761
+ sourceRevisionHash: sha256Canonical(sourceRevision),
2762
+ releasePlanFingerprint: candidateReleasePlanFingerprint(plan),
2763
+ };
2764
+ return {
2765
+ ...payload,
2766
+ verificationHash: sha256Canonical(payload),
2767
+ };
2768
+ }
2769
+
2770
+ function assertCandidatePrepublishVerification(candidate, plan) {
2771
+ const attestation = candidate?.prepublishVerification;
2772
+ if (
2773
+ !attestation ||
2774
+ attestation.schemaVersion !==
2775
+ 'openxiangda_candidate_prepublish_v1' ||
2776
+ attestation.stage !== 'prepublish' ||
2777
+ attestation.passed !== true
2778
+ ) {
2779
+ return false;
2780
+ }
2781
+ const { verificationHash, ...payload } = attestation;
2782
+ if (
2783
+ !verificationHash ||
2784
+ sha256Canonical(payload) !== verificationHash ||
2785
+ candidate.sourceBundleManifest?.prepublishVerificationHash !==
2786
+ verificationHash ||
2787
+ payload.changeId !== candidate.changeId ||
2788
+ payload.sourceRevisionHash !==
2789
+ sha256Canonical(candidate.sourceRevision) ||
2790
+ payload.releasePlanFingerprint !==
2791
+ candidateReleasePlanFingerprint(plan)
2792
+ ) {
2793
+ fail(
2794
+ 'CANDIDATE_PREPUBLISH_ATTESTATION_INVALID: candidate 的预发布校验凭证与当前主线计划不一致'
2795
+ );
2796
+ }
2797
+ return true;
2798
+ }
2799
+
2567
2800
  async function createApplicationCandidate(config, target, flags, positional) {
2568
2801
  const logicalApp = requireLogicalApplication(target);
2569
2802
  const changeId =
@@ -2605,6 +2838,12 @@ async function createApplicationCandidate(config, target, flags, positional) {
2605
2838
  .join('; ')}`
2606
2839
  );
2607
2840
  }
2841
+ const prepublishVerification =
2842
+ buildCandidatePrepublishVerification(
2843
+ changeId,
2844
+ sourceRevision,
2845
+ plan
2846
+ );
2608
2847
  const testPlan = flags['test-plan-json']
2609
2848
  ? readJsonInput(flags['test-plan-json'], 'test-plan-json')
2610
2849
  : undefined;
@@ -2617,6 +2856,8 @@ async function createApplicationCandidate(config, target, flags, positional) {
2617
2856
  targets: plan.activationTargets,
2618
2857
  files: plan.changedFiles,
2619
2858
  testPlan,
2859
+ prepublishVerificationHash:
2860
+ prepublishVerification.verificationHash,
2620
2861
  openxiangdaVersion: CURRENT_VERSION,
2621
2862
  });
2622
2863
  const data = await requestWithAuth(
@@ -2645,6 +2886,7 @@ async function createApplicationCandidate(config, target, flags, positional) {
2645
2886
  sourceBundleManifest: bundle.sourceBundleManifest,
2646
2887
  testPlan: bundle.testPlan,
2647
2888
  releasePlan: plan,
2889
+ prepublishVerification,
2648
2890
  savedAt: new Date().toISOString(),
2649
2891
  };
2650
2892
  const file = saveCandidate(localCandidate);
@@ -2722,6 +2964,7 @@ async function deployApplicationCandidate(
2722
2964
  environment: target.targetName,
2723
2965
  'candidate-id': candidate.id,
2724
2966
  'deployment-id': deployment.id,
2967
+ __verifiedCandidate: candidate,
2725
2968
  });
2726
2969
  const detail = await requestWithAuth(
2727
2970
  config,
@@ -2797,6 +3040,299 @@ function buildBoundDeploymentEvidence(input, target, deployment, candidate) {
2797
3040
  };
2798
3041
  }
2799
3042
 
3043
+ function managedShipTestPlan() {
3044
+ return {
3045
+ schemaVersion: 1,
3046
+ requiredGates: [
3047
+ 'preproductionDeployment',
3048
+ 'cleanup',
3049
+ ],
3050
+ maximumEvidenceAgeHours: 24,
3051
+ policy: 'fast_explicit_confirmation_v1',
3052
+ };
3053
+ }
3054
+
3055
+ function managedEnvironmentTarget(config, state, kind) {
3056
+ const selected = resolveEnvironmentTarget(state, kind);
3057
+ return getWorkspaceTarget(
3058
+ config,
3059
+ selected.binding.profile,
3060
+ { environment: selected.targetName }
3061
+ );
3062
+ }
3063
+
3064
+ async function readManagedDeployment(
3065
+ config,
3066
+ target,
3067
+ logicalAppCode,
3068
+ deploymentId
3069
+ ) {
3070
+ const deployment = await requestWithAuth(
3071
+ config,
3072
+ target.profileName,
3073
+ applicationDeploymentApiPath(
3074
+ logicalAppCode,
3075
+ deploymentId
3076
+ )
3077
+ );
3078
+ rememberTargetDeployment(target, deployment);
3079
+ return deployment;
3080
+ }
3081
+
3082
+ async function runApplicationShipCommand(
3083
+ config,
3084
+ initialTarget,
3085
+ positional,
3086
+ flags
3087
+ ) {
3088
+ const changeId =
3089
+ readStringFlag(flags, 'change') || positional[0];
3090
+ if (!changeId) {
3091
+ fail(
3092
+ '用法: openxiangda release ship --change <id> [--confirm-production] [--acceptance-note "..."]'
3093
+ );
3094
+ }
3095
+ const logicalApp = requireLogicalApplication(initialTarget);
3096
+ const preproduction = managedEnvironmentTarget(
3097
+ config,
3098
+ initialTarget.state,
3099
+ 'preproduction'
3100
+ );
3101
+ const production = managedEnvironmentTarget(
3102
+ config,
3103
+ initialTarget.state,
3104
+ 'production'
3105
+ );
3106
+ const existingShip = readManagedShip(changeId);
3107
+ const confirmProduction = Boolean(flags['confirm-production']);
3108
+
3109
+ if (confirmProduction && !existingShip?.preproductionDeploymentId) {
3110
+ fail(
3111
+ 'PREPRODUCTION_PHASE_REQUIRED: 必须先单独执行 release ship 完成预发部署;确认预发结果后再携带 --confirm-production'
3112
+ );
3113
+ }
3114
+
3115
+ if (!confirmProduction) {
3116
+ let candidate = existingShip?.candidateId
3117
+ ? readCandidate(existingShip.candidateId)
3118
+ : null;
3119
+ if (!candidate) {
3120
+ const created = await createApplicationCandidate(
3121
+ config,
3122
+ preproduction,
3123
+ {
3124
+ ...flags,
3125
+ change: changeId,
3126
+ 'test-plan-json': JSON.stringify(
3127
+ managedShipTestPlan()
3128
+ ),
3129
+ },
3130
+ [changeId]
3131
+ );
3132
+ candidate = created.candidate;
3133
+ writeManagedShip(changeId, {
3134
+ status: 'candidate_created',
3135
+ candidateId: candidate.id,
3136
+ candidateHash: candidate.candidateHash,
3137
+ sourceRevision: candidate.sourceRevision,
3138
+ candidateFile: created.candidateFile,
3139
+ });
3140
+ } else {
3141
+ assertLocalCandidate(candidate);
3142
+ }
3143
+
3144
+ let deployment = null;
3145
+ if (existingShip?.preproductionDeploymentId) {
3146
+ deployment = await readManagedDeployment(
3147
+ config,
3148
+ preproduction,
3149
+ logicalApp.code,
3150
+ existingShip.preproductionDeploymentId
3151
+ );
3152
+ }
3153
+ if (
3154
+ !deployment ||
3155
+ !['deployed', 'succeeded'].includes(deployment.status)
3156
+ ) {
3157
+ const deployed = await deployApplicationCandidate(
3158
+ config,
3159
+ preproduction,
3160
+ candidate,
3161
+ { kind: 'deploy', flags }
3162
+ );
3163
+ deployment = deployed.deployment;
3164
+ }
3165
+ const saved = writeManagedShip(changeId, {
3166
+ status: 'awaiting_production_confirmation',
3167
+ candidateId: candidate.id,
3168
+ candidateHash: candidate.candidateHash,
3169
+ sourceRevision: candidate.sourceRevision,
3170
+ preproductionEnvironment: preproduction.targetName,
3171
+ preproductionDeploymentId: deployment.id,
3172
+ preproductionAppReleaseId:
3173
+ deployment.targetAppReleaseId || null,
3174
+ preproductionStatus: deployment.status,
3175
+ preparedAt:
3176
+ existingShip?.preparedAt ||
3177
+ new Date().toISOString(),
3178
+ nextCommand:
3179
+ `openxiangda release ship --change ${changeId} ` +
3180
+ '--confirm-production',
3181
+ });
3182
+ return {
3183
+ phase: 'preproduction',
3184
+ status: 'awaiting_production_confirmation',
3185
+ candidate,
3186
+ deployment,
3187
+ automaticPromotion: false,
3188
+ manualAcceptanceRecommended: true,
3189
+ manualAcceptanceRequired: false,
3190
+ shipFile: saved.file,
3191
+ nextCommand: saved.record.nextCommand,
3192
+ };
3193
+ }
3194
+
3195
+ const acceptanceNote = readStringFlag(
3196
+ flags,
3197
+ 'acceptance-note'
3198
+ );
3199
+ const candidate = readCandidate(existingShip.candidateId);
3200
+ assertLocalCandidate(candidate);
3201
+ let preproductionDeployment = await readManagedDeployment(
3202
+ config,
3203
+ preproduction,
3204
+ logicalApp.code,
3205
+ existingShip.preproductionDeploymentId
3206
+ );
3207
+ if (
3208
+ !['deployed', 'succeeded'].includes(
3209
+ preproductionDeployment.status
3210
+ ) ||
3211
+ !preproductionDeployment.targetAppReleaseId
3212
+ ) {
3213
+ fail(
3214
+ `PREPRODUCTION_NOT_READY_FOR_ACCEPTANCE: 预发 deployment ${preproductionDeployment.id} 当前状态为 ${preproductionDeployment.status}`
3215
+ );
3216
+ }
3217
+
3218
+ const confirmedAt = new Date().toISOString();
3219
+ const manualAcceptance = acceptanceNote
3220
+ ? {
3221
+ status: 'passed',
3222
+ note: acceptanceNote,
3223
+ acceptedAt: confirmedAt,
3224
+ }
3225
+ : null;
3226
+ if (preproductionDeployment.status !== 'succeeded') {
3227
+ const suppliedEvidence = flags['evidence-json']
3228
+ ? readJsonInput(flags['evidence-json'], 'evidence-json')
3229
+ : {};
3230
+ const requiredGates =
3231
+ candidate.testPlan?.requiredGates ||
3232
+ managedShipTestPlan().requiredGates;
3233
+ const evidence = buildBoundDeploymentEvidence(
3234
+ {
3235
+ ...suppliedEvidence,
3236
+ requiredGates,
3237
+ results: {
3238
+ ...(suppliedEvidence.results || {}),
3239
+ preproductionDeployment: {
3240
+ status: 'passed',
3241
+ appReleaseId:
3242
+ preproductionDeployment.targetAppReleaseId,
3243
+ },
3244
+ productionConfirmation: {
3245
+ status: 'passed',
3246
+ confirmedAt,
3247
+ note: acceptanceNote || null,
3248
+ },
3249
+ cleanup: {
3250
+ status: 'passed',
3251
+ residueCount: 0,
3252
+ reason:
3253
+ 'release ship does not create test business data',
3254
+ },
3255
+ },
3256
+ cleanup: {
3257
+ passed: true,
3258
+ residueCount: 0,
3259
+ reason:
3260
+ 'release ship does not create test business data',
3261
+ },
3262
+ ...(manualAcceptance
3263
+ ? { manualAcceptance }
3264
+ : {}),
3265
+ },
3266
+ preproduction,
3267
+ preproductionDeployment,
3268
+ candidate
3269
+ );
3270
+ preproductionDeployment = await requestWithAuth(
3271
+ config,
3272
+ preproduction.profileName,
3273
+ applicationDeploymentApiPath(
3274
+ logicalApp.code,
3275
+ preproductionDeployment.id,
3276
+ 'complete'
3277
+ ),
3278
+ {
3279
+ method: 'POST',
3280
+ body: {
3281
+ evidenceSummary: evidence,
3282
+ evidenceHash: sha256Canonical(evidence),
3283
+ },
3284
+ }
3285
+ );
3286
+ rememberTargetDeployment(
3287
+ preproduction,
3288
+ preproductionDeployment
3289
+ );
3290
+ }
3291
+
3292
+ writeManagedShip(changeId, {
3293
+ status: 'confirmed_for_production',
3294
+ productionConfirmation: {
3295
+ confirmedAt,
3296
+ profile: preproduction.profileName,
3297
+ acceptanceNote: acceptanceNote || null,
3298
+ },
3299
+ manualAcceptance,
3300
+ preproductionStatus: preproductionDeployment.status,
3301
+ });
3302
+ const promoted = await deployApplicationCandidate(
3303
+ config,
3304
+ production,
3305
+ candidate,
3306
+ {
3307
+ kind: 'promotion',
3308
+ sourceEnvironmentId:
3309
+ preproduction.environmentId,
3310
+ flags,
3311
+ }
3312
+ );
3313
+ const saved = writeManagedShip(changeId, {
3314
+ status: 'completed',
3315
+ productionEnvironment: production.targetName,
3316
+ productionDeploymentId: promoted.deployment.id,
3317
+ productionAppReleaseId:
3318
+ promoted.deployment.targetAppReleaseId || null,
3319
+ productionStatus: promoted.deployment.status,
3320
+ completedAt: new Date().toISOString(),
3321
+ });
3322
+ return {
3323
+ phase: 'production',
3324
+ status: 'completed',
3325
+ candidate,
3326
+ preproductionDeployment,
3327
+ productionDeployment: promoted.deployment,
3328
+ productionConfirmation:
3329
+ saved.record.productionConfirmation,
3330
+ manualAcceptance: saved.record.manualAcceptance,
3331
+ sameCandidate: true,
3332
+ shipFile: saved.file,
3333
+ };
3334
+ }
3335
+
2800
3336
  async function runApplicationEnvironmentReleaseCommand(
2801
3337
  config,
2802
3338
  target,
@@ -2805,6 +3341,14 @@ async function runApplicationEnvironmentReleaseCommand(
2805
3341
  flags
2806
3342
  ) {
2807
3343
  const logicalApp = requireLogicalApplication(target);
3344
+ if (subcommand === 'ship') {
3345
+ return await runApplicationShipCommand(
3346
+ config,
3347
+ target,
3348
+ positional,
3349
+ flags
3350
+ );
3351
+ }
2808
3352
  if (subcommand === 'candidate') {
2809
3353
  return await createApplicationCandidate(
2810
3354
  config,
@@ -2976,10 +3520,13 @@ async function release(args) {
2976
3520
  if (wantsSubcommandHelp(subcommand, flags)) {
2977
3521
  print([
2978
3522
  '用法: openxiangda release publish|begin|status|explain|integration-status|renew|end [--change id] [--profile name] [--json]',
2979
- ' openxiangda release candidate|deploy|reconcile|test|promote|rollback [--candidate id] [--environment target] [--confirm-production] [--json]',
3523
+ ' openxiangda release ship|candidate|deploy|reconcile|test|promote|rollback [--candidate id] [--environment target] [--confirm-production] [--json]',
2980
3524
  ' openxiangda release backend-head|backend-list|backend-detail|backend-diff|backend-rollback|backend-abort|backend-retry [releaseId] [--profile name] [--json]',
2981
3525
  ' 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]',
2982
3526
  '常用流程:',
3527
+ ' openxiangda release ship --change <id> --profile <name>',
3528
+ ' # 确认预发结果后;可选 --acceptance-note 留下人工验收记录:',
3529
+ ' openxiangda release ship --change <id> --profile <name> --confirm-production',
2983
3530
  ' openxiangda release publish --change <id> --profile <name>',
2984
3531
  ' openxiangda release begin --change <id> --profile <name>',
2985
3532
  ' openxiangda resource publish function --only <code> --change <id> --profile <name>',
@@ -2991,6 +3538,7 @@ async function release(args) {
2991
3538
  ' openxiangda release backend-rollback <releaseId> --change <id> --reason "..." --profile <name>',
2992
3539
  ' openxiangda release app-finalize --change <id> --staged-resources-json <JSON|file> --profile <name>',
2993
3540
  '说明:',
3541
+ ' - ship 是环境托管应用的两段式快车道:首次只部署预发并停止;确认预发结果后,第二次以同一 candidate 晋级正式。人工验收备注可选。',
2994
3542
  ' - publish 默认等待租约并按私有执行日志恢复;上次写结果不确定时必须只读核对后显式 --resume-after-review。',
2995
3543
  ' - app-head 默认只输出紧凑 head 摘要;需要完整 manifest 时显式追加 --full。',
2996
3544
  ' - begin 获取应用级单写者 promotion lease;不同工作区仍可并行开发和验证。',
@@ -3014,7 +3562,7 @@ async function release(args) {
3014
3562
  flags
3015
3563
  );
3016
3564
  if (
3017
- ['candidate', 'deploy', 'reconcile', 'test', 'promote', 'rollback'].includes(
3565
+ ['ship', 'candidate', 'deploy', 'reconcile', 'test', 'promote', 'rollback'].includes(
3018
3566
  subcommand
3019
3567
  )
3020
3568
  ) {
@@ -4349,7 +4897,7 @@ function recordSatisfiedAppReleaseSelectors(changeId, selectors, target) {
4349
4897
  changeId,
4350
4898
  target.deploymentId
4351
4899
  );
4352
- const context = assertStagedResourcesContext(target, changeId);
4900
+ assertStagedResourcesContext(target, changeId);
4353
4901
  const satisfiedSelectors = normalizeSatisfiedAppReleaseSelectors([
4354
4902
  ...(context.satisfiedSelectors || []),
4355
4903
  ...incoming,
@@ -7702,7 +8250,7 @@ async function form(args) {
7702
8250
  const { flags, positional } = parseArgs(rest);
7703
8251
  if (wantsSubcommandHelp(subcommand, flags)) {
7704
8252
  print([
7705
- '用法: openxiangda form list|create|bind|pull|export|publish|schema-plan [--profile name] [--json]',
8253
+ '用法: openxiangda form list|create|ensure|bind|pull|export|publish|schema-plan [--profile name] [--json]',
7706
8254
  'Form Release: release-head|release-list|release-detail|release-diff|release-activate|release-rollback|release-abort',
7707
8255
  '配置写会先冻结 snapshot,并携带 If-Match、expectedRevision、expectedParent;冲突后不会自动重放。',
7708
8256
  ].join('\n'));
@@ -7735,6 +8283,108 @@ async function form(args) {
7735
8283
  return;
7736
8284
  }
7737
8285
 
8286
+ if (subcommand === 'ensure') {
8287
+ const requested = unique(
8288
+ [
8289
+ flags.only,
8290
+ flags.code,
8291
+ flags.codes,
8292
+ ...positional,
8293
+ ]
8294
+ .filter(Boolean)
8295
+ .flatMap(value => String(value).split(','))
8296
+ .map(value => value.trim())
8297
+ .filter(Boolean)
8298
+ ).sort();
8299
+ if (requested.length === 0) {
8300
+ fail('用法: openxiangda form ensure --only <formCode1,formCode2>');
8301
+ }
8302
+ const target = getWorkspaceTarget(config, profileName, flags);
8303
+ await ensureDirectMutationPublishContext(
8304
+ config,
8305
+ target,
8306
+ flags,
8307
+ 'form ensure'
8308
+ );
8309
+ const results = [];
8310
+ for (const formCode of requested) {
8311
+ const existing = [
8312
+ target.bound.resources?.forms?.[formCode],
8313
+ target.bound.resources?.formSettings?.[formCode],
8314
+ ].find(entry => String(entry?.formUuid || '').trim());
8315
+ if (existing?.formUuid) {
8316
+ results.push({
8317
+ code: formCode,
8318
+ formUuid: existing.formUuid,
8319
+ action: 'noop',
8320
+ });
8321
+ continue;
8322
+ }
8323
+ const localSchema = await loadLocalFormSchema(
8324
+ process.cwd(),
8325
+ formCode
8326
+ );
8327
+ if (!localSchema) {
8328
+ fail(
8329
+ `FORM_ENSURE_SCHEMA_REQUIRED: ${formCode} 缺少 src/forms/${formCode}/schema.ts`
8330
+ );
8331
+ }
8332
+ const formUuid = managedFormUuidForCode(
8333
+ target.appType,
8334
+ formCode
8335
+ );
8336
+ let existingRemote = null;
8337
+ try {
8338
+ existingRemote = await requestWithAuth(
8339
+ config,
8340
+ target.profileName,
8341
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}`
8342
+ );
8343
+ } catch (error) {
8344
+ if (!isHttpNotFound(error)) throw error;
8345
+ }
8346
+ if (!existingRemote) {
8347
+ await requestWithAuth(
8348
+ config,
8349
+ target.profileName,
8350
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms`,
8351
+ {
8352
+ method: 'POST',
8353
+ headers: { 'if-match': '0' },
8354
+ body: {
8355
+ name: localSchema.name || formCode,
8356
+ formType: localSchema.formType || 'receipt',
8357
+ formUuid,
8358
+ createMenu: false,
8359
+ expectedRevision: 0,
8360
+ },
8361
+ }
8362
+ );
8363
+ }
8364
+ saveFormResource(target, formCode, formUuid, {
8365
+ name: localSchema.name || formCode,
8366
+ });
8367
+ results.push({
8368
+ code: formCode,
8369
+ formUuid,
8370
+ action: existingRemote ? 'bind' : 'create',
8371
+ });
8372
+ }
8373
+ const result = {
8374
+ appType: target.appType,
8375
+ profile: target.profileName,
8376
+ forms: results,
8377
+ summary: {
8378
+ create: results.filter(item => item.action === 'create').length,
8379
+ bind: results.filter(item => item.action === 'bind').length,
8380
+ noop: results.filter(item => item.action === 'noop').length,
8381
+ },
8382
+ };
8383
+ if (flags.json) return writeJson(result);
8384
+ print(JSON.stringify(result, null, 2));
8385
+ return;
8386
+ }
8387
+
7738
8388
  if (subcommand === 'create') {
7739
8389
  const [formCode] = positional;
7740
8390
  const name = flags.name || positional.slice(1).join(' ') || formCode;
@@ -7978,7 +8628,28 @@ async function form(args) {
7978
8628
  return outputFormResult(data, flags);
7979
8629
  }
7980
8630
 
7981
- fail('用法: openxiangda form list|create|bind|pull|export|publish|schema-plan|release-head|release-list|release-detail|release-diff|release-activate|release-rollback|release-abort');
8631
+ fail('用法: openxiangda form list|create|ensure|bind|pull|export|publish|schema-plan|release-head|release-list|release-detail|release-diff|release-activate|release-rollback|release-abort');
8632
+ }
8633
+
8634
+ function managedFormUuidForCode(appType, formCode) {
8635
+ const hex = crypto
8636
+ .createHash('sha256')
8637
+ .update(`openxiangda-managed-form-v1\0${appType}\0${formCode}`)
8638
+ .digest('hex')
8639
+ .slice(0, 32)
8640
+ .split('');
8641
+ hex[12] = '5';
8642
+ hex[16] = ['8', '9', 'a', 'b'][
8643
+ Number.parseInt(hex[16], 16) % 4
8644
+ ];
8645
+ const value = hex.join('');
8646
+ return [
8647
+ value.slice(0, 8),
8648
+ value.slice(8, 12),
8649
+ value.slice(12, 16),
8650
+ value.slice(16, 20),
8651
+ value.slice(20),
8652
+ ].join('-');
7982
8653
  }
7983
8654
 
7984
8655
  function pageApiPath(target, suffix = '') {
@@ -11842,7 +12513,7 @@ async function resource(args) {
11842
12513
  );
11843
12514
  try {
11844
12515
  const plan = await telemetry.runPhase('plan', async () =>
11845
- buildResourcePlan(config, target, manifest)
12516
+ buildResourcePlan(config, target, manifest, { flags })
11846
12517
  );
11847
12518
  if (dryRun) {
11848
12519
  plan.dryRun = true;
@@ -11860,7 +12531,7 @@ async function resource(args) {
11860
12531
  const telemetry = createCliReleaseTelemetry('resource publish');
11861
12532
  try {
11862
12533
  const publishPlan = await telemetry.runPhase('plan', async () =>
11863
- buildResourcePlan(config, target, manifest)
12534
+ buildResourcePlan(config, target, manifest, { flags })
11864
12535
  );
11865
12536
  if (publishPlan.functionSecretRefs?.valid === false) {
11866
12537
  const missing = publishPlan.functionSecretRefs.functions
@@ -13407,13 +14078,13 @@ async function commands(args) {
13407
14078
  'open-api credential list|get|create|update|rotate-secret [id]',
13408
14079
  'design gates|template|review [--topic code]',
13409
14080
  'sdd init|migrate|propose|quick|bundle|approve|status|context|verify|sync|archive',
13410
- 'environment init|attach|bind|swap|list|status|diff|use',
13411
- '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',
14081
+ 'environment init|attach|bind|swap|policy update|list|status|diff|use',
14082
+ 'release ship|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',
13412
14083
  'studio [--no-open] [--port 0]',
13413
14084
  'env',
13414
14085
  'workspace init|bind|plan|check|publish [--app-name] [--changed|--since|--form|--page|--only|--dry-run|--force|--resources|--skip-resources|--prune]',
13415
14086
  'app list|create|snapshot',
13416
- 'form list|create|bind|pull|export|publish|schema-plan|release-head|release-list|release-detail|release-diff|release-activate|release-rollback|release-abort',
14087
+ 'form list|create|ensure|bind|pull|export|publish|schema-plan|release-head|release-list|release-detail|release-diff|release-activate|release-rollback|release-abort',
13417
14088
  'page list|publish|bind|head|releases|detail|diff|activate|rollback',
13418
14089
  'menu list|create|update|sort|bind|delete',
13419
14090
  'workflow compile|list|create|bind|pull|publish|delete|validate',
@@ -16523,7 +17194,12 @@ async function publishResourcesForWorkspace(config, profileName, options = {}) {
16523
17194
  return result;
16524
17195
  }
16525
17196
 
16526
- async function buildResourcePlan(config, target, manifest) {
17197
+ async function buildResourcePlan(
17198
+ config,
17199
+ target,
17200
+ manifest,
17201
+ options = {}
17202
+ ) {
16527
17203
  return await withReadOnlyHttpGuard('resource plan', async () => {
16528
17204
  const secretCapabilities = await assertManifestAppFunctionSecretCapabilities(
16529
17205
  config,
@@ -16531,9 +17207,16 @@ async function buildResourcePlan(config, target, manifest) {
16531
17207
  manifest
16532
17208
  );
16533
17209
  await prepareManifestJsCodeBundlesForPlan(manifest);
17210
+ const stagedLocalFormSnapshots =
17211
+ await resolveStagedLocalFormContractSnapshots(
17212
+ target,
17213
+ options.flags || {}
17214
+ );
16534
17215
  const [existing, formFieldContracts] = await Promise.all([
16535
17216
  fetchExistingResourceMaps(config, target, manifest),
16536
- buildFormFieldContractPlan(config, target, manifest),
17217
+ buildFormFieldContractPlan(config, target, manifest, {
17218
+ stagedLocalFormSnapshots,
17219
+ }),
16537
17220
  ]);
16538
17221
  const actions = [];
16539
17222
  addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
@@ -24482,15 +25165,23 @@ function changedResourceCodesForFile(file, spec, baseRef = 'HEAD') {
24482
25165
  const codes = unique([...current.keys(), ...base.keys()]).filter(
24483
25166
  code => current.get(code) !== base.get(code)
24484
25167
  );
24485
- if (codes.length > 0) return codes.sort();
25168
+ if (codes.length > 0) {
25169
+ return {
25170
+ codes: codes.sort(),
25171
+ deletedCodes: codes
25172
+ .filter(code => base.has(code) && !current.has(code))
25173
+ .sort(),
25174
+ };
25175
+ }
24486
25176
  if (currentItems.some(item => item?.__invalid) || baseItems.some(item => item?.__invalid)) {
24487
- return ['*'];
25177
+ return { codes: ['*'], deletedCodes: [] };
24488
25178
  }
24489
- return [];
25179
+ return { codes: [], deletedCodes: [] };
24490
25180
  }
24491
25181
 
24492
25182
  function classifyGenericResourceFiles(files, baseRef = 'HEAD') {
24493
25183
  const resourceSelectors = {};
25184
+ const resourceDeletes = {};
24494
25185
  const formSettings = new Set();
24495
25186
  const unknown = new Set();
24496
25187
  const codeResourceKeys = new Set([
@@ -24506,7 +25197,8 @@ function classifyGenericResourceFiles(files, baseRef = 'HEAD') {
24506
25197
  continue;
24507
25198
  }
24508
25199
  if (codeResourceKeys.has(spec.key)) continue;
24509
- const codes = changedResourceCodesForFile(file, spec, baseRef);
25200
+ const diff = changedResourceCodesForFile(file, spec, baseRef);
25201
+ const codes = diff.codes;
24510
25202
  if (spec.key === 'formSettings') {
24511
25203
  for (const code of codes) formSettings.add(code);
24512
25204
  continue;
@@ -24516,6 +25208,12 @@ function classifyGenericResourceFiles(files, baseRef = 'HEAD') {
24516
25208
  ...(resourceSelectors[spec.key] || []),
24517
25209
  ...codes,
24518
25210
  ]).sort();
25211
+ if (diff.deletedCodes.length > 0) {
25212
+ resourceDeletes[spec.key] = unique([
25213
+ ...(resourceDeletes[spec.key] || []),
25214
+ ...diff.deletedCodes,
25215
+ ]).sort();
25216
+ }
24519
25217
  }
24520
25218
  if (unknown.size > 0) {
24521
25219
  resourceSelectors.unknown = Array.from(unknown).sort();
@@ -24523,6 +25221,7 @@ function classifyGenericResourceFiles(files, baseRef = 'HEAD') {
24523
25221
  return {
24524
25222
  formSettings: Array.from(formSettings).sort(),
24525
25223
  resourceSelectors,
25224
+ resourceDeletes,
24526
25225
  };
24527
25226
  }
24528
25227
 
@@ -24617,6 +25316,7 @@ function classifyWorkspaceFiles(files, runtimeMode, options = {}) {
24617
25316
  jsCodeNodes: Array.from(jsCodeNodes).sort(),
24618
25317
  resources,
24619
25318
  resourceSelectors: genericTargets.resourceSelectors,
25319
+ resourceDeletes: genericTargets.resourceDeletes,
24620
25320
  runtime,
24621
25321
  other,
24622
25322
  };
@@ -24825,6 +25525,11 @@ function buildWorkspaceReleasePlan(options = {}) {
24825
25525
  ]).sort(),
24826
25526
  ])
24827
25527
  ),
25528
+ resourceDeletes: Object.fromEntries(
25529
+ Object.entries(inferred.resourceDeletes || {}).map(
25530
+ ([key, codes]) => [key, unique(codes || []).sort()]
25531
+ )
25532
+ ),
24828
25533
  runtime: Boolean(changeScope.targets.runtime),
24829
25534
  other: [...(changeScope.targets.other || [])],
24830
25535
  };
@@ -25798,7 +26503,74 @@ async function runWorkspaceChildCommand(command, args, options = {}) {
25798
26503
  });
25799
26504
  }
25800
26505
 
25801
- async function buildFormFieldContractPlan(config, target, manifest) {
26506
+ async function resolveStagedLocalFormContractSnapshots(
26507
+ target,
26508
+ flags = {}
26509
+ ) {
26510
+ const formCodes = unique(
26511
+ String(flags['staged-form-contracts'] || '')
26512
+ .split(',')
26513
+ .map(value => value.trim())
26514
+ .filter(Boolean)
26515
+ ).sort();
26516
+ if (formCodes.length === 0) return new Map();
26517
+ const changeId = readStringFlag(flags, 'change');
26518
+ if (!changeId) {
26519
+ fail(
26520
+ 'FORM_FIELD_STAGED_CONTRACT_CHANGE_REQUIRED: --staged-form-contracts 必须与 --change 一起使用'
26521
+ );
26522
+ }
26523
+ const context = assertStagedResourcesContext(target, changeId);
26524
+ const file = stagedResourcesFileForChange(
26525
+ changeId,
26526
+ target.deploymentId
26527
+ );
26528
+ const resources = fs.existsSync(file)
26529
+ ? normalizeStagedAppReleaseResources(
26530
+ JSON.parse(fs.readFileSync(file, 'utf8'))
26531
+ )
26532
+ : [];
26533
+ const snapshots = new Map();
26534
+ for (const formCode of formCodes) {
26535
+ const formUuid = resolveManifestFormUuid(
26536
+ target.bound,
26537
+ { formCode },
26538
+ { fallbackToCode: false }
26539
+ );
26540
+ const staged = resources.find(
26541
+ resource =>
26542
+ resource.kind === 'FormRelease' &&
26543
+ String(resource.identity?.formUuid || '').trim() === formUuid
26544
+ );
26545
+ if (!staged) {
26546
+ fail(
26547
+ `FORM_FIELD_STAGED_CONTRACT_REQUIRED: ${formCode} 尚未在 change ${changeId} 的当前 deployment 中获得 verified FormRelease`
26548
+ );
26549
+ }
26550
+ const localSchema = await loadLocalFormSchema(
26551
+ process.cwd(),
26552
+ formCode
26553
+ );
26554
+ if (!localSchema) {
26555
+ fail(
26556
+ `FORM_FIELD_STAGED_CONTRACT_SCHEMA_MISSING: ${formCode} 缺少本地 schema`
26557
+ );
26558
+ }
26559
+ const schema = JSON.parse(localSchema.schema);
26560
+ snapshots.set(
26561
+ formUuid,
26562
+ collectFormSnapshotFieldIds({ form: { schema } })
26563
+ );
26564
+ }
26565
+ return snapshots;
26566
+ }
26567
+
26568
+ async function buildFormFieldContractPlan(
26569
+ config,
26570
+ target,
26571
+ manifest,
26572
+ options = {}
26573
+ ) {
25802
26574
  const targets = [];
25803
26575
  for (const [manifestKey, kind] of [
25804
26576
  ['functions', 'Function'],
@@ -25842,12 +26614,15 @@ async function buildFormFieldContractPlan(config, target, manifest) {
25842
26614
  errors: [],
25843
26615
  };
25844
26616
  }
25845
- const snapshots = new Map();
26617
+ const snapshots = new Map(
26618
+ options.stagedLocalFormSnapshots || []
26619
+ );
25846
26620
  const formTargets = unique(
25847
26621
  targets.map(item => item.formUuid).filter(Boolean)
25848
26622
  ).sort();
25849
26623
  await Promise.all(
25850
26624
  formTargets.map(async formUuid => {
26625
+ if (snapshots.has(formUuid)) return;
25851
26626
  const frozen = await fetchFrozenFormConfig(
25852
26627
  config,
25853
26628
  target,