bf9395e022
CI / results (push) Blocked by required conditions
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / e2e-live (push) Waiting to run
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / deadcode (push) Waiting to run
391 lines
12 KiB
JavaScript
391 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { execFileSync, execFile } = require("child_process");
|
|
|
|
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
|
|
// CommonJS script works on all supported Node versions (require() of an ESM
|
|
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
|
|
// point below before main() runs.
|
|
let p;
|
|
|
|
const PKG = "@larksuite/cli";
|
|
const SKILLS_REPO = "https://open.feishu.cn";
|
|
const SKILLS_REPO_FALLBACK = "larksuite/cli";
|
|
const isWindows = process.platform === "win32";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// i18n
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const messages = {
|
|
zh: {
|
|
setup: "正在设置 Feishu/Lark CLI...",
|
|
step1: "正在安装 %s...",
|
|
step1Upgrade: "正在升级 %s (v%s → v%s)...",
|
|
step1Skip: "已安装 (v%s),跳过",
|
|
step1Done: "已全局安装",
|
|
step1Upgraded: "已升级到 v%s",
|
|
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
|
|
step2: "安装 AI Skills",
|
|
step2Skip: "已安装,跳过",
|
|
step2Spinner: "正在安装 Skills...",
|
|
step2Done: "Skills 已安装",
|
|
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
|
|
step3: "正在配置应用...",
|
|
step3NotFound: "未找到 lark-cli,终止",
|
|
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
|
|
step3Skip: "跳过应用配置",
|
|
step3Done: "应用已配置",
|
|
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
|
|
step4: "授权",
|
|
step4NotFound: "未找到 lark-cli,跳过授权",
|
|
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
|
|
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
|
|
step4Done: "授权完成",
|
|
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
|
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等)说:\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
|
cancelled: "安装已取消",
|
|
nonTtyHint: "要完成配置,请在终端中运行:\n lark-cli config init --new\n lark-cli auth login",
|
|
},
|
|
en: {
|
|
setup: "Setting up Feishu/Lark CLI...",
|
|
step1: "Installing %s globally...",
|
|
step1Upgrade: "Upgrading %s (v%s → v%s)...",
|
|
step1Skip: "Already installed (v%s). Skipped",
|
|
step1Done: "Installed globally",
|
|
step1Upgraded: "Upgraded to v%s",
|
|
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
|
|
step2: "Install AI skills",
|
|
step2Skip: "Already installed. Skipped",
|
|
step2Spinner: "Installing skills...",
|
|
step2Done: "Skills installed",
|
|
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
|
|
step3: "Configuring app...",
|
|
step3NotFound: "lark-cli not found. Aborting",
|
|
step3Found: "Found existing app (App ID: %s). Use this app?",
|
|
step3Skip: "Skipped app configuration",
|
|
step3Done: "App configured",
|
|
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
|
|
step4: "Authorization",
|
|
step4NotFound: "lark-cli not found. Skipping authorization",
|
|
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
|
|
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
|
|
step4Done: "Authorization complete",
|
|
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
|
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
|
|
cancelled: "Installation cancelled",
|
|
nonTtyHint: "To complete setup, run interactively:\n lark-cli config init --new\n lark-cli auth login",
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleCancel(value, msg) {
|
|
if (p.isCancel(value)) {
|
|
p.cancel(msg.cancelled);
|
|
process.exit(0);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function execCmd(cmd, args, opts) {
|
|
if (isWindows) {
|
|
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
|
|
}
|
|
return execFileSync(cmd, args, opts);
|
|
}
|
|
|
|
function run(cmd, args, opts = {}) {
|
|
execCmd(cmd, args, { stdio: "inherit", ...opts });
|
|
}
|
|
|
|
function runSilent(cmd, args, opts = {}) {
|
|
return execCmd(cmd, args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
...opts,
|
|
});
|
|
}
|
|
|
|
function runSilentAsync(cmd, args, opts = {}) {
|
|
const actualCmd = isWindows ? "cmd.exe" : cmd;
|
|
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
|
|
return new Promise((resolve, reject) => {
|
|
execFile(actualCmd, actualArgs, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
...opts,
|
|
}, (err, stdout) => {
|
|
if (err) reject(err);
|
|
else resolve(stdout);
|
|
});
|
|
});
|
|
}
|
|
|
|
function fmt(template, ...values) {
|
|
let i = 0;
|
|
return template.replace(/%s/g, () => values[i++] ?? "");
|
|
}
|
|
|
|
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
|
|
function whichLarkCli() {
|
|
try {
|
|
const prefix = execFileSync("npm", ["prefix", "-g"], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
}).toString().trim();
|
|
const bin = isWindows
|
|
? path.join(prefix, "lark-cli.cmd")
|
|
: path.join(prefix, "bin", "lark-cli");
|
|
if (fs.existsSync(bin)) return bin;
|
|
} catch (_) {
|
|
// fall through
|
|
}
|
|
// Fallback to which/where if npm prefix lookup fails.
|
|
try {
|
|
const cmd = isWindows ? "where" : "which";
|
|
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
|
|
.toString()
|
|
.split("\n")[0]
|
|
.trim();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
|
|
function getLatestVersion() {
|
|
try {
|
|
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
|
|
const ver = out.toString().trim();
|
|
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Compare two semver strings. Returns true if a < b. */
|
|
function semverLessThan(a, b) {
|
|
const pa = a.replace(/-.*$/, "").split(".").map(Number);
|
|
const pb = b.replace(/-.*$/, "").split(".").map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
if ((pa[i] || 0) < (pb[i] || 0)) return true;
|
|
if ((pa[i] || 0) > (pb[i] || 0)) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
|
|
function getGloballyInstalledVersion() {
|
|
try {
|
|
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
|
|
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
|
|
return match ? match[1] : "unknown";
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Check whether lark-cli config already exists. Returns app ID or null. */
|
|
function getExistingAppId(binPath) {
|
|
try {
|
|
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
|
|
const json = JSON.parse(out.toString());
|
|
return json.appId || null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Parse --lang from process.argv, returns "zh", "en", or null. */
|
|
function parseLangArg() {
|
|
const args = process.argv.slice(2);
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === "--lang" && args[i + 1]) {
|
|
const val = args[i + 1].toLowerCase();
|
|
if (val === "zh" || val === "en") return val;
|
|
}
|
|
if (args[i].startsWith("--lang=")) {
|
|
const val = args[i].split("=")[1].toLowerCase();
|
|
if (val === "zh" || val === "en") return val;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Steps
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function stepSelectLang() {
|
|
const fromArg = parseLangArg();
|
|
if (fromArg) return fromArg;
|
|
|
|
const lang = await p.select({
|
|
message: "请选择语言 / Select language",
|
|
options: [
|
|
{ value: "zh", label: "中文" },
|
|
{ value: "en", label: "English" },
|
|
],
|
|
});
|
|
return handleCancel(lang, messages.zh);
|
|
}
|
|
|
|
async function stepInstallGlobally(msg) {
|
|
const installedVer = getGloballyInstalledVersion();
|
|
const latestVer = getLatestVersion();
|
|
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
|
|
|
|
if (installedVer && !needsUpgrade) {
|
|
p.log.info(fmt(msg.step1Skip, installedVer));
|
|
return false;
|
|
}
|
|
|
|
const s = p.spinner();
|
|
if (needsUpgrade) {
|
|
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
|
|
} else {
|
|
s.start(fmt(msg.step1, PKG));
|
|
}
|
|
try {
|
|
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
|
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
|
|
return needsUpgrade;
|
|
} catch (_) {
|
|
s.stop(fmt(msg.step1Fail, PKG));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function skillsAlreadyInstalled() {
|
|
try {
|
|
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
|
|
timeout: 120000,
|
|
});
|
|
return /^lark-/m.test(out.toString());
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function stepInstallSkills(msg) {
|
|
const s = p.spinner();
|
|
s.start(msg.step2Spinner);
|
|
try {
|
|
if (await skillsAlreadyInstalled()) {
|
|
s.stop(msg.step2Skip);
|
|
return;
|
|
}
|
|
try {
|
|
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
|
|
timeout: 120000,
|
|
});
|
|
} catch (_) {
|
|
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
|
|
timeout: 120000,
|
|
});
|
|
}
|
|
s.stop(msg.step2Done);
|
|
} catch (_) {
|
|
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function stepConfigInit(msg, lang) {
|
|
const s = p.spinner();
|
|
s.start(msg.step3);
|
|
|
|
const larkCli = whichLarkCli();
|
|
if (!larkCli) {
|
|
s.stop(msg.step3NotFound);
|
|
process.exit(1);
|
|
}
|
|
|
|
const appId = getExistingAppId(larkCli);
|
|
s.stop(msg.step3);
|
|
|
|
if (appId) {
|
|
const reuse = await p.confirm({
|
|
message: fmt(msg.step3Found, appId),
|
|
});
|
|
if (handleCancel(reuse, msg) && reuse) {
|
|
p.log.info(msg.step3Skip);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
run(larkCli, ["config", "init", "--new", "--lang", lang]);
|
|
p.log.success(msg.step3Done);
|
|
} catch (_) {
|
|
p.log.error(msg.step3Fail);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function stepAuthLogin(msg) {
|
|
const larkCli = whichLarkCli();
|
|
if (!larkCli) {
|
|
p.log.warn(msg.step4NotFound);
|
|
return;
|
|
}
|
|
|
|
const yes = await p.confirm({
|
|
message: msg.step4Confirm,
|
|
});
|
|
if (p.isCancel(yes)) {
|
|
p.cancel(msg.cancelled);
|
|
process.exit(0);
|
|
}
|
|
if (!yes) {
|
|
p.log.info(msg.step4Skip);
|
|
return;
|
|
}
|
|
|
|
p.log.step(msg.step4);
|
|
try {
|
|
run(larkCli, ["auth", "login"]);
|
|
p.log.success(msg.step4Done);
|
|
} catch (_) {
|
|
p.log.warn(msg.step4Fail);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
const isInteractive = !!process.stdin.isTTY;
|
|
const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en");
|
|
const msg = messages[lang];
|
|
|
|
if (isInteractive) {
|
|
p.intro(msg.setup);
|
|
await stepInstallGlobally(msg);
|
|
await stepInstallSkills(msg);
|
|
await stepConfigInit(msg, lang);
|
|
await stepAuthLogin(msg);
|
|
p.outro(msg.done);
|
|
} else {
|
|
console.log(msg.setup);
|
|
await stepInstallGlobally(msg);
|
|
await stepInstallSkills(msg);
|
|
console.log(msg.nonTtyHint);
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
p = await import("@clack/prompts");
|
|
await main();
|
|
})().catch((err) => {
|
|
const msg = "Unexpected error: " + (err.message || err);
|
|
if (p) p.cancel(msg);
|
|
else console.error(msg);
|
|
process.exit(1);
|
|
});
|