UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

308 lines (307 loc) 16.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.checkDiffService = exports.formatUrlHealthSection = exports.resolvePointLabel = exports.pickOnlineVersion = exports.formatConfigOverwriteSection = void 0; const logger_1 = require("../../../utils/logger"); const local_plugin_config_1 = require("../../../local-plugin-config"); const local_config_1 = require("../local-config"); const write_local_point_config_1 = require("../../../utils/write-local-point-config"); const validate_runtime_urls_1 = require("../../../utils/validate-runtime-urls"); const version_diff_1 = require("../../../api/tools/version-diff"); const version_1 = require("../../../api/tools/version"); const update_plugin_description_1 = require("../../../api/tools/update-plugin-description"); const list_categories_1 = require("../../../api/tools/list-categories"); /** * `lpm check diff` — 发布前总检(本地 vs 远端),充当发布前的【确认门】。 * * 聚合四段: * ①基本信息——完整罗列 GetAppDescriptionInfo 的字段(应用类型 / 名称 / 短描述 / 详情描述 / * 分类 / icon / 语言 / 协议 等)+ 当前最新版本。 * ②配置覆盖(本地 → 远端草稿)——点位级 ADDED / MODIFIED / DELETED 全量 diff * (computeConfigVsRemoteDiff,本地点位配置 vs 远端反向转换)。这正是「把本地配置推上去 * (update --source-type=local)会改什么」的清单。MODIFIED 逐项附字段级 local→remote 明细, * 空值/缺失差异已被 canonicalJson 折叠(残留伪差仅 0/false vs 缺失,可看字段明细自行甄别); * DELETED 会被推送闸口硬拦,附 --allow-delete 提示。 * ③权限变更——打 version/diff 接口(新增/删除)。 * ④运行时 URL 健康——validateRuntimeUrls 扫本地点位配置里的 placeholder 级 URL * (your-domain.com / example.com / localhost …)。这些发布后会让点位运行时静默失效, * 必须在按下发布前暴露给用户(CLI 锚定,不靠 AI 转呈 set/update 的临时 NOTICE)。 * * 输出是一个**固定模板的确认文本块**,写到 stdout。编排 skill 必须把它**原样** * 转呈用户、等用户显式确认后才执行同步配置 + 发布。模板由 CLI 锚定,AI 不得改写。 * * 四段标题恒定输出、顺序固定;某段无内容时填「无」,但段标题照出。 */ const NONE = '无'; const EMPTY = '(未填)'; function fmtPointEntry(d) { const nameSuffix = d.name ? `「${d.name}」` : ''; const head = ` ${d.type}[${d.key}]${nameSuffix}`; // modified 桶附字段级 local→remote 明细:确认门里直接看到"到底改了哪些字段", // 用户/AI 能据此判定是实质改动还是可忽略的残留伪差(如 0/false vs 缺失)。 const block = d.status === 'modified' && d.changes && d.changes.length > 0 ? [head, ...d.changes.map(c => ` ${c.field}: ${c.local}${c.remote}`)].join('\n') : head; // 配色:删除红、修改黄、新增默认。非 TTY 下 chalk 不加码,确认块转呈仍是干净文本。 return (0, local_config_1.colorizeByStatus)(d.status, block); } /** * ②配置覆盖段:把点位级 diff 分成 新增/修改/删除 三桶。 * - 修改桶逐项附字段级 local→remote 明细;MODIFIED 经 canonicalJson 比对,已折叠 * 空值/缺失差异(往返默认值补齐 / 空串兜底),残留伪差仅 0/false vs 缺失,故附备注 * 提示看字段明细自行甄别。 * - 删除桶附闸口提示:推送(update --source-type=local)会硬拦删除,需 --allow-delete。 */ function formatConfigOverwriteSection(diffs) { const added = diffs.filter(d => d.status === 'added'); const modified = diffs.filter(d => d.status === 'modified'); const deleted = diffs.filter(d => d.status === 'deleted'); const lines = []; lines.push(' 新增(本地有 / 远端无):'); lines.push(added.length ? added.map(fmtPointEntry).join('\n') : ` ${NONE}`); lines.push(' 修改(两端同 key、内容不同):'); if (modified.length) { lines.push(modified.map(fmtPointEntry).join('\n')); lines.push(' (注:空值/缺失差异已折叠;残留伪差仅 0/false vs 缺失,看上面字段明细自行甄别)'); } else { lines.push(` ${NONE}`); } lines.push(' 删除(远端有 / 本地无):'); if (deleted.length) { lines.push(deleted.map(fmtPointEntry).join('\n')); lines.push(' (删除项已被推送闸口硬拦,需 --allow-delete 才放行——不可逆,务必先与用户确认)'); } else { lines.push(` ${NONE}`); } return lines.join('\n'); } exports.formatConfigOverwriteSection = formatConfigOverwriteSection; function formatPermLines(perms) { if (!perms || perms.length === 0) { return ` ${NONE}`; } return perms .map(p => { const name = p.name || p.key || ''; return p.name && p.key ? ` ${name}${p.key})` : ` ${name}`; }) .join('\n'); } /** * 从版本列表里挑出「线上生效版本号」= `status === OnShelf` 那条的 `app_version`。 * 对齐前端 `openappStore.getDiffInfo`(按 OnShelf 选版本),不是 `list[0]`(最新一条, * 可能是草稿/审核中)。用作:①基本信息「当前线上版本」②③权限变更的对比基线。 * * 取不到(从未上架)→ 返回 ''。空基线传给 version/diff 时后端按草稿全量算(add=全部、 * remove 空),这对「从未发布」的插件语义正确(全是新增、无可移除)。 */ function pickOnlineVersion(list) { var _a; return ((_a = list === null || list === void 0 ? void 0 : list.find(v => v.status === version_1.AppVersionStatus.OnShelf)) === null || _a === void 0 ? void 0 : _a.app_version) || ''; } exports.pickOnlineVersion = pickOnlineVersion; /** * validateRuntimeUrls 用 Object.entries 遍历点位桶,桶是数组时拿到的 key 是数组**下标** * (path 形如 `intercept[0].url`)。这里用本地配置把下标映射回点位真实 key + name, * 让确认块显示 `intercept[intercept_mr_check]「MR 合并检查」` 而非 `intercept[0]`。 */ function resolvePointLabel(path, localConfig) { const m = /^([a-zA-Z_]+)\[(\d+)\]/.exec(path); if (!m) { return { label: path.split('.')[0] || path }; } const [, type, idxStr] = m; const bucket = localConfig === null || localConfig === void 0 ? void 0 : localConfig[type]; const item = Array.isArray(bucket) ? bucket[Number(idxStr)] : undefined; const key = item && typeof item.key === 'string' ? item.key : idxStr; const name = item && typeof item.name === 'string' ? item.name : undefined; return { label: `${type}[${key}]`, name }; } exports.resolvePointLabel = resolvePointLabel; /** * ④运行时 URL 健康段:列出 placeholder 级违规(占位/编造 URL)。 * 仅 placeholder——invalid(缺失/空/非 http(s))在 set/update 就 exit 1 拦死、到不了发布。 */ function formatUrlHealthSection(placeholders, localConfig) { if (!placeholders.length) { return ` ${NONE}`; } return placeholders .map(v => { const { label, name } = resolvePointLabel(v.path, localConfig); const nameSuffix = name ? `「${name}」` : ''; return [ ` ⚠️ ${label}${nameSuffix}`, ` 占位值:${v.value || '(空)'}`, ' 发布后该点位运行时静默失效,上线前必须替换成真实后端地址', ].join('\n'); }) .join('\n'); } exports.formatUrlHealthSection = formatUrlHealthSection; async function checkDiffService() { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; let pluginCfg; try { pluginCfg = (0, local_plugin_config_1.getLocalPluginConfig)(); } catch (e) { logger_1.logger.error('Invalid plugin.config.json:', e instanceof Error ? e.message : e); process.exit(1); } if (!pluginCfg) { logger_1.logger.error('Please init project first (plugin.config.json not found).'); process.exit(1); } const { pluginId, siteDomain } = pluginCfg; // ① 基本信息 — GetAppDescriptionInfo 全量字段(取数失败按致命处理,避免给出残缺的确认信息) let desc; try { desc = await (0, update_plugin_description_1.getPluginDescriptionInfo)({ siteDomain, appKey: pluginId }); } catch (e) { logger_1.logger.error('Failed to fetch plugin description info:', e instanceof Error ? e.message : e); process.exit(1); } // 应用类型 / 能力点位 const isAiApp = desc.app_type === 1; const appTypeText = isAiApp ? 'AI 应用' : '普通插件'; const pointTypesText = ((_a = desc.point_types) === null || _a === void 0 ? void 0 : _a.length) ? `,能力点位:${desc.point_types.join(' / ')}` : ''; // 分类:仅普通插件有分类;AI 应用没有分类,整行省略。 // 普通插件按 type=0 拉分类表,把 category_ids 映射成中文名(best-effort)。 let catText = ''; if (!isAiApp) { const categoryNameById = new Map(); try { const cats = await (0, list_categories_1.listCategories)({ siteDomain, type: 0 }); for (const c of cats.list || []) { if (c.id) { categoryNameById.set(c.id, ((_b = c.name) === null || _b === void 0 ? void 0 : _b['zh-CN']) || ((_c = c.name) === null || _c === void 0 ? void 0 : _c['en-US']) || c.id); } } } catch (e) { logger_1.logger.debug('listCategories failed (non-fatal):', e); } catText = ((_d = desc.category_ids) === null || _d === void 0 ? void 0 : _d.length) ? desc.category_ids.map(id => categoryNameById.get(id) || id).join('、') : '(未分类)'; } // 当前线上生效版本(status===OnShelf;best-effort,取不到不阻断)。 // 同时作为 ③ 权限变更的对比基线传给 version/diff——空(从未上架)则后端按草稿全量算。 let onlineVersion = ''; try { const { list } = await (0, version_1.getVersionList)({ siteDomain, appKey: pluginId }); onlineVersion = pickOnlineVersion(list); } catch (e) { logger_1.logger.debug('getVersionList failed (non-fatal):', e); } const currentVersion = onlineVersion || '未发布'; // 取主语言(default_lang → zh-cn → 首个)的 name/short/description const lang = desc.default_lang || 'zh-cn'; const i18n = ((_e = desc.i18n_info) === null || _e === void 0 ? void 0 : _e[lang]) || (desc.i18n_info ? Object.values(desc.i18n_info)[0] : undefined) || {}; const name = i18n.name || EMPTY; const short = i18n.short || EMPTY; const detail = ((_g = (_f = i18n.description) === null || _f === void 0 ? void 0 : _f.doc_text) === null || _g === void 0 ? void 0 : _g.trim().replace(/\s*\n\s*/g, ' ')) || EMPTY; const icon = desc.icon || '(无)'; const langs = ((_h = desc.i18n_lang) === null || _h === void 0 ? void 0 : _h.length) ? desc.i18n_lang.join('、') : lang; const basicInfoLines = [ '【1. 基本信息】', ` • 插件 Key:${pluginId}`, ` • 站点:${siteDomain}`, ` • 应用类型:${appTypeText}(app_type=${(_j = desc.app_type) !== null && _j !== void 0 ? _j : '未知'}${pointTypesText}`, ` • 名称:${name}`, ` • 短描述:${short}`, ` • 详情描述:${detail}`, // 分类仅普通插件有;AI 应用无分类,省略此行 ...(isAiApp ? [] : [` • 分类:${catText}`]), ` • icon:${icon}`, ` • 默认语言:${lang}`, ` • 多语言:${langs}`, ` • 当前线上版本:${currentVersion}`, ]; if (((_k = desc.protocol) === null || _k === void 0 ? void 0 : _k.clause_url) || ((_l = desc.protocol) === null || _l === void 0 ? void 0 : _l.privacy_policy_url)) { basicInfoLines.push(` • 协议:服务条款 ${desc.protocol.clause_url || '(无)'} / 隐私政策 ${desc.protocol.privacy_policy_url || '(无)'}`); } if (desc.product_key) { basicInfoLines.push(` • product_key:${desc.product_key}`); } // 轮播图 / 文档 / 文档链接:非必填的市场展示物料,逐语言平铺展示(lang 直接用语言代码标注、不翻译)。 // 内部为不透明数组,逐项展示——字符串原样、对象转紧凑 JSON。 const fmtItem = (it) => (typeof it === 'string' ? it : JSON.stringify(it)); const pushFlatList = (label, l, arr) => { if (Array.isArray(arr) && arr.length) { basicInfoLines.push(` • ${label}${l}):`); for (const it of arr) basicInfoLines.push(` - ${fmtItem(it)}`); } }; for (const [l, entry] of Object.entries((_m = desc.i18n_info) !== null && _m !== void 0 ? _m : {})) { pushFlatList('轮播图', l, entry.carousel_list); pushFlatList('文档', l, entry.document); pushFlatList('文档链接', l, entry.document_url); } // ② 配置覆盖(本地 → 远端草稿)— 点位级 ADDED/MODIFIED/DELETED 全量 diff。 // 一次读本地配置,既喂 diff 又喂 ④ URL 健康,避免重复读盘。 let localConfig = {}; let pointDiffs = []; try { localConfig = (await (0, write_local_point_config_1.getLocalPointConfig)()); pointDiffs = await (0, local_config_1.computeConfigVsRemoteDiff)(localConfig); } catch (e) { logger_1.logger.error('Failed to compute config diff:', e instanceof Error ? e.message : e); process.exit(1); } // ③ 权限变更 — version/diff,以线上生效版本为对比基线(onlineVersion)。 // 后端 GetPermDiff(appKey, version=onlineVersion, targetVersion=""):targetVersion 空时, // 后端在「最新版本是草稿且 ≠ 基线」时取它为目标,于是 add=草稿新增、remove=草稿移除(真实增量)。 // onlineVersion 为空(从未上架)→ 后端走 version=="" 退化分支:add=草稿全部权限、remove 空。 let permAdd = []; let permRemove = []; try { const diff = await (0, version_diff_1.getVersionDiff)({ siteDomain, appKey: pluginId, appVersion: onlineVersion || undefined, }); permAdd = diff.add || []; permRemove = diff.remove || []; } catch (e) { logger_1.logger.error('Failed to fetch permission diff:', e instanceof Error ? e.message : e); process.exit(1); } // ④ 运行时 URL 健康 — placeholder 级违规(占位/编造 URL,发布后静默失效) const urlPlaceholders = (0, validate_runtime_urls_1.splitViolations)((0, validate_runtime_urls_1.validateRuntimeUrls)(localConfig).violations).placeholders; const out = [ `════════════ 发布前总检 · ${pluginId} ════════════`, '', ...basicInfoLines, '', '【2. 配置覆盖(本地→远端最新草稿的点位增删改)】', formatConfigOverwriteSection(pointDiffs), ' → 确认是否用本地配置覆盖远端草稿(这一步会把上面的增删改推到远端)。', '', '【3. 权限变更】', isAiApp ? '默认存在所有权限' : ' 新增:', formatPermLines(permAdd), isAiApp ? '' : ' 删除:', formatPermLines(permRemove), ' → 权限不对?先 `lpm perm apply` 调整再回来重跑,别带错误权限发布。', '', '【4. 运行时 URL 健康】', formatUrlHealthSection(urlPlaceholders, localConfig), ' → 占位 URL 发布后静默失效;要修请改配置后 `lpm local-config set` 再回来重跑。', '', '═══════════════════════════════════════════', '发布后对所有可见用户生效、当前不支持回退。以上四项确认无误请确认发布;任一项要改请先修复再重跑。', '', ].join('\n'); process.stdout.write(out); } exports.checkDiffService = checkDiffService;