Skip to content
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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions changes.d/init/verify-runtime-version.md
Comment thread
userjmmm marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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]
117 changes: 73 additions & 44 deletions packages/init/src/ask/pm.ts
Original file line number Diff line number Diff line change
@@ -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 }> //
<
T extends {
packageManager?: PackageManager;
webFramework: WebFramework;
testMode: boolean;
},
> //
(options: T) => //
Promise<Omit<T, "packageManager"> & { 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) };
Comment thread
2chanhaeng marked this conversation as resolved.
};

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);
}
Comment thread
userjmmm marked this conversation as resolved.
return choices;
};

const askPackageManager = (
choices: Awaited<ReturnType<typeof calculateChoices>>,
) =>
select<PackageManager>({
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<Runtime, RuntimeCheck>) =>
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 = <T extends Record<string, { label: string }>>(desc: T) =>
when((name: string) => name in desc, (name) => desc[name as keyof T].label);
3 changes: 3 additions & 0 deletions packages/init/src/const.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down
27 changes: 7 additions & 20 deletions packages/init/src/json/rt.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,25 @@
"deno",
"--version"
],
"outputPattern": "^deno\\s+\\d+\\.\\d+\\.\\d+\\b"
"outputPattern": "^deno\\s+(\\d+\\.\\d+\\.\\d+\\b)",
"minVersion": "2.0.0"
},
"bun": {
"label": "Bun",
"checkCommand": [
"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"
}
}
54 changes: 53 additions & 1 deletion packages/init/src/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<void> {
await mkdir(join(dir, ".git", "objects"), { recursive: true });
await mkdir(join(dir, ".git", "refs", "heads"), { recursive: true });
Expand Down
Loading
Loading