openxiangda 1.0.159 → 1.0.161
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 +39 -13
- package/lib/change-baseline.js +1077 -0
- package/lib/cli.js +12002 -6723
- package/lib/config.js +272 -7
- package/lib/http.js +2 -0
- package/lib/js-code-build.js +61 -0
- package/lib/publish-context.js +98 -0
- package/lib/publish-lease-heartbeat.js +182 -0
- package/lib/publish-lease.js +190 -0
- package/lib/release-telemetry.js +182 -0
- package/lib/runtime-lineage.js +297 -0
- package/lib/sdd.js +567 -35
- package/lib/source-dependencies.js +742 -0
- package/lib/workspace-init.js +6 -1
- package/lib/worktree-owner.js +219 -0
- package/openxiangda-skills/SKILL.md +103 -228
- package/openxiangda-skills/references/architecture-design.md +2 -1
- package/openxiangda-skills/references/openxiangda-api.md +27 -0
- package/openxiangda-skills/references/pages/page-sdk.md +11 -1
- package/openxiangda-skills/references/resource-manifest-cheatsheet.md +12 -2
- package/openxiangda-skills/references/workspace-state.md +2 -0
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +77 -243
- package/openxiangda-skills/skills/openxiangda-form/SKILL.md +8 -0
- package/openxiangda-skills/skills/openxiangda-page/SKILL.md +18 -0
- package/package.json +20 -1
- package/packages/sdk/dist/{ProcessPreview-DMkzccq4.d.mts → ProcessPreview-Dux73lY_.d.mts} +5 -0
- package/packages/sdk/dist/{ProcessPreview-DMkzccq4.d.ts → ProcessPreview-Dux73lY_.d.ts} +5 -0
- package/packages/sdk/dist/components/index.cjs +29 -1
- package/packages/sdk/dist/components/index.cjs.map +1 -1
- package/packages/sdk/dist/components/index.d.mts +4 -4
- package/packages/sdk/dist/components/index.d.ts +4 -4
- package/packages/sdk/dist/components/index.mjs +29 -1
- package/packages/sdk/dist/components/index.mjs.map +1 -1
- package/packages/sdk/dist/{dataManagementApi-C9O-Bb0j.d.ts → dataManagementApi-BNLH2o7f.d.ts} +2 -1
- package/packages/sdk/dist/{dataManagementApi-gpZkgRDM.d.mts → dataManagementApi-BOcIx4U-.d.mts} +2 -1
- package/packages/sdk/dist/runtime/index.cjs +150 -22
- package/packages/sdk/dist/runtime/index.cjs.map +1 -1
- package/packages/sdk/dist/runtime/index.d.mts +3 -3
- package/packages/sdk/dist/runtime/index.d.ts +3 -3
- package/packages/sdk/dist/runtime/index.mjs +150 -22
- package/packages/sdk/dist/runtime/index.mjs.map +1 -1
- package/packages/sdk/dist/runtime/react.cjs +121 -21
- package/packages/sdk/dist/runtime/react.cjs.map +1 -1
- package/packages/sdk/dist/runtime/react.d.mts +15 -2
- package/packages/sdk/dist/runtime/react.d.ts +15 -2
- package/packages/sdk/dist/runtime/react.mjs +121 -21
- package/packages/sdk/dist/runtime/react.mjs.map +1 -1
- package/packages/sdk/src/build-source/scripts/register.mjs +54 -23
- package/packages/sdk/src/build-source/scripts/sync-schema.mjs +59 -19
- package/packages/sdk/src/build-source/scripts/utils/form-api.mjs +242 -2
- package/templates/openxiangda-react-spa/.cursor/rules/openxiangda-resources.mdc +7 -3
- package/templates/openxiangda-react-spa/.cursor/rules/openxiangda.mdc +11 -4
- package/templates/openxiangda-react-spa/.qoder/rules/openxiangda-resources.md +7 -3
- package/templates/openxiangda-react-spa/.qoder/rules/openxiangda.md +10 -4
- package/templates/openxiangda-react-spa/AGENTS.md +25 -12
- package/templates/openxiangda-react-spa/scripts/build-js-code.mjs +344 -60
- package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda-resources.mdc +5 -1
- package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda.mdc +8 -2
- package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda-resources.md +8 -4
- package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda.md +8 -2
- package/templates/sy-lowcode-app-workspace/AGENTS.md +14 -4
- package/templates/sy-lowcode-app-workspace/scripts/build-js-code.mjs +344 -60
package/lib/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const os = require('os');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const { parse: parseDotenv } = require('dotenv');
|
|
@@ -9,6 +10,9 @@ const GLOBAL_ENV_FILE = path.join(CONFIG_DIR, '.env');
|
|
|
9
10
|
const PROJECT_DIR = '.openxiangda';
|
|
10
11
|
const PROJECT_STATE_FILE = path.join(PROJECT_DIR, 'state.json');
|
|
11
12
|
const LEGACY_PROJECT_CONFIG_FILE = path.join(PROJECT_DIR, 'profiles.json');
|
|
13
|
+
const PROJECT_STATE_LOCK_TIMEOUT_MS = 15_000;
|
|
14
|
+
const PROJECT_STATE_STALE_LOCK_MS = 30_000;
|
|
15
|
+
const projectStateBaselines = new WeakMap();
|
|
12
16
|
|
|
13
17
|
function emptyConfig() {
|
|
14
18
|
return {
|
|
@@ -145,20 +149,281 @@ function getProfile(config, name) {
|
|
|
145
149
|
return { profileName, profile };
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
function
|
|
149
|
-
return
|
|
152
|
+
function emptyProjectState() {
|
|
153
|
+
return {
|
|
150
154
|
version: 1,
|
|
151
155
|
profiles: {},
|
|
152
|
-
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeProjectState(state) {
|
|
160
|
+
return {
|
|
161
|
+
version: 1,
|
|
162
|
+
...(state || {}),
|
|
163
|
+
profiles: state?.profiles || {},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cloneJson(value) {
|
|
168
|
+
return JSON.parse(JSON.stringify(value));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadProjectState(cwd = process.cwd()) {
|
|
172
|
+
const state = normalizeProjectState(
|
|
173
|
+
readJson(path.join(cwd, PROJECT_STATE_FILE), emptyProjectState())
|
|
174
|
+
);
|
|
175
|
+
projectStateBaselines.set(state, cloneJson(state));
|
|
176
|
+
return state;
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
function saveProjectState(state, cwd = process.cwd()) {
|
|
156
180
|
const dir = path.join(cwd, PROJECT_DIR);
|
|
157
181
|
fs.mkdirSync(dir, { recursive: true });
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
182
|
+
const stateFile = path.join(cwd, PROJECT_STATE_FILE);
|
|
183
|
+
const lockFile = `${stateFile}.lock`;
|
|
184
|
+
const desired = normalizeProjectState(state);
|
|
185
|
+
const baseline = projectStateBaselines.get(state) || emptyProjectState();
|
|
186
|
+
const operations = collectProjectStateOperations(baseline, desired);
|
|
187
|
+
const lockFd = acquireProjectStateLock(lockFile);
|
|
188
|
+
try {
|
|
189
|
+
const current = readProjectStateForUpdate(stateFile);
|
|
190
|
+
const merged = applyProjectStateOperations(current, operations);
|
|
191
|
+
atomicWriteJson(stateFile, normalizeProjectState(merged));
|
|
192
|
+
// Keep the caller's own snapshot as its next three-way-merge baseline. Do
|
|
193
|
+
// not replace it with `merged`: the caller may still hold nested object
|
|
194
|
+
// references, and unrelated concurrent additions must not look like local
|
|
195
|
+
// deletions on its next save.
|
|
196
|
+
projectStateBaselines.set(state, cloneJson(desired));
|
|
197
|
+
return merged;
|
|
198
|
+
} finally {
|
|
199
|
+
releaseProjectStateLock(lockFile, lockFd);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isPlainObject(value) {
|
|
204
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
205
|
+
const prototype = Object.getPrototypeOf(value);
|
|
206
|
+
return prototype === Object.prototype || prototype === null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function jsonEqual(left, right) {
|
|
210
|
+
if (Object.is(left, right)) return true;
|
|
211
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
212
|
+
return (
|
|
213
|
+
left.length === right.length &&
|
|
214
|
+
left.every((value, index) => jsonEqual(value, right[index]))
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (isPlainObject(left) && isPlainObject(right)) {
|
|
218
|
+
const leftKeys = Object.keys(left).sort();
|
|
219
|
+
const rightKeys = Object.keys(right).sort();
|
|
220
|
+
return (
|
|
221
|
+
leftKeys.length === rightKeys.length &&
|
|
222
|
+
leftKeys.every(
|
|
223
|
+
(key, index) => key === rightKeys[index] && jsonEqual(left[key], right[key])
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function collectProjectStateOperations(base, desired, pathParts = [], result = []) {
|
|
231
|
+
if (jsonEqual(base, desired)) return result;
|
|
232
|
+
|
|
233
|
+
if (isPlainObject(desired) && (base === undefined || isPlainObject(base))) {
|
|
234
|
+
const baseObject = isPlainObject(base) ? base : {};
|
|
235
|
+
const desiredKeys = Object.keys(desired);
|
|
236
|
+
if (base === undefined && desiredKeys.length === 0) {
|
|
237
|
+
result.push({ type: 'set', path: pathParts, base, value: {} });
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
for (const key of new Set([...Object.keys(baseObject), ...desiredKeys])) {
|
|
241
|
+
const baseHasKey = Object.prototype.hasOwnProperty.call(baseObject, key);
|
|
242
|
+
const desiredHasKey = Object.prototype.hasOwnProperty.call(desired, key);
|
|
243
|
+
if (!desiredHasKey) {
|
|
244
|
+
result.push({
|
|
245
|
+
type: 'delete',
|
|
246
|
+
path: [...pathParts, key],
|
|
247
|
+
base: baseObject[key],
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
collectProjectStateOperations(
|
|
251
|
+
baseHasKey ? baseObject[key] : undefined,
|
|
252
|
+
desired[key],
|
|
253
|
+
[...pathParts, key],
|
|
254
|
+
result
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
result.push({ type: 'set', path: pathParts, base, value: cloneJsonValue(desired) });
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cloneJsonValue(value) {
|
|
266
|
+
return value === undefined ? undefined : cloneJson(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readPathValue(root, pathParts) {
|
|
270
|
+
let current = root;
|
|
271
|
+
for (const part of pathParts) {
|
|
272
|
+
if (!isPlainObject(current) || !Object.prototype.hasOwnProperty.call(current, part)) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
current = current[part];
|
|
276
|
+
}
|
|
277
|
+
return current;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function setPathValue(root, pathParts, value) {
|
|
281
|
+
if (pathParts.length === 0) return cloneJsonValue(value);
|
|
282
|
+
let current = root;
|
|
283
|
+
for (const part of pathParts.slice(0, -1)) {
|
|
284
|
+
if (!isPlainObject(current[part])) current[part] = {};
|
|
285
|
+
current = current[part];
|
|
286
|
+
}
|
|
287
|
+
current[pathParts[pathParts.length - 1]] = cloneJsonValue(value);
|
|
288
|
+
return root;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function deletePathValue(root, pathParts) {
|
|
292
|
+
if (pathParts.length === 0) return emptyProjectState();
|
|
293
|
+
let current = root;
|
|
294
|
+
for (const part of pathParts.slice(0, -1)) {
|
|
295
|
+
if (!isPlainObject(current?.[part])) return root;
|
|
296
|
+
current = current[part];
|
|
297
|
+
}
|
|
298
|
+
delete current[pathParts[pathParts.length - 1]];
|
|
299
|
+
return root;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isTimestampPath(pathParts) {
|
|
303
|
+
return pathParts[pathParts.length - 1] === 'updatedAt';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function applyProjectStateOperations(currentState, operations) {
|
|
307
|
+
let merged = cloneJson(normalizeProjectState(currentState));
|
|
308
|
+
for (const operation of operations) {
|
|
309
|
+
const currentValue = readPathValue(merged, operation.path);
|
|
310
|
+
const desiredValue = operation.type === 'delete' ? undefined : operation.value;
|
|
311
|
+
const unchangedSinceLoad = jsonEqual(currentValue, operation.base);
|
|
312
|
+
const alreadyApplied = jsonEqual(currentValue, desiredValue);
|
|
313
|
+
if (!unchangedSinceLoad && !alreadyApplied && !isTimestampPath(operation.path)) {
|
|
314
|
+
const error = new Error(
|
|
315
|
+
`项目状态并发冲突: ${formatProjectStatePath(operation.path)} 已被其他进程修改,请重新执行当前命令`
|
|
316
|
+
);
|
|
317
|
+
error.code = 'OPENXIANGDA_STATE_CONFLICT';
|
|
318
|
+
error.path = operation.path.join('.');
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
merged =
|
|
322
|
+
operation.type === 'delete'
|
|
323
|
+
? deletePathValue(merged, operation.path)
|
|
324
|
+
: setPathValue(merged, operation.path, operation.value);
|
|
325
|
+
}
|
|
326
|
+
return merged;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function formatProjectStatePath(pathParts) {
|
|
330
|
+
return pathParts.length > 0 ? pathParts.join('.') : '<root>';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function readProjectStateForUpdate(stateFile) {
|
|
334
|
+
if (!fs.existsSync(stateFile)) return emptyProjectState();
|
|
335
|
+
try {
|
|
336
|
+
return normalizeProjectState(JSON.parse(fs.readFileSync(stateFile, 'utf8')));
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const wrapped = new Error(`项目状态文件不是有效 JSON,已停止覆盖: ${stateFile}`);
|
|
339
|
+
wrapped.code = 'OPENXIANGDA_STATE_INVALID';
|
|
340
|
+
wrapped.cause = error;
|
|
341
|
+
throw wrapped;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function acquireProjectStateLock(lockFile) {
|
|
346
|
+
const startedAt = Date.now();
|
|
347
|
+
while (true) {
|
|
348
|
+
try {
|
|
349
|
+
const fd = fs.openSync(lockFile, 'wx', 0o600);
|
|
350
|
+
fs.writeFileSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
351
|
+
fs.fsyncSync(fd);
|
|
352
|
+
return fd;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error.code !== 'EEXIST') throw error;
|
|
355
|
+
removeStaleProjectStateLock(lockFile);
|
|
356
|
+
if (Date.now() - startedAt >= PROJECT_STATE_LOCK_TIMEOUT_MS) {
|
|
357
|
+
const timeout = new Error(`等待项目状态锁超时: ${lockFile}`);
|
|
358
|
+
timeout.code = 'OPENXIANGDA_STATE_LOCK_TIMEOUT';
|
|
359
|
+
throw timeout;
|
|
360
|
+
}
|
|
361
|
+
sleepSync(10 + Math.floor(Math.random() * 20));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function removeStaleProjectStateLock(lockFile) {
|
|
367
|
+
try {
|
|
368
|
+
const stat = fs.statSync(lockFile);
|
|
369
|
+
if (Date.now() - stat.mtimeMs <= PROJECT_STATE_STALE_LOCK_MS) return;
|
|
370
|
+
fs.unlinkSync(lockFile);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error.code !== 'ENOENT') throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function releaseProjectStateLock(lockFile, fd) {
|
|
377
|
+
try {
|
|
378
|
+
fs.closeSync(fd);
|
|
379
|
+
} finally {
|
|
380
|
+
try {
|
|
381
|
+
fs.unlinkSync(lockFile);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error.code !== 'ENOENT') throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function sleepSync(milliseconds) {
|
|
389
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
390
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function atomicWriteJson(file, value) {
|
|
394
|
+
const dir = path.dirname(file);
|
|
395
|
+
const tempFile = `${file}.${process.pid}.${Date.now()}.${crypto
|
|
396
|
+
.randomBytes(6)
|
|
397
|
+
.toString('hex')}.tmp`;
|
|
398
|
+
let fd;
|
|
399
|
+
try {
|
|
400
|
+
fd = fs.openSync(tempFile, 'wx', 0o600);
|
|
401
|
+
fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
402
|
+
fs.fsyncSync(fd);
|
|
403
|
+
fs.closeSync(fd);
|
|
404
|
+
fd = undefined;
|
|
405
|
+
fs.renameSync(tempFile, file);
|
|
406
|
+
fsyncDirectory(dir);
|
|
407
|
+
} finally {
|
|
408
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
409
|
+
try {
|
|
410
|
+
fs.unlinkSync(tempFile);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error.code !== 'ENOENT') throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function fsyncDirectory(dir) {
|
|
418
|
+
let fd;
|
|
419
|
+
try {
|
|
420
|
+
fd = fs.openSync(dir, 'r');
|
|
421
|
+
fs.fsyncSync(fd);
|
|
422
|
+
} catch {
|
|
423
|
+
// Directory fsync is not supported on every platform/filesystem.
|
|
424
|
+
} finally {
|
|
425
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
426
|
+
}
|
|
162
427
|
}
|
|
163
428
|
|
|
164
429
|
function loadGlobalEnv() {
|
package/lib/http.js
CHANGED
|
@@ -64,6 +64,8 @@ async function requestJson(baseUrl, apiPath, options = {}) {
|
|
|
64
64
|
const error = new Error(maskText(`HTTP ${response.status}: ${message}`));
|
|
65
65
|
error.status = response.status;
|
|
66
66
|
error.payload = payload;
|
|
67
|
+
error.code = payload?.errorCode || payload?.code;
|
|
68
|
+
error.data = payload?.data;
|
|
67
69
|
throw error;
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { spawnSync } = require('child_process');
|
|
2
|
+
|
|
3
|
+
const JS_CODE_SOURCE_KINDS = new Set(['js-code-nodes', 'automations', 'functions']);
|
|
4
|
+
|
|
5
|
+
function normalizeJsCodeBuildTargets(targets = []) {
|
|
6
|
+
const normalized = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
for (const target of targets) {
|
|
9
|
+
const sourceKind = String(target?.sourceKind || target?.kind || '').trim();
|
|
10
|
+
const scriptCode = String(target?.scriptCode || target?.code || '').trim();
|
|
11
|
+
if (!JS_CODE_SOURCE_KINDS.has(sourceKind)) {
|
|
12
|
+
throw new Error(`unsupported JS_CODE source: ${sourceKind || '<empty>'}`);
|
|
13
|
+
}
|
|
14
|
+
if (
|
|
15
|
+
!scriptCode ||
|
|
16
|
+
scriptCode === '.' ||
|
|
17
|
+
scriptCode === '..' ||
|
|
18
|
+
scriptCode.includes('/') ||
|
|
19
|
+
scriptCode.includes('\\')
|
|
20
|
+
) {
|
|
21
|
+
throw new Error(`invalid JS_CODE script code: ${scriptCode || '<empty>'}`);
|
|
22
|
+
}
|
|
23
|
+
const key = `${sourceKind}:${scriptCode}`;
|
|
24
|
+
if (seen.has(key)) continue;
|
|
25
|
+
seen.add(key);
|
|
26
|
+
normalized.push({ sourceKind, scriptCode });
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildJsCodeBatchArgs(targets, options = {}) {
|
|
32
|
+
const normalized = normalizeJsCodeBuildTargets(targets);
|
|
33
|
+
if (normalized.length === 0) {
|
|
34
|
+
throw new Error('JS_CODE batch build requires at least one target');
|
|
35
|
+
}
|
|
36
|
+
const specs = normalized.map(target => `${target.sourceKind}:${target.scriptCode}`);
|
|
37
|
+
return [
|
|
38
|
+
'build-js-code',
|
|
39
|
+
'--scripts',
|
|
40
|
+
specs.join(','),
|
|
41
|
+
...(options.force ? ['--force'] : []),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runWorkspaceJsCodeBuildBatch(workspaceRoot, targets, options = {}) {
|
|
46
|
+
return spawnSync(
|
|
47
|
+
options.packageManager || 'pnpm',
|
|
48
|
+
buildJsCodeBatchArgs(targets, options),
|
|
49
|
+
{
|
|
50
|
+
cwd: workspaceRoot,
|
|
51
|
+
encoding: 'utf8',
|
|
52
|
+
...options.spawnOptions,
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
buildJsCodeBatchArgs,
|
|
59
|
+
normalizeJsCodeBuildTargets,
|
|
60
|
+
runWorkspaceJsCodeBuildBatch,
|
|
61
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const HEADER_NAMES = Object.freeze({
|
|
2
|
+
publishLeaseId: 'x-openxiangda-publish-lease-id',
|
|
3
|
+
changeBaselineId: 'x-openxiangda-change-baseline-id',
|
|
4
|
+
changeId: 'x-openxiangda-change-id',
|
|
5
|
+
clientSessionId: 'x-openxiangda-client-session-id',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const ENV_NAMES = Object.freeze({
|
|
9
|
+
publishLeaseId: 'OPENXIANGDA_PUBLISH_LEASE_ID',
|
|
10
|
+
changeBaselineId: 'OPENXIANGDA_CHANGE_BASELINE_ID',
|
|
11
|
+
changeId: 'OPENXIANGDA_CHANGE_ID',
|
|
12
|
+
clientSessionId: 'OPENXIANGDA_CLIENT_SESSION_ID',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function normalizePublishContext(value, options = {}) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
17
|
+
const context = {
|
|
18
|
+
appType: normalizeOptionalString(value.appType),
|
|
19
|
+
profileName: normalizeOptionalString(value.profileName || value.profile),
|
|
20
|
+
publishLeaseId: normalizeOptionalString(
|
|
21
|
+
value.publishLeaseId || value.leaseId
|
|
22
|
+
),
|
|
23
|
+
changeBaselineId: normalizeOptionalString(
|
|
24
|
+
value.changeBaselineId || value.baselineId
|
|
25
|
+
),
|
|
26
|
+
changeId: normalizeOptionalString(value.changeId),
|
|
27
|
+
clientSessionId: normalizeOptionalString(value.clientSessionId),
|
|
28
|
+
};
|
|
29
|
+
if (options.requireComplete) assertCompletePublishContext(context);
|
|
30
|
+
return context;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertCompletePublishContext(value) {
|
|
34
|
+
const context = normalizePublishContext(value) || {};
|
|
35
|
+
const missing = [];
|
|
36
|
+
if (!context.publishLeaseId) missing.push('publishLeaseId');
|
|
37
|
+
if (!context.changeBaselineId) missing.push('changeBaselineId');
|
|
38
|
+
if (!context.changeId) missing.push('changeId');
|
|
39
|
+
if (!context.clientSessionId) missing.push('clientSessionId');
|
|
40
|
+
if (missing.length > 0) {
|
|
41
|
+
const error = new Error(
|
|
42
|
+
`发布上下文不完整,缺少 ${missing.join(', ')};请先执行 openxiangda release begin --change <id>`
|
|
43
|
+
);
|
|
44
|
+
error.code = 'PUBLISH_CONTEXT_REQUIRED';
|
|
45
|
+
error.missing = missing;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildPublishContextHeaders(value, options = {}) {
|
|
52
|
+
const context = options.requireComplete
|
|
53
|
+
? assertCompletePublishContext(value)
|
|
54
|
+
: normalizePublishContext(value);
|
|
55
|
+
if (!context) return {};
|
|
56
|
+
return compactMappedValues(context, HEADER_NAMES);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildPublishContextEnv(value, options = {}) {
|
|
60
|
+
const context = options.requireComplete
|
|
61
|
+
? assertCompletePublishContext(value)
|
|
62
|
+
: normalizePublishContext(value);
|
|
63
|
+
if (!context) return {};
|
|
64
|
+
return compactMappedValues(context, ENV_NAMES);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readPublishContextEnv(env = process.env) {
|
|
68
|
+
return normalizePublishContext({
|
|
69
|
+
publishLeaseId: env?.[ENV_NAMES.publishLeaseId],
|
|
70
|
+
changeBaselineId: env?.[ENV_NAMES.changeBaselineId],
|
|
71
|
+
changeId: env?.[ENV_NAMES.changeId],
|
|
72
|
+
clientSessionId: env?.[ENV_NAMES.clientSessionId],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compactMappedValues(context, names) {
|
|
77
|
+
const entries = [];
|
|
78
|
+
for (const [field, outputName] of Object.entries(names)) {
|
|
79
|
+
const value = normalizeOptionalString(context[field]);
|
|
80
|
+
if (value) entries.push([outputName, value]);
|
|
81
|
+
}
|
|
82
|
+
return Object.fromEntries(entries);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeOptionalString(value) {
|
|
86
|
+
const text = String(value === undefined || value === null ? '' : value).trim();
|
|
87
|
+
return text || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
ENV_NAMES,
|
|
92
|
+
HEADER_NAMES,
|
|
93
|
+
assertCompletePublishContext,
|
|
94
|
+
buildPublishContextEnv,
|
|
95
|
+
buildPublishContextHeaders,
|
|
96
|
+
normalizePublishContext,
|
|
97
|
+
readPublishContextEnv,
|
|
98
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
2
|
+
const DEFAULT_RENEW_WINDOW_MS = 180_000;
|
|
3
|
+
|
|
4
|
+
class PublishLeaseLostError extends Error {
|
|
5
|
+
constructor(message, cause) {
|
|
6
|
+
const detail = String(
|
|
7
|
+
message || '应用发布租约已丢失,已停止后续写入。'
|
|
8
|
+
);
|
|
9
|
+
super(
|
|
10
|
+
detail.startsWith('PUBLISH_LEASE_LOST:')
|
|
11
|
+
? detail
|
|
12
|
+
: `PUBLISH_LEASE_LOST: ${detail}`
|
|
13
|
+
);
|
|
14
|
+
this.name = 'PublishLeaseLostError';
|
|
15
|
+
this.code = 'PUBLISH_LEASE_LOST';
|
|
16
|
+
if (cause !== undefined) this.cause = cause;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class PublishLeaseHeartbeat {
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
if (typeof options.renew !== 'function') {
|
|
23
|
+
throw new TypeError('publish lease heartbeat renew 必须是函数');
|
|
24
|
+
}
|
|
25
|
+
this.lease = options.lease || null;
|
|
26
|
+
this.renew = options.renew;
|
|
27
|
+
this.now = options.now || Date.now;
|
|
28
|
+
this.intervalMs = normalizePositiveInteger(
|
|
29
|
+
options.intervalMs,
|
|
30
|
+
DEFAULT_HEARTBEAT_INTERVAL_MS
|
|
31
|
+
);
|
|
32
|
+
this.renewWindowMs = normalizePositiveInteger(
|
|
33
|
+
options.renewWindowMs,
|
|
34
|
+
DEFAULT_RENEW_WINDOW_MS
|
|
35
|
+
);
|
|
36
|
+
this.setInterval = options.setInterval || global.setInterval;
|
|
37
|
+
this.clearInterval = options.clearInterval || global.clearInterval;
|
|
38
|
+
this.onRenew =
|
|
39
|
+
typeof options.onRenew === 'function' ? options.onRenew : () => {};
|
|
40
|
+
this.onLost =
|
|
41
|
+
typeof options.onLost === 'function' ? options.onLost : () => {};
|
|
42
|
+
this.timer = null;
|
|
43
|
+
this.inFlight = null;
|
|
44
|
+
this.lostError = null;
|
|
45
|
+
this.stopped = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
start() {
|
|
49
|
+
if (this.stopped || this.timer) return this;
|
|
50
|
+
this.timer = this.setInterval(() => {
|
|
51
|
+
void this.tick();
|
|
52
|
+
}, this.intervalMs);
|
|
53
|
+
this.timer?.unref?.();
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setLease(lease) {
|
|
58
|
+
if (!lease?.leaseId) {
|
|
59
|
+
throw new TypeError('publish lease heartbeat lease 缺少 leaseId');
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
this.lease?.leaseId &&
|
|
63
|
+
String(lease.leaseId) !== String(this.lease.leaseId)
|
|
64
|
+
) {
|
|
65
|
+
throw new TypeError('不能把 heartbeat 切换到另一条 publish lease');
|
|
66
|
+
}
|
|
67
|
+
this.lease = lease;
|
|
68
|
+
return this.lease;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async tick() {
|
|
72
|
+
if (this.stopped || this.lostError || !this.shouldRenew()) return this.lease;
|
|
73
|
+
try {
|
|
74
|
+
return await this.renewOnce();
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async beforeWrite(options = {}) {
|
|
81
|
+
this.assertHealthy();
|
|
82
|
+
if (options.forceRenew || this.shouldRenew()) {
|
|
83
|
+
await this.renewOnce();
|
|
84
|
+
}
|
|
85
|
+
this.assertHealthy();
|
|
86
|
+
return this.lease;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
shouldRenew() {
|
|
90
|
+
const expiresAt = new Date(this.lease?.expiresAt || 0).getTime();
|
|
91
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return true;
|
|
92
|
+
return expiresAt - Number(this.now()) <= this.renewWindowMs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async renewOnce() {
|
|
96
|
+
this.assertHealthy();
|
|
97
|
+
if (this.stopped) {
|
|
98
|
+
throw this.markLost(
|
|
99
|
+
new Error('publish lease heartbeat 已停止,不能继续续租')
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (this.inFlight) return await this.inFlight;
|
|
103
|
+
|
|
104
|
+
this.inFlight = Promise.resolve()
|
|
105
|
+
.then(() => this.renew(this.lease))
|
|
106
|
+
.then(nextLease => {
|
|
107
|
+
if (!nextLease?.leaseId || !nextLease?.expiresAt) {
|
|
108
|
+
throw new Error('续租响应缺少 leaseId 或 expiresAt');
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
this.lease?.leaseId &&
|
|
112
|
+
String(nextLease.leaseId) !== String(this.lease.leaseId)
|
|
113
|
+
) {
|
|
114
|
+
throw new Error('续租响应 leaseId 与当前租约不一致');
|
|
115
|
+
}
|
|
116
|
+
this.lease = nextLease;
|
|
117
|
+
this.onRenew(nextLease);
|
|
118
|
+
return nextLease;
|
|
119
|
+
})
|
|
120
|
+
.catch(error => {
|
|
121
|
+
throw this.markLost(error);
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
this.inFlight = null;
|
|
125
|
+
});
|
|
126
|
+
return await this.inFlight;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
assertHealthy() {
|
|
130
|
+
if (this.lostError) throw this.lostError;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
markLost(cause) {
|
|
134
|
+
if (!this.lostError) {
|
|
135
|
+
const detail = String(cause?.message || cause || '').trim();
|
|
136
|
+
this.lostError = new PublishLeaseLostError(
|
|
137
|
+
detail
|
|
138
|
+
? `应用发布租约续期失败,已停止后续写入:${detail}`
|
|
139
|
+
: undefined,
|
|
140
|
+
cause
|
|
141
|
+
);
|
|
142
|
+
try {
|
|
143
|
+
this.onLost(this.lostError);
|
|
144
|
+
} catch {
|
|
145
|
+
// Observability callbacks must not replace the stable safety error.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return this.lostError;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async stop() {
|
|
152
|
+
if (this.timer) {
|
|
153
|
+
this.clearInterval(this.timer);
|
|
154
|
+
this.timer = null;
|
|
155
|
+
}
|
|
156
|
+
this.stopped = true;
|
|
157
|
+
if (this.inFlight) {
|
|
158
|
+
try {
|
|
159
|
+
await this.inFlight;
|
|
160
|
+
} catch {
|
|
161
|
+
// The caller can inspect the stable error with assertHealthy().
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizePositiveInteger(value, fallback) {
|
|
168
|
+
const numeric = Number(value);
|
|
169
|
+
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createPublishLeaseHeartbeat(options) {
|
|
173
|
+
return new PublishLeaseHeartbeat(options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
178
|
+
DEFAULT_RENEW_WINDOW_MS,
|
|
179
|
+
PublishLeaseHeartbeat,
|
|
180
|
+
PublishLeaseLostError,
|
|
181
|
+
createPublishLeaseHeartbeat,
|
|
182
|
+
};
|