Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: verify @types/node version with registry #32

Merged
merged 2 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/create-app/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Copyright 2024 Bloomberg Finance L.P.
// Distributed under the terms of the Apache 2.0 license.
import { buildApplication, buildCommand } from "@stricli/core";
import { buildApplication, buildCommand, numberParser } from "@stricli/core";
import packageJson from "../package.json";

const command = buildCommand({
/* c8 ignore next */
loader: async () => import("./impl"),
parameters: {
positional: {
Expand Down Expand Up @@ -66,6 +67,13 @@ const command = buildCommand({
parse: String,
default: "",
},
nodeVersion: {
kind: "parsed",
brief: "Node.js major version to use for engines.node minimum and @types/node, bypasses version discovery logic",
parse: numberParser,
optional: true,
hidden: true,
},
},
aliases: {
n: "name",
Expand Down
21 changes: 14 additions & 7 deletions packages/create-app/src/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Distributed under the terms of the Apache 2.0 license.
import type { PackageJson, TsConfigJson } from "type-fest";
import self from "../package.json";
import type { LocalContext } from "./context";
import {
binBashCompleteModuleText,
binBashCompleteScriptText,
Expand All @@ -19,8 +20,8 @@ import {
singleCommandAppText,
singleCommandImplText,
} from "./files";
import { calculateAcceptableNodeVersions, type NodeVersions } from "./node";
import srcTsconfig from "./tsconfig.json";
import type { LocalContext } from "./context";

interface TsupConfig {
entry?: string[];
Expand All @@ -45,7 +46,7 @@ type PackageJsonTemplateValues = Pick<PackageJson.PackageJsonStandard, "name"> &
function buildPackageJson(
values: PackageJsonTemplateValues,
commandName: string,
nodeMajorVersion: number,
nodeVersions: NodeVersions,
): LocalPackageJson {
return {
...values,
Expand All @@ -55,7 +56,7 @@ function buildPackageJson(
[commandName]: "dist/cli.js",
},
engines: {
node: `>=${nodeMajorVersion}`,
node: nodeVersions.engine,
},
scripts: {
prebuild: "tsc -p src/tsconfig.json",
Expand All @@ -74,7 +75,7 @@ function buildPackageJson(
"@stricli/core": self.dependencies["@stricli/core"],
},
devDependencies: {
"@types/node": `${nodeMajorVersion}.x`,
"@types/node": nodeVersions.types,
tsup: self.devDependencies.tsup,
typescript: self.devDependencies.typescript,
},
Expand Down Expand Up @@ -114,6 +115,7 @@ export interface CreateProjectFlags extends PackageJsonTemplateValues {
readonly template: "single" | "multi";
readonly autoComplete: boolean;
readonly command?: string;
readonly nodeVersion?: number;
}

export default async function (this: LocalContext, flags: CreateProjectFlags, directoryPath: string): Promise<void> {
Expand All @@ -135,8 +137,13 @@ export default async function (this: LocalContext, flags: CreateProjectFlags, di

const packageName = flags.name ?? path.basename(directoryPath);
const commandName = flags.command ?? packageName;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const nodeMajorVersion = this.process.versions.node.split(".").map(Number)[0]!;

let nodeVersions: NodeVersions;
if (flags.nodeVersion) {
nodeVersions = { engine: `>=${flags.nodeVersion}`, types: `${flags.nodeVersion}.x` };
} else {
nodeVersions = await calculateAcceptableNodeVersions(this.process);
}

let packageJson = buildPackageJson(
{
Expand All @@ -147,7 +154,7 @@ export default async function (this: LocalContext, flags: CreateProjectFlags, di
type: flags.type,
},
commandName,
nodeMajorVersion,
nodeVersions,
);

const bashCommandName = calculateBashCompletionCommand(commandName);
Expand Down
64 changes: 64 additions & 0 deletions packages/create-app/src/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2024 Bloomberg Finance L.P.
// Distributed under the terms of the Apache 2.0 license.
import { discoverPackageRegistry, fetchPackageVersions } from "./registry";

export interface NodeVersions {
readonly engine: string;
readonly types: string;
}

const MAXIMUM_KNOWN_SAFE_NODE_TYPES_VERSION = 22;

export async function calculateAcceptableNodeVersions(process: NodeJS.Process): Promise<NodeVersions> {
const majorVersion = Number(process.versions.node.split(".")[0]);
let typesVersion: string | undefined;

if (majorVersion > MAXIMUM_KNOWN_SAFE_NODE_TYPES_VERSION) {
// To avoid hitting the registry every time, only run when higher than a statically-known maximum safe value.
const registry = discoverPackageRegistry(process);
const versions = registry && (await fetchPackageVersions(registry, "@types/node"));
if (versions?.includes(process.versions.node)) {
typesVersion = `^${process.versions.node}`;
} else if (versions) {
const typeMajors = new Set(versions.map((version) => Number(version.split(".")[0])));
if (typeMajors.has(majorVersion)) {
// Previously unknown major version exists, which means MAXIMUM_KNOWN_SAFE_NODE_TYPES_VERSION should be updated.
typesVersion = `${majorVersion}.x`;
} else {
// Filter available major versions to just even (LTS) and pick highest.
// This assumes that types will exist for the LTS version just prior to the current unknown major version.
const highestEvenTypeMajor = [...typeMajors]
.filter((major) => major % 2 === 0)
.toSorted()
.at(-1);
if (highestEvenTypeMajor) {
typesVersion = `${highestEvenTypeMajor}.x`;
process.stderr.write(
`No version of @types/node found with major ${majorVersion}, falling back to ${typesVersion}\n`,
);
process.stderr.write(
`Rerun this command with the hidden flag --node-version to manually specify the Node.js major version`,
);
}
}
}
} else {
typesVersion = `${majorVersion}.x`;
}

if (!typesVersion) {
typesVersion = `${majorVersion}.x`;
// Should only be hit if something went wrong determining registry URL or fetching from registry.
process.stderr.write(
`Unable to determine version of @types/node for ${process.versions.node}, assuming ${typesVersion}\n`,
);
process.stderr.write(
`Rerun this command with the hidden flag --node-version to manually specify the Node.js major version`,
);
}

return {
engine: `>=${majorVersion}`,
types: typesVersion,
};
}
29 changes: 29 additions & 0 deletions packages/create-app/src/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2024 Bloomberg Finance L.P.
// Distributed under the terms of the Apache 2.0 license.
import child_process from "node:child_process";

export function discoverPackageRegistry(process: NodeJS.Process): string | undefined {
if (process.env["NPM_CONFIG_REGISTRY"]) {
return process.env["NPM_CONFIG_REGISTRY"];
}

if (process.env["NPM_EXECPATH"]) {
return child_process
.execFileSync(process.execPath, [process.env["NPM_EXECPATH"], "config", "get", "registry"], {
encoding: "utf-8",
})
.trim();
}
}

export async function fetchPackageVersions(
registry: string,
packageName: string,
): Promise<readonly string[] | undefined> {
const input = registry + (registry.endsWith("/") ? packageName : `/${packageName}`);
const response = await fetch(input);
const data = await response.json();
if (typeof data === "object" && data && "versions" in data && typeof data.versions === "object") {
return Object.keys(data.versions ?? {});
}
}
Loading