Herebyfile.mjs (1,205 lines of code) (raw):
// @ts-check
import AdmZip from "adm-zip";
import chokidar from "chokidar";
import { $ as _$ } from "execa";
import { glob } from "glob";
import { task } from "hereby";
import assert from "node:assert";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import { parseArgs } from "node:util";
import os from "os";
import pLimit from "p-limit";
import pc from "picocolors";
import which from "which";
const __filename = url.fileURLToPath(new URL(import.meta.url));
const __dirname = path.dirname(__filename);
const isCI = !!process.env.CI;
const $pipe = _$({ verbose: "short" });
const $ = _$({ verbose: "short", stdio: "inherit" });
/**
* @param {string} name
* @param {boolean} defaultValue
* @returns {boolean}
*/
function parseEnvBoolean(name, defaultValue = false) {
name = "TSGO_HEREBY_" + name.toUpperCase();
const value = process.env[name];
if (!value) {
return defaultValue;
}
switch (value.toUpperCase()) {
case "1":
case "TRUE":
case "YES":
case "ON":
return true;
case "0":
case "FALSE":
case "NO":
case "OFF":
return false;
}
throw new Error(`Invalid value for ${name}: ${value}`);
}
const { values: rawOptions } = parseArgs({
args: process.argv.slice(2),
options: {
tests: { type: "string", short: "t" },
fix: { type: "boolean" },
debug: { type: "boolean" },
dirty: { type: "boolean" },
insiders: { type: "boolean" },
setPrerelease: { type: "string" },
forRelease: { type: "boolean" },
race: { type: "boolean", default: parseEnvBoolean("RACE") },
noembed: { type: "boolean", default: parseEnvBoolean("NOEMBED") },
concurrentTestPrograms: { type: "boolean", default: parseEnvBoolean("CONCURRENT_TEST_PROGRAMS") },
coverage: { type: "boolean", default: parseEnvBoolean("COVERAGE") },
},
strict: false,
allowPositionals: true,
allowNegative: true,
});
// We can't use parseArgs' strict mode as it errors on hereby's --tasks flag.
/**
* @typedef {{ [K in keyof typeof rawOptions as {} extends Record<K, 1> ? never : K]: typeof rawOptions[K] }} Options
*/
const options = /** @type {Options} */ (rawOptions);
if (options.forRelease && !options.setPrerelease) {
throw new Error("forRelease requires setPrerelease");
}
const defaultGoBuildTags = [
...(options.noembed ? ["noembed"] : []),
];
/**
* @param {...string} extra
* @returns {string[]}
*/
function goBuildTags(...extra) {
const tags = new Set(defaultGoBuildTags.concat(extra));
return tags.size ? [`-tags=${[...tags].join(",")}`] : [];
}
const goBuildFlags = [
...(options.race ? ["-race"] : []),
// https://github.com/go-delve/delve/blob/62cd2d423c6a85991e49d6a70cc5cb3e97d6ceef/Documentation/usage/dlv_exec.md?plain=1#L12
...(options.debug ? ["-gcflags=all=-N -l"] : []),
];
/**
* @template T
* @param {() => T} fn
* @returns {() => T}
*/
function memoize(fn) {
/** @type {T} */
let value;
return () => {
if (fn !== undefined) {
value = fn();
fn = /** @type {any} */ (undefined);
}
return value;
};
}
const typeScriptSubmodulePath = path.join(__dirname, "_submodules", "TypeScript");
const isTypeScriptSubmoduleCloned = memoize(() => {
try {
const stat = fs.statSync(path.join(typeScriptSubmodulePath, "package.json"));
if (stat.isFile()) {
return true;
}
}
catch {}
return false;
});
const warnIfTypeScriptSubmoduleNotCloned = memoize(() => {
if (!isTypeScriptSubmoduleCloned()) {
console.warn(pc.yellow("Warning: TypeScript submodule is not cloned; some tests may be skipped."));
}
});
function assertTypeScriptCloned() {
if (!isTypeScriptSubmoduleCloned()) {
throw new Error("_submodules/TypeScript does not exist; try running `git submodule update --init --recursive`");
}
}
const tools = new Map([
["gotest.tools/gotestsum", "latest"],
]);
/**
* @param {string} tool
*/
function isInstalled(tool) {
return !!which.sync(tool, { nothrow: true });
}
const builtLocal = "./built/local";
const libsDir = "./internal/bundled/libs";
const libsRegexp = /(?:^|[\\/])internal[\\/]bundled[\\/]libs[\\/]/;
/**
* @param {string} out
*/
async function generateLibs(out) {
await fs.promises.mkdir(out, { recursive: true });
const libs = await fs.promises.readdir(libsDir);
await Promise.all(libs.map(async lib => {
fs.promises.copyFile(path.join(libsDir, lib), path.join(out, lib));
}));
}
export const lib = task({
name: "lib",
description: "Copies the libs to built/local.",
run: () => generateLibs(builtLocal),
});
/**
* @param {object} [opts]
* @param {string} [opts.out]
* @param {AbortSignal} [opts.abortSignal]
* @param {Record<string, string | undefined>} [opts.env]
* @param {string[]} [opts.extraFlags]
*/
function buildTsgo(opts) {
opts ||= {};
const out = opts.out ?? "./built/local/";
return $({ cancelSignal: opts.abortSignal, env: opts.env })`go build ${goBuildFlags} ${opts.extraFlags ?? []} ${options.debug ? goBuildTags("noembed") : goBuildTags("noembed", "release")} -o ${out} ./cmd/tsgo`;
}
export const tsgoBuild = task({
name: "tsgo:build",
description: "Builds the tsgo binary.",
run: async () => {
await buildTsgo();
},
});
export const tsgo = task({
name: "tsgo",
dependencies: [lib, tsgoBuild],
});
export const local = task({
name: "local",
dependencies: [tsgo],
});
export const build = task({
name: "build",
dependencies: [local],
});
export const buildWatch = task({
name: "build:watch",
description: "Builds the tsgo binary and watches for changes.",
run: async () => {
await watchDebounced("build:watch", async (paths, abortSignal) => {
let libsChanged = false;
let goChanged = false;
if (paths) {
for (const p of paths) {
if (libsRegexp.test(p)) {
libsChanged = true;
}
else if (p.endsWith(".go")) {
goChanged = true;
}
if (libsChanged && goChanged) {
break;
}
}
}
else {
libsChanged = true;
goChanged = true;
}
if (libsChanged) {
console.log("Generating libs...");
await generateLibs(builtLocal);
}
if (goChanged) {
console.log("Building tsgo...");
await buildTsgo({ abortSignal });
}
}, {
paths: ["cmd", "internal"],
ignored: path => /[\\/]testdata[\\/]/.test(path),
});
},
});
export const cleanBuilt = task({
name: "clean:built",
hiddenFromTaskList: true,
run: () => rimraf("built"),
});
export const generate = task({
name: "generate",
description: "Runs go generate on the project.",
run: async () => {
assertTypeScriptCloned();
await $`go generate -v ./...`;
},
});
const coverageDir = path.join(__dirname, "coverage");
const ensureCoverageDirExists = memoize(() => {
if (options.coverage) {
fs.mkdirSync(coverageDir, { recursive: true });
}
});
/**
* @param {string} taskName
*/
function goTestFlags(taskName) {
ensureCoverageDirExists();
return [
...goBuildFlags,
...goBuildTags(),
...(options.tests ? [`-run=${options.tests}`] : []),
...(options.coverage ? [`-coverprofile=${path.join(coverageDir, "coverage." + taskName + ".out")}`, "-coverpkg=./..."] : []),
];
}
const goTestEnv = {
...(options.concurrentTestPrograms ? { TS_TEST_PROGRAM_SINGLE_THREADED: "false" } : {}),
// Go test caching takes a long time on Windows.
// https://github.com/golang/go/issues/72992
...(process.platform === "win32" ? { GOFLAGS: "-count=1" } : {}),
};
const goTestSumFlags = [
"--format-hide-empty-pkg",
...(!isCI ? ["--hide-summary", "skipped"] : []),
];
const $test = $({ env: goTestEnv });
/**
* @param {string} taskName
*/
function gotestsum(taskName) {
const args = isInstalled("gotestsum") ? ["gotestsum", ...goTestSumFlags, "--"] : ["go", "test"];
return args.concat(goTestFlags(taskName));
}
/**
* @param {string} taskName
*/
function goTest(taskName) {
return ["go", "test"].concat(goTestFlags(taskName));
}
async function runTests() {
warnIfTypeScriptSubmoduleNotCloned();
if (!options.dirty) {
await rimraf(localBaseline);
await fs.promises.mkdir(localBaseline, { recursive: true });
}
await $test`${gotestsum("tests")} ./... ${isCI ? ["--timeout=45m"] : []}`;
}
export const test = task({
name: "test",
description: "Runs all tests. This is the most typical test task to need.",
run: runTests,
});
async function runTestBenchmarks() {
warnIfTypeScriptSubmoduleNotCloned();
// Run the benchmarks once to ensure they compile and run without errors.
await $test`${goTest("benchmarks")} -run=- -bench=. -benchtime=1x ./...`;
}
export const testBenchmarks = task({
name: "test:benchmarks",
description: "Runs all benchmarks.",
run: runTestBenchmarks,
});
async function runTestTools() {
await $test({ cwd: path.join(__dirname, "_tools") })`${gotestsum("tools")} ./...`;
}
async function runTestAPI() {
await $`npm run -w @typescript/api test`;
}
export const testTools = task({
name: "test:tools",
description: "Runs all tests in the _tools module.",
run: runTestTools,
});
export const buildAPITests = task({
name: "build:api:test",
description: "Builds the @typescript/api tests.",
run: async () => {
await $`npm run -w @typescript/api build:test`;
},
});
export const testAPI = task({
name: "test:api",
description: "Runs the @typescript/api tests.",
dependencies: [tsgo, buildAPITests],
run: runTestAPI,
});
export const testAll = task({
name: "test:all",
description: "Runs ALL tests in the repo, including benchmarks, _tools, and the API tests.",
dependencies: [tsgo, buildAPITests],
run: async () => {
// Prevent interleaving by running these directly instead of in parallel.
await runTests();
await runTestBenchmarks();
await runTestTools();
await runTestAPI();
},
});
const customLinterPath = "./_tools/custom-gcl";
const customLinterHashPath = customLinterPath + ".hash";
const golangciLintPackage = memoize(() => {
// const golangciLintYml = fs.readFileSync(".custom-gcl.yml", "utf8");
// const pattern = /^version:\s*(v\d+\.\d+\.\d+).*$/m;
// const match = pattern.exec(golangciLintYml);
// if (!match) {
// throw new Error("Expected version in .custom-gcl.yml");
// }
// const version = match[1];
const version = "v2.6.3-0.20251130135459-0212d7c8deac"; // https://github.com/golangci/golangci-lint/issues/6205
const major = version.split(".")[0];
const versionSuffix = ["v0", "v1"].includes(major) ? "" : "/" + major;
return `github.com/golangci/golangci-lint${versionSuffix}/cmd/golangci-lint@${version}`;
});
const customlintHash = memoize(() => {
const files = glob.sync([
"./_tools/go.mod",
"./_tools/customlint/**/*",
"./.custom-gcl.yml",
], {
ignore: "**/testdata/**",
nodir: true,
absolute: true,
});
files.sort();
const hash = crypto.createHash("sha256");
for (const file of files) {
hash.update(file);
hash.update(fs.readFileSync(file));
}
return hash.digest("hex") + "\n";
});
const buildCustomLinter = memoize(async () => {
const hash = customlintHash();
if (
isInstalled(customLinterPath)
&& fs.existsSync(customLinterHashPath)
&& fs.readFileSync(customLinterHashPath, "utf8") === hash
) {
return;
}
await $`go run ${golangciLintPackage()} custom`;
await $`${customLinterPath} cache clean`;
fs.writeFileSync(customLinterHashPath, hash);
});
export const lint = task({
name: "lint",
description: "Runs golangci-lint.",
run: async () => {
await buildCustomLinter();
const lintArgs = ["run"];
if (defaultGoBuildTags.length) {
lintArgs.push("--build-tags", defaultGoBuildTags.join(","));
}
if (options.fix) {
lintArgs.push("--fix");
}
const resolvedCustomLinterPath = path.resolve(customLinterPath);
await $`${resolvedCustomLinterPath} ${lintArgs}`;
console.log("Linting _tools");
await $({ cwd: "./_tools" })`${resolvedCustomLinterPath} ${lintArgs}`;
},
});
export const installTools = task({
name: "install-tools",
description: "Installs optional tools for developing within the repo.",
run: async () => {
await Promise.all([
...[...tools].map(([tool, version]) => $`go install ${tool}${version ? `@${version}` : ""}`),
buildCustomLinter(),
]);
},
});
export const format = task({
name: "format",
description: "Formats the repo.",
run: async () => {
await $`dprint fmt`;
},
});
export const checkFormat = task({
name: "check:format",
description: "Checks that the repo is formatted.",
run: async () => {
await $`dprint check`;
},
});
/**
* @param {string} localBaseline Path to the local copy of the baselines
* @param {string} refBaseline Path to the reference copy of the baselines
*/
function baselineAcceptTask(localBaseline, refBaseline) {
/**
* @param {string} p
*/
function localPathToRefPath(p) {
const relative = path.relative(localBaseline, p);
return path.join(refBaseline, relative);
}
return async () => {
const toCopy = await glob(`${localBaseline}/**`, { nodir: true, ignore: `${localBaseline}/**/*.delete` });
for (const p of toCopy) {
const out = localPathToRefPath(p);
await fs.promises.mkdir(path.dirname(out), { recursive: true });
await fs.promises.copyFile(p, out);
}
const toDelete = await glob(`${localBaseline}/**/*.delete`, { nodir: true });
for (const p of toDelete) {
const out = localPathToRefPath(p).replace(/\.delete$/, "");
await rimraf(out);
await rimraf(p); // also delete the .delete file so that it no longer shows up in a diff tool.
}
};
}
const localBaseline = "testdata/baselines/local/";
const refBaseline = "testdata/baselines/reference/";
export const baselineAccept = task({
name: "baseline-accept",
description: "Makes the most recent test results the new baseline, overwriting the old baseline.",
run: baselineAcceptTask(localBaseline, refBaseline),
});
/**
* @param {fs.PathLike} p
*/
function rimraf(p) {
// The rimraf package uses maxRetries=10 on Windows, but Node's fs.rm does not have that special case.
return fs.promises.rm(p, { recursive: true, force: true, maxRetries: process.platform === "win32" ? 10 : 0 });
}
/** @typedef {{
* name: string;
* paths: string | string[];
* ignored?: (path: string) => boolean;
* run: (paths: Set<string>, abortSignal: AbortSignal) => void | Promise<unknown>;
* }} WatchTask */
void 0;
/**
* @param {string} name
* @param {(paths: Set<string> | undefined, abortSignal: AbortSignal) => void | Promise<unknown>} run
* @param {object} options
* @param {string | string[]} options.paths
* @param {(path: string) => boolean} [options.ignored]
* @param {string} [options.name]
*/
async function watchDebounced(name, run, options) {
let watching = true;
let running = true;
let lastChangeTimeMs = Date.now();
let changedDeferred = /** @type {Deferred<void>} */ (new Deferred());
let abortController = new AbortController();
const debouncer = new Debouncer(1_000, endRun);
const watcher = chokidar.watch(options.paths, {
ignored: options.ignored,
ignorePermissionErrors: true,
alwaysStat: true,
});
// The paths that have changed since the last run.
/** @type {Set<string> | undefined} */
let paths;
process.on("SIGINT", endWatchMode);
process.on("beforeExit", endWatchMode);
watcher.on("all", onChange);
while (watching) {
const promise = changedDeferred.promise;
const token = abortController.signal;
if (!token.aborted) {
running = true;
try {
const thePaths = paths;
paths = new Set();
await run(thePaths, token);
}
catch {
// ignore
}
running = false;
}
if (watching) {
console.log(pc.yellowBright(`[${name}] run complete, waiting for changes...`));
await promise;
}
}
console.log("end");
/**
* @param {'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' | 'all' | 'ready' | 'raw' | 'error'} eventName
* @param {string} path
* @param {fs.Stats | undefined} stats
*/
function onChange(eventName, path, stats) {
switch (eventName) {
case "change":
case "unlink":
case "unlinkDir":
break;
case "add":
case "addDir":
// skip files that are detected as 'add' but haven't actually changed since the last time we ran.
if (stats && stats.mtimeMs <= lastChangeTimeMs) {
return;
}
break;
}
beginRun(path);
}
/**
* @param {string} path
*/
function beginRun(path) {
if (debouncer.empty) {
console.log(pc.yellowBright(`[${name}] changed due to '${path}', restarting...`));
if (running) {
console.log(pc.yellowBright(`[${name}] aborting in-progress run...`));
}
abortController.abort();
abortController = new AbortController();
}
debouncer.enqueue();
paths ??= new Set();
paths.add(path);
}
function endRun() {
lastChangeTimeMs = Date.now();
changedDeferred.resolve();
changedDeferred = /** @type {Deferred<void>} */ (new Deferred());
}
function endWatchMode() {
if (watching) {
watching = false;
console.log(pc.yellowBright(`[${name}] exiting watch mode...`));
abortController.abort();
watcher.close();
}
}
}
/**
* @template T
*/
export class Deferred {
constructor() {
/** @type {Promise<T>} */
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
export class Debouncer {
/**
* @param {number} timeout
* @param {() => Promise<any> | void} action
*/
constructor(timeout, action) {
this._timeout = timeout;
this._action = action;
}
get empty() {
return !this._deferred;
}
enqueue() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = undefined;
}
if (!this._deferred) {
this._deferred = new Deferred();
}
this._timer = setTimeout(() => this.run(), 100);
return this._deferred.promise;
}
run() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = undefined;
}
const deferred = this._deferred;
assert(deferred);
this._deferred = undefined;
try {
deferred.resolve(this._action());
}
catch (e) {
deferred.reject(e);
}
}
}
const getVersion = memoize(() => {
const f = fs.readFileSync("./internal/core/version.go", "utf8");
const match = f.match(/var version\s*=\s*"(\d+\.\d+\.\d+)(-[^"]+)?"/);
if (!match) {
throw new Error("Failed to extract version from version.go");
}
let version = match[1];
if (options.setPrerelease) {
version += `-${options.setPrerelease}`;
}
else if (match[2]) {
version += match[2];
}
return version;
});
const extensionDir = path.resolve("./_extension");
const builtNpm = path.resolve("./built/npm");
const builtVsix = path.resolve("./built/vsix");
const builtSignTmp = path.resolve("./built/sign-tmp");
const getSignTempDir = memoize(async () => {
const dir = path.resolve(builtSignTmp);
await rimraf(dir);
await fs.promises.mkdir(dir, { recursive: true });
return dir;
});
const cleanSignTempDirectory = task({
name: "clean:sign-tmp",
hiddenFromTaskList: true,
run: () => rimraf(builtSignTmp),
});
let signCount = 0;
/**
* @typedef {{
* SignFileRecordList: {
* SignFileList: { SrcPath: string; DstPath: string | null }[];
* Certs: Cert;
* MacAppName: string | undefined
* }[]
* }} DDSignFileList
*
* @param {DDSignFileList} filelist
*/
async function sign(filelist, unchangedOutputOkay = false) {
let data = JSON.stringify(filelist, undefined, 4);
console.log("filelist:", data);
if (!process.env.MBSIGN_APPFOLDER) {
console.log(pc.yellow("Faking signing because MBSIGN_APPFOLDER is not set."));
// Fake signing for testing.
for (const record of filelist.SignFileRecordList) {
for (const file of record.SignFileList) {
const src = file.SrcPath;
const dst = file.DstPath ?? src;
if (!fs.existsSync(src)) {
throw new Error(`Source file does not exist: ${src}`);
}
const dstDir = path.dirname(dst);
if (!fs.existsSync(dstDir)) {
throw new Error(`Destination directory does not exist: ${dstDir}`);
}
if (dst.endsWith(".sig")) {
console.log(`Faking signature for ${src} -> ${dst}`);
// No great way to fake a signature.
await fs.promises.writeFile(dst, "fake signature");
}
else {
if (src === dst) {
console.log(`Faking signing ${src}`);
}
else {
console.log(`Faking signing ${src} -> ${dst}`);
}
const contents = await fs.promises.readFile(src);
await fs.promises.writeFile(dst, contents);
}
}
}
return;
}
const signingWorkaround = true;
/** @type {{ source: string; target: string }[]} */
const signingWorkaroundFiles = [];
if (signingWorkaround) {
// DstPath is currently broken in the signing tool.
// Copy all of the files to a new tempdir and then leave DstPath unset
// so that it's overwritten, then move the file to the destination.
console.log("Working around DstPath bug");
/** @type {DDSignFileList} */
const newFileList = {
SignFileRecordList: filelist.SignFileRecordList.map(list => {
return {
Certs: list.Certs,
SignFileList: list.SignFileList.map(file => {
const dstPath = file.DstPath;
if (dstPath === null) {
return file;
}
const src = file.SrcPath;
// File extensions must be preserved; use a prefix.
const dstPathTemp = `${path.dirname(src)}/signing-temp-${path.basename(src)}`;
console.log(`Copying: ${src} -> ${dstPathTemp}`);
fs.cpSync(src, dstPathTemp);
signingWorkaroundFiles.push({ source: dstPathTemp, target: dstPath });
return {
SrcPath: dstPathTemp,
DstPath: null,
};
}),
MacAppName: list.MacAppName,
};
}),
};
data = JSON.stringify(newFileList, undefined, 4);
console.log("new filelist:", data);
}
/** @type {Map<string, string>} */
const srcHashes = new Map();
for (const record of filelist.SignFileRecordList) {
for (const file of record.SignFileList) {
const src = file.SrcPath;
const dst = file.DstPath ?? src;
if (!fs.existsSync(src)) {
throw new Error(`Source file does not exist: ${src}`);
}
const hash = crypto.createHash("sha256").update(fs.readFileSync(src)).digest("hex");
srcHashes.set(src, hash);
console.log(`Will sign ${src} -> ${dst}`);
console.log(` sha256: ${hash}`);
}
}
const tmp = await getSignTempDir();
const filelistPath = path.resolve(tmp, `signing-filelist-${signCount++}.json`);
await fs.promises.writeFile(filelistPath, data);
try {
const dll = path.join(process.env.MBSIGN_APPFOLDER, "DDSignFiles.dll");
const filelistFlag = `/filelist:${filelistPath}`;
await $`dotnet ${dll} -- ${filelistFlag}`;
}
finally {
await fs.promises.unlink(filelistPath);
}
if (signingWorkaround) {
// Now, copy the files back.
for (const { source, target } of signingWorkaroundFiles) {
console.log(`Moving signed file: ${source} -> ${target}`);
await fs.promises.rename(source, target);
}
}
/** @type {string[]} */
let failures = [];
for (const record of filelist.SignFileRecordList) {
for (const file of record.SignFileList) {
const src = file.SrcPath;
const dst = file.DstPath ?? src;
if (!fs.existsSync(dst)) {
failures.push(`Signed file does not exist: ${dst}`);
const newSrcHash = crypto.createHash("sha256").update(fs.readFileSync(src)).digest("hex");
const oldSrcHash = srcHashes.get(src);
assert(oldSrcHash);
if (oldSrcHash !== newSrcHash) {
failures.push(` Source file changed during signing: ${src}\n before: ${oldSrcHash}\n after: ${newSrcHash}`);
}
continue;
}
const srcHash = srcHashes.get(src);
assert(srcHash);
const dstHash = crypto.createHash("sha256").update(fs.readFileSync(dst)).digest("hex");
if (srcHash === dstHash) {
const message = `Signed file is identical to source file (not signed?): ${src} -> ${dst}\n sha256: ${dstHash}`;
if (unchangedOutputOkay) {
console.log(message);
}
else {
failures.push(message);
continue;
}
}
if (src === dst) {
console.log(`Signed ${src}`);
}
else {
console.log(`Signed ${src} -> ${dst}`);
}
console.log(` sha256: ${dstHash}`);
}
}
if (failures.length) {
throw new Error("Some files failed to sign:\n" + failures.map(f => " - " + f).join("\n"));
}
}
/**
* @param {string} src
* @param {string} dest
* @param {(p: string) => boolean} [filter]
*/
function cpRecursive(src, dest, filter) {
return fs.promises.cp(src, dest, {
recursive: true,
filter: filter ? src => filter(src.replace(/\\/g, "/")) : undefined,
});
}
/**
* @param {string} src
* @param {string} dest
*/
function cpWithoutNodeModulesOrTsconfig(src, dest) {
return cpRecursive(src, dest, p => !p.endsWith("/node_modules") && !p.endsWith("/tsconfig.json"));
}
const mainNativePreviewPackage = {
npmPackageName: "@typescript/native-preview",
npmDir: path.join(builtNpm, "native-preview"),
npmTarball: path.join(builtNpm, "native-preview.tgz"),
};
/**
* @typedef {"win32" | "linux" | "darwin"} OS
* @typedef {"x64" | "arm" | "arm64"} Arch
* @typedef {"Microsoft400" | "LinuxSign" | "MacDeveloperHarden" | "8020" | "VSCodePublisher"} Cert
* @typedef {`${OS | "alpine"}-${Exclude<Arch, "arm"> | "armhf"}`} VSCodeTarget
*/
void 0;
const nativePreviewPlatforms = memoize(() => {
/** @type {[os: OS, arch: Arch, cert: Cert, alpine?: boolean][]} */
let supportedPlatforms = [
["win32", "x64", "Microsoft400"],
["win32", "arm64", "Microsoft400"],
["linux", "x64", "LinuxSign", true],
["linux", "arm", "LinuxSign"],
["linux", "arm64", "LinuxSign", true],
["darwin", "x64", "MacDeveloperHarden"],
["darwin", "arm64", "MacDeveloperHarden"],
// Wasm?
];
if (!options.forRelease) {
supportedPlatforms = supportedPlatforms.filter(([os, arch]) => os === process.platform && arch === process.arch);
assert.equal(supportedPlatforms.length, 1, "No supported platforms found");
}
return supportedPlatforms.map(([os, arch, cert, alpine]) => {
const npmDirName = `native-preview-${os}-${arch}`;
const npmDir = path.join(builtNpm, npmDirName);
const npmTarball = `${npmDir}.tgz`;
const npmPackageName = `@typescript/${npmDirName}`;
/** @type {VSCodeTarget[]} */
const vscodeTargets = [`${os}-${arch === "arm" ? "armhf" : arch}`];
if (alpine) {
vscodeTargets.push(`alpine-${arch === "arm" ? "armhf" : arch}`);
}
const extensions = vscodeTargets.map(vscodeTarget => {
const extensionDir = path.join(builtVsix, `typescript-native-preview-${vscodeTarget}`);
const vsixPath = extensionDir + ".vsix";
const vsixManifestPath = extensionDir + ".manifest";
const vsixSignaturePath = extensionDir + ".signature.p7s";
return {
vscodeTarget,
extensionDir,
vsixPath,
vsixManifestPath,
vsixSignaturePath,
};
});
return {
nodeOs: os,
nodeArch: arch,
goos: nodeToGOOS(os),
goarch: nodeToGOARCH(arch),
npmPackageName,
npmDirName,
npmDir,
npmTarball,
extensions,
cert,
};
});
/**
* @param {string} os
* @returns {"darwin" | "linux" | "windows"}
*/
function nodeToGOOS(os) {
switch (os) {
case "darwin":
return "darwin";
case "linux":
return "linux";
case "win32":
return "windows";
default:
throw new Error(`Unsupported OS: ${os}`);
}
}
/**
* @param {string} arch
* @returns {"amd64" | "arm" | "arm64"}
*/
function nodeToGOARCH(arch) {
switch (arch) {
case "x64":
return "amd64";
case "arm":
return "arm";
case "arm64":
return "arm64";
default:
throw new Error(`Unsupported ARCH: ${arch}`);
}
}
});
export const buildNativePreviewPackages = task({
name: "native-preview:build-packages",
hiddenFromTaskList: true,
run: async () => {
await rimraf(builtNpm);
const platforms = nativePreviewPlatforms();
const inputDir = "./_packages/native-preview";
const inputPackageJson = JSON.parse(fs.readFileSync(path.join(inputDir, "package.json"), "utf8"));
inputPackageJson.version = getVersion();
delete inputPackageJson.private;
delete inputPackageJson.engines;
const { stdout: gitHead } = await $pipe`git rev-parse HEAD`;
inputPackageJson.gitHead = gitHead;
const mainPackage = {
...inputPackageJson,
optionalDependencies: Object.fromEntries(platforms.map(p => [p.npmPackageName, getVersion()])),
};
const mainPackageDir = mainNativePreviewPackage.npmDir;
await fs.promises.mkdir(mainPackageDir, { recursive: true });
await cpWithoutNodeModulesOrTsconfig(inputDir, mainPackageDir);
await fs.promises.writeFile(path.join(mainPackageDir, "package.json"), JSON.stringify(mainPackage, undefined, 4));
await fs.promises.copyFile("LICENSE", path.join(mainPackageDir, "LICENSE"));
// No NOTICE.txt here; does not ship the binary or libs. If this changes, we should add it.
let ldflags = "-ldflags=-s -w";
if (options.setPrerelease) {
ldflags += ` -X github.com/microsoft/typescript-go/internal/core.version=${getVersion()}`;
}
const extraFlags = ["-trimpath", ldflags];
const buildLimit = pLimit(os.availableParallelism());
await Promise.all(platforms.map(async ({ npmDir, npmPackageName, nodeOs, nodeArch, goos, goarch }) => {
const packageJson = {
...inputPackageJson,
bin: undefined,
imports: undefined,
name: npmPackageName,
os: [nodeOs],
cpu: [nodeArch],
exports: {
"./package.json": "./package.json",
},
};
const out = path.join(npmDir, "lib");
await fs.promises.mkdir(out, { recursive: true });
await fs.promises.writeFile(path.join(npmDir, "package.json"), JSON.stringify(packageJson, undefined, 4));
await fs.promises.copyFile("LICENSE", path.join(npmDir, "LICENSE"));
await fs.promises.copyFile("NOTICE.txt", path.join(npmDir, "NOTICE.txt"));
const readme = [
`# \`${npmPackageName}\``,
"",
`This package provides ${nodeOs}-${nodeArch} support for [${mainNativePreviewPackage.npmPackageName}](https://www.npmjs.com/package/${mainNativePreviewPackage.npmPackageName}).`,
];
fs.promises.writeFile(path.join(npmDir, "README.md"), readme.join("\n") + "\n");
await Promise.all([
generateLibs(out),
buildLimit(() =>
buildTsgo({
out,
env: { GOOS: goos, GOARCH: goarch, GOARM: "6", CGO_ENABLED: "0" },
extraFlags,
})
),
]);
}));
},
});
export const signNativePreviewPackages = task({
name: "native-preview:sign-packages",
hiddenFromTaskList: true,
run: async () => {
if (!options.forRelease) {
throw new Error("This task should not be run in non-release builds.");
}
const platforms = nativePreviewPlatforms();
/** @type {Map<Cert, { tmpName: string; path: string }[]>} */
const filelistByCert = new Map();
for (const { npmDir, nodeOs, cert, npmDirName } of platforms) {
let certFilelist = filelistByCert.get(cert);
if (!certFilelist) {
filelistByCert.set(cert, certFilelist = []);
}
certFilelist.push({
tmpName: npmDirName,
path: path.join(npmDir, "lib", nodeOs === "win32" ? "tsgo.exe" : "tsgo"),
});
}
const tmp = await getSignTempDir();
/** @type {DDSignFileList} */
const filelist = {
SignFileRecordList: [],
};
/** @type {{ path: string; unsignedZipPath: string; signedZipPath: string; notarizedZipPath: string; }[]} */
const macZips = [];
// First, sign the files.
for (const [cert, filelistPaths] of filelistByCert) {
switch (cert) {
case "Microsoft400":
filelist.SignFileRecordList.push({
SignFileList: filelistPaths.map(p => ({ SrcPath: p.path, DstPath: null })),
Certs: cert,
MacAppName: undefined,
});
break;
case "LinuxSign":
filelist.SignFileRecordList.push({
SignFileList: filelistPaths.map(p => ({ SrcPath: p.path, DstPath: p.path + ".sig" })),
Certs: cert,
MacAppName: undefined,
});
break;
case "MacDeveloperHarden":
// Mac signing requires putting files into zips and then signing those,
// along with a notarization step.
for (const p of filelistPaths) {
const unsignedZipPath = path.join(tmp, `${p.tmpName}.unsigned.zip`);
const signedZipPath = path.join(tmp, `${p.tmpName}.signed.zip`);
const notarizedZipPath = path.join(tmp, `${p.tmpName}.notarized.zip`);
const zip = new AdmZip();
zip.addLocalFile(p.path);
zip.writeZip(unsignedZipPath);
macZips.push({
path: p.path,
unsignedZipPath,
signedZipPath,
notarizedZipPath,
});
}
filelist.SignFileRecordList.push({
SignFileList: macZips.map(p => ({ SrcPath: p.unsignedZipPath, DstPath: p.signedZipPath })),
Certs: cert,
MacAppName: undefined, // MacAppName is only for notarization
});
break;
default:
throw new Error(`Unknown cert: ${cert}`);
}
}
await sign(filelist);
// All of the files have been signed in place / had signatures added.
if (macZips.length) {
// Now, notarize the Mac files.
/** @type {DDSignFileList} */
const notarizeFilelist = {
SignFileRecordList: [
{
SignFileList: macZips.map(p => ({ SrcPath: p.signedZipPath, DstPath: p.notarizedZipPath })),
Certs: "8020", // "MacNotarize" (friendly name not supported by the tooling)
MacAppName: "MicrosoftTypeScript",
},
],
};
// Notarizing does not change the file, it just sends it to Apple, so ignore the case
// where the input files are the same as the output files.
await sign(notarizeFilelist, /*unchangedOutputOkay*/ true);
// Finally, unzip the notarized files and move them back to their original locations.
for (const p of macZips) {
const zip = new AdmZip(p.notarizedZipPath);
zip.extractEntryTo(path.basename(p.path), path.dirname(p.path), false, true);
}
// chmod +x the unsipped files.
for (const p of macZips) {
await fs.promises.chmod(p.path, 0o755);
}
}
},
});
export const packNativePreviewPackages = task({
name: "native-preview:pack-packages",
hiddenFromTaskList: true,
dependencies: options.forRelease ? undefined : [buildNativePreviewPackages, cleanSignTempDirectory],
run: async () => {
const platforms = nativePreviewPlatforms();
await Promise.all([mainNativePreviewPackage, ...platforms].map(async ({ npmDir, npmTarball }) => {
const { stdout } = await $pipe`npm pack --json ${npmDir}`;
const filename = JSON.parse(stdout)[0].filename.replace("@", "").replace("/", "-");
await fs.promises.rename(filename, npmTarball);
}));
// npm packages need to be published in reverse dep order, e.g. such that no package
// is published before its dependencies.
const publishOrder = [
...platforms.map(p => p.npmTarball),
mainNativePreviewPackage.npmTarball,
].map(p => path.basename(p));
const publishOrderPath = path.join(builtNpm, "publish-order.txt");
await fs.promises.writeFile(publishOrderPath, publishOrder.join("\n") + "\n");
},
});
export const packNativePreviewExtensions = task({
name: "native-preview:pack-extensions",
hiddenFromTaskList: true,
dependencies: options.forRelease ? undefined : [buildNativePreviewPackages, cleanSignTempDirectory],
run: async () => {
await rimraf(builtVsix);
await fs.promises.mkdir(builtVsix, { recursive: true });
await $({ cwd: extensionDir })`npm run bundle`;
let version = "0.0.0";
if (options.forRelease) {
// No real semver prerelease versioning.
// https://code.visualstudio.com/api/working-with-extensions/publishing-extension#prerelease-extensions
assert(options.setPrerelease, "forRelease is true but setPrerelease is not set");
const prerelease = options.setPrerelease;
assert(typeof prerelease === "string", "setPrerelease is not a string");
// parse `dev.<number>.<number>`.
const match = prerelease.match(/dev\.(\d+)\.(\d+)/);
if (!match) {
throw new Error(`Prerelease version should be in the form of dev.<number>.<number>, but got ${prerelease}`);
}
// Set version to `0.<number>.<number>`.
version = `0.${match[1]}.${match[2]}`;
}
console.log("Version:", version);
const platforms = nativePreviewPlatforms();
const extensions = platforms.flatMap(({ npmDir, extensions }) => extensions.map(e => ({ npmDir, ...e })));
await Promise.all(extensions.map(async ({ npmDir, vscodeTarget, extensionDir: thisExtensionDir, vsixPath, vsixManifestPath, vsixSignaturePath }) => {
const npmLibDir = path.join(npmDir, "lib");
const extensionLibDir = path.join(thisExtensionDir, "lib");
await fs.promises.mkdir(extensionLibDir, { recursive: true });
await cpWithoutNodeModulesOrTsconfig(extensionDir, thisExtensionDir);
await cpWithoutNodeModulesOrTsconfig(npmLibDir, extensionLibDir);
const packageJsonPath = path.join(thisExtensionDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
packageJson.version = version;
packageJson.main = "dist/extension.bundle.js";
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 4));
await fs.promises.copyFile("NOTICE.txt", path.join(thisExtensionDir, "NOTICE.txt"));
await $({ cwd: thisExtensionDir })`vsce package ${version} --no-update-package-json --no-dependencies --out ${vsixPath} --target ${vscodeTarget}`;
if (options.forRelease) {
await $({ cwd: thisExtensionDir })`vsce generate-manifest --packagePath ${vsixPath} --out ${vsixManifestPath}`;
await fs.promises.cp(vsixManifestPath, vsixSignaturePath);
}
}));
},
});
export const signNativePreviewExtensions = task({
name: "native-preview:sign-extensions",
hiddenFromTaskList: true,
run: async () => {
if (!options.forRelease) {
throw new Error("This task should not be run in non-release builds.");
}
const platforms = nativePreviewPlatforms();
const extensions = platforms.flatMap(({ npmDir, extensions }) => extensions.map(e => ({ npmDir, ...e })));
await sign({
SignFileRecordList: [
{
SignFileList: extensions.map(({ vsixSignaturePath }) => ({ SrcPath: vsixSignaturePath, DstPath: null })),
Certs: "VSCodePublisher",
MacAppName: undefined,
},
],
});
},
});
export const nativePreview = task({
name: "native-preview",
hiddenFromTaskList: true,
dependencies: options.forRelease ? undefined : [packNativePreviewPackages, packNativePreviewExtensions],
run: options.forRelease ? async () => {
throw new Error("This task should not be run in release builds.");
} : undefined,
});
export const installExtension = task({
name: "install-extension",
hiddenFromTaskList: true,
dependencies: options.forRelease ? undefined : [packNativePreviewExtensions],
run: async () => {
if (options.forRelease) {
throw new Error("This task should not be run in release builds.");
}
const platforms = nativePreviewPlatforms();
const myPlatform = platforms.find(p => p.nodeOs === process.platform && p.nodeArch === process.arch);
if (!myPlatform) {
throw new Error(`No platform found for ${process.platform}-${process.arch}`);
}
await $`${options.insiders ? "code-insiders" : "code"} --install-extension ${myPlatform.extensions[0].vsixPath}`;
console.log(pc.yellowBright("\nExtension installed. ") + "To enable this extension, set:\n");
console.log(pc.whiteBright(` "typescript.experimental.useTsgo": true\n`));
console.log("To configure the extension to use built/local instead of its bundled tsgo, set:\n");
console.log(pc.whiteBright(` "typescript.native-preview.tsdk": "${path.join(__dirname, "built", "local")}"\n`));
},
});