From 9b6f43fe351aadd043f6d605625c12cd65a30c9f Mon Sep 17 00:00:00 2001 From: userjmmm Date: Mon, 3 Aug 2026 21:07:43 +0900 Subject: [PATCH 01/13] Verify runtime version in `fedify init` Fedify has minimum runtime versions, and some frameworks require even newer ones. `fedify init` did not check this, so it could scaffold a project on a runtime below the minimum, which can then fail at runtime. Verify the selected runtime version before creating any files. The base minimums (Deno 2.0.0, Node.js 22.0.0, Bun 1.1.0) mirror the values declared by `@fedify/fedify` and are defined directly in `rt.json`. Frameworks with stricter requirements, such as Astro's Node.js 22.12, raise the minimum through `minRuntimeVersions`. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/ask/pm.ts | 83 ++++++++++++++--- packages/init/src/const.ts | 3 + packages/init/src/json/rt.json | 27 ++---- packages/init/src/lib.ts | 113 +++++++++++++++++++++++ packages/init/src/types.ts | 16 +++- packages/init/src/webframeworks/astro.ts | 1 + 6 files changed, 209 insertions(+), 34 deletions(-) diff --git a/packages/init/src/ask/pm.ts b/packages/init/src/ask/pm.ts index 342170152..17bd48348 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -2,8 +2,11 @@ import { pipe, when } from "@fxts/core"; import { select } from "@inquirer/prompts"; import { message } from "@optique/core/message"; import { print } from "@optique/run"; +import process from "node:process"; import { PACKAGE_MANAGER } from "../const.ts"; import { + checkAllRuntimes, + checkRuntimeRequirement, getInstallUrl, isPackageManagerAvailable, kvStores, @@ -11,8 +14,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,7 +37,31 @@ const fillPackageManager: // (options: T) => // Promise & { packageManager: PackageManager }> = // async ({ packageManager, ...options }) => { - const pm = packageManager ?? await askPackageManager(options.webFramework); + if (packageManager != null) { + const pm = packageManager; + if (!await isPackageManagerAvailable(pm)) { + noticeInstallUrl(pm); + process.exit(1); + } + const result = await checkRuntimeRequirement( + pmToRt(pm), + webFrameworks[options.webFramework].minRuntimeVersions, + ); + if (result.status === "ok") { + return ({ ...options, packageManager: pm }); + } + if (result.status === "missing") { + printErrorMessage`The runtime for package manager ${pm} is missing.`; + } + if (result.status === "unsupported") { + printErrorMessage`The runtime for package manager ${pm} is unsupported. Detected: ${result.detected}, Required: ${result.required}.`; + } + if (result.status === "malformed") { + printErrorMessage`The detected runtime version for package manager ${pm} is malformed.`; + } + process.exit(1); + } + const pm = await askPackageManager(options.webFramework); if (await isPackageManagerAvailable(pm)) { return ({ ...options, packageManager: pm }); } @@ -38,19 +72,44 @@ const fillPackageManager: // export default fillPackageManager; -const askPackageManager = (wf: WebFramework) => - select({ +const askPackageManager = 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 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 reason = !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` + : ""; + const disabled = !isWfSupportsPm(wf, value) || check.status !== "ok"; + return { + name: disabled ? `${value} (${reason})` : 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.ts b/packages/init/src/lib.ts index ab28a0a76..e848efe1d 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,116 @@ 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`. + */ +export async function checkRuntimeVersion( + { checkCommand, outputPattern, minVersion }: { + checkCommand: [string, ...string[]]; + outputPattern: RegExp; + minVersion: string; + }, +): Promise< + | { status: "ok" | "unsupported"; detected: string; required: string } + | { status: "missing" | "malformed"; detected: null; required: string } +> { + 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. + */ +export 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 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/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 From 497f7980bb4c4933817d8641d4e5f8bc601e4cc9 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Mon, 3 Aug 2026 21:08:59 +0900 Subject: [PATCH 02/13] Add tests for runtime version verification Cover `verifyRuntimeVersion` for versions below, equal to, and above the minimum, and `resolveRequiredVersion` for framework overrides that raise the base minimum. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/lib.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 }); From 718ae8d7a6e1c10ba9f9751e1e23c103c00bc0bb Mon Sep 17 00:00:00 2001 From: userjmmm Date: Mon, 3 Aug 2026 21:19:03 +0900 Subject: [PATCH 03/13] Add changelog entry for runtime version check Assisted-by: Claude Code:claude-opus-4-8 --- CHANGES.md | 7 +++++++ changes.d/init/verify-runtime-version.md | 6 ++++++ 2 files changed, 13 insertions(+) create mode 100644 changes.d/init/verify-runtime-version.md diff --git a/CHANGES.md b/CHANGES.md index 73b65d407..46359f0ea 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] 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,7 @@ 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 ### @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..c3a249c70 --- /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] by Lee Jeongmin] From c48415da80029ff9cba990ad49dd2168d7a102c3 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 5 Aug 2026 16:56:08 +0900 Subject: [PATCH 04/13] Add PR Reference to init changelog --- CHANGES.md | 3 ++- changes.d/init/verify-runtime-version.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 46359f0ea..e64d1f45b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -180,7 +180,7 @@ To be released. 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] by Lee Jeongmin\] + 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`: @@ -192,6 +192,7 @@ 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 index c3a249c70..9ec20ae6d 100644 --- a/changes.d/init/verify-runtime-version.md +++ b/changes.d/init/verify-runtime-version.md @@ -3,4 +3,4 @@ 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] by Lee Jeongmin] + package managers in interactive mode. [[#964], [#981] by Lee Jeongmin] From 7c5e02f4ff6f66c7d23f36fbdca2beb2e7f9918e Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 5 Aug 2026 17:08:57 +0900 Subject: [PATCH 05/13] Remove unnecessary export and reuse RuntimeCheck type --- packages/init/src/lib.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index e848efe1d..bf4bb5ce4 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -212,16 +212,13 @@ export function verifyRuntimeVersion(detected: string, required: string) { * Runs a runtime's version command and classifies the result as `"ok"`, * `"unsupported"`, `"missing"`, or `"malformed"` against its `minVersion`. */ -export async function checkRuntimeVersion( +async function checkRuntimeVersion( { checkCommand, outputPattern, minVersion }: { checkCommand: [string, ...string[]]; outputPattern: RegExp; minVersion: string; }, -): Promise< - | { status: "ok" | "unsupported"; detected: string; required: string } - | { status: "missing" | "malformed"; detected: null; required: string } -> { +): Promise { try { const { stdout } = await $`${checkCommand}`.stdout("piped").spawn(); logger.debug( From 8440cacd73a7a3bf3446e70c8d2e87cc6c6ad3bd Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 5 Aug 2026 18:32:49 +0900 Subject: [PATCH 06/13] Extract and reuse package-manager choices `askPackageManager` recomputed the runtime checks through `checkAllRuntimes` every time it built the prompt. Following the review, that computation was separated into `calculateChoices` to run once. When `fillPackageManager` already has a `packageManager`, it now looks the value up in `choices` instead of calling `checkRuntimeRequirement` again. `checkRuntimeRequirement` is no longer used outside `lib.ts`, so its export was removed. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/ask/pm.ts | 47 ++++++++++++++++--------------------- packages/init/src/lib.ts | 2 +- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/init/src/ask/pm.ts b/packages/init/src/ask/pm.ts index 17bd48348..4fa4de621 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -1,12 +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, - checkRuntimeRequirement, getInstallUrl, isPackageManagerAvailable, kvStores, @@ -37,31 +36,21 @@ const fillPackageManager: // (options: T) => // Promise & { packageManager: PackageManager }> = // async ({ packageManager, ...options }) => { + 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); } - const result = await checkRuntimeRequirement( - pmToRt(pm), - webFrameworks[options.webFramework].minRuntimeVersions, - ); - if (result.status === "ok") { - return ({ ...options, packageManager: pm }); - } - if (result.status === "missing") { - printErrorMessage`The runtime for package manager ${pm} is missing.`; - } - if (result.status === "unsupported") { - printErrorMessage`The runtime for package manager ${pm} is unsupported. Detected: ${result.detected}, Required: ${result.required}.`; - } - if (result.status === "malformed") { - printErrorMessage`The detected runtime version for package manager ${pm} is malformed.`; - } - process.exit(1); + return ({ ...options, packageManager: pm }); } - const pm = await askPackageManager(options.webFramework); + const pm = await askPackageManager(choices); if (await isPackageManagerAvailable(pm)) { return ({ ...options, packageManager: pm }); } @@ -72,7 +61,7 @@ const fillPackageManager: // export default fillPackageManager; -const askPackageManager = async (wf: WebFramework) => { +const calculateChoices = async (wf: WebFramework) => { const runtimeChecks = await checkAllRuntimes( webFrameworks[wf].minRuntimeVersions, ); @@ -83,18 +72,23 @@ const askPackageManager = async (wf: WebFramework) => { }.`; process.exit(1); } - return select({ + return choices; +}; + +const askPackageManager = ( + choices: Awaited>, +) => + select({ message: "Choose the package manager to use", choices, }); -}; const choicePackageManager = (wf: WebFramework, runtimeChecks: Record) => (value: PackageManager) => { const check = runtimeChecks[pmToRt(value)]; const label = runtimes[pmToRt(value)].label; - const reason = !isWfSupportsPm(wf, value) + const disabled = !isWfSupportsPm(wf, value) ? `not supported with ${webFrameworks[wf].label}` : check.status === "unsupported" ? `requires ${label} ${check.required} or later` @@ -103,9 +97,8 @@ const choicePackageManager = : check.status === "malformed" ? `could not detect ${label} version` : ""; - const disabled = !isWfSupportsPm(wf, value) || check.status !== "ok"; - return { - name: disabled ? `${value} (${reason})` : value, + return disabled === "" ? { name: value, value } : { + name: value, value, disabled, }; diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index bf4bb5ce4..4de69f8f6 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -263,7 +263,7 @@ export function resolveRequiredVersion( * Checks whether `runtime` meets its required version, applying framework * `overrides` on top of the base minimum. */ -export function checkRuntimeRequirement( +function checkRuntimeRequirement( runtime: Runtime, overrides: Partial> = {}, ): Promise { From a98e404970115d97d8d2407d784cbb4f8f48825c Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 5 Aug 2026 18:51:37 +0900 Subject: [PATCH 07/13] Use a loop for the package-manager prompt retry When the chosen package manager was not installed, `fillPackageManager` recursed to prompt again, recomputing `calculateChoices` on every retry, so replace the recursion with a loop that reuses the `choices` computed once at the start. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/ask/pm.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/init/src/ask/pm.ts b/packages/init/src/ask/pm.ts index 4fa4de621..b2448afb4 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -50,13 +50,13 @@ const fillPackageManager: // } return ({ ...options, packageManager: pm }); } - const pm = await askPackageManager(choices); - if (await isPackageManagerAvailable(pm)) { - return ({ ...options, packageManager: pm }); + while (true) { + const pm = await askPackageManager(choices); + if (await isPackageManagerAvailable(pm)) { + return ({ ...options, packageManager: pm }); + } + noticeInstallUrl(pm); } - noticeInstallUrl(pm); - return await fillPackageManager(options) as // - typeof options & { packageManager: PackageManager }; }; export default fillPackageManager; From 9063ab2c99111140554d3886e87d415b01b7b5f3 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 12 Aug 2026 23:05:45 +0900 Subject: [PATCH 08/13] Simplify the runtime version check chain `checkRuntimeRequirement` only forwarded to `checkRuntimeVersion`, so it was inlined into `checkAllRuntimes` to make the chain simpler. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/lib.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index 4de69f8f6..ba1a5cab1 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -259,23 +259,10 @@ export function resolveRequiredVersion( : 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. + * identifier to its version-check result, applying framework `overrides` on + * top of each runtime's base minimum. */ export async function checkAllRuntimes( overrides: Partial> = {}, @@ -284,7 +271,13 @@ export async function checkAllRuntimes( > { const checked = await Promise.all( RUNTIME.map(async (runtime) => - [runtime, await checkRuntimeRequirement(runtime, overrides)] as const + [ + runtime, + await checkRuntimeVersion({ + ...runtimes[runtime], + minVersion: resolveRequiredVersion(runtime, overrides[runtime]), + }), + ] as const ), ); return Object.fromEntries(checked) as Record; From 4274475b12fe99f200af7d663c703e41bf2db1e7 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Thu, 13 Aug 2026 00:29:38 +0900 Subject: [PATCH 09/13] Remove redundant package-manager checks `choices` recorded each runtime's version but not whether a package manager's binary was installed, so `fillPackageManager` checked the binary separately and looped to retry. Move that check into `choicePackageManager` so `choices` also reflects binary availability. `fillPackageManager` then trusts `choices` and drops the re-check, the retry `while` loop, and the `pm` alias. A disabled choice falls back to `askPackageManager` instead of exiting. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/ask/pm.ts | 69 +++++++++++++------------------------ 1 file changed, 23 insertions(+), 46 deletions(-) diff --git a/packages/init/src/ask/pm.ts b/packages/init/src/ask/pm.ts index b2448afb4..622c9bfba 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -1,4 +1,3 @@ -import { pipe, when } from "@fxts/core"; import { select } from "@inquirer/prompts"; import { message, optionName, text } from "@optique/core/message"; import { print } from "@optique/run"; @@ -8,9 +7,7 @@ import { checkAllRuntimes, getInstallUrl, isPackageManagerAvailable, - kvStores, - messageQueues, - packageManagers, + isTest, runtimes, } from "../lib.ts"; import type { @@ -25,38 +22,34 @@ 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 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); + const choice = choices.find(({ value }) => value === packageManager)!; + if (choice.disabled == null) { + return { ...options, packageManager }; } - if (!await isPackageManagerAvailable(pm)) { - noticeInstallUrl(pm); - process.exit(1); - } - return ({ ...options, packageManager: pm }); - } - while (true) { - const pm = await askPackageManager(choices); - if (await isPackageManagerAvailable(pm)) { - return ({ ...options, packageManager: pm }); - } - noticeInstallUrl(pm); + print(message`${optionName(choice.name)} ${text(choice.disabled)}`); + if (isTest(options)) process.exit(1); } + return { ...options, packageManager: await askPackageManager(choices) }; }; export default fillPackageManager; @@ -65,7 +58,9 @@ const calculateChoices = async (wf: WebFramework) => { const runtimeChecks = await checkAllRuntimes( webFrameworks[wf].minRuntimeVersions, ); - const choices = PACKAGE_MANAGER.map(choicePackageManager(wf, runtimeChecks)); + 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 @@ -85,7 +80,7 @@ const askPackageManager = ( const choicePackageManager = (wf: WebFramework, runtimeChecks: Record) => - (value: PackageManager) => { + async (value: PackageManager) => { const check = runtimeChecks[pmToRt(value)]; const label = runtimes[pmToRt(value)].label; const disabled = !isWfSupportsPm(wf, value) @@ -96,6 +91,8 @@ const choicePackageManager = ? `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, @@ -108,23 +105,3 @@ 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); From 759954e2da70ace4d5936351131424dcb3ea3289 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Thu, 13 Aug 2026 17:16:55 +0900 Subject: [PATCH 10/13] Show detected version in disabled PM message --- packages/init/src/ask/pm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/init/src/ask/pm.ts b/packages/init/src/ask/pm.ts index 622c9bfba..78d901828 100644 --- a/packages/init/src/ask/pm.ts +++ b/packages/init/src/ask/pm.ts @@ -86,7 +86,7 @@ const choicePackageManager = const disabled = !isWfSupportsPm(wf, value) ? `not supported with ${webFrameworks[wf].label}` : check.status === "unsupported" - ? `requires ${label} ${check.required} or later` + ? `requires ${label} ${check.required} or later (detected: ${check.detected})` : check.status === "missing" ? `requires ${label} which is not installed` : check.status === "malformed" From 10567c1aaafbb52cb645c4e0106ea5981d1f8e35 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Thu, 13 Aug 2026 17:50:05 +0900 Subject: [PATCH 11/13] Catch dax's missing command in isNotFoundError When a command is not installed, dax throws a ShellError with exit code 127. `isNotFoundError` only checked `ENOENT`, so it missed the error and init crashed. Recognize exit code 127 as well. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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. From 01775ec647e2dfe5cac422b010f7b3321debd6d9 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Thu, 13 Aug 2026 18:16:33 +0900 Subject: [PATCH 12/13] Make `outputPattern` match pre-release versions The `outputPattern` for bun and node was anchored to the end of the string with `$`, so it only matched a bare `major.minor.patch`. That missed pre-release versions such as bun's `1.2.14-canary.96` or Node.js nightly builds. Replace the trailing `$` with a word boundary (`\b`) so the pattern still captures the `major.minor.patch` core while allowing a pre-release or build-metadata suffix to follow. Assisted-by: Claude Code:claude-opus-4-8 --- packages/init/src/json/rt.json | 4 ++-- packages/init/src/lib.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/init/src/json/rt.json b/packages/init/src/json/rt.json index 7400a8e5a..7d805efb5 100644 --- a/packages/init/src/json/rt.json +++ b/packages/init/src/json/rt.json @@ -14,7 +14,7 @@ "bun", "--version" ], - "outputPattern": "^(\\d+\\.\\d+\\.\\d+$)", + "outputPattern": "^(\\d+\\.\\d+\\.\\d+\\b)", "minVersion": "1.1.0" }, "node": { @@ -23,7 +23,7 @@ "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 b97bd1c6f..5ca2a1c62 100644 --- a/packages/init/src/lib.test.ts +++ b/packages/init/src/lib.test.ts @@ -6,6 +6,7 @@ import test from "node:test"; import { isDirectoryEmpty, resolveRequiredVersion, + runtimes, verifyRuntimeVersion, } from "./lib.ts"; import { runSubCommand } from "./utils.ts"; @@ -176,6 +177,30 @@ test("resolveRequiredVersion raises the base minimum for stricter frameworks", ( 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 }); From 766524ba9972fc9f25924b355c32fd28c2f687dc Mon Sep 17 00:00:00 2001 From: userjmmm Date: Thu, 13 Aug 2026 18:25:50 +0900 Subject: [PATCH 13/13] Add links to verify-runtime-version.md --- CHANGES.md | 2 +- changes.d/init/verify-runtime-version.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index e64d1f45b..f4ff61c7a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -192,7 +192,7 @@ 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 +[#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 index 9ec20ae6d..de73aff1a 100644 --- a/changes.d/init/verify-runtime-version.md +++ b/changes.d/init/verify-runtime-version.md @@ -1,3 +1,8 @@ +--- +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