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/README.md +18 -6
- package/lib/application-environments.js +130 -0
- package/lib/cli.js +811 -36
- package/lib/release-plan.js +139 -14
- package/openxiangda-skills/SKILL.md +6 -6
- package/openxiangda-skills/references/openxiangda-api.md +4 -0
- package/openxiangda-skills/references/workspace-state.md +11 -3
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +6 -6
- package/package.json +4 -2
- package/templates/openxiangda-react-spa/AGENTS.md +8 -10
- package/templates/openxiangda-react-spa/scripts/deploy.mjs +2 -2
- package/templates/sy-lowcode-app-workspace/AGENTS.md +1 -1
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
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
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,
|