'use strict'; const fs = require('node:fs'); const path = require('node:path'); const cp = require('node:child_process'); const assert = require('node:assert/strict'); const vm = require('node:vm'); const { isDeepStrictEqual } = require('node:util'); const APP = 'APP_ZQ3I7XO2RSHDJ4QDEVNB'; const FORM = 'FORM-1741B1D56FE04480B0E4F5412FF9AE6ATYGR'; /** @returns {object} 使用已安装OpenYida的编译、认证、校验和发布传输。 */ function toolchain() { const executable = cp.execFileSync('which', ['openyida'], { encoding: 'utf8' }).trim(); const root = path.resolve(path.dirname(fs.realpathSync(executable)), '..'); return { transform: require(root + '/lib/core/babel-transform').default, guard: require(root + '/lib/core/no-emoji-guard'), client: require(root + '/lib/core/yida-client'), utils: require(root + '/lib/core/utils'), transport: require(root + '/lib/app/publish').sendSaveRequestWithAuth }; } /** @param {object} value Schema响应或Schema @returns {object} 完整Schema */ function schemaContent(value) { assert.ok(value && typeof value === 'object' && value.success !== false, 'Schema响应无效'); const content = value.content === undefined ? value : value.content; const schema = typeof content === 'string' ? JSON.parse(content) : content; assert.ok(schema && Array.isArray(schema.pages) && schema.actions, '缺少页面或动作配置'); return schema; } /** @param {object} schema 完整Schema @returns {{node:object,path:string}} 唯一原生Jsx节点 */ function jsxNode(schema) { const found = []; /** @param {object} value 节点 @param {string} pointer JSON路径 @returns {void} */ function visit(value, pointer) { if (!value || typeof value !== 'object') return; if (value.componentName === 'Jsx' && value.props && value.props.render) found.push({ node: value, path: pointer }); for (const [key, child] of Object.entries(value)) visit(child, pointer + '/' + key); } visit(schema.pages, '/pages'); assert.equal(found.length, 1, '正式页必须包含唯一的既有Jsx渲染节点'); return found[0]; } /** @param {string} source 源码 @param {boolean} render 是否为独立渲染 @param {object} tools 工具链 @returns {string} 平台编译码 */ function compile(source, render, tools) { const result = tools.transform(source, {}, render, { RE_VERSION: '7.4.0' }); if (result.error) throw result.error; assert.ok(result.compiled, '平台编译码不能为空'); new vm.Script(render ? '(' + result.compiled + ')' : result.compiled); return result.compiled; } /** @param {object} baseline 最新基线 @param {string} actions 合并后的JS @param {string} render 合并后的JSX @param {object} tools 工具链 @returns {{schema:object,paths:string[],functions:number}} 限定范围候选 */ function buildCandidate(baseline, actions, render, tools = toolchain()) { const schema = structuredClone(baseline); const target = jsxNode(schema); const module = { source: actions, compiled: compile(actions, false, tools) }; const names = [...actions.matchAll(/export\s+(?:async\s+)?function\s+(\w+)\s*\(/g)].map(match => match[1]); assert.ok(names.length > 0, '动作模块没有导出函数'); assert.equal(names.length, new Set(names).size, '动作函数不能重名'); for (const item of schema.actions.list) assert.ok(names.includes(item.id), '不能移除原动作:' + item.id); schema.actions.module = { ...schema.actions.module, ...module }; const oldNames = new Set(schema.actions.list.map(item => item.id)); schema.actions.list.push(...names.filter(id => !oldNames.has(id)).map(id => ({ id, title: id }))); target.node.props.render = { ...target.node.props.render, source: render, compiled: compile(render, true, tools) }; const paths = ['/actions/module/source', '/actions/module/compiled', '/actions/list', target.path + '/props/render/source', target.path + '/props/render/compiled']; // prd 只清理当前页面内的连接器描述装饰,不触及URL、参数、鉴权或连接器主资源。 const root = schema.pages[0].componentsTree[0]; (root.dataSource.online || []).forEach((dataSource, index) => { const connector = dataSource.options && dataSource.options.connector; if (!connector || typeof connector.description !== 'string') return; const cleaned = connector.description.replace(tools.guard.EMOJI_PATTERN, ''); if (cleaned === connector.description) return; connector.description = cleaned; paths.push('/pages/0/componentsTree/0/dataSource/online/' + index + '/options/connector/description'); }); tools.guard.assertNoEmojiInValue(schema, { artifact: 'TimeCard双文件候选', code: 'OPENYIDA_FORM_SCHEMA_EMOJI_FORBIDDEN' }); verifyUnchanged(baseline, schema, paths); return { schema, paths, functions: names.length }; } /** @param {object} target 待规范化副本 @param {string} pointer 已验证JSON路径 @returns {void} */ function omit(target, pointer) { const parts = pointer.slice(1).split('/'); const parent = parts.slice(0, -1).reduce((node, key) => node[key], target); delete parent[parts.at(-1)]; } /** @param {object} before 基线 @param {object} after 候选或回读 @param {string[]} paths 允许变化路径 @returns {void} */ function verifyUnchanged(before, after, paths) { const a = structuredClone(before), b = structuredClone(after); for (const pointer of [...paths, '/gmtModified']) { omit(a, pointer); omit(b, pointer); } assert.deepEqual(b, a, '检测到发布范围外的Schema变化'); } /** @param {object} baseline 候选绑定基线 @param {object} current 保存前线上基线 @returns {void} */ function assertCurrentBaseline(baseline, current) { assert.ok(Number.isFinite(baseline.gmtModified), '缺少服务端版本标识'); assert.ok(isDeepStrictEqual(baseline, current), '线上页面已变化,必须重新拉取并合并;停止发布'); } /** @param {object} authRef 平台登录引用 @param {object} tools 工具链 @returns {Promise} 只读最新Schema */ async function fetchSchema(authRef, tools) { const result = await tools.utils.requestWithAutoLogin(auth => tools.utils.httpGet(auth.baseUrl, '/alibaba/web/' + APP + '/_view/query/formdesign/getFormSchema.json', { formUuid: FORM, schemaVersion: 'V5' }), authRef); assert.ok(result && result.success !== false && !result.__needLogin && !result.__csrfExpired, '读取线上Schema失败'); return schemaContent(result); } /** @param {object} baseline 已备份最新Schema @param {object} candidate 已验证候选 @param {string} output 证据目录 @param {object} tools 工具链 @returns {Promise} 绑定原版本保存并独立回读 */ async function publish(baseline, candidate, output, tools) { const auth = tools.client.createAuthRef(); const current = await fetchSchema(auth, tools); assertCurrentBaseline(baseline, current); // fixme 保存始终传候选绑定的原revision,写前及写入时任意并发变化均不能被覆盖。 fs.writeFileSync(path.join(output, '发布尝试.json'), JSON.stringify({ formUuid: FORM, revision: baseline.gmtModified, attemptedAt: new Date().toISOString() }, null, 2)); const result = await tools.transport(auth, JSON.stringify(candidate.schema), APP, FORM, baseline.gmtModified); fs.writeFileSync(path.join(output, '发布回执.json'), JSON.stringify(result, null, 2)); assert.ok(result && result.success, '发布未成功;先只读核对回执及线上状态,不自动重试'); const readback = await fetchSchema(auth, tools); fs.writeFileSync(path.join(output, '发布后Schema.json'), JSON.stringify(readback, null, 2)); verifyUnchanged(candidate.schema, readback, []); const receipt = { success: true, formUuid: FORM, version: result.content && result.content.version, gmtModified: readback.gmtModified, originalRevision: baseline.gmtModified, codeAndConfigurationVerified: true, retainedDataSources: readback.pages[0].componentsTree[0].dataSource.online.length, runtimeSmokeVerified: false, url: auth.baseUrl + '/' + APP + '/workbench/' + FORM }; fs.writeFileSync(path.join(output, '独立回读验收.json'), JSON.stringify(receipt, null, 2)); console.log(JSON.stringify(receipt)); } /** @returns {Promise} 默认仅生成候选,显式--apply才实际发布指定正式页 */ async function main() { const [baselineFile, outputDir, ...flags] = process.argv.slice(2); assert.ok(baselineFile && outputDir && flags.every(flag => flag === '--apply'), '用法:node pages/scripts/publish-timecard-main.cjs <最新Schema文件> <证据目录> [--apply]'); const baseline = schemaContent(JSON.parse(fs.readFileSync(baselineFile, 'utf8'))); const tools = toolchain(); const actions = fs.readFileSync(path.join(__dirname, '../src/timecard-main.actions.js'), 'utf8'); const render = fs.readFileSync(path.join(__dirname, '../src/timecard-main.render.jsx'), 'utf8'); const candidate = buildCandidate(baseline, actions, render, tools); fs.mkdirSync(outputDir, { recursive: true }); assert.ok(!fs.existsSync(path.join(outputDir, '发布尝试.json')), '证据目录已存在发布尝试,请先核对,不能盲目重试'); fs.writeFileSync(path.join(outputDir, '候选Schema.json'), JSON.stringify(candidate.schema, null, 2)); fs.writeFileSync(path.join(outputDir, '候选校验.json'), JSON.stringify({ functions: candidate.functions, allowedPaths: candidate.paths, baselineRevision: baseline.gmtModified }, null, 2)); if (flags.includes('--apply')) await publish(baseline, candidate, outputDir, tools); else console.log(JSON.stringify({ prepared: true, functions: candidate.functions, changedPaths: candidate.paths.length, published: false })); } module.exports = { buildCandidate, assertCurrentBaseline, verifyUnchanged, schemaContent }; if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });