diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 0d8502bb..c2a5c680 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -146,11 +146,20 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult { return r } -// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y. +// RunSkillsUpdate installs skills, trying the .well-known source first and +// falling back to the GitHub repo on failure or timeout. func (u *Updater) RunSkillsUpdate() *NpmResult { if u.SkillsUpdateOverride != nil { return u.SkillsUpdateOverride() } + r := u.runSkillsAdd("https://open.feishu.cn") + if r.Err != nil { + r = u.runSkillsAdd("larksuite/cli") + } + return r +} + +func (u *Updater) runSkillsAdd(source string) *NpmResult { r := &NpmResult{} npxPath, err := exec.LookPath("npx") if err != nil { @@ -159,7 +168,7 @@ func (u *Updater) RunSkillsUpdate() *NpmResult { } ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) defer cancel() - cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y") + cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y") cmd.Stdout = &r.Stdout cmd.Stderr = &r.Stderr r.Err = cmd.Run() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..5c63f1dc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "@larksuite/cli", + "version": "1.0.11", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@larksuite/cli", + "version": "1.0.11", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@clack/prompts": "^1.2.0" + }, + "bin": { + "lark-cli": "scripts/run.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@clack/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", + "integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz", + "integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.2.0", + "fast-string-width": "^1.1.0", + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", + "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz", + "integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^1.2.0" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz", + "integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^1.1.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 1c6caa56..f63fb059 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,11 @@ "license": "MIT", "files": [ "scripts/install.js", + "scripts/install-wizard.js", "scripts/run.js", "CHANGELOG.md" - ] + ], + "dependencies": { + "@clack/prompts": "^1.2.0" + } } diff --git a/scripts/install-wizard.js b/scripts/install-wizard.js new file mode 100644 index 00000000..5691fdd0 --- /dev/null +++ b/scripts/install-wizard.js @@ -0,0 +1,372 @@ +#!/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"); +const p = require("@clack/prompts"); + +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 访问你的飞书数据(消息、文档、日历等)?", + step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权", + step4Done: "授权完成", + step4Fail: "授权失败。运行以下命令重试: lark-cli auth login", + done: "安装完成!\n现在可以对你的 AI 工具(Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"", + cancelled: "安装已取消", + }, + 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 AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?", + 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", + }, +}; + +// --------------------------------------------------------------------------- +// 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 lang = await stepSelectLang(); + const msg = messages[lang]; + + p.intro(msg.setup); + + await stepInstallGlobally(msg); + await stepInstallSkills(msg); + await stepConfigInit(msg, lang); + await stepAuthLogin(msg); + + p.outro(msg.done); +} + +main().catch((err) => { + p.cancel("Unexpected error: " + (err.message || err)); + process.exit(1); +}); diff --git a/scripts/install.js b/scripts/install.js index 3a3b1ecc..17903ba6 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -3,10 +3,10 @@ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execFileSync } = require("child_process"); const os = require("os"); -const VERSION = require("../package.json").version; +const VERSION = require("../package.json").version.replace(/-.*$/, ""); const REPO = "larksuite/cli"; const NAME = "lark-cli"; @@ -43,13 +43,16 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : "")); fs.mkdirSync(binDir, { recursive: true }); function download(url, destPath) { + const args = [ + "--fail", "--location", "--silent", "--show-error", + "--connect-timeout", "10", "--max-time", "120", + "--output", destPath, + ]; // --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE // errors when the certificate revocation list server is unreachable - const sslFlag = isWindows ? "--ssl-revoke-best-effort " : ""; - execSync( - `curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`, - { stdio: ["ignore", "ignore", "pipe"] } - ); + if (isWindows) args.unshift("--ssl-revoke-best-effort"); + args.push(url); + execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] }); } function install() { @@ -64,12 +67,12 @@ function install() { } if (isWindows) { - execSync( - `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`, - { stdio: "ignore" } - ); + execFileSync("powershell", [ + "-Command", + `Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`, + ], { stdio: "ignore" }); } else { - execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { + execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], { stdio: "ignore", }); } @@ -85,6 +88,16 @@ function install() { } } +// When triggered as a postinstall hook under npx, skip the binary download. +// The "install" wizard doesn't need it, and run.js calls install.js directly +// (with LARK_CLI_RUN=1) for other commands that do need the binary. +const isNpxPostinstall = + process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN; + +if (isNpxPostinstall) { + process.exit(0); +} + try { install(); } catch (err) { diff --git a/scripts/run.js b/scripts/run.js index c61e7138..a560fc02 100755 --- a/scripts/run.js +++ b/scripts/run.js @@ -41,21 +41,32 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) { } } -if (!fs.existsSync(bin)) { - console.error( - `Error: lark-cli binary not found at ${bin}\n\n` + - `This usually means the postinstall script was skipped.\n` + - `Common causes:\n` + - ` - npm is configured with ignore-scripts=true\n` + - ` - The postinstall download failed\n\n` + - `To fix, run the install script manually:\n` + - ` node "${path.join(__dirname, "install.js")}"\n` - ); - process.exit(1); -} +// Intercept "install" subcommand — run the setup wizard directly, +// bypassing the native binary (which may not exist yet under npx). +const args = process.argv.slice(2); +if (args[0] === "install") { + require("./install-wizard.js"); +} else { + // Auto-download binary if missing (e.g. npx skipped postinstall). + if (!fs.existsSync(bin)) { + try { + execFileSync(process.execPath, [path.join(__dirname, "install.js")], { + stdio: "inherit", + env: { ...process.env, LARK_CLI_RUN: "true" }, + }); + } catch (_) { + console.error( + `\nFailed to auto-install lark-cli binary.\n` + + `To fix, run the install script manually:\n` + + ` node "${path.join(__dirname, "install.js")}"\n` + ); + process.exit(1); + } + } -try { - execFileSync(bin, process.argv.slice(2), { stdio: "inherit" }); -} catch (e) { - process.exit(e.status || 1); + try { + execFileSync(bin, args, { stdio: "inherit" }); + } catch (e) { + process.exit(e.status || 1); + } }