diff --git a/CHANGES.md b/CHANGES.md index 73b65d407..e64d1f45b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -175,6 +175,12 @@ To be released. ### @fedify/init + - Added runtime version verification to `fedify init`. It checks that the + selected Deno, Bun, or Node.js meets Fedify's minimum version, or a higher + version required by a framework (such as Astro's Node.js 22.12), before + generating a project. A missing, malformed, or unsupported runtime now + produces a clear error in non-interactive mode and disables the affected + package managers in interactive mode. [[#964], [#981] by Lee Jeongmin\] - Fixed `fedify init`'s hydration test validation to run `format` before `format:check`, which previously caused the entire test suite to fail when the package manager is `npm` or `pnpm`: @@ -185,6 +191,8 @@ To be released. [#950]: https://github.com/fedify-dev/fedify/issues/950 [#952]: https://github.com/fedify-dev/fedify/pull/952 +[#964]: https://github.com/fedify-dev/fedify/issues/964 +[#981]: https://github.com/fedify-dev/fedify/issues/981 ### @fedify/interaction-controls diff --git a/changes.d/init/verify-runtime-version.md b/changes.d/init/verify-runtime-version.md new file mode 100644 index 000000000..9ec20ae6d --- /dev/null +++ b/changes.d/init/verify-runtime-version.md @@ -0,0 +1,6 @@ + - Added runtime version verification to `fedify init`. It checks that the + selected Deno, Bun, or Node.js meets Fedify's minimum version, or a higher + version required by a framework (such as Astro's Node.js 22.12), before + generating a project. A missing, malformed, or unsupported runtime now + produces a clear error in non-interactive mode and disables the affected + package managers in interactive mode. [[#964], [#981] by Lee Jeongmin] diff --git a/packages/init/src/ask/pm.ts b/packages/init/src/ask/pm.ts index 342170152..b2448afb4 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -1,9 +1,11 @@ import { pipe, when } from "@fxts/core"; import { select } from "@inquirer/prompts"; -import { message } from "@optique/core/message"; +import { message, optionName, text } from "@optique/core/message"; import { print } from "@optique/run"; +import process from "node:process"; import { PACKAGE_MANAGER } from "../const.ts"; import { + checkAllRuntimes, getInstallUrl, isPackageManagerAvailable, kvStores, @@ -11,8 +13,15 @@ import { packageManagers, runtimes, } from "../lib.ts"; -import type { PackageManager, WebFramework } from "../types.ts"; +import type { + PackageManager, + Runtime, + RuntimeCheck, + WebFramework, +} from "../types.ts"; +import { printErrorMessage } from "../utils.ts"; import webFrameworks from "../webframeworks/mod.ts"; +import { pmToRt } from "../webframeworks/utils.ts"; /** * Fills in the package manager by prompting the user if not provided. @@ -27,30 +36,73 @@ const fillPackageManager: // (options: T) => // Promise & { packageManager: PackageManager }> = // async ({ packageManager, ...options }) => { - const pm = packageManager ?? await askPackageManager(options.webFramework); - if (await isPackageManagerAvailable(pm)) { + const choices = await calculateChoices(options.webFramework); + if (packageManager != null) { + const pm = packageManager; + const choice = choices.find(({ value }) => value === pm)!; + if (choice.disabled != null) { + print(message`${optionName(choice.name)} ${text(choice.disabled)}`); + process.exit(1); + } + if (!await isPackageManagerAvailable(pm)) { + noticeInstallUrl(pm); + process.exit(1); + } return ({ ...options, packageManager: pm }); } - noticeInstallUrl(pm); - return await fillPackageManager(options) as // - typeof options & { packageManager: PackageManager }; + while (true) { + const pm = await askPackageManager(choices); + if (await isPackageManagerAvailable(pm)) { + return ({ ...options, packageManager: pm }); + } + noticeInstallUrl(pm); + } }; export default fillPackageManager; -const askPackageManager = (wf: WebFramework) => +const calculateChoices = async (wf: WebFramework) => { + const runtimeChecks = await checkAllRuntimes( + webFrameworks[wf].minRuntimeVersions, + ); + const choices = PACKAGE_MANAGER.map(choicePackageManager(wf, runtimeChecks)); + if (choices.every((choice) => choice.disabled)) { + printErrorMessage`No package manager with a supported runtime is available for ${ + webFrameworks[wf].label + }.`; + process.exit(1); + } + return choices; +}; + +const askPackageManager = ( + choices: Awaited>, +) => select({ message: "Choose the package manager to use", - choices: PACKAGE_MANAGER.map(choicePackageManager(wf)), + choices, }); -const choicePackageManager = (wf: WebFramework) => (value: PackageManager) => ({ - name: isWfSupportsPm(wf, value) - ? value - : `${value} (not supported with ${webFrameworks[wf].label})`, - value, - disabled: !isWfSupportsPm(wf, value), -}); +const choicePackageManager = + (wf: WebFramework, runtimeChecks: Record) => + (value: PackageManager) => { + const check = runtimeChecks[pmToRt(value)]; + const label = runtimes[pmToRt(value)].label; + const disabled = !isWfSupportsPm(wf, value) + ? `not supported with ${webFrameworks[wf].label}` + : check.status === "unsupported" + ? `requires ${label} ${check.required} or later` + : check.status === "missing" + ? `requires ${label} which is not installed` + : check.status === "malformed" + ? `could not detect ${label} version` + : ""; + return disabled === "" ? { name: value, value } : { + name: value, + value, + disabled, + }; + }; const isWfSupportsPm = ( wf: WebFramework, diff --git a/packages/init/src/const.ts b/packages/init/src/const.ts index af5cc2e71..a8b889915 100644 --- a/packages/init/src/const.ts +++ b/packages/init/src/const.ts @@ -1,5 +1,6 @@ import kv from "./json/kv.json" with { type: "json" }; import mq from "./json/mq.json" with { type: "json" }; +import rt from "./json/rt.json" with { type: "json" }; /** All supported package manager identifiers, in display order. */ export const PACKAGE_MANAGER = ["deno", "pnpm", "bun", "yarn", "npm"] as const; @@ -23,6 +24,8 @@ export const MESSAGE_QUEUE = Object.keys(mq) as readonly (keyof typeof mq)[]; /** All supported key-value store backend identifiers. */ export const KV_STORE = Object.keys(kv) as readonly (keyof typeof kv)[]; +/** All supported runtime identifiers. */ +export const RUNTIME = Object.keys(rt) as readonly (keyof typeof rt)[]; /** * External database services that need to be running for integration tests. * Used by the test suite to check service availability before running tests. diff --git a/packages/init/src/json/rt.json b/packages/init/src/json/rt.json index 68d546d72..7400a8e5a 100644 --- a/packages/init/src/json/rt.json +++ b/packages/init/src/json/rt.json @@ -5,7 +5,8 @@ "deno", "--version" ], - "outputPattern": "^deno\\s+\\d+\\.\\d+\\.\\d+\\b" + "outputPattern": "^deno\\s+(\\d+\\.\\d+\\.\\d+\\b)", + "minVersion": "2.0.0" }, "bun": { "label": "Bun", @@ -13,30 +14,16 @@ "bun", "--version" ], - "outputPattern": "^\\d+\\.\\d+\\.\\d+$" + "outputPattern": "^(\\d+\\.\\d+\\.\\d+$)", + "minVersion": "1.1.0" }, - "pnpm": { + "node": { "label": "Node.js", "checkCommand": [ "node", "--version" ], - "outputPattern": "^v\\d+\\.\\d+\\.\\d+$" - }, - "yarn": { - "label": "Node.js", - "checkCommand": [ - "node", - "--version" - ], - "outputPattern": "^v\\d+\\.\\d+\\.\\d+$" - }, - "npm": { - "label": "Node.js", - "checkCommand": [ - "node", - "--version" - ], - "outputPattern": "^v\\d+\\.\\d+\\.\\d+$" + "outputPattern": "^v(\\d+\\.\\d+\\.\\d+$)", + "minVersion": "22.0.0" } } diff --git a/packages/init/src/lib.test.ts b/packages/init/src/lib.test.ts index 4c1f40673..b97bd1c6f 100644 --- a/packages/init/src/lib.test.ts +++ b/packages/init/src/lib.test.ts @@ -3,7 +3,11 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { isDirectoryEmpty } from "./lib.ts"; +import { + isDirectoryEmpty, + resolveRequiredVersion, + verifyRuntimeVersion, +} from "./lib.ts"; import { runSubCommand } from "./utils.ts"; test("isDirectoryEmpty allows an unborn Git repository", async () => { @@ -149,6 +153,29 @@ test("isDirectoryEmpty rejects a .git file", async () => { }); }); +test("verifyRuntimeVersion accepts equal versions", () => { + strictEqual(verifyRuntimeVersion("2.0.0", "2.0.0"), true); + strictEqual(verifyRuntimeVersion("2", "2.0.0"), true); +}); + +test("verifyRuntimeVersion accepts higher versions", () => { + strictEqual(verifyRuntimeVersion("2.0.1", "2.0"), true); + strictEqual(verifyRuntimeVersion("2.1.0", "2.0.0"), true); + strictEqual(verifyRuntimeVersion("3.0.0", "2.0.0"), true); +}); + +test("verifyRuntimeVersion rejects lower versions", () => { + strictEqual(verifyRuntimeVersion("1.9.9", "2.0.0"), false); + strictEqual(verifyRuntimeVersion("2.0.1", "2.1.0"), false); + strictEqual(verifyRuntimeVersion("2.0.0", "2.0.1"), false); +}); + +test("resolveRequiredVersion raises the base minimum for stricter frameworks", () => { + strictEqual(resolveRequiredVersion("node", "22.12.0"), "22.12.0"); + strictEqual(resolveRequiredVersion("node", "21.0.0"), "22.0.0"); + strictEqual(resolveRequiredVersion("node", undefined), "22.0.0"); +}); + async function createUnbornGitRepository(dir: string): Promise { await mkdir(join(dir, ".git", "objects"), { recursive: true }); await mkdir(join(dir, ".git", "refs", "heads"), { recursive: true }); diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index ab28a0a76..4de69f8f6 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -16,6 +16,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, join as joinPath } from "node:path"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; +import { RUNTIME } from "./const.ts"; import kv from "./json/kv.json" with { type: "json" }; import mq from "./json/mq.json" with { type: "json" }; import pm from "./json/pm.json" with { type: "json" }; @@ -25,6 +26,8 @@ import type { MessageQueues, PackageManager, PackageManagers, + Runtime, + RuntimeCheck, Runtimes, } from "./types.ts"; import { CommandError, isNotFoundError, runSubCommand } from "./utils.ts"; @@ -180,6 +183,113 @@ async function isCommandAvailable( } } +/** + * Compares two dotted version strings segment by segment and returns whether + * `detected` is higher than or equal to `required`. + */ +export function verifyRuntimeVersion(detected: string, required: string) { + const detectedParts = detected.split(".").map(Number); + const requiredParts = required.split(".").map(Number); + + for ( + let i = 0; + i < Math.max(detectedParts.length, requiredParts.length); + i++ + ) { + const detectedPart = detectedParts[i] ?? 0; + const requiredPart = requiredParts[i] ?? 0; + if (detectedPart > requiredPart) { + return true; + } + if (detectedPart < requiredPart) { + return false; + } + } + return true; +} + +/** + * Runs a runtime's version command and classifies the result as `"ok"`, + * `"unsupported"`, `"missing"`, or `"malformed"` against its `minVersion`. + */ +async function checkRuntimeVersion( + { checkCommand, outputPattern, minVersion }: { + checkCommand: [string, ...string[]]; + outputPattern: RegExp; + minVersion: string; + }, +): Promise { + try { + const { stdout } = await $`${checkCommand}`.stdout("piped").spawn(); + logger.debug( + "The stdout of the command {command} is: {stdout}", + { command: checkCommand, stdout }, + ); + const detected = outputPattern.exec(stdout.trim())?.[1] ?? null; + if (detected == null) { + return { status: "malformed", detected: null, required: minVersion }; + } + if (!verifyRuntimeVersion(detected, minVersion)) { + return { status: "unsupported", detected, required: minVersion }; + } + return { status: "ok", detected, required: minVersion }; + } catch (error) { + if (isNotFoundError(error)) { + return { status: "missing", detected: null, required: minVersion }; + } + logger.debug( + "The command {command} failed with the error: {error}", + { command: checkCommand, error }, + ); + throw error; + } +} + +/** + * Resolves the required version for `runtime` as the higher of its base minimum + * and an optional framework `override`. + */ +export function resolveRequiredVersion( + runtime: Runtime, + override?: string, +): string { + const base = runtimes[runtime].minVersion; + return override != null && verifyRuntimeVersion(override, base) + ? override + : base; +} + +/** + * Checks whether `runtime` meets its required version, applying framework + * `overrides` on top of the base minimum. + */ +function checkRuntimeRequirement( + runtime: Runtime, + overrides: Partial> = {}, +): Promise { + return checkRuntimeVersion({ + ...runtimes[runtime], + minVersion: resolveRequiredVersion(runtime, overrides[runtime]), + }); +} + +/** + * Checks every supported runtime once and returns a map from each runtime + * identifier to its {@link checkRuntimeRequirement} result. + */ +export async function checkAllRuntimes( + overrides: Partial> = {}, +): Promise< + Record +> { + const checked = await Promise.all( + RUNTIME.map(async (runtime) => + [runtime, await checkRuntimeRequirement(runtime, overrides)] as const + ), + ); + return Object.fromEntries(checked) as Record; +} + /** * Creates a file at the given path with the given content, creating * any necessary parent directories along the way. diff --git a/packages/init/src/types.ts b/packages/init/src/types.ts index b750bd76a..3d0fef00b 100644 --- a/packages/init/src/types.ts +++ b/packages/init/src/types.ts @@ -4,10 +4,14 @@ import type { KV_STORE, MESSAGE_QUEUE, PACKAGE_MANAGER, + RUNTIME, WEB_FRAMEWORK, } from "./const.ts"; import type { RequiredNotNull } from "./utils.ts"; +/** Supported runtime identifiers: `"deno"`, `"bun"`, `"node"`. */ +export type Runtime = typeof RUNTIME[number]; + /** Supported package manager identifiers: `"deno"`, `"pnpm"`, `"bun"`, `"yarn"`, `"npm"`. */ export type PackageManager = typeof PACKAGE_MANAGER[number]; @@ -32,8 +36,13 @@ export type WebFrameworks = Record; /** A mapping from each {@link PackageManager} identifier to its description. */ export type PackageManagers = Record; -/** A mapping from each {@link PackageManager} identifier to its runtime description. */ -export type Runtimes = Record; +/** A mapping from each {@link Runtime} identifier to its description. */ +export type Runtimes = Record; + +/** The result of checking a runtime's installed version against its minimum. */ +export type RuntimeCheck = + | { status: "ok" | "unsupported"; detected: string; required: string } + | { status: "missing" | "malformed"; detected: null; required: string }; /** * Describes a JavaScript runtime (Deno, Node.js, or Bun) and how to check @@ -45,6 +54,7 @@ export interface RuntimeDescription { checkCommand: [string, ...string[]]; /** Regex to match against the command's stdout to confirm the runtime is installed. */ outputPattern: RegExp; + minVersion: string; } /** @@ -122,6 +132,8 @@ export interface WebFrameworkDescription { packageManagers: readonly PackageManager[]; /** Default port for the development server. */ defaultPort: number; + /** Minimum runtime versions this framework requires, if higher than Fedify's baseline. */ + minRuntimeVersions?: Partial>; /** * Factory function that returns the initializer configuration for this * framework, given the user's selected options. diff --git a/packages/init/src/webframeworks/astro.ts b/packages/init/src/webframeworks/astro.ts index 1d6933a98..a9d5d20b5 100644 --- a/packages/init/src/webframeworks/astro.ts +++ b/packages/init/src/webframeworks/astro.ts @@ -32,6 +32,7 @@ const astroDescription: WebFrameworkDescription = { label: "Astro", packageManagers: PACKAGE_MANAGER, defaultPort: 4321, + minRuntimeVersions: { node: "22.12.0" }, init: async ({ packageManager: pm }) => { // Astro loads integrations and middleware through Vite. Vite resolves // bare imports from node_modules rather than Deno's JSR import map, so