@lark-project/cli
Version:
飞书项目插件开发工具
308 lines (307 loc) • 16.7 kB
JavaScript
;
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;