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