publish-timecard-main.cjs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. 'use strict';
  2. const fs = require('node:fs');
  3. const path = require('node:path');
  4. const cp = require('node:child_process');
  5. const assert = require('node:assert/strict');
  6. const vm = require('node:vm');
  7. const { isDeepStrictEqual } = require('node:util');
  8. const APP = 'APP_ZQ3I7XO2RSHDJ4QDEVNB';
  9. const FORM = 'FORM-1741B1D56FE04480B0E4F5412FF9AE6ATYGR';
  10. /** @returns {object} 使用已安装OpenYida的编译、认证、校验和发布传输。 */
  11. function toolchain() {
  12. const executable = cp.execFileSync('which', ['openyida'], { encoding: 'utf8' }).trim();
  13. const root = path.resolve(path.dirname(fs.realpathSync(executable)), '..');
  14. return {
  15. transform: require(root + '/lib/core/babel-transform').default,
  16. guard: require(root + '/lib/core/no-emoji-guard'),
  17. client: require(root + '/lib/core/yida-client'),
  18. utils: require(root + '/lib/core/utils'),
  19. transport: require(root + '/lib/app/publish').sendSaveRequestWithAuth
  20. };
  21. }
  22. /** @param {object} value Schema响应或Schema @returns {object} 完整Schema */
  23. function schemaContent(value) {
  24. assert.ok(value && typeof value === 'object' && value.success !== false, 'Schema响应无效');
  25. const content = value.content === undefined ? value : value.content;
  26. const schema = typeof content === 'string' ? JSON.parse(content) : content;
  27. assert.ok(schema && Array.isArray(schema.pages) && schema.actions, '缺少页面或动作配置');
  28. return schema;
  29. }
  30. /** @param {object} schema 完整Schema @returns {{node:object,path:string}} 唯一原生Jsx节点 */
  31. function jsxNode(schema) {
  32. const found = [];
  33. /** @param {object} value 节点 @param {string} pointer JSON路径 @returns {void} */
  34. function visit(value, pointer) {
  35. if (!value || typeof value !== 'object') return;
  36. if (value.componentName === 'Jsx' && value.props && value.props.render) found.push({ node: value, path: pointer });
  37. for (const [key, child] of Object.entries(value)) visit(child, pointer + '/' + key);
  38. }
  39. visit(schema.pages, '/pages');
  40. assert.equal(found.length, 1, '正式页必须包含唯一的既有Jsx渲染节点');
  41. return found[0];
  42. }
  43. /** @param {string} source 源码 @param {boolean} render 是否为独立渲染 @param {object} tools 工具链 @returns {string} 平台编译码 */
  44. function compile(source, render, tools) {
  45. const result = tools.transform(source, {}, render, { RE_VERSION: '7.4.0' });
  46. if (result.error) throw result.error;
  47. assert.ok(result.compiled, '平台编译码不能为空');
  48. new vm.Script(render ? '(' + result.compiled + ')' : result.compiled);
  49. return result.compiled;
  50. }
  51. /** @param {object} baseline 最新基线 @param {string} actions 合并后的JS @param {string} render 合并后的JSX @param {object} tools 工具链 @returns {{schema:object,paths:string[],functions:number}} 限定范围候选 */
  52. function buildCandidate(baseline, actions, render, tools = toolchain()) {
  53. const schema = structuredClone(baseline);
  54. const target = jsxNode(schema);
  55. const module = { source: actions, compiled: compile(actions, false, tools) };
  56. const names = [...actions.matchAll(/export\s+(?:async\s+)?function\s+(\w+)\s*\(/g)].map(match => match[1]);
  57. assert.ok(names.length > 0, '动作模块没有导出函数');
  58. assert.equal(names.length, new Set(names).size, '动作函数不能重名');
  59. for (const item of schema.actions.list) assert.ok(names.includes(item.id), '不能移除原动作:' + item.id);
  60. schema.actions.module = { ...schema.actions.module, ...module };
  61. const oldNames = new Set(schema.actions.list.map(item => item.id));
  62. schema.actions.list.push(...names.filter(id => !oldNames.has(id)).map(id => ({ id, title: id })));
  63. target.node.props.render = { ...target.node.props.render, source: render, compiled: compile(render, true, tools) };
  64. const paths = ['/actions/module/source', '/actions/module/compiled', '/actions/list',
  65. target.path + '/props/render/source', target.path + '/props/render/compiled'];
  66. // prd 只清理当前页面内的连接器描述装饰,不触及URL、参数、鉴权或连接器主资源。
  67. const root = schema.pages[0].componentsTree[0];
  68. (root.dataSource.online || []).forEach((dataSource, index) => {
  69. const connector = dataSource.options && dataSource.options.connector;
  70. if (!connector || typeof connector.description !== 'string') return;
  71. const cleaned = connector.description.replace(tools.guard.EMOJI_PATTERN, '');
  72. if (cleaned === connector.description) return;
  73. connector.description = cleaned;
  74. paths.push('/pages/0/componentsTree/0/dataSource/online/' + index + '/options/connector/description');
  75. });
  76. tools.guard.assertNoEmojiInValue(schema, { artifact: 'TimeCard双文件候选', code: 'OPENYIDA_FORM_SCHEMA_EMOJI_FORBIDDEN' });
  77. verifyUnchanged(baseline, schema, paths);
  78. return { schema, paths, functions: names.length };
  79. }
  80. /** @param {object} target 待规范化副本 @param {string} pointer 已验证JSON路径 @returns {void} */
  81. function omit(target, pointer) {
  82. const parts = pointer.slice(1).split('/');
  83. const parent = parts.slice(0, -1).reduce((node, key) => node[key], target);
  84. delete parent[parts.at(-1)];
  85. }
  86. /** @param {object} before 基线 @param {object} after 候选或回读 @param {string[]} paths 允许变化路径 @returns {void} */
  87. function verifyUnchanged(before, after, paths) {
  88. const a = structuredClone(before), b = structuredClone(after);
  89. for (const pointer of [...paths, '/gmtModified']) { omit(a, pointer); omit(b, pointer); }
  90. assert.deepEqual(b, a, '检测到发布范围外的Schema变化');
  91. }
  92. /** @param {object} baseline 候选绑定基线 @param {object} current 保存前线上基线 @returns {void} */
  93. function assertCurrentBaseline(baseline, current) {
  94. assert.ok(Number.isFinite(baseline.gmtModified), '缺少服务端版本标识');
  95. assert.ok(isDeepStrictEqual(baseline, current), '线上页面已变化,必须重新拉取并合并;停止发布');
  96. }
  97. /** @param {object} authRef 平台登录引用 @param {object} tools 工具链 @returns {Promise<object>} 只读最新Schema */
  98. async function fetchSchema(authRef, tools) {
  99. const result = await tools.utils.requestWithAutoLogin(auth => tools.utils.httpGet(auth.baseUrl,
  100. '/alibaba/web/' + APP + '/_view/query/formdesign/getFormSchema.json',
  101. { formUuid: FORM, schemaVersion: 'V5' }), authRef);
  102. assert.ok(result && result.success !== false && !result.__needLogin && !result.__csrfExpired, '读取线上Schema失败');
  103. return schemaContent(result);
  104. }
  105. /** @param {object} baseline 已备份最新Schema @param {object} candidate 已验证候选 @param {string} output 证据目录 @param {object} tools 工具链 @returns {Promise<void>} 绑定原版本保存并独立回读 */
  106. async function publish(baseline, candidate, output, tools) {
  107. const auth = tools.client.createAuthRef();
  108. const current = await fetchSchema(auth, tools);
  109. assertCurrentBaseline(baseline, current);
  110. // fixme 保存始终传候选绑定的原revision,写前及写入时任意并发变化均不能被覆盖。
  111. fs.writeFileSync(path.join(output, '发布尝试.json'), JSON.stringify({ formUuid: FORM, revision: baseline.gmtModified, attemptedAt: new Date().toISOString() }, null, 2));
  112. const result = await tools.transport(auth, JSON.stringify(candidate.schema), APP, FORM, baseline.gmtModified);
  113. fs.writeFileSync(path.join(output, '发布回执.json'), JSON.stringify(result, null, 2));
  114. assert.ok(result && result.success, '发布未成功;先只读核对回执及线上状态,不自动重试');
  115. const readback = await fetchSchema(auth, tools);
  116. fs.writeFileSync(path.join(output, '发布后Schema.json'), JSON.stringify(readback, null, 2));
  117. verifyUnchanged(candidate.schema, readback, []);
  118. const receipt = { success: true, formUuid: FORM, version: result.content && result.content.version,
  119. gmtModified: readback.gmtModified, originalRevision: baseline.gmtModified,
  120. codeAndConfigurationVerified: true, retainedDataSources: readback.pages[0].componentsTree[0].dataSource.online.length,
  121. runtimeSmokeVerified: false, url: auth.baseUrl + '/' + APP + '/workbench/' + FORM };
  122. fs.writeFileSync(path.join(output, '独立回读验收.json'), JSON.stringify(receipt, null, 2));
  123. console.log(JSON.stringify(receipt));
  124. }
  125. /** @returns {Promise<void>} 默认仅生成候选,显式--apply才实际发布指定正式页 */
  126. async function main() {
  127. const [baselineFile, outputDir, ...flags] = process.argv.slice(2);
  128. assert.ok(baselineFile && outputDir && flags.every(flag => flag === '--apply'),
  129. '用法:node pages/scripts/publish-timecard-main.cjs <最新Schema文件> <证据目录> [--apply]');
  130. const baseline = schemaContent(JSON.parse(fs.readFileSync(baselineFile, 'utf8')));
  131. const tools = toolchain();
  132. const actions = fs.readFileSync(path.join(__dirname, '../src/timecard-main.actions.js'), 'utf8');
  133. const render = fs.readFileSync(path.join(__dirname, '../src/timecard-main.render.jsx'), 'utf8');
  134. const candidate = buildCandidate(baseline, actions, render, tools);
  135. fs.mkdirSync(outputDir, { recursive: true });
  136. assert.ok(!fs.existsSync(path.join(outputDir, '发布尝试.json')), '证据目录已存在发布尝试,请先核对,不能盲目重试');
  137. fs.writeFileSync(path.join(outputDir, '候选Schema.json'), JSON.stringify(candidate.schema, null, 2));
  138. fs.writeFileSync(path.join(outputDir, '候选校验.json'), JSON.stringify({ functions: candidate.functions, allowedPaths: candidate.paths, baselineRevision: baseline.gmtModified }, null, 2));
  139. if (flags.includes('--apply')) await publish(baseline, candidate, outputDir, tools);
  140. else console.log(JSON.stringify({ prepared: true, functions: candidate.functions, changedPaths: candidate.paths.length, published: false }));
  141. }
  142. module.exports = { buildCandidate, assertCurrentBaseline, verifyUnchanged, schemaContent };
  143. if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });