Skip to content
Open
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/issues/981

### @fedify/interaction-controls

Expand Down
6 changes: 6 additions & 0 deletions changes.d/init/verify-runtime-version.md
Original file line number Diff line number Diff line change
@@ -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]
84 changes: 68 additions & 16 deletions packages/init/src/ask/pm.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
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,
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.
Expand All @@ -27,30 +36,73 @@ const fillPackageManager: //
(options: T) => //
Promise<Omit<T, "packageManager"> & { 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);
}
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>) =>
(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,
Expand Down
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+$)",
"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"
}
}
29 changes: 28 additions & 1 deletion packages/init/src/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<void> {
await mkdir(join(dir, ".git", "objects"), { recursive: true });
await mkdir(join(dir, ".git", "refs", "heads"), { recursive: true });
Expand Down
110 changes: 110 additions & 0 deletions packages/init/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand All @@ -25,6 +26,8 @@ import type {
MessageQueues,
PackageManager,
PackageManagers,
Runtime,
RuntimeCheck,
Runtimes,
} from "./types.ts";
import { CommandError, isNotFoundError, runSubCommand } from "./utils.ts";
Expand Down Expand Up @@ -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<RuntimeCheck> {
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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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<Record<Runtime, string>> = {},
): Promise<RuntimeCheck> {
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<Record<Runtime, string>> = {},
): Promise<
Record<Runtime, RuntimeCheck>
> {
const checked = await Promise.all(
RUNTIME.map(async (runtime) =>
[runtime, await checkRuntimeRequirement(runtime, overrides)] as const
),
);
return Object.fromEntries(checked) as Record<Runtime, RuntimeCheck>;
}

/**
* Creates a file at the given path with the given content, creating
* any necessary parent directories along the way.
Expand Down
Loading