fix: harden proxied client installation
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:
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId,
|
||||
npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
|
||||
nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
|
||||
type ClientReleaseManifest,
|
||||
} from "../src/client-installer.js";
|
||||
|
||||
@@ -86,6 +86,50 @@ test("npm subprocess timeout is bounded and returns an actionable diagnostic", (
|
||||
}
|
||||
});
|
||||
|
||||
test("npm subprocess removes Node fetch proxy flags without dropping npm proxy settings", () => {
|
||||
const input = {
|
||||
NODE_OPTIONS: "--trace-warnings --use-env-proxy --max-old-space-size=512",
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
HTTPS_PROXY: "http://proxy.example.test:8080",
|
||||
NO_PROXY: "localhost,127.0.0.1",
|
||||
};
|
||||
const output = npmChildEnvironment(input);
|
||||
assert.equal(output.NODE_OPTIONS, "--trace-warnings --max-old-space-size=512");
|
||||
assert.equal(output.NODE_USE_ENV_PROXY, undefined);
|
||||
assert.equal(output.HTTPS_PROXY, input.HTTPS_PROXY);
|
||||
assert.equal(output.NO_PROXY, input.NO_PROXY);
|
||||
assert.equal(input.NODE_OPTIONS.includes("--use-env-proxy"), true, "the caller environment is not mutated");
|
||||
|
||||
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set(["--use-env-proxy"])), ["--use-env-proxy"]);
|
||||
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY, NODE_OPTIONS: "--use-env-proxy" }, new Set(["--use-env-proxy"])), []);
|
||||
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set()), []);
|
||||
assert.deepEqual(nodeProxyArgs({}, new Set(["--use-env-proxy"])), []);
|
||||
});
|
||||
|
||||
test("runNpm passes the isolated proxy environment to the real child process", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-client-npm-env-"));
|
||||
try {
|
||||
const inspectNpm = join(root, "inspect-npm.mjs");
|
||||
writeFileSync(inspectNpm, "console.log(JSON.stringify({nodeOptions:process.env.NODE_OPTIONS,nodeProxy:process.env.NODE_USE_ENV_PROXY,httpsProxy:process.env.HTTPS_PROXY,noProxy:process.env.NO_PROXY}));\n");
|
||||
const result = runNpm({
|
||||
env: {
|
||||
npm_execpath: inspectNpm,
|
||||
NODE_OPTIONS: "--trace-warnings --use-env-proxy",
|
||||
NODE_USE_ENV_PROXY: "true",
|
||||
HTTPS_PROXY: "http://proxy.example.test:8080",
|
||||
NO_PROXY: "localhost",
|
||||
},
|
||||
platform: process.platform,
|
||||
}, ["inspect"], { timeoutMs: 2_000 });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
nodeOptions: "--trace-warnings", httpsProxy: "http://proxy.example.test:8080", noProxy: "localhost",
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("signed manifest verification rejects tampering and a foreign key", () => {
|
||||
const trusted = generateKeyPairSync("ed25519");
|
||||
const foreign = generateKeyPairSync("ed25519");
|
||||
@@ -158,17 +202,27 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"),
|
||||
};
|
||||
const manifest = manifestFixture(keyId, publicPem, bodies);
|
||||
const mirrorBase = "https://mirror.example.test/dingmcp/";
|
||||
manifest.distribution = { manifestMirrors: [`${mirrorBase}manifest.json`] };
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
artifact.mirrors = [`${mirrorBase}${basename(new URL(artifact.url).pathname)}`];
|
||||
}
|
||||
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]),
|
||||
[`${mirrorBase}manifest.json`, manifestBytes],
|
||||
[`${mirrorBase}manifest.sig`, signature],
|
||||
...Object.entries(manifest.artifacts).flatMap(([name, artifact]) =>
|
||||
[artifact.url, ...(artifact.mirrors ?? [])].map((url) => [url, bodies[name as keyof typeof bodies]] as [string, Buffer])),
|
||||
]);
|
||||
let downloadCalls = 0;
|
||||
const unavailableUrls = new Set<string>();
|
||||
const fetchFn = async (input: string | URL | Request) => {
|
||||
downloadCalls += 1;
|
||||
const url = String(input);
|
||||
if (unavailableUrls.has(url)) return new Response("missing", { status: 404 });
|
||||
const body = responses.get(url);
|
||||
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
|
||||
};
|
||||
@@ -189,6 +243,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
};
|
||||
const installed = await runClientInstaller([
|
||||
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
|
||||
"--manifest-mirror-url", `${mirrorBase}manifest.json`,
|
||||
"--public-key", publicPath, "--app-root", appRoot, "--json",
|
||||
], commandContext);
|
||||
assert.equal(installed, 0);
|
||||
@@ -217,6 +272,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
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.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]);
|
||||
assert.equal(state.host, undefined);
|
||||
assert.equal(state.nodeExecutable, realpathSync(process.execPath));
|
||||
assert.equal(state.managedNode, false);
|
||||
@@ -226,6 +282,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
});
|
||||
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
|
||||
assert.match(readFileSync(paths.bridgeCommand, "utf8"), new RegExp(realpathSync(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(readFileSync(paths.launcherFile, "utf8"), /allowedNodeEnvironmentFlags\.has\("--use-env-proxy"\)/);
|
||||
const stableBridgePath = paths.bridgeCommand;
|
||||
const initialRuntime = state.runtimeDir;
|
||||
|
||||
@@ -276,6 +333,9 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
output.length = 0;
|
||||
const interruptedStaging = join(paths.versionsDir, ".staging-interrupted-test");
|
||||
mkdirSync(interruptedStaging, { recursive: true });
|
||||
unavailableUrls.add("https://approval.example.test/downloads/manifest.json");
|
||||
unavailableUrls.add("https://approval.example.test/downloads/manifest.sig");
|
||||
for (const artifact of Object.values(manifest.artifacts)) unavailableUrls.add(artifact.url);
|
||||
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");
|
||||
@@ -287,6 +347,8 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
|
||||
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
|
||||
assert.equal(existsSync(interruptedStaging), false, "a dead installer staging directory is recovered on the next run");
|
||||
assert.match(progress.join(""), /下载源不可用.*mirror\.example\.test/);
|
||||
unavailableUrls.clear();
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
|
||||
@@ -325,10 +387,12 @@ test("generic installer preserves identity across safe updates, rollback, downgr
|
||||
const beforeFailedVersions = readdirSync(paths.versionsDir);
|
||||
const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") };
|
||||
const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
||||
failedUpgrade.artifacts.client.mirrors = [`${mirrorBase}client-v3`];
|
||||
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"));
|
||||
responses.set(failedUpgrade.artifacts.client.mirrors[0]!, failedBodies.client);
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1);
|
||||
assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/);
|
||||
|
||||
@@ -25,6 +25,8 @@ test("customer distribution is a generic single-login gateway bridge", () => {
|
||||
assert.match(installer, /安装长时间无最终 JSON[\s\S]*残留 staging/s);
|
||||
assert.match(installer, /系统无需预装 Node\.js/);
|
||||
assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s);
|
||||
assert.match(installer, /国内备用发布源[\s\S]*主发布源.*国内 Gitea 备用源/s);
|
||||
assert.match(installer, /不要设置全局 `NODE_OPTIONS=--use-env-proxy`[\s\S]*不要手工预装 `node_modules`/s);
|
||||
assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/);
|
||||
assert.match(installer, /Linux\/macOS 安装引导/);
|
||||
assert.match(installer, /dingtalk-approval-bridge\.cmd/);
|
||||
@@ -50,10 +52,12 @@ test("customer distribution is a generic single-login gateway bridge", () => {
|
||||
assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/);
|
||||
assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/);
|
||||
assert.match(windowsBootstrap, /Write-DingProgress[\s\S]*\[dingtalk-approval\]/);
|
||||
assert.match(windowsBootstrap, /dingInstallerUrls[\s\S]*dingManifestMirrorUrls[\s\S]*use-env-proxy/s);
|
||||
assert.match(posixBootstrap, /^#!\/bin\/sh\nset -eu/);
|
||||
assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/);
|
||||
assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/);
|
||||
assert.match(posixBootstrap, /ding_progress[\s\S]*\[dingtalk-approval\]/);
|
||||
assert.match(posixBootstrap, /ding_installer_urls[\s\S]*ding_manifest_mirror_urls[\s\S]*use-env-proxy/s);
|
||||
assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0);
|
||||
assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/);
|
||||
|
||||
@@ -69,6 +73,7 @@ test("customer distribution is a generic single-login gateway bridge", () => {
|
||||
|
||||
const builder = readFileSync("scripts/build-release.mjs", "utf8");
|
||||
assert.match(builder, /schemaVersion: 3,\s+version,/s);
|
||||
assert.match(builder, /download-mirror-base-url[\s\S]*manifestMirrors[\s\S]*mirrors:/s);
|
||||
assert.match(builder, /signData\(null, manifestBytes/);
|
||||
assert.match(builder, /dingtalk-approval-client/);
|
||||
assert.match(builder, /bootstrap-windows\.template\.ps1/);
|
||||
|
||||
@@ -34,6 +34,36 @@ test("POSIX bootstrap installs a private Node and falls back after a network fai
|
||||
}
|
||||
});
|
||||
|
||||
test("POSIX bootstrap scopes the Node environment proxy flag to the networked installer", {
|
||||
skip: process.platform === "win32",
|
||||
}, () => {
|
||||
const fixture = createPosixFixture();
|
||||
try {
|
||||
const result = runPosixFixture(fixture, "proxy");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const invocations = readFileSync(fixture.nodeArgsLog, "utf8").trim().split(/\r?\n/);
|
||||
assert.ok(invocations.some((value) => value.startsWith("--use-env-proxy ") && value.includes("installer.mjs install")));
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("POSIX bootstrap falls back for the signed installer and public key downloads", {
|
||||
skip: process.platform === "win32",
|
||||
}, () => {
|
||||
const fixture = createPosixFixture();
|
||||
try {
|
||||
const result = runPosixFixture(fixture, "release-fallback");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/);
|
||||
assert.ok(calls.includes("https://release.example.test/installer.mjs"));
|
||||
assert.ok(calls.includes("https://release-mirror.example.test/installer.mjs"));
|
||||
assert.ok(calls.includes("https://release-mirror.example.test/release-signing-public.pem"));
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("POSIX bootstrap stops immediately when a downloaded runtime has the wrong hash", {
|
||||
skip: process.platform === "win32",
|
||||
}, () => {
|
||||
@@ -94,6 +124,7 @@ interface PosixFixture {
|
||||
archive: string;
|
||||
installer: string;
|
||||
publicKey: string;
|
||||
nodeArgsLog: string;
|
||||
}
|
||||
|
||||
function createPosixFixture(): PosixFixture {
|
||||
@@ -105,6 +136,7 @@ function createPosixFixture(): PosixFixture {
|
||||
const archive = join(root, RUNTIME_ARCHIVE);
|
||||
const installer = join(root, "installer.mjs");
|
||||
const publicKey = join(root, "release-signing-public.pem");
|
||||
const nodeArgsLog = join(root, "node-args.log");
|
||||
const script = join(root, "bootstrap.sh");
|
||||
mkdirSync(join(archiveRoot, "bin"), { recursive: true });
|
||||
mkdirSync(join(archiveRoot, "lib", "node_modules", "npm", "bin"), { recursive: true });
|
||||
@@ -114,6 +146,7 @@ function createPosixFixture(): PosixFixture {
|
||||
const privateNode = join(archiveRoot, "bin", "node");
|
||||
writeFileSync(privateNode, [
|
||||
"#!/bin/sh",
|
||||
"if [ -n \"${DING_TEST_NODE_ARGS_LOG:-}\" ]; then printf '%s\\n' \"$*\" >> \"$DING_TEST_NODE_ARGS_LOG\"; fi",
|
||||
"if [ \"${1:-}\" = '-e' ]; then exit 0; fi",
|
||||
"printf '%s\\n' '{\"ok\":true,\"status\":\"installed\",\"managedNode\":true}'",
|
||||
"",
|
||||
@@ -125,6 +158,7 @@ function createPosixFixture(): PosixFixture {
|
||||
writeFileSync(installer, "// fixture installer\n");
|
||||
writeFileSync(publicKey, "fixture public key\n");
|
||||
writeFileSync(callLog, "");
|
||||
writeFileSync(nodeArgsLog, "");
|
||||
|
||||
const curl = join(fakeBin, "curl");
|
||||
writeFileSync(curl, `#!/bin/sh
|
||||
@@ -145,8 +179,14 @@ case "$ding_url" in
|
||||
printf '000'; exit 7 ;;
|
||||
https://mirror.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;;
|
||||
https://official.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;;
|
||||
https://release.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
|
||||
https://release.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
|
||||
https://release.example.test/installer.mjs)
|
||||
if [ "$DING_TEST_MODE" = 'release-fallback' ]; then printf '000'; exit 7; fi
|
||||
/bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
|
||||
https://release.example.test/release-signing-public.pem)
|
||||
if [ "$DING_TEST_MODE" = 'release-fallback' ]; then printf '000'; exit 7; fi
|
||||
/bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
|
||||
https://release-mirror.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
|
||||
https://release-mirror.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
|
||||
*) printf '404'; exit 22 ;;
|
||||
esac
|
||||
`);
|
||||
@@ -158,11 +198,12 @@ esac
|
||||
let body = readFileSync("distribution/bootstrap-posix.template.sh", "utf8");
|
||||
const replacements: Record<string, string> = {
|
||||
"{{NODE_SOURCE_BASES_POSIX}}": "https://primary.example.test/node/ https://mirror.example.test/node/ https://official.example.test/node/",
|
||||
"{{INSTALLER_URL}}": "https://release.example.test/installer.mjs",
|
||||
"{{INSTALLER_URLS_POSIX}}": "https://release.example.test/installer.mjs https://release-mirror.example.test/installer.mjs",
|
||||
"{{INSTALLER_SHA256}}": digest(installer),
|
||||
"{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem",
|
||||
"{{PUBLIC_KEY_URLS_POSIX}}": "https://release.example.test/release-signing-public.pem https://release-mirror.example.test/release-signing-public.pem",
|
||||
"{{PUBLIC_KEY_SHA256}}": digest(publicKey),
|
||||
"{{MANIFEST_URL}}": "https://release.example.test/manifest.json",
|
||||
"{{MANIFEST_MIRROR_URLS_POSIX}}": "https://release-mirror.example.test/manifest.json",
|
||||
};
|
||||
for (const platform of ["LINUX_X64", "LINUX_ARM64", "DARWIN_X64", "DARWIN_ARM64"]) {
|
||||
replacements[`{{NODE_${platform}_FILE}}`] = RUNTIME_ARCHIVE;
|
||||
@@ -172,10 +213,10 @@ esac
|
||||
assert.doesNotMatch(body, /\{\{/);
|
||||
writeFileSync(script, body);
|
||||
chmodSync(script, 0o700);
|
||||
return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey };
|
||||
return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey, nodeArgsLog };
|
||||
}
|
||||
|
||||
function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error") {
|
||||
function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error" | "proxy" | "release-fallback") {
|
||||
return spawnSync("/bin/sh", [fixture.script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
@@ -187,6 +228,8 @@ function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "
|
||||
DING_TEST_ARCHIVE: fixture.archive,
|
||||
DING_TEST_INSTALLER: fixture.installer,
|
||||
DING_TEST_PUBLIC_KEY: fixture.publicKey,
|
||||
DING_TEST_NODE_ARGS_LOG: fixture.nodeArgsLog,
|
||||
...(mode === "proxy" ? { HTTPS_PROXY: "http://proxy.example.test:8080" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -195,11 +238,12 @@ function renderWindowsBootstrap(): string {
|
||||
let body = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8");
|
||||
const replacements: Record<string, string> = {
|
||||
"{{NODE_SOURCE_BASES_POWERSHELL}}": '"https://primary.example.test/node/", "https://official.example.test/node/"',
|
||||
"{{INSTALLER_URL}}": "https://release.example.test/installer.mjs",
|
||||
"{{INSTALLER_URLS_POWERSHELL}}": '"https://release.example.test/installer.mjs", "https://release-mirror.example.test/installer.mjs"',
|
||||
"{{INSTALLER_SHA256}}": "a".repeat(64),
|
||||
"{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem",
|
||||
"{{PUBLIC_KEY_URLS_POWERSHELL}}": '"https://release.example.test/release-signing-public.pem", "https://release-mirror.example.test/release-signing-public.pem"',
|
||||
"{{PUBLIC_KEY_SHA256}}": "b".repeat(64),
|
||||
"{{MANIFEST_URL}}": "https://release.example.test/manifest.json",
|
||||
"{{MANIFEST_MIRROR_URLS_POWERSHELL}}": '"https://release-mirror.example.test/manifest.json"',
|
||||
};
|
||||
for (const platform of ["WIN32_X64", "WIN32_ARM64"]) {
|
||||
replacements[`{{NODE_${platform}_FILE}}`] = `node-v${NODE_VERSION}-win-x64.zip`;
|
||||
|
||||
Reference in New Issue
Block a user