openxiangda 1.0.251 → 1.0.253

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 CHANGED
@@ -1,4 +1,4 @@
1
- <!-- OpenXiangda-Policy-Version: 5 -->
1
+ <!-- OpenXiangda-Policy-Version: 7 -->
2
2
  # OpenXiangda
3
3
 
4
4
  OpenXiangda is a lightweight CLI and skill package for private low-code platforms.
@@ -28,6 +28,23 @@ checkpoints live on the platform, so a CLI/AI interruption does not discard
28
28
  completed work. V2 does not require SDD, a clean/pushed Git mainline,
29
29
  `--change`, `--only`, or candidate/ship orchestration.
30
30
 
31
+ `check --json` reports the exact changed resources, dependency-only Form
32
+ bindings, and ordered execution plan. Authored Form/Backend/Workflow code is
33
+ built only with the CLI-sealed toolchain; a V2 package never links the caller's
34
+ `node_modules`, and undeclared third-party build dependencies fail before any
35
+ remote write. Resource removals also fail closed until a dedicated migration
36
+ path is used.
37
+
38
+ `status` and `retry` locate the run across preproduction and production when
39
+ `--environment` is omitted. Retry resumes only unfinished checkpoints, safely
40
+ replays local Form bindings on another machine, and uses a server-side attempt
41
+ fence so an older executor cannot continue writing after takeover. Runtime
42
+ build IDs include both the Runtime layer and sealed package digests, so a newly
43
+ sealed package cannot collide with an older package that happens to contain the
44
+ same Runtime bytes. An exact same-package retry may reuse an `uploaded` release
45
+ only after verifying its content hash, source revision, and Runtime parent;
46
+ immutable storage objects are never overwritten.
47
+
31
48
  The lower-level commands later in this README remain available for V1
32
49
  compatibility and diagnostics; do not use them to assemble a normal V2 release.
33
50
 
package/lib/cli.js CHANGED
@@ -10656,6 +10656,7 @@ async function form(args) {
10656
10656
  fail('用法: openxiangda form ensure --only <formCode1,formCode2>');
10657
10657
  }
10658
10658
  const target = getWorkspaceTarget(config, profileName, flags);
10659
+ const replayLocal = Boolean(flags['replay-local']);
10659
10660
  await ensureDirectMutationPublishContext(
10660
10661
  config,
10661
10662
  target,
@@ -10669,22 +10670,30 @@ async function form(args) {
10669
10670
  target.bound.resources?.formSettings?.[formCode],
10670
10671
  ].find(entry => String(entry?.formUuid || '').trim());
10671
10672
  if (existing?.formUuid) {
10673
+ if (replayLocal) {
10674
+ try {
10675
+ await requestWithAuth(
10676
+ config,
10677
+ target.profileName,
10678
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(existing.formUuid)}`
10679
+ );
10680
+ } catch (error) {
10681
+ if (!isHttpNotFound(error)) throw error;
10682
+ const missing = new Error(
10683
+ `FORM_ENSURE_REPLAY_TARGET_MISSING: ${formCode} 的已绑定表单 ${existing.formUuid} 在目标环境不存在,不能以 replay-local 补写远端资源`
10684
+ );
10685
+ missing.code = 'FORM_ENSURE_REPLAY_TARGET_MISSING';
10686
+ missing.retryable = false;
10687
+ throw missing;
10688
+ }
10689
+ }
10672
10690
  results.push({
10673
10691
  code: formCode,
10674
10692
  formUuid: existing.formUuid,
10675
- action: 'noop',
10693
+ action: replayLocal ? 'verify-local' : 'noop',
10676
10694
  });
10677
10695
  continue;
10678
10696
  }
10679
- const localSchema = await loadLocalFormSchema(
10680
- process.cwd(),
10681
- formCode
10682
- );
10683
- if (!localSchema) {
10684
- fail(
10685
- `FORM_ENSURE_SCHEMA_REQUIRED: ${formCode} 缺少 src/forms/${formCode}/schema.ts`
10686
- );
10687
- }
10688
10697
  const formUuid = managedFormUuidForCode(
10689
10698
  target.appType,
10690
10699
  formCode
@@ -10699,6 +10708,22 @@ async function form(args) {
10699
10708
  } catch (error) {
10700
10709
  if (!isHttpNotFound(error)) throw error;
10701
10710
  }
10711
+ if (!existingRemote && replayLocal) {
10712
+ const missing = new Error(
10713
+ `FORM_ENSURE_REPLAY_TARGET_MISSING: ${formCode} 对应的确定性表单 ${formUuid} 在目标环境不存在;replay-local 禁止创建远端资源`
10714
+ );
10715
+ missing.code = 'FORM_ENSURE_REPLAY_TARGET_MISSING';
10716
+ missing.retryable = false;
10717
+ throw missing;
10718
+ }
10719
+ const localSchema = existingRemote
10720
+ ? null
10721
+ : await loadLocalFormSchema(process.cwd(), formCode);
10722
+ if (!existingRemote && !localSchema) {
10723
+ fail(
10724
+ `FORM_ENSURE_SCHEMA_REQUIRED: ${formCode} 缺少 src/forms/${formCode}/schema.ts`
10725
+ );
10726
+ }
10702
10727
  if (!existingRemote) {
10703
10728
  await requestWithAuth(
10704
10729
  config,
@@ -10718,12 +10743,16 @@ async function form(args) {
10718
10743
  );
10719
10744
  }
10720
10745
  saveFormResource(target, formCode, formUuid, {
10721
- name: localSchema.name || formCode,
10746
+ name: localSchema?.name || existingRemote?.name || formCode,
10722
10747
  });
10723
10748
  results.push({
10724
10749
  code: formCode,
10725
10750
  formUuid,
10726
- action: existingRemote ? 'bind' : 'create',
10751
+ action: replayLocal
10752
+ ? 'rebind-local'
10753
+ : existingRemote
10754
+ ? 'bind'
10755
+ : 'create',
10727
10756
  });
10728
10757
  }
10729
10758
  const result = {
@@ -10734,6 +10763,9 @@ async function form(args) {
10734
10763
  create: results.filter(item => item.action === 'create').length,
10735
10764
  bind: results.filter(item => item.action === 'bind').length,
10736
10765
  noop: results.filter(item => item.action === 'noop').length,
10766
+ replayed: results.filter(item =>
10767
+ ['verify-local', 'rebind-local'].includes(item.action)
10768
+ ).length,
10737
10769
  },
10738
10770
  };
10739
10771
  if (flags.json) return writeJson(result);
@@ -15642,7 +15674,13 @@ async function runtime(args) {
15642
15674
  const files = collectRuntimeDistFiles(distDir, {
15643
15675
  includeSourceMaps: Boolean(flags['include-sourcemaps']),
15644
15676
  });
15677
+ const declaredReleaseFiles = runtimeReleaseFileDescriptors(files);
15645
15678
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
15679
+ const reusableRuntimeRelease = await telemetry.runPhase(
15680
+ 'reuse-check',
15681
+ async () =>
15682
+ findRuntimeReleaseByBuildId(config, target, buildId)
15683
+ );
15646
15684
  printRuntimeProgress(
15647
15685
  `runtime release upload: mode=${effectiveUploadMode} traceId=${traceId} files=${files.length} size=${formatBytes(totalBytes)} timeout=${uploadTimeoutMs}ms`
15648
15686
  );
@@ -15650,6 +15688,20 @@ async function runtime(args) {
15650
15688
  'upload',
15651
15689
  { fileCount: files.length, totalBytes, mode: effectiveUploadMode },
15652
15690
  async () => {
15691
+ if (reusableRuntimeRelease) {
15692
+ assertRuntimeReleaseReusable(reusableRuntimeRelease, {
15693
+ appType: target.appType,
15694
+ buildId,
15695
+ files: declaredReleaseFiles,
15696
+ sourceRevision: runtimeLineage.sourceRevision,
15697
+ parentReleaseId: runtimeLineage.parentReleaseId,
15698
+ });
15699
+ effectiveUploadMode = 'reuse-existing';
15700
+ printRuntimeProgress(
15701
+ `runtime immutable release reused: releaseId=${reusableRuntimeRelease.id} buildId=${buildId} traceId=${traceId}`
15702
+ );
15703
+ return declaredReleaseFiles;
15704
+ }
15653
15705
  if (uploadMode === 'legacy-json') {
15654
15706
  effectiveUploadMode = 'legacy-json';
15655
15707
  return files.map(file => ({
@@ -15682,6 +15734,27 @@ async function runtime(args) {
15682
15734
  timeoutMs: uploadTimeoutMs,
15683
15735
  });
15684
15736
  } catch (error) {
15737
+ if (isRuntimeBuildImmutableConflict(error)) {
15738
+ const racedRuntimeRelease = await findRuntimeReleaseByBuildId(
15739
+ config,
15740
+ target,
15741
+ buildId
15742
+ );
15743
+ if (racedRuntimeRelease) {
15744
+ assertRuntimeReleaseReusable(racedRuntimeRelease, {
15745
+ appType: target.appType,
15746
+ buildId,
15747
+ files: declaredReleaseFiles,
15748
+ sourceRevision: runtimeLineage.sourceRevision,
15749
+ parentReleaseId: runtimeLineage.parentReleaseId,
15750
+ });
15751
+ effectiveUploadMode = 'reuse-existing';
15752
+ printRuntimeProgress(
15753
+ `runtime immutable release won upload race and was reused: releaseId=${racedRuntimeRelease.id} buildId=${buildId} traceId=${traceId}`
15754
+ );
15755
+ return declaredReleaseFiles;
15756
+ }
15757
+ }
15685
15758
  if (uploadMode !== 'auto' || !isRuntimeStagedUploadBlocked(error)) {
15686
15759
  throw error;
15687
15760
  }
@@ -16044,6 +16117,134 @@ function collectRuntimeDistFiles(distDir, options = {}) {
16044
16117
  return files;
16045
16118
  }
16046
16119
 
16120
+ function runtimeReleaseFileDescriptors(files) {
16121
+ return (files || []).map(file => ({
16122
+ path: file.path,
16123
+ size: file.size,
16124
+ sha256: file.sha256,
16125
+ contentType: file.contentType,
16126
+ }));
16127
+ }
16128
+
16129
+ function calculateRuntimeReleaseContentHash(files) {
16130
+ const hash = crypto.createHash('sha256');
16131
+ for (const file of files || []) {
16132
+ const filePath = String(file?.path || '').trim();
16133
+ const fileSha256 = String(file?.sha256 || '').trim().toLowerCase();
16134
+ if (!filePath || !/^[a-f0-9]{64}$/.test(fileSha256)) {
16135
+ throw runtimeReleaseReuseError(
16136
+ 'RUNTIME_RELEASE_REUSE_LOCAL_MANIFEST_INVALID',
16137
+ `本地 Runtime 文件描述不完整: ${filePath || '(unknown)'}`,
16138
+ { filePath: filePath || null }
16139
+ );
16140
+ }
16141
+ hash.update(filePath);
16142
+ hash.update(fileSha256);
16143
+ }
16144
+ return hash.digest('hex');
16145
+ }
16146
+
16147
+ async function findRuntimeReleaseByBuildId(config, target, buildId) {
16148
+ const appPath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases`;
16149
+ try {
16150
+ const exact = await requestWithAuth(
16151
+ config,
16152
+ target.profileName,
16153
+ `${appPath}/by-build/${encodeURIComponent(buildId)}`
16154
+ );
16155
+ return exact?.id ? exact : null;
16156
+ } catch (error) {
16157
+ if (Number(error?.status) !== 404) throw error;
16158
+ }
16159
+ const releases = await requestWithAuth(
16160
+ config,
16161
+ target.profileName,
16162
+ appPath
16163
+ );
16164
+ const items = Array.isArray(releases)
16165
+ ? releases
16166
+ : Array.isArray(releases?.items)
16167
+ ? releases.items
16168
+ : [];
16169
+ return (
16170
+ items.find(item => String(item?.buildId || '') === String(buildId)) ||
16171
+ null
16172
+ );
16173
+ }
16174
+
16175
+ function assertRuntimeReleaseReusable(existing, expected) {
16176
+ const context = {
16177
+ appType: expected.appType,
16178
+ buildId: expected.buildId,
16179
+ existingReleaseId: existing?.id || null,
16180
+ };
16181
+ if (String(existing?.status || '') !== 'uploaded') {
16182
+ throw runtimeReleaseReuseError(
16183
+ 'RUNTIME_RELEASE_REUSE_STATUS_INVALID',
16184
+ `相同 buildId 的 Runtime Release 状态为 ${existing?.status || '(unknown)'},仅 uploaded 状态可作为失败发布的暂存恢复点`,
16185
+ { ...context, existingStatus: existing?.status || null }
16186
+ );
16187
+ }
16188
+ const expectedContentHash = calculateRuntimeReleaseContentHash(
16189
+ expected.files
16190
+ );
16191
+ if (
16192
+ String(existing?.contentHash || '').trim().toLowerCase() !==
16193
+ expectedContentHash
16194
+ ) {
16195
+ throw runtimeReleaseReuseError(
16196
+ 'RUNTIME_RELEASE_REUSE_CONTENT_MISMATCH',
16197
+ '相同 buildId 的历史 Runtime Release 内容与当前密封包不一致,拒绝复用',
16198
+ {
16199
+ ...context,
16200
+ expectedContentHash,
16201
+ existingContentHash: existing?.contentHash || null,
16202
+ }
16203
+ );
16204
+ }
16205
+ if (
16206
+ !runtimeSourceRevisionEquals(
16207
+ existing?.sourceRevision,
16208
+ expected.sourceRevision
16209
+ )
16210
+ ) {
16211
+ throw runtimeReleaseReuseError(
16212
+ 'RUNTIME_RELEASE_REUSE_SOURCE_MISMATCH',
16213
+ '相同 buildId 的历史 Runtime Release 源码血缘与当前密封包不一致,拒绝复用',
16214
+ context
16215
+ );
16216
+ }
16217
+ const existingParentReleaseId =
16218
+ String(existing?.parentReleaseId || '').trim() || null;
16219
+ const expectedParentReleaseId =
16220
+ String(expected.parentReleaseId || '').trim() || null;
16221
+ if (existingParentReleaseId !== expectedParentReleaseId) {
16222
+ throw runtimeReleaseReuseError(
16223
+ 'RUNTIME_RELEASE_REUSE_PARENT_MISMATCH',
16224
+ '相同 buildId 的历史 Runtime Release 父版本已与当前 Runtime head 不一致,拒绝复用',
16225
+ {
16226
+ ...context,
16227
+ existingParentReleaseId,
16228
+ expectedParentReleaseId,
16229
+ }
16230
+ );
16231
+ }
16232
+ return existing;
16233
+ }
16234
+
16235
+ function runtimeSourceRevisionEquals(left, right) {
16236
+ return ['repo', 'baseCommit', 'treeHash'].every(
16237
+ key => String(left?.[key] || '').trim() === String(right?.[key] || '').trim()
16238
+ );
16239
+ }
16240
+
16241
+ function runtimeReleaseReuseError(code, message, data = {}) {
16242
+ const error = new Error(`${code}: ${message}`);
16243
+ error.code = code;
16244
+ error.data = data;
16245
+ return error;
16246
+ }
16247
+
16047
16248
  function walkRuntimeDist(rootDir, currentDir, files, options) {
16048
16249
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
16049
16250
  for (const entry of entries) {
@@ -16279,6 +16480,16 @@ function isRuntimeStagedUploadBlocked(error) {
16279
16480
  return /^HTTP 403\b/.test(String(error?.message || ''));
16280
16481
  }
16281
16482
 
16483
+ function isRuntimeBuildImmutableConflict(error) {
16484
+ return (
16485
+ Number(error?.status) === 409 &&
16486
+ (String(error?.code || '') === 'RUNTIME_BUILD_IMMUTABLE' ||
16487
+ /Runtime buildId .*禁止覆盖|RUNTIME_BUILD_IMMUTABLE/.test(
16488
+ String(error?.message || '')
16489
+ ))
16490
+ );
16491
+ }
16492
+
16282
16493
  function normalizeRuntimeUploadTimeoutMs(value) {
16283
16494
  const candidate =
16284
16495
  value === undefined || value === null || value === ''
@@ -20068,7 +20279,8 @@ async function prepareManifestJsCodeBundlesForPlan(manifest) {
20068
20279
  sourceInfos.map(item => ({
20069
20280
  sourceKind: item.sourceKind,
20070
20281
  scriptCode: item.scriptCode,
20071
- }))
20282
+ })),
20283
+ { forceCanonical: Boolean(activeDeliveryV2Context) }
20072
20284
  );
20073
20285
  if (result.status !== 0) {
20074
20286
  const legacyFallback = result.openxiangdaBuildMode === 'workspace-script'
@@ -28056,7 +28268,8 @@ function ensureJsCodeBundleForPlan(sourceInfo) {
28056
28268
  sourceKind: sourceInfo.sourceKind,
28057
28269
  scriptCode: sourceInfo.scriptCode,
28058
28270
  },
28059
- ]
28271
+ ],
28272
+ { forceCanonical: Boolean(activeDeliveryV2Context) }
28060
28273
  );
28061
28274
  if (result.stdout) process.stderr.write(result.stdout);
28062
28275
  if (result.stderr) process.stderr.write(result.stderr);
@@ -28399,7 +28612,8 @@ async function resolveJsCodeBundlePath(localPath, scriptCode) {
28399
28612
  function runWorkspaceJsCodeBuild(workspaceRoot, resolvedScriptCode, sourceKind) {
28400
28613
  return runWorkspaceJsCodeBuildBatch(
28401
28614
  workspaceRoot,
28402
- [{ sourceKind, scriptCode: resolvedScriptCode }]
28615
+ [{ sourceKind, scriptCode: resolvedScriptCode }],
28616
+ { forceCanonical: Boolean(activeDeliveryV2Context) }
28403
28617
  );
28404
28618
  }
28405
28619
 
@@ -30629,12 +30843,14 @@ function buildWorkspacePublishEnv(
30629
30843
  }
30630
30844
 
30631
30845
  module.exports = {
30846
+ assertRuntimeReleaseReusable,
30632
30847
  buildResourceManifestSddTargets,
30633
30848
  buildScopeGrantSourceSyncDecision,
30634
30849
  deploymentEvidencePendingError,
30635
30850
  deploymentQueueWaitDecision,
30636
30851
  inspectSucceededPreproductionDeployment,
30637
30852
  main,
30853
+ calculateRuntimeReleaseContentHash,
30638
30854
  recoveredPreproductionShipPatch,
30639
30855
  resolveReleaseCommandScopedFiles,
30640
30856
  selectSucceededPreproductionDeployment,
@@ -33,6 +33,7 @@ function createDeliveryV2Cli(dependencies) {
33
33
  packageDigest: inspected.compiled.packageDigest,
34
34
  summary: inspected.compiled.summary,
35
35
  targets: inspected.targets,
36
+ plan: inspected.plan,
36
37
  previousPackageDigest: inspected.previous?.packageDigest || null,
37
38
  },
38
39
  `Delivery V2 检查通过: ${inspected.compiled.packageDigest}`
@@ -62,8 +63,8 @@ function createDeliveryV2Cli(dependencies) {
62
63
  if (!runId) {
63
64
  throw usageError('用法: openxiangda status <runId> [--json]');
64
65
  }
65
- const target = resolveTarget(config, flags, [], false);
66
- const result = await executor.status({ config, target, runId });
66
+ const resolved = await resolveRunTarget(config, flags, runId);
67
+ const result = resolved.run;
67
68
  return output(
68
69
  flags,
69
70
  result,
@@ -75,7 +76,8 @@ function createDeliveryV2Cli(dependencies) {
75
76
  if (!runId) {
76
77
  throw usageError('用法: openxiangda retry <runId> [--json]');
77
78
  }
78
- const target = resolveTarget(config, flags, [], false);
79
+ const resolved = await resolveRunTarget(config, flags, runId);
80
+ const target = resolved.target;
79
81
  const result = await executor.retry({
80
82
  config,
81
83
  target,
@@ -132,9 +134,83 @@ function createDeliveryV2Cli(dependencies) {
132
134
  );
133
135
  }
134
136
 
137
+ async function resolveRunTarget(config, flags, runId) {
138
+ const explicitEnvironment =
139
+ stringFlag(flags, 'environment') || stringFlag(flags, 'target');
140
+ if (explicitEnvironment) {
141
+ const target = resolveTarget(
142
+ config,
143
+ { ...flags, environment: explicitEnvironment },
144
+ [],
145
+ false
146
+ );
147
+ return {
148
+ target,
149
+ run: await executor.status({ config, target, runId }),
150
+ };
151
+ }
152
+
153
+ const candidates = [];
154
+ const addCandidate = candidate => {
155
+ if (!candidate) return;
156
+ const key = [
157
+ candidate.profileName,
158
+ candidate.appType,
159
+ candidate.environmentId || 'direct',
160
+ ].join(':');
161
+ if (candidates.some(item => item.key === key)) return;
162
+ candidates.push({ key, target: candidate });
163
+ };
164
+ try {
165
+ addCandidate(resolveTarget(config, flags, [], false));
166
+ } catch {
167
+ // A multi-environment workspace may require an explicit target; probe
168
+ // the canonical targets below before returning a not-found error.
169
+ }
170
+ for (const environment of ['preproduction', 'production']) {
171
+ try {
172
+ addCandidate(
173
+ deps.getWorkspaceTarget(
174
+ config,
175
+ stringFlag(flags, 'profile') || config.currentProfile,
176
+ { ...flags, environment }
177
+ )
178
+ );
179
+ } catch {
180
+ // This workspace does not define the candidate environment.
181
+ }
182
+ }
183
+
184
+ let notFound = null;
185
+ for (const candidate of candidates) {
186
+ try {
187
+ const run = await executor.status({
188
+ config,
189
+ target: candidate.target,
190
+ runId,
191
+ });
192
+ return { target: candidate.target, run };
193
+ } catch (error) {
194
+ if (!isRunNotFoundError(error)) throw error;
195
+ notFound = error;
196
+ }
197
+ }
198
+ if (notFound) throw notFound;
199
+ throw usageError(
200
+ `无法解析 ReleaseRun ${runId} 所属环境;请显式添加 --environment preproduction|production`
201
+ );
202
+ }
203
+
135
204
  return { run };
136
205
  }
137
206
 
207
+ function isRunNotFoundError(error) {
208
+ return (
209
+ Number(error?.status || error?.statusCode) === 404 ||
210
+ /DELIVERY_RUN_NOT_FOUND|HTTP 404/i.test(String(error?.message || ''))
211
+ );
212
+ }
213
+
138
214
  function output(flags, value, message) {
139
215
  if (flags.json) return writeJson(value);
140
216
  print(message);
@@ -206,14 +282,21 @@ function printHelp(command) {
206
282
  const lines = {
207
283
  check: [
208
284
  '用法: openxiangda check [--environment <name>] [--build] [--json]',
209
- '编译并校验 App Package V2;默认复用现有 dist,不执行 Runtime 构建。',
285
+ '编译并校验密封 App Package,输出精确资源差异和执行计划;默认复用现有 dist',
286
+ '删除或未进入密封工具链的第三方构建依赖会在远程写入前失败。',
210
287
  ],
211
288
  deploy: [
212
289
  '用法: openxiangda deploy <environment> [--package <sha256>] [--json]',
213
290
  '不带 --package 时从当前工作区构建;带 --package 时直接部署已封存制品。',
214
291
  ],
215
- status: ['用法: openxiangda status <runId> [--json]'],
216
- retry: ['用法: openxiangda retry <runId> [--json]'],
292
+ status: [
293
+ '用法: openxiangda status <runId> [--environment <name>] [--json]',
294
+ '未指定环境时自动在预发和生产定位 ReleaseRun。',
295
+ ],
296
+ retry: [
297
+ '用法: openxiangda retry <runId> [--environment <name>] [--json]',
298
+ '从服务端检查点继续;隔离旧 attempt,并在跨机器场景重建本地 Form 绑定。',
299
+ ],
217
300
  rollback: [
218
301
  '用法: openxiangda rollback <environment> --to <appReleaseId> [--json]',
219
302
  ],