diff --git a/CHANGES.md b/CHANGES.md index 73b65d407..f4ff61c7a 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/pull/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..de73aff1a --- /dev/null +++ b/changes.d/init/verify-runtime-version.md @@ -0,0 +1,11 @@ +--- +links: + '#964': https://github.com/fedify-dev/fedify/issues/964 + '#981': https://github.com/fedify-dev/fedify/pull/981 +--- + - 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..78d901828 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -1,78 +1,107 @@ -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, - messageQueues, - packageManagers, + isTest, 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. - * Ensures the selected package manager is compatible with the chosen web framework. - * If the selected package manager is not installed, informs the user and prompts again. + * Ensures the selected package manager is compatible with the chosen web + * framework and installed on the system. When an explicitly requested package + * manager is unavailable, informs the user and prompts again. * * @param options - Initialization options possibly containing a packageManager and webFramework * @returns A promise resolving to options with a guaranteed packageManager */ const fillPackageManager: // - // + < + T extends { + packageManager?: PackageManager; + webFramework: WebFramework; + testMode: boolean; + }, + > // (options: T) => // Promise & { packageManager: PackageManager }> = // async ({ packageManager, ...options }) => { - const pm = packageManager ?? await askPackageManager(options.webFramework); - if (await isPackageManagerAvailable(pm)) { - return ({ ...options, packageManager: pm }); + const choices = await calculateChoices(options.webFramework); + if (packageManager != null) { + const choice = choices.find(({ value }) => value === packageManager)!; + if (choice.disabled == null) { + return { ...options, packageManager }; + } + print(message`${optionName(choice.name)} ${text(choice.disabled)}`); + if (isTest(options)) process.exit(1); } - noticeInstallUrl(pm); - return await fillPackageManager(options) as // - typeof options & { packageManager: PackageManager }; + return { ...options, packageManager: await askPackageManager(choices) }; }; export default fillPackageManager; -const askPackageManager = (wf: WebFramework) => +const calculateChoices = async (wf: WebFramework) => { + const runtimeChecks = await checkAllRuntimes( + webFrameworks[wf].minRuntimeVersions, + ); + const choices = await Promise.all( + 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) => + async (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 (detected: ${check.detected})` + : check.status === "missing" + ? `requires ${label} which is not installed` + : check.status === "malformed" + ? `could not detect ${label} version` + : pmToRt(value) === "node" && !await isPackageManagerAvailable(value) + ? `is not installed; install it from ${getInstallUrl(value)}` + : ""; + return disabled === "" ? { name: value, value } : { + name: value, + value, + disabled, + }; + }; const isWfSupportsPm = ( wf: WebFramework, pm: PackageManager, ) => webFrameworks[wf].packageManagers.includes(pm); - -const noticeInstallUrl = (pm: PackageManager) => { - const label = getLabel(pm); - const url = getInstallUrl(pm); - print(message` Package manager ${label} is not installed.`); - print(message` You can install it from following link: ${url}`); - print(message` or choose another package manager:`); -}; - -const getLabel = (name: string) => - pipe( - name, - whenHasLabel(webFrameworks), - whenHasLabel(packageManagers), - whenHasLabel(messageQueues), - whenHasLabel(kvStores), - whenHasLabel(runtimes), - ); -const whenHasLabel = >(desc: T) => - when((name: string) => name in desc, (name) => desc[name as keyof T].label); 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..7d805efb5 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+\\b)", + "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+\\b)", + "minVersion": "22.0.0" } } diff --git a/packages/init/src/lib.test.ts b/packages/init/src/lib.test.ts index 4c1f40673..5ca2a1c62 100644 --- a/packages/init/src/lib.test.ts +++ b/packages/init/src/lib.test.ts @@ -3,7 +3,12 @@ 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, + runtimes, + verifyRuntimeVersion, +} from "./lib.ts"; import { runSubCommand } from "./utils.ts"; test("isDirectoryEmpty allows an unborn Git repository", async () => { @@ -149,6 +154,53 @@ 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"); +}); + +test("deno outputPattern extracts stable and pre-release versions", () => { + strictEqual(runtimes.deno.outputPattern.exec("deno 2.8.3")?.[1], "2.8.3"); + strictEqual( + runtimes.deno.outputPattern.exec("deno 2.8.3+e5f6a7b")?.[1], + "2.8.3", + ); +}); + +test("bun outputPattern extracts stable and pre-release versions", () => { + strictEqual(runtimes.bun.outputPattern.exec("1.1.0")?.[1], "1.1.0"); + strictEqual( + runtimes.bun.outputPattern.exec("1.2.14-canary.96")?.[1], + "1.2.14", + ); +}); + +test("node outputPattern extracts stable and pre-release versions", () => { + strictEqual(runtimes.node.outputPattern.exec("v22.23.1")?.[1], "22.23.1"); + strictEqual( + runtimes.node.outputPattern.exec("v24.0.0-nightly20250412795dd8eb79")?.[1], + "24.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..ba1a5cab1 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,106 @@ 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 every supported runtime once and returns a map from each runtime + * identifier to its version-check result, applying framework `overrides` on + * top of each runtime's base minimum. + */ +export async function checkAllRuntimes( + overrides: Partial> = {}, +): Promise< + Record +> { + const checked = await Promise.all( + RUNTIME.map(async (runtime) => + [ + runtime, + await checkRuntimeVersion({ + ...runtimes[runtime], + minVersion: resolveRequiredVersion(runtime, overrides[runtime]), + }), + ] 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 8ca76ef64..883d9f29d 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/utils.ts b/packages/init/src/utils.ts index 9af00d263..2a5f17193 100644 --- a/packages/init/src/utils.ts +++ b/packages/init/src/utils.ts @@ -116,8 +116,12 @@ export const notEmpty = (s: T) => s.length > 0; /** Type guard that checks whether an error is a "file not found" (`ENOENT`) error. */ -export const isNotFoundError = (e: unknown): e is { code: "ENOENT" } => - isObject(e) && "code" in e && e.code === "ENOENT"; +export const isNotFoundError = ( + e: unknown, +): e is { code: "ENOENT" } | { exitCode: 127 } => + isObject(e) && + (("code" in e && e.code === "ENOENT") || + ("exitCode" in e && e.exitCode === 127)); /** * Error thrown when a spawned shell command exits with a non-zero code. 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