UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

235 lines (234 loc) 11.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createProject = exports.pickProjectName = exports.deriveProjectName = void 0; const path_1 = __importDefault(require("path")); const fs_extra_1 = require("fs-extra"); const inquirer_1 = __importDefault(require("inquirer")); const create_plugin_1 = require("../../../api/tools/create-plugin"); const update_plugin_description_1 = require("../../../api/tools/update-plugin-description"); const generate_and_upload_icon_1 = require("../../../utils/generate-and-upload-icon"); const logger_1 = require("../../../utils/logger"); const is_plugin_project_1 = require("../../../utils/is-plugin-project"); const init_1 = require("../init"); const types_1 = require("../../../types"); /** * Sentinel:保存上次 lpm create 申请的 pluginId,防止 init 失败重跑时再次申请新 pluginId * 残留多个 plugin-* 目录 + 后端孤儿 pluginId(ISS-N)。 * 全部步骤成功后删除;失败留下,重跑自动复用。 */ const SENTINEL_FILENAME = '.lpm-create-incomplete.json'; function readSentinel(cwd) { const p = path_1.default.join(cwd, SENTINEL_FILENAME); if (!(0, fs_extra_1.existsSync)(p)) return null; try { return JSON.parse((0, fs_extra_1.readFileSync)(p, 'utf8')); } catch (_a) { return null; } } function writeSentinel(cwd, data) { (0, fs_extra_1.writeFileSync)(path_1.default.join(cwd, SENTINEL_FILENAME), JSON.stringify(data, null, 2)); } function removeSentinel(cwd) { const p = path_1.default.join(cwd, SENTINEL_FILENAME); if ((0, fs_extra_1.existsSync)(p)) (0, fs_extra_1.removeSync)(p); } function getErrorMessage(error) { return error instanceof Error ? error.message : String(error); } function normalizeSiteDomain(siteDomain) { return siteDomain.replace(/\/$/, ''); } /** * 纯函数:name → base slug。**不查 fs**,所有调用同 (name, appKey) 必产同样输出。 * * 撞名处理交给 pickProjectName,避免在"算名字"这一步做悲观启发式(如"含中文必加后缀") * 而牺牲了 readability——大多数场景同一 cwd 下不会撞,clean slug 最舒服。 * * slug 完全为空(如纯中文名 "节点表单")走 `plugin-<appKeySuffix>` 兜底,保证非空。 */ function deriveProjectName(name, appKey) { const normalizedName = name .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-{2,}/g, '-'); if (normalizedName) { return normalizedName; } return `plugin-${appKey.slice(-6).toLowerCase()}`; } exports.deriveProjectName = deriveProjectName; /** * 新建场景下选一个**实际不撞名**的 dir 名: * 1. base slug(来自 deriveProjectName)——不撞就用,保留 readability * 2. base 撞 → 追加 `-<appKeySuffix>`(pluginId 末 6 位,globally unique) * 3. 还撞 → `-<appKeySuffix>-2` / `-3` …… 直到不撞或耗尽 999 次 * * 算出来的结果必须由 caller 写入 sentinel.projectName,让后续 resume 不再算这一遍—— * 否则 resume 时 fs 状态变了,重算会得到不同的 dir 名,导致清理对象错位。 * * 解决用户场景:中文名经 slugify 信息丢失("节点表单 AI 字段" → "ai"),导致同语境多个 * 插件全撞 `ai/`——查 fs 命中 → 走 `ai-xxxyyy` / `ai-aaabbb` 自然区分。纯英文名只要不撞 * 仍保留干净 slug,不被无脑加后缀污染。 */ function pickProjectName(name, appKey, cwd) { const base = deriveProjectName(name, appKey); if (!(0, fs_extra_1.existsSync)(path_1.default.join(cwd, base))) { return base; } const suffix = appKey.slice(-6).toLowerCase(); // base 已经以 `-<suffix>` 结尾的情况(来自 plugin-<suffix> 兜底分支)就不再重复追加 const withSuffix = base.endsWith(`-${suffix}`) ? base : `${base}-${suffix}`; if (!(0, fs_extra_1.existsSync)(path_1.default.join(cwd, withSuffix))) { return withSuffix; } for (let i = 2; i < 1000; i++) { const candidate = `${withSuffix}-${i}`; if (!(0, fs_extra_1.existsSync)(path_1.default.join(cwd, candidate))) { return candidate; } } throw new Error(`Cannot derive a non-colliding project dir for "${name}" in ${cwd} after 999 attempts; ` + 'please move or rename the existing directories and retry.'); } exports.pickProjectName = pickProjectName; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function runInit(siteDomain, projectName, pluginId, pluginSecret, appType) { const maxRetries = 3; const delayMs = 3000; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { logger_1.logger.info(`Waiting for plugin data to sync... (attempt ${attempt}/${maxRetries})`); await sleep(delayMs); await (0, init_1.initProject)({ projectName, pluginId, pluginSecret, siteDomain, templateId: 'react-initialized', appType, }); return; } catch (err) { if (attempt === maxRetries) throw err; logger_1.logger.warn(`Init attempt ${attempt} failed, retrying...`); } } } async function createProject(options) { var _a; const siteDomain = normalizeSiteDomain(options.siteDomain); // Check if current directory is already a plugin project const [isPlugin] = (0, is_plugin_project_1.isPluginProject)(); if (isPlugin && !options.force) { const { proceed } = await inquirer_1.default.prompt([ { name: 'proceed', type: 'confirm', message: 'Current directory is already a plugin project. Do you want to create a new plugin here?', default: false, }, ]); if (!proceed) { logger_1.logger.warn('Create cancelled. Use --force to skip this confirmation.'); process.exit(0); } } let appType = options.appType && types_1.APP_TYPES.includes(options.appType) ? options.appType : 'normal'; // ISS-N idempotent:检测 sentinel,有的话复用上次申请的 pluginId 续跑 init const cwd = process.cwd(); const existingSentinel = readSentinel(cwd); const isResume = !!(existingSentinel && existingSentinel.name === options.name && existingSentinel.siteDomain === siteDomain); let pluginId; let pluginSecret; let projectName; if (isResume && existingSentinel) { pluginId = existingSentinel.pluginId; pluginSecret = existingSentinel.pluginSecret; // resume 优先读上次的 projectName;老 sentinel 没这字段时 fallback 到 base slug // (等价于本次重构前的行为,保持向后兼容)。 projectName = (_a = existingSentinel.projectName) !== null && _a !== void 0 ? _a : deriveProjectName(options.name, pluginId); // resume 时优先用 sentinel 里记下的形态,使重跑不必再带 --app-type(老 sentinel 无此 // 字段则沿用本次 options 推出的 appType)。 if (existingSentinel.appType && types_1.APP_TYPES.includes(existingSentinel.appType)) { appType = existingSentinel.appType; } logger_1.logger.info(`Resuming previous incomplete lpm create (app_type=${appType}). Reusing Plugin ID: ${pluginId}`); } else { logger_1.logger.info(`Creating plugin (app_type=${appType})...`); const createdPlugin = await (0, create_plugin_1.createPlugin)({ siteDomain, name: options.name, appType, }).catch(error => { throw new Error(`Create plugin failed: ${getErrorMessage(error)}`); }); pluginId = createdPlugin.app_key; pluginSecret = createdPlugin.app_secret; if (!pluginId || !pluginSecret) { throw new Error('Create plugin failed: missing app_key or app_secret in response.'); } // 先写不带 projectName 的 sentinel 兜底:申请到 pluginId 后任何后续步骤(含 pickProjectName // 999 上限抛错 / icon 上传失败 / runInit 失败 / …)都能让用户用同样 name resume 复用此 // pluginId,不会留后端孤儿。 writeSentinel(cwd, { pluginId, pluginSecret, name: options.name, siteDomain, appType }); // 选 dir 名:先 base slug,撞了才递增后缀。结果覆写到 sentinel,让 resume 不重算。 projectName = pickProjectName(options.name, pluginId, cwd); writeSentinel(cwd, { pluginId, pluginSecret, name: options.name, siteDomain, projectName, appType }); logger_1.logger.success(`Plugin created successfully. Plugin ID: ${pluginId}`); // 根据插件名称生成图标并上传 await (0, generate_and_upload_icon_1.generateAndUploadIcon)({ siteDomain, appKey: pluginId }); } // 续跑场景:上次 init 失败可能在 targetDir 留了部分文件(模板下载到一半 / 部分 yarn install 等), // 由本函数显式清理以兼容 initProject 内部新加的「已存在且不为空 → 拒绝覆盖」硬卡。 // 新建场景不清理:initProject 的硬卡负责挡撞名(如 slug 撞兄弟工程目录)。 if (isResume) { const resumeTargetDir = path_1.default.resolve(cwd, projectName); if ((0, fs_extra_1.existsSync)(resumeTargetDir)) { logger_1.logger.info(`Resuming: cleaning incomplete init at ${resumeTargetDir}`); (0, fs_extra_1.removeSync)(resumeTargetDir); } } logger_1.logger.info(`Initializing project "${projectName}"...`); // app_type 由 initProject 以**后端 GetAppDescriptionInfo 为准**、在 yarn install **之前** // 写进 plugin.config.json(见 resolve-backend-app-type.ts / persist-app-type.ts)。这里传下去的 // appType 只是后端不可达时的兜底,不是事实来源。故此处不再于 init 之后补写。 await runInit(siteDomain, projectName, pluginId, pluginSecret, appType).catch((error) => { throw new Error(`Init project failed: ${getErrorMessage(error)}\n\nHint: rerun "lpm create" with the same name to resume (sentinel ${SENTINEL_FILENAME} preserved).`); }); // 仅在传入了描述信息时才更新,scaffold 阶段可以不传 const hasDescInfo = options.description || options.detailDescription; if (hasDescInfo) { logger_1.logger.info('Updating plugin description...'); await (0, update_plugin_description_1.updatePluginDescription)({ siteDomain, appKey: pluginId, name: options.name, short: options.description, detailDescription: options.detailDescription, }).catch(error => { logger_1.logger.warn(`Update plugin description failed: ${getErrorMessage(error)}`); }); } // 全部成功:清理 sentinel removeSentinel(cwd); logger_1.logger.success(`🍻🍻🍻 Create successfully! Plugin ID: ${pluginId}. Local project directory: ${projectName}.`); } exports.createProject = createProject;