fix: make client installation observable
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
This commit is contained in:
+120
-16
@@ -2,7 +2,7 @@
|
||||
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
|
||||
import {
|
||||
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
|
||||
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
|
||||
readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
||||
@@ -106,6 +106,9 @@ const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/;
|
||||
const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const MANIFEST_MAX_AGE_MS = 180 * 24 * 60 * 60_000;
|
||||
const NPM_CHECK_TIMEOUT_MS = 15_000;
|
||||
const NPM_INSTALL_TIMEOUT_MS = 180_000;
|
||||
const INSTALL_LOCK_STALE_MS = 15 * 60_000;
|
||||
|
||||
export function clientPaths(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -177,8 +180,8 @@ export async function runClientInstaller(
|
||||
platform: overrides.platform ?? process.platform,
|
||||
home: overrides.home ?? homedir(),
|
||||
fetchFn: overrides.fetchFn ?? fetch,
|
||||
stdout: overrides.stdout ?? ((value) => { process.stdout.write(value); }),
|
||||
stderr: overrides.stderr ?? ((value) => { process.stderr.write(value); }),
|
||||
stdout: overrides.stdout ?? ((value) => { writeSync(1, value); }),
|
||||
stderr: overrides.stderr ?? ((value) => { writeSync(2, value); }),
|
||||
};
|
||||
try {
|
||||
const args = parseClientArgs(context.argv);
|
||||
@@ -197,8 +200,10 @@ export async function runClientInstaller(
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (context.argv.includes("--json") || context.argv.includes("mcp-json")) context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
|
||||
else context.stderr(`${message}\n`);
|
||||
if (context.argv.includes("--json") || context.argv.includes("mcp-json")) {
|
||||
context.stderr(`[dingtalk-approval] 失败 ${message}\n`);
|
||||
context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
|
||||
} else context.stderr(`${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -266,6 +271,18 @@ function parseClientArgs(argv: string[]): ParsedArgs {
|
||||
|
||||
async function installClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise<Record<string, unknown>> {
|
||||
requireNodeVersion();
|
||||
mkdirPrivate(paths.root);
|
||||
reportInstallProgress(context, "启动", "正在取得单实例安装锁");
|
||||
const releaseLock = acquireClientInstallLock(paths.root);
|
||||
try {
|
||||
return await installClientLocked(paths, args, context);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise<Record<string, unknown>> {
|
||||
reportInstallProgress(context, "1/6", "检查 Node、npm 与本地安装状态");
|
||||
const npmCheck = checkNpm(context);
|
||||
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
|
||||
const previous = readState(paths.stateFile);
|
||||
@@ -273,6 +290,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
|
||||
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
|
||||
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
|
||||
const publicKey = readFileSync(publicKeyPath);
|
||||
reportInstallProgress(context, "2/6", "下载并验证签名发布清单");
|
||||
const [manifestBytes, signatureBytes] = await Promise.all([
|
||||
download(context.fetchFn, manifestUrl),
|
||||
download(context.fetchFn, new URL("manifest.sig", manifestUrl)),
|
||||
@@ -283,38 +301,45 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
|
||||
}
|
||||
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-install-"));
|
||||
try {
|
||||
reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`);
|
||||
const clientArchive = join(temporary, "client.tgz");
|
||||
await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive);
|
||||
const skillBody = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md"));
|
||||
const publicArtifact = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem"));
|
||||
const [, skillBody, publicArtifact] = await Promise.all([
|
||||
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive),
|
||||
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md")),
|
||||
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem")),
|
||||
]);
|
||||
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
|
||||
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
|
||||
}
|
||||
mkdirPrivate(paths.root);
|
||||
mkdirPrivate(paths.versionsDir);
|
||||
cleanupInterruptedInstallStaging(paths.versionsDir);
|
||||
const versionName = `${safeName(manifest.version)}-${manifest.artifacts.client.sha256.slice(0, 12)}`;
|
||||
const versionDir = join(paths.versionsDir, versionName);
|
||||
const clientEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js");
|
||||
const cliEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "client-installer.js");
|
||||
if (!existsSync(clientEntry) || !existsSync(cliEntry)) {
|
||||
reportInstallProgress(context, "4/6", "离线展开客户端运行时;首次安装可能需要一些时间");
|
||||
if (existsSync(versionDir)) rmSync(versionDir, { recursive: true, force: true });
|
||||
const staging = join(paths.versionsDir, `.staging-${process.pid}-${randomUUID()}`);
|
||||
mkdirPrivate(staging);
|
||||
const installed = runNpm(
|
||||
context,
|
||||
["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive],
|
||||
join(temporary, "npm-cache"),
|
||||
{ cache: join(temporary, "npm-cache"), timeoutMs: NPM_INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
if (installed.status !== 0) {
|
||||
if (installed.error || installed.status !== 0) {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`);
|
||||
throw new Error(`客户端运行时安装失败:${npmFailureMessage(installed, NPM_INSTALL_TIMEOUT_MS)}`);
|
||||
}
|
||||
if (!existsSync(join(staging, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))) {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
throw new Error("客户端包缺少 Bridge 入口");
|
||||
}
|
||||
renameSync(staging, versionDir);
|
||||
} else {
|
||||
reportInstallProgress(context, "4/6", "复用已校验的本地客户端运行时");
|
||||
}
|
||||
reportInstallProgress(context, "5/6", "写入稳定启动器、Skill 与安装状态");
|
||||
installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody);
|
||||
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
|
||||
const nodeRuntime = activeNodeRuntime(paths);
|
||||
@@ -337,7 +362,9 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
|
||||
const legacyCleanupFailed: string[] = [];
|
||||
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
|
||||
cleanupOldVersions(paths, versionDir);
|
||||
reportInstallProgress(context, "6/6", "连接网关并准备登录状态");
|
||||
const postInstall = attemptLogin(clientEntry, manifest.gatewayUrl, paths, context);
|
||||
reportInstallProgress(context, "完成", "客户端安装成功,正在输出最终 JSON");
|
||||
return installResult(paths, state, legacyRemoved, legacyCleanupFailed, postInstall);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
@@ -415,26 +442,103 @@ function doctor(paths: ClientPaths, context: CommandContext): Record<string, unk
|
||||
}
|
||||
|
||||
function checkNpm(context: Pick<CommandContext, "env" | "platform">): { ok: boolean; value?: string; message?: string } {
|
||||
const result = runNpm(context, ["--version"]);
|
||||
if (result.error) return { ok: false, message: result.error.message };
|
||||
const result = runNpm(context, ["--version"], { timeoutMs: NPM_CHECK_TIMEOUT_MS });
|
||||
if (result.error) return { ok: false, message: npmFailureMessage(result, NPM_CHECK_TIMEOUT_MS) };
|
||||
if (result.status !== 0) return { ok: false, message: (result.stderr || result.stdout || `退出码 ${result.status}`).trim() };
|
||||
const version = result.stdout.trim();
|
||||
return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" };
|
||||
}
|
||||
|
||||
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[], cache?: string) {
|
||||
export function runNpm(
|
||||
context: Pick<CommandContext, "env" | "platform">,
|
||||
args: string[],
|
||||
options: { cache?: string; timeoutMs: number },
|
||||
) {
|
||||
const invocation = npmInvocation(context, args);
|
||||
return spawnSync(invocation.command, invocation.args, {
|
||||
encoding: "utf8",
|
||||
timeout: options.timeoutMs,
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
env: {
|
||||
...context.env,
|
||||
...(cache ? { npm_config_cache: cache } : {}),
|
||||
...(options.cache ? { npm_config_cache: options.cache } : {}),
|
||||
npm_config_update_notifier: "false",
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function npmFailureMessage(
|
||||
result: { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string },
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (errorCode === "ETIMEDOUT") return `npm 超过 ${Math.ceil(timeoutMs / 1000)} 秒仍未完成,已安全终止;请检查磁盘、杀毒软件或宿主工具超时设置`;
|
||||
if (result.error) return result.error.message;
|
||||
if (result.signal) return `npm 被信号 ${result.signal} 终止`;
|
||||
const details = (result.stderr || result.stdout).trim();
|
||||
return details ? details.slice(-4_000) : `npm 退出码 ${result.status ?? "unknown"},未返回诊断文本`;
|
||||
}
|
||||
|
||||
function reportInstallProgress(context: Pick<CommandContext, "stderr">, stage: string, message: string): void {
|
||||
context.stderr(`[dingtalk-approval] ${stage} ${message}\n`);
|
||||
}
|
||||
|
||||
export function acquireClientInstallLock(root: string): () => void {
|
||||
const lockDir = join(root, ".install-lock");
|
||||
const ownerFile = join(lockDir, "owner.json");
|
||||
const token = randomUUID();
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
mkdirSync(lockDir, { mode: 0o700 });
|
||||
if (process.platform !== "win32") chmodSync(lockDir, 0o700);
|
||||
try {
|
||||
writeAtomic(ownerFile, Buffer.from(`${JSON.stringify({ pid: process.pid, token, startedAt: new Date().toISOString() })}\n`), 0o600);
|
||||
} catch (error) {
|
||||
rmSync(lockDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
const owner = JSON.parse(readFileSync(ownerFile, "utf8")) as { token?: unknown };
|
||||
if (owner.token === token) rmSync(lockDir, { recursive: true, force: true });
|
||||
} catch { /* another process already recovered or removed the lock */ }
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
if (installLockIsActive(lockDir)) {
|
||||
throw new Error("另一个安装或更新进程仍在运行;请等待它结束,不要并发重试");
|
||||
}
|
||||
rmSync(lockDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
throw new Error("无法恢复上次被中断的安装锁,请稍后重试");
|
||||
}
|
||||
|
||||
function installLockIsActive(lockDir: string): boolean {
|
||||
let recent = true;
|
||||
try { recent = Date.now() - statSync(lockDir).mtimeMs < INSTALL_LOCK_STALE_MS; }
|
||||
catch { return false; }
|
||||
try {
|
||||
const owner = JSON.parse(readFileSync(join(lockDir, "owner.json"), "utf8")) as { pid?: unknown };
|
||||
if (Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0) return recent && processIsAlive(Number(owner.pid));
|
||||
} catch { /* fall back to the lock directory age */ }
|
||||
return recent;
|
||||
}
|
||||
|
||||
function processIsAlive(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
||||
}
|
||||
|
||||
function cleanupInterruptedInstallStaging(versionsDir: string): void {
|
||||
for (const entry of readdirSync(versionsDir)) {
|
||||
if (!entry.startsWith(".staging-")) continue;
|
||||
rmSync(join(versionsDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function npmInvocation(context: Pick<CommandContext, "env" | "platform">, args: string[]): { command: string; args: string[] } {
|
||||
const pathSeparator = context.platform === "win32" ? ";" : ":";
|
||||
const candidates = [
|
||||
|
||||
Reference in New Issue
Block a user