feat: bootstrap portable Node runtimes
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:
2026-08-18 13:53:54 +00:00
parent 2e275b684d
commit dd189c2acb
22 changed files with 900 additions and 96 deletions
+32 -3
View File
@@ -1,13 +1,13 @@
import assert from "node:assert/strict";
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync,
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, 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,
cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest,
type ClientReleaseManifest,
} from "../src/client-installer.js";
@@ -19,6 +19,18 @@ test("client paths stay in stable per-user locations on all supported platforms"
assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge");
});
test("client launchers pin the selected Node executable on Windows and POSIX", () => {
const windows = clientLauncherBodies("win32", "C:\\Users\\100% ready\\node.exe");
assert.match(windows.cli, /^@echo off\r\n"C:\\Users\\100%% ready\\node\.exe"/);
assert.match(windows.bridge, /launcher\.mjs" bridge %\*/);
assert.doesNotMatch(windows.bridge, /\r\nnode /);
const posix = clientLauncherBodies("linux", "/Users/alice's runtime/node");
assert.match(posix.cli, /exec '\/Users\/alice'"'"'s runtime\/node'/);
assert.match(posix.bridge, /launcher\.mjs" bridge "\$@"/);
assert.throws(() => clientLauncherBodies("win32", "C:\\bad\nnode.exe"), /不支持的字符/);
});
test("signed manifest verification rejects tampering and a foreign key", () => {
const trusted = generateKeyPairSync("ed25519");
const foreign = generateKeyPairSync("ed25519");
@@ -107,7 +119,13 @@ test("generic installer preserves identity across safe updates, rollback, downgr
};
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 env: NodeJS.ProcessEnv = {
PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter),
DINGTALK_APPROVAL_CLIENT_HOME: appRoot,
npm_execpath: process.platform === "win32"
? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js")
: join(fakeBin, "fake-npm.mjs"),
};
const output: string[] = [];
const commandContext = {
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
@@ -143,7 +161,14 @@ test("generic installer preserves identity across safe updates, rollback, downgr
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(state.version, "1.1.0-rc.5");
assert.equal(state.host, undefined);
assert.equal(state.nodeExecutable, realpathSync(process.execPath));
assert.equal(state.managedNode, false);
assert.equal(state.nodeVersion, process.versions.node);
assert.deepEqual(installOutput.nodeRuntime, {
version: process.versions.node, managed: false, executable: realpathSync(process.execPath),
});
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
assert.match(readFileSync(paths.bridgeCommand, "utf8"), new RegExp(realpathSync(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
const stableBridgePath = paths.bridgeCommand;
const initialRuntime = state.runtimeDir;
@@ -189,6 +214,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
const health = JSON.parse(output.pop()!);
assert.equal(health.ok, true);
assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true);
assert.equal(health.checks.find((check: { name: string }) => check.name === "nodeRuntime").ok, true);
output.length = 0;
assert.equal(await runClientInstaller([
@@ -371,6 +397,9 @@ if (process.argv.includes("--login") || process.argv.includes("--post-install-lo
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); }
if (!process.env.npm_config_cache || !process.env.npm_config_cache.includes("dingtalk-approval-install-")) {
console.error("installer did not isolate the npm cache"); process.exit(3);
}
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];