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
392 lines
22 KiB
TypeScript
392 lines
22 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
|
|
import {
|
|
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
cleanupLegacyInstallations, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest,
|
|
type ClientReleaseManifest,
|
|
} from "../src/client-installer.js";
|
|
|
|
test("client paths stay in stable per-user locations on all supported platforms", () => {
|
|
assert.equal(clientPaths({}, "linux", "/users/alice").root, "/users/alice/.local/share/dingtalk-approval");
|
|
assert.equal(clientPaths({ XDG_DATA_HOME: "/data/alice" }, "linux", "/users/alice").root, "/data/alice/dingtalk-approval");
|
|
assert.equal(clientPaths({}, "darwin", "/Users/alice").root, "/Users/alice/Library/Application Support/dingtalk-approval");
|
|
assert.equal(clientPaths({ LOCALAPPDATA: "C:\\Users\\alice\\AppData\\Local" }, "win32", "C:\\Users\\alice").root, "C:\\Users\\alice\\AppData\\Local\\DingTalkApproval");
|
|
assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge");
|
|
});
|
|
|
|
test("signed manifest verification rejects tampering and a foreign key", () => {
|
|
const trusted = generateKeyPairSync("ed25519");
|
|
const foreign = generateKeyPairSync("ed25519");
|
|
const publicPem = trusted.publicKey.export({ type: "spki", format: "pem" });
|
|
const keyId = releaseKeyId(publicPem);
|
|
const manifest = manifestFixture(keyId, publicPem);
|
|
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
const signature = signatureEnvelope(keyId, sign(null, bytes, trusted.privateKey));
|
|
assert.equal(verifySignedManifest(bytes, signature, publicPem, "https://approval.example.test/downloads/manifest.json").version, "1.1.0-rc.5");
|
|
assert.throws(() => verifySignedManifest(Buffer.concat([bytes, Buffer.from(" ")]), signature, publicPem, "https://approval.example.test/downloads/manifest.json"), /签名无效/);
|
|
assert.throws(() => verifySignedManifest(bytes, signature, foreign.publicKey.export({ type: "spki", format: "pem" }), "https://approval.example.test/downloads/manifest.json"), /密钥编号不受信任/);
|
|
|
|
const stale = { ...manifest, createdAt: new Date(Date.now() - 181 * 24 * 60 * 60_000).toISOString() };
|
|
const staleBytes = Buffer.from(`${JSON.stringify(stale, null, 2)}\n`);
|
|
const staleSignature = signatureEnvelope(keyId, sign(null, staleBytes, trusted.privateKey));
|
|
assert.throws(() => verifySignedManifest(staleBytes, staleSignature, publicPem, "https://approval.example.test/downloads/manifest.json"), /超过 180 天/);
|
|
});
|
|
|
|
test("legacy cleanup removes only owned broken bins and exact old package", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-legacy-"));
|
|
const home = join(root, "home");
|
|
const bin = join(home, ".nvm", "versions", "node", "v22.22.2", "bin");
|
|
const packageRoot = join(home, ".nvm", "versions", "node", "v22.22.2", "lib", "node_modules", "dingtalk-approval-mcp");
|
|
mkdirSync(bin, { recursive: true });
|
|
mkdirSync(packageRoot, { recursive: true });
|
|
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
|
|
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) {
|
|
symlinkSync(`../lib/node_modules/dingtalk-approval-mcp/dist/src/${name}.js`, join(bin, name));
|
|
}
|
|
symlinkSync("../lib/node_modules/some-other-package/cli.js", join(bin, "unrelated"));
|
|
const removed = cleanupLegacyInstallations({ PATH: bin }, "linux", home);
|
|
assert.equal(removed.filter((path) => path.includes("dingtalk-")).length, 5);
|
|
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) assert.equal(existsSync(join(bin, name)), false);
|
|
assert.equal(existsSync(packageRoot), false);
|
|
assert.equal(existsSync(join(bin, "unrelated")), false, "existsSync is false for dangling links");
|
|
assert.equal(lstatSync(join(bin, "unrelated")).isSymbolicLink(), true, "unrelated dangling link remains");
|
|
});
|
|
|
|
test("legacy cleanup removes owned Windows npm shims without touching unrelated commands", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-windows-legacy-"));
|
|
const bin = join(root, "npm");
|
|
const packageRoot = join(bin, "node_modules", "dingtalk-approval-mcp");
|
|
mkdirSync(packageRoot, { recursive: true });
|
|
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
|
|
writeFileSync(join(bin, "dingtalk-approval-bridge.cmd"), "@node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\bridge.js %*\r\n");
|
|
writeFileSync(join(bin, "dingtalk-mcp.ps1"), "& node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\index.js $args\r\n");
|
|
writeFileSync(join(bin, "dingtalk-admin.cmd"), "@node C:\\npm\\node_modules\\another-package\\admin.js %*\r\n");
|
|
|
|
cleanupLegacyInstallations({ PATH: bin, APPDATA: bin }, "win32", join(root, "home"));
|
|
assert.equal(existsSync(join(bin, "dingtalk-approval-bridge.cmd")), false);
|
|
assert.equal(existsSync(join(bin, "dingtalk-mcp.ps1")), false);
|
|
assert.equal(existsSync(packageRoot), false);
|
|
assert.equal(existsSync(join(bin, "dingtalk-admin.cmd")), true);
|
|
});
|
|
|
|
test("generic installer preserves identity across safe updates, rollback, downgrade, and uninstall", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-client-install-"));
|
|
const appRoot = join(root, "dingtalk-approval");
|
|
const fakeBin = join(root, "fake-bin");
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
createFakeNpm(fakeBin);
|
|
|
|
const keys = generateKeyPairSync("ed25519");
|
|
const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" }));
|
|
const keyId = releaseKeyId(publicPem);
|
|
const bodies = {
|
|
installer: Buffer.from("installer"),
|
|
publicKey: publicPem,
|
|
client: Buffer.from("fake-client-tgz"),
|
|
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"),
|
|
};
|
|
const manifest = manifestFixture(keyId, publicPem, bodies);
|
|
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
|
|
const responses = new Map<string, Buffer>([
|
|
["https://approval.example.test/downloads/manifest.json", manifestBytes],
|
|
["https://approval.example.test/downloads/manifest.sig", signature],
|
|
...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]),
|
|
]);
|
|
let downloadCalls = 0;
|
|
const fetchFn = async (input: string | URL | Request) => {
|
|
downloadCalls += 1;
|
|
const url = String(input);
|
|
const body = responses.get(url);
|
|
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
|
|
};
|
|
const publicPath = join(root, "release-public.pem");
|
|
writeFileSync(publicPath, publicPem, { mode: 0o600 });
|
|
const env: NodeJS.ProcessEnv = { PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter), DINGTALK_APPROVAL_CLIENT_HOME: appRoot };
|
|
const output: string[] = [];
|
|
const commandContext = {
|
|
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
|
|
stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { output.push(value); },
|
|
};
|
|
const installed = await runClientInstaller([
|
|
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
|
|
"--public-key", publicPath, "--app-root", appRoot, "--json",
|
|
], commandContext);
|
|
assert.equal(installed, 0);
|
|
const installOutput = JSON.parse(output.pop()!);
|
|
assert.equal(installOutput.restartRequired, undefined);
|
|
assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/);
|
|
assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s);
|
|
assert.equal(installOutput.postInstall.status, "authorization_required");
|
|
assert.equal(installOutput.postInstall.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
|
|
assert.deepEqual(installOutput.loginCommand, {
|
|
command: join(appRoot, "bin", process.platform === "win32" ? "dingtalk-approval.cmd" : "dingtalk-approval"),
|
|
args: ["login", "--json"],
|
|
});
|
|
assert.deepEqual(installOutput.postInstall.loginCommand, installOutput.loginCommand);
|
|
assert.deepEqual(installOutput.postInstall.confirmTool, { name: "approval_login", arguments: {} });
|
|
assert.deepEqual(installOutput.postInstall.verifyTool, {
|
|
name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 },
|
|
});
|
|
assert.equal(installOutput.mcpConfig.mcpServers["dingtalk-approval"].args.length, 0);
|
|
const paths = clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: appRoot }, process.platform, join(root, "home"));
|
|
assert.equal(existsSync(paths.bridgeCommand), true);
|
|
assert.equal(existsSync(paths.skillFile), true);
|
|
assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true);
|
|
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(state.version, "1.1.0-rc.5");
|
|
assert.equal(state.host, undefined);
|
|
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
|
|
const stableBridgePath = paths.bridgeCommand;
|
|
const initialRuntime = state.runtimeDir;
|
|
|
|
const callsBeforeStandaloneLogin = downloadCalls;
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
const loginOutput = JSON.parse(output.pop()!);
|
|
assert.equal(loginOutput.operation, "login");
|
|
assert.equal(loginOutput.status, "authorization_required");
|
|
assert.equal(loginOutput.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
|
|
assert.deepEqual(loginOutput.loginCommand, installOutput.loginCommand);
|
|
assert.equal(downloadCalls, callsBeforeStandaloneLogin, "standalone login must not download or reinstall artifacts");
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], {
|
|
...commandContext, env: { ...env, FAKE_BRIDGE_AUTHENTICATED: "1" },
|
|
}), 0);
|
|
const authenticatedLogin = JSON.parse(output.pop()!);
|
|
assert.equal(authenticatedLogin.status, "authenticated");
|
|
assert.equal(authenticatedLogin.authenticated, true);
|
|
assert.equal(authenticatedLogin.authChallenge, undefined);
|
|
assert.equal(downloadCalls, callsBeforeStandaloneLogin, "authenticated login check must stay local except for the Bridge flow");
|
|
|
|
mkdirSync(dirname(paths.sessionFile), { recursive: true });
|
|
const sessionBody = Buffer.from(`${JSON.stringify({
|
|
authenticated: true,
|
|
marker: "preserve-across-updates",
|
|
onboarding: { schemaVersion: 1, status: "delivered", deliveredAt: "2030-01-01T00:00:00.000Z" },
|
|
})}\n`);
|
|
writeFileSync(paths.sessionFile, sessionBody, { mode: 0o600 });
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["print-path", "bridge", "--app-root", appRoot], commandContext), 0);
|
|
assert.equal(output.pop(), `${paths.bridgeCommand}\n`);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["print-config", "--format", "mcp-json", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
const mcpConfig = JSON.parse(output.pop()!);
|
|
assert.deepEqual(mcpConfig, { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } });
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["doctor", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
const health = JSON.parse(output.pop()!);
|
|
assert.equal(health.ok, true);
|
|
assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--app-root", appRoot, "--json",
|
|
], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client");
|
|
const sameVersionOutput = JSON.parse(output.pop()!);
|
|
assert.equal(sameVersionOutput.postInstall.status, "unavailable");
|
|
const sameVersionState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(sameVersionState.host, undefined);
|
|
assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime");
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
|
|
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /未知参数:--host/);
|
|
|
|
const upgradedBodies = {
|
|
...bodies,
|
|
client: Buffer.from("fake-client-tgz-v2"),
|
|
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test v2\n---\n\n# Test v2\n"),
|
|
};
|
|
const upgraded = manifestFixture(keyId, publicPem, upgradedBodies, "1.1.1");
|
|
const upgradedBytes = Buffer.from(`${JSON.stringify(upgraded, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", upgradedBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, upgradedBytes, keys.privateKey)));
|
|
for (const [name, artifact] of Object.entries(upgraded.artifacts)) {
|
|
responses.set(artifact.url, upgradedBodies[name as keyof typeof upgradedBodies]);
|
|
}
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--app-root", appRoot, "--json",
|
|
], commandContext), 0, "update reuses the pinned manifest URL and public key");
|
|
const upgradedOutput = JSON.parse(output.pop()!);
|
|
const upgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(upgradedOutput.version, "1.1.1");
|
|
assert.equal(upgradedOutput.restartRequired, undefined);
|
|
assert.equal(upgradedState.host, undefined);
|
|
assert.notEqual(upgradedState.runtimeDir, initialRuntime);
|
|
assert.equal(existsSync(join(appRoot, initialRuntime)), false, "successful upgrade removes the obsolete runtime");
|
|
assert.deepEqual(readdirSync(paths.versionsDir), [basename(upgradedState.runtimeDir)]);
|
|
assert.equal(readFileSync(paths.skillFile, "utf8"), upgradedBodies.skill.toString("utf8"));
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "cross-version update preserves the OAuth session");
|
|
assert.equal(paths.bridgeCommand, stableBridgePath, "the MCP command path remains stable across versions");
|
|
|
|
const beforeFailedState = readFileSync(paths.stateFile);
|
|
const beforeFailedSkill = readFileSync(paths.skillFile);
|
|
const beforeFailedVersions = readdirSync(paths.versionsDir);
|
|
const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") };
|
|
const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
|
const failedUpgradeBytes = Buffer.from(`${JSON.stringify(failedUpgrade, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", failedUpgradeBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, failedUpgradeBytes, keys.privateKey)));
|
|
responses.set(failedUpgrade.artifacts.client.url, Buffer.from("corrupted-client-tgz"));
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/);
|
|
assert.deepEqual(readFileSync(paths.stateFile), beforeFailedState, "failed update keeps the active install state");
|
|
assert.deepEqual(readFileSync(paths.skillFile), beforeFailedSkill, "failed update keeps the active Skill");
|
|
assert.deepEqual(readdirSync(paths.versionsDir), beforeFailedVersions, "failed update keeps the active runtime");
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "failed update keeps the OAuth session");
|
|
|
|
const nextRelease = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
|
const nextBytes = Buffer.from(`${JSON.stringify(nextRelease, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", nextBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, nextBytes, keys.privateKey)));
|
|
for (const [name, artifact] of Object.entries(nextRelease.artifacts)) {
|
|
responses.set(artifact.url, failedBodies[name as keyof typeof failedBodies]);
|
|
}
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 0);
|
|
assert.equal(JSON.parse(output.pop()!).version, "1.1.2");
|
|
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).host, undefined);
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
|
|
|
const downgraded = manifestFixture(keyId, publicPem, bodies, "1.0.0");
|
|
const downgradedBytes = Buffer.from(`${JSON.stringify(downgraded, null, 2)}\n`);
|
|
responses.set("https://approval.example.test/downloads/manifest.json", downgradedBytes);
|
|
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, downgradedBytes, keys.privateKey)));
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--app-root", appRoot, "--json",
|
|
], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /拒绝.*降级/);
|
|
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).version, "1.1.2");
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
|
|
|
for (const [name, artifact] of Object.entries(downgraded.artifacts)) {
|
|
responses.set(artifact.url, bodies[name as keyof typeof bodies]);
|
|
}
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller([
|
|
"update", "--allow-downgrade", "--app-root", appRoot, "--json",
|
|
], commandContext), 0, "an explicitly authorized downgrade remains possible");
|
|
const downgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
assert.equal(downgradedState.version, "1.0.0");
|
|
assert.equal(downgradedState.host, undefined);
|
|
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
|
assert.equal(readdirSync(paths.versionsDir).length, 1);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["print-config", "--bogus", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /未知参数/);
|
|
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["uninstall", "--yes", "--app-root", root, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /专用.*用户数据目录/);
|
|
|
|
output.length = 0;
|
|
const removed = await runClientInstaller(["uninstall", "--yes", "--app-root", appRoot, "--json"], {
|
|
...commandContext,
|
|
});
|
|
assert.equal(removed, 0);
|
|
assert.equal(existsSync(appRoot), false);
|
|
output.length = 0;
|
|
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 1);
|
|
assert.match(JSON.parse(output.pop()!).error, /尚未安装/);
|
|
});
|
|
|
|
function manifestFixture(
|
|
keyId: string,
|
|
publicKey: string | Buffer,
|
|
bodies = {
|
|
installer: Buffer.from("installer"), publicKey: Buffer.from(publicKey), client: Buffer.from("client"),
|
|
skill: Buffer.from("skill"),
|
|
},
|
|
version = "1.1.0-rc.5",
|
|
): ClientReleaseManifest {
|
|
const base = "https://approval.example.test/downloads/";
|
|
const artifact = (name: keyof typeof bodies) => ({ url: `${base}${name}`, sha256: digest(bodies[name]) });
|
|
return {
|
|
schemaVersion: 3,
|
|
version,
|
|
createdAt: new Date().toISOString(),
|
|
gatewayUrl: "https://approval.example.test/mcp",
|
|
signing: { algorithm: "Ed25519", keyId },
|
|
requirements: { node: ">=22.13.0" },
|
|
artifacts: {
|
|
installer: artifact("installer"),
|
|
publicKey: { ...artifact("publicKey"), keyId },
|
|
client: artifact("client"),
|
|
skill: artifact("skill"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function signatureEnvelope(keyId: string, value: Buffer): Buffer {
|
|
return Buffer.from(JSON.stringify({ schemaVersion: 1, algorithm: "Ed25519", keyId, signature: value.toString("base64") }));
|
|
}
|
|
|
|
function digest(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
|
|
|
function createFakeNpm(bin: string): void {
|
|
const fakeBridge = `#!/usr/bin/env node
|
|
if (process.env.FAKE_BRIDGE_FAIL === "1") process.exit(2);
|
|
if (process.argv.includes("--login") || process.argv.includes("--post-install-login")) {
|
|
if (process.env.FAKE_BRIDGE_AUTHENTICATED === "1") {
|
|
console.log(JSON.stringify({ authenticated: true, message: "已登录,可以读取审批。" }));
|
|
process.exit(0);
|
|
}
|
|
console.log(JSON.stringify({
|
|
authenticated: false,
|
|
message: "需要完成钉钉授权登录。",
|
|
authChallenge: {
|
|
provider: "dingtalk",
|
|
type: "link",
|
|
directUrl: "https://approval.example.test/oauth/device?user_code=INSTALL-1234",
|
|
userCode: "INSTALL-1234",
|
|
expiresAt: "2030-01-01T00:00:00.000Z",
|
|
pollAfterSeconds: 5
|
|
}
|
|
}));
|
|
}
|
|
`;
|
|
const implementation = process.platform === "win32"
|
|
? join(bin, "node_modules", "npm", "bin", "npm-cli.js")
|
|
: join(bin, "fake-npm.mjs");
|
|
mkdirSync(join(implementation, ".."), { recursive: true });
|
|
writeFileSync(implementation, `#!/usr/bin/env node
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); }
|
|
const index = process.argv.indexOf("--prefix");
|
|
if (index < 0 || !process.argv[index + 1]) { console.error("missing prefix"); process.exit(2); }
|
|
const prefix = process.argv[index + 1];
|
|
const target = join(prefix, "node_modules", "dingtalk-approval-client", "dist", "src");
|
|
mkdirSync(target, { recursive: true });
|
|
writeFileSync(join(target, "bridge.js"), ${JSON.stringify(fakeBridge)});
|
|
writeFileSync(join(target, "client-installer.js"), "#!/usr/bin/env node\\n");
|
|
const skill = join(prefix, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval");
|
|
mkdirSync(join(skill, "references"), { recursive: true });
|
|
writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n");
|
|
writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n");
|
|
`);
|
|
if (process.platform === "win32") {
|
|
writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n");
|
|
} else {
|
|
const executable = join(bin, "npm");
|
|
writeFileSync(executable, `#!/usr/bin/env sh\nexec "${process.execPath}" "$(dirname "$0")/fake-npm.mjs" "$@"\n`, { mode: 0o700 });
|
|
chmodSync(executable, 0o700);
|
|
}
|
|
}
|