diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 000c7bb..224b905 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check
+ - run: npm run smoke:packed
generated-runtime-matrix:
name: generated-runtime-matrix (${{ matrix.group.name }})
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f38a60d..62c44f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,19 @@ The format follows Keep a Changelog and the version numbers follow Semantic Vers
## [Unreleased]
+## [0.4.4] - 2026-03-31
+
+### Added
+
+- Added `devforge doctor`, a machine-level readiness command that inspects Node.js, package managers, Corepack, Bun, Playwright browser installs, Git, Docker, and SSH setup before scaffold generation.
+- Added `devforge init --preflight-only` so users can run the same stack-aware readiness checks as the normal init flow without writing project files yet.
+- Added a packed-tarball smoke script and CI gate so DevForge now validates the shipped npm artifact in addition to the source checkout.
+
+### Changed
+
+- Centralized machine-remediation commands into a shared module so `doctor`, preflight output, and runtime guidance all recommend the same OS-specific fix commands.
+- Improved CLI help and repository docs to explain the new preflight workflow, `doctor`, and the difference between source smoke checks and packed-artifact smoke checks.
+
## [0.4.3] - 2026-03-27
### Fixed
diff --git a/README.md b/README.md
index a02a000..e9d6091 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,18 @@ npm install -g @ali-dev11/devforge
devforge
```
+Machine readiness check:
+
+```bash
+npx --yes @ali-dev11/devforge@latest doctor
+```
+
+Plan-only preflight:
+
+```bash
+npx --yes @ali-dev11/devforge@latest init --preflight-only
+```
+
## What The CLI Asks You
DevForge keeps core setup decisions required, and pushes the rest behind optional customization steps.
@@ -82,6 +94,7 @@ The full prompt-by-prompt guide is here:
```bash
npm install
+npm run dev -- --help
npm run lint
npm run typecheck
npm run test
@@ -89,6 +102,7 @@ npm run build
npm run check
npm run docs:changelog
npm run smoke
+npm run smoke:packed
npm run runtime:matrix -- --scenario backend-hono --scenario cli-tool
```
@@ -102,6 +116,7 @@ npm run runtime:matrix -- --scenario backend-hono --scenario cli-tool
- `npm run check` is the main contributor safety command because it combines linting, typechecking, tests, and build verification.
- `npm run docs:changelog` refreshes the GitHub Pages changelog page from `CHANGELOG.md`.
- `npm run smoke` verifies a non-interactive scaffold run end to end.
+- `npm run smoke:packed` packs the actual npm tarball, installs it into a temp directory, and verifies the published artifact shape instead of only the source checkout.
- `npm run runtime:matrix -- --scenario ...` installs, builds, and verifies generated projects so the scaffold output is tested as a product, not just as source code.
## Repository Docs
diff --git a/docs/changelog.md b/docs/changelog.md
index cca553a..9c6246a 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -9,6 +9,19 @@ Track what changed in DevForge CLI across releases, including scaffolding behavi
- [GitHub Releases](https://github.com/Ali-dev11/devforge/releases)
- [Repository Changelog](https://github.com/Ali-dev11/devforge/blob/main/CHANGELOG.md)
+## [0.4.4] - 2026-03-31
+
+### Added
+
+- Added `devforge doctor`, a machine-level readiness command that inspects Node.js, package managers, Corepack, Bun, Playwright browser installs, Git, Docker, and SSH setup before scaffold generation.
+- Added `devforge init --preflight-only` so users can run the same stack-aware readiness checks as the normal init flow without writing project files yet.
+- Added a packed-tarball smoke script and CI gate so DevForge now validates the shipped npm artifact in addition to the source checkout.
+
+### Changed
+
+- Centralized machine-remediation commands into a shared module so `doctor`, preflight output, and runtime guidance all recommend the same OS-specific fix commands.
+- Improved CLI help and repository docs to explain the new preflight workflow, `doctor`, and the difference between source smoke checks and packed-artifact smoke checks.
+
## [0.4.3] - 2026-03-27
### Fixed
diff --git a/docs/development.md b/docs/development.md
index 36f0365..3cd8b2e 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -15,6 +15,7 @@ title: Development
```bash
npm install
npm run check
+npx --yes @ali-dev11/devforge@latest doctor
```
## Repository Commands
@@ -28,6 +29,7 @@ npm run build
npm run check
npm run docs:changelog
npm run smoke
+npm run smoke:packed
npm run runtime:matrix -- --scenario backend-hono --scenario cli-tool
```
@@ -39,14 +41,18 @@ npm run runtime:matrix -- --scenario backend-hono --scenario cli-tool
- `npm run test` runs focused regression tests for prompting, normalization, generator output, changelog rendering, and runtime-matrix coverage.
- `npm run build` compiles the CLI to `dist/`, which mirrors what npm users receive.
- `npm run check` is the primary contributor gate because it runs lint, types, tests, and build verification together.
+- `npx --yes @ali-dev11/devforge@latest doctor` checks the local machine for the tool and runtime prerequisites that commonly break first-run scaffolds.
- `npm run docs:changelog` keeps the GitHub Pages changelog synchronized with `CHANGELOG.md`.
- `npm run smoke` verifies a fast end-to-end scaffold run without interactive prompts.
+- `npm run smoke:packed` verifies the built npm tarball by installing the packed artifact into a temp directory and running the shipped CLI from there.
- `npm run runtime:matrix -- --scenario ...` validates generated projects as products by installing, building, and checking runtime behavior for representative stacks.
## Working On Generated Scaffolds
- Prefer `npm run smoke` for quick CLI sanity checks.
+- Run `npx --yes @ali-dev11/devforge@latest init --preflight-only` when you want stack-aware readiness checks without writing a new project yet.
- Use `npm run runtime:matrix` when changing templates, prompts, package-manager behavior, or generated runtime surfaces.
+- Use `npm run smoke:packed` when changing the package entrypoints, published files, CLI dispatch, or install-time behavior.
- If you touch microfrontend templates, validate the generated `dev` workflow, not just build output.
- If you touch docs or release notes, rerun `npm run docs:changelog`.
diff --git a/docs/index.md b/docs/index.md
index 14e07e2..01b8f37 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -33,9 +33,12 @@ DevForge CLI turns project intent into a runnable JavaScript or TypeScript repos
## Command Reference
- `npx --yes @ali-dev11/devforge@latest`: run DevForge without a global install.
+- `npx --yes @ali-dev11/devforge@latest doctor`: inspect local machine readiness before generating a scaffold.
+- `npx --yes @ali-dev11/devforge@latest init --preflight-only`: run stack-aware checks for the chosen plan without writing files yet.
- `Project prompts`: use the [Prompt Reference](./prompts.md) when you want to know what a question changes before answering it.
- `npm run check`: validate the DevForge repository itself before pushing changes.
- `npm run smoke`: verify a non-interactive scaffold path.
+- `npm run smoke:packed`: validate the built npm tarball instead of only the source tree.
- `npm run runtime:matrix -- --scenario ...`: validate generated installs, builds, and runtime behavior for representative stacks.
- `npm run docs:changelog`: refresh the GitHub Pages changelog page from the main changelog file.
diff --git a/package-lock.json b/package-lock.json
index 64ce871..bbcc3a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ali-dev11/devforge",
- "version": "0.4.3",
+ "version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ali-dev11/devforge",
- "version": "0.4.3",
+ "version": "0.4.4",
"license": "MIT",
"dependencies": {
"prompts": "^2.4.2"
diff --git a/package.json b/package.json
index 1033a0e..f5adab6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@ali-dev11/devforge",
- "version": "0.4.3",
+ "version": "0.4.4",
"description": "Production-focused AI-native project scaffolding CLI for JavaScript and TypeScript teams.",
"license": "MIT",
"author": "Ali-dev11",
@@ -64,7 +64,8 @@
"pretypecheck": "npm run prepare:metadata",
"version": "npm run prepare:metadata && git add src/generated/package-metadata.ts",
"prepack": "npm run check",
- "smoke": "npm run build && node --eval \"require('node:fs').rmSync('/tmp/devforge-smoke', { recursive: true, force: true })\" && node dist/bin/devforge.js init --yes --skip-install --output /tmp/devforge-smoke"
+ "smoke": "npm run build && node --eval \"require('node:fs').rmSync('/tmp/devforge-smoke', { recursive: true, force: true })\" && node dist/bin/devforge.js init --yes --skip-install --output /tmp/devforge-smoke",
+ "smoke:packed": "node scripts/smoke-packed.mjs"
},
"engines": {
"node": ">=20.0.0"
diff --git a/scripts/smoke-packed.mjs b/scripts/smoke-packed.mjs
new file mode 100644
index 0000000..c1c2762
--- /dev/null
+++ b/scripts/smoke-packed.mjs
@@ -0,0 +1,81 @@
+import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { spawnSync } from "node:child_process";
+import process from "node:process";
+
+function run(command, args, cwd, extraEnv = {}) {
+ const result = spawnSync(command, args, {
+ cwd,
+ encoding: "utf8",
+ stdio: "inherit",
+ env: {
+ ...process.env,
+ ...extraEnv,
+ },
+ });
+
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`);
+ }
+}
+
+const rootDir = process.cwd();
+const workspace = mkdtempSync(join(tmpdir(), "devforge-packed-smoke-"));
+const packDir = join(workspace, "pack");
+const installDir = join(workspace, "install");
+const outputDir = join(workspace, "output");
+const npmCacheDir = join(workspace, "npm-cache");
+
+mkdirSync(packDir, { recursive: true });
+mkdirSync(installDir, { recursive: true });
+
+try {
+ run(
+ "npm",
+ ["pack", "--ignore-scripts", "--pack-destination", packDir],
+ rootDir,
+ {
+ npm_config_cache: npmCacheDir,
+ },
+ );
+
+ const tarball = readdirSync(packDir)
+ .find((entry) => entry.endsWith(".tgz"));
+
+ if (!tarball) {
+ throw new Error("Could not find the packed DevForge tarball.");
+ }
+
+ run(
+ "npm",
+ [
+ "install",
+ "--ignore-scripts",
+ "--no-audit",
+ "--no-fund",
+ "--prefix",
+ installDir,
+ resolve(packDir, tarball),
+ ],
+ rootDir,
+ {
+ npm_config_cache: npmCacheDir,
+ },
+ );
+
+ run(
+ "node",
+ [
+ resolve(installDir, "node_modules/@ali-dev11/devforge/dist/bin/devforge.js"),
+ "init",
+ "--yes",
+ "--skip-install",
+ "--output",
+ outputDir,
+ ],
+ rootDir,
+ );
+} finally {
+ rmSync(workspace, { recursive: true, force: true });
+}
diff --git a/src/cli.ts b/src/cli.ts
index 19a9326..4d8f401 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,4 +1,5 @@
import type { CliOptions } from "./types.js";
+import { runDoctorCommand } from "./commands/doctor.js";
import { runInitCommand } from "./commands/init.js";
import { DEVFORGE_VERSION } from "./version.js";
@@ -12,11 +13,18 @@ function readFlagValue(flag: string, args: string[]): string {
return value;
}
-function parseArgs(argv: string[]): CliOptions {
+function assertInitOnlyFlag(currentCommand: CliOptions["command"], flag: string): void {
+ if (currentCommand !== "init") {
+ throw new Error(`${flag} can only be used with \`devforge init\`.`);
+ }
+}
+
+export function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
command: "init",
resume: false,
skipInstall: false,
+ preflightOnly: false,
yes: false,
};
@@ -33,6 +41,10 @@ function parseArgs(argv: string[]): CliOptions {
return options;
}
+ if (firstArg === "doctor") {
+ options.command = "doctor";
+ args.shift();
+ } else
if (firstArg === "init") {
args.shift();
} else if (firstArg && !firstArg.startsWith("-")) {
@@ -44,6 +56,7 @@ function parseArgs(argv: string[]): CliOptions {
switch (current) {
case "--resume":
+ assertInitOnlyFlag(options.command, "--resume");
options.resume = true;
break;
case "--help":
@@ -55,16 +68,24 @@ function parseArgs(argv: string[]): CliOptions {
options.command = "version";
return options;
case "--skip-install":
+ assertInitOnlyFlag(options.command, "--skip-install");
options.skipInstall = true;
break;
+ case "--preflight-only":
+ assertInitOnlyFlag(options.command, "--preflight-only");
+ options.preflightOnly = true;
+ break;
case "--yes":
case "-y":
+ assertInitOnlyFlag(options.command, "--yes");
options.yes = true;
break;
case "--output":
+ assertInitOnlyFlag(options.command, "--output");
options.outputDir = readFlagValue("--output", args);
break;
case "--name":
+ assertInitOnlyFlag(options.command, "--name");
options.projectName = readFlagValue("--name", args);
break;
default:
@@ -86,15 +107,18 @@ Usage:
devforge
devforge init
devforge init --resume
+ devforge doctor
Commands:
init Start a new scaffold session
+ doctor Inspect local machine readiness for DevForge scaffolds
help Show command help
version Print the current CLI version
Flags:
--resume Resume the last saved init session
--skip-install Generate files without installing dependencies
+ --preflight-only Stop after printing stack-aware readiness checks
--yes, -y Use defaults without prompts
--output
Write the generated project to a custom directory
--name Override the generated project name
@@ -103,7 +127,9 @@ Flags:
Examples:
npx @ali-dev11/devforge@latest
+ npx @ali-dev11/devforge@latest doctor
npx @ali-dev11/devforge@latest init --yes --skip-install --output ./my-app
+ devforge init --preflight-only
devforge init --resume
`);
}
@@ -125,5 +151,10 @@ export async function runCli(argv = process.argv.slice(2)): Promise {
return;
}
+ if (options.command === "doctor") {
+ await runDoctorCommand();
+ return;
+ }
+
await runInitCommand(options);
}
diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts
new file mode 100644
index 0000000..1ff48dd
--- /dev/null
+++ b/src/commands/doctor.ts
@@ -0,0 +1,23 @@
+import { detectEnvironment } from "../engines/environment.js";
+import { buildDoctorPreflightReport, printPreflightReport } from "../preflight.js";
+import { banner, info, step, success, warn } from "../utils/logger.js";
+
+export async function runDoctorCommand(): Promise {
+ const environment = detectEnvironment();
+ const report = buildDoctorPreflightReport(environment);
+
+ banner("DevForge Doctor");
+ info(`Platform: ${environment.platform}/${environment.arch}`);
+ info(`Node.js: ${environment.nodeVersion}`);
+ step(`Package manager preference: ${environment.recommendedPackageManager}`);
+
+ printPreflightReport(report, { showHealthy: true });
+
+ if (report.hasBlockingIssues) {
+ warn("Doctor found blocking issues that should be resolved before scaffolding.");
+ process.exitCode = 1;
+ return;
+ }
+
+ success("\nDoctor completed. Your machine-level setup looks workable for DevForge, with any optional follow-ups listed above.");
+}
diff --git a/src/commands/init.ts b/src/commands/init.ts
index b60c22b..ef842d0 100644
--- a/src/commands/init.ts
+++ b/src/commands/init.ts
@@ -5,6 +5,11 @@ import { generateProject } from "../engines/generator.js";
import { runInstallers } from "../engines/installer.js";
import { normalizeProjectPlan } from "../engines/decision.js";
import { buildDefaultPlan, collectProjectPlan } from "../engines/prompts.js";
+import {
+ buildPlanPreflightReport,
+ hasVisiblePreflightOutput,
+ printPreflightReport,
+} from "../preflight.js";
import type { AdvisoryItem, CliOptions, ResumeState } from "../types.js";
import { RESUME_STATE_PATH } from "../constants.js";
import {
@@ -116,6 +121,29 @@ export async function runInitCommand(options: CliOptions): Promise {
}
}
+ const preflightReport = buildPlanPreflightReport(plan, environment);
+
+ if (options.preflightOnly) {
+ printPreflightReport(preflightReport, { showHealthy: true });
+
+ if (preflightReport.hasBlockingIssues) {
+ warn("Preflight found blocking issues. Fix them, then rerun `devforge init --resume` to continue.");
+ process.exitCode = 1;
+ return;
+ }
+
+ success("\nPreflight completed. Rerun `devforge init --resume` to generate the project with the same saved plan.");
+ return;
+ }
+
+ if (hasVisiblePreflightOutput(preflightReport)) {
+ printPreflightReport(preflightReport);
+
+ if (preflightReport.hasBlockingIssues) {
+ warn("Preflight found blocking issues. DevForge will still write the scaffold, but dependency installation may be skipped until you fix the items above.");
+ }
+ }
+
step(`Generating project in ${plan.targetDir}`);
const progressReporter = createProgressReporter("Writing files");
const generated = await generateProject(plan, environment, {
diff --git a/src/engines/environment.ts b/src/engines/environment.ts
index 4c43166..abe6d97 100644
--- a/src/engines/environment.ts
+++ b/src/engines/environment.ts
@@ -67,6 +67,7 @@ export function detectEnvironment(): EnvironmentInfo {
docker: detectBinary("docker", ["--version"]),
corepack: detectBinary("corepack", ["--version"]),
fnm: detectBinary("fnm", ["--version"]),
+ ssh: detectBinary("ssh", ["-V"]),
},
};
}
diff --git a/src/generated/package-metadata.ts b/src/generated/package-metadata.ts
index b763643..5226af9 100644
--- a/src/generated/package-metadata.ts
+++ b/src/generated/package-metadata.ts
@@ -1,5 +1,5 @@
// This file is generated by scripts/sync-package-metadata.mjs.
// Do not edit it manually.
-export const DEVFORGE_VERSION = "0.4.3";
+export const DEVFORGE_VERSION = "0.4.4";
export const DEVFORGE_PACKAGE_NAME = "@ali-dev11/devforge";
export const DEVFORGE_AUTHOR = "Ali-dev11";
diff --git a/src/guidance.ts b/src/guidance.ts
index f44dddb..35be1ac 100644
--- a/src/guidance.ts
+++ b/src/guidance.ts
@@ -18,6 +18,16 @@ import type {
DataFetchingChoice,
} from "./types.js";
import { REACT_FAMILY_FRAMEWORKS, VUE_FAMILY_FRAMEWORKS } from "./constants.js";
+import {
+ hasFrontendLikeSurface,
+ hasSystemTool,
+ platformScopedDockerInstallCommand,
+ platformScopedGitInstallCommand,
+ platformScopedNodeSetupCommand,
+ platformScopedPackageManagerInstallCommand,
+ platformScopedPlaywrightInstallCommand,
+ preferredNodeVersionForPlan,
+} from "./remediation.js";
import {
isNodeVersionSupportedForPlan,
minimumSupportedNodeVersionHint,
@@ -71,103 +81,6 @@ const SUPPORTED_RENDERING_MODES: Record,
-): boolean {
- return environment.systemTools?.[tool]?.installed ?? false;
-}
-
-function platformScopedPackageManagerInstallCommand(
- packageManager: PackageManager,
- environment: EnvironmentInfo,
-): string | undefined {
- switch (packageManager) {
- case "pnpm":
- case "yarn":
- return hasSystemTool(environment, "corepack")
- ? `corepack enable && corepack prepare ${packageManager}@latest --activate`
- : `npm install -g ${packageManager}`;
- case "bun":
- return environment.platform === "win32"
- ? 'powershell -c "irm bun.sh/install.ps1 | iex"'
- : "curl -fsSL https://bun.sh/install | bash";
- case "npm":
- switch (environment.platform) {
- case "darwin":
- return "brew install node";
- case "win32":
- return "winget install OpenJS.NodeJS.LTS";
- case "linux":
- default:
- return "nvm install --lts";
- }
- default:
- return undefined;
- }
-}
-
export function packageManagerInstallCommand(packageManager: PackageManager): string {
switch (packageManager) {
case "pnpm":
diff --git a/src/preflight.ts b/src/preflight.ts
new file mode 100644
index 0000000..07312db
--- /dev/null
+++ b/src/preflight.ts
@@ -0,0 +1,374 @@
+import type { AdvisoryItem, EnvironmentInfo, PreflightReport, ProjectPlan } from "./types.js";
+import {
+ hasInstalledPlaywrightBrowsers,
+ hasLocalSshPublicKey,
+ hasSystemTool,
+ platformScopedDockerInstallCommand,
+ platformScopedGitInstallCommand,
+ platformScopedNodeSetupCommand,
+ platformScopedPackageManagerInstallCommand,
+ platformScopedPlaywrightInstallCommand,
+ platformScopedSshInstallCommand,
+ platformScopedSshKeygenCommand,
+ preferredNodeVersionForPlan,
+} from "./remediation.js";
+import {
+ isNodeVersionSupportedForFrontendToolchains,
+ isNodeVersionSupportedForPlan,
+ minimumSupportedFrontendNodeVersionHint,
+ minimumSupportedNodeVersionHint,
+} from "./utils/node-compat.js";
+import { info, step, warn } from "./utils/logger.js";
+
+type BuildReportOptions = {
+ homeDir?: string;
+ processEnv?: NodeJS.ProcessEnv;
+};
+
+function printAdvisorySection(
+ title: string,
+ items: AdvisoryItem[],
+ options?: { warnItems?: boolean },
+): void {
+ if (items.length === 0) {
+ return;
+ }
+
+ info("");
+ step(`${title}:`);
+
+ for (const item of items) {
+ const log = options?.warnItems ? warn : info;
+ log(` ${item.title}: ${item.detail}`);
+ if (item.command) {
+ info(` ${item.command}`);
+ }
+ }
+}
+
+function printNextCommands(commands: string[]): void {
+ if (commands.length === 0) {
+ return;
+ }
+
+ info("");
+ step("Next commands:");
+ for (const command of commands) {
+ info(` ${command}`);
+ }
+}
+
+function sortInstalledPackageManagers(environment: EnvironmentInfo): string[] {
+ return Object.entries(environment.packageManagers)
+ .filter(([, status]) => status.installed)
+ .map(([name, status]) => {
+ const version = status.version ? ` ${status.version}` : "";
+ return `${name}${version}`;
+ });
+}
+
+function sshKeyReady(
+ options?: BuildReportOptions,
+): boolean {
+ return hasLocalSshPublicKey(options?.homeDir);
+}
+
+export function buildDoctorPreflightReport(
+ environment: EnvironmentInfo,
+ options?: BuildReportOptions,
+): PreflightReport {
+ const healthy: AdvisoryItem[] = [];
+ const requiredBeforeRun: AdvisoryItem[] = [];
+ const recommended: AdvisoryItem[] = [];
+
+ if (isNodeVersionSupportedForFrontendToolchains(environment.nodeVersion)) {
+ healthy.push({
+ title: "Node.js is ready for frontend and extension scaffolds",
+ detail: `Current Node.js ${environment.nodeVersion} satisfies the browser-oriented toolchain floor used by DevForge-generated Vite-family and extension stacks.`,
+ });
+ } else {
+ recommended.push({
+ title: "Upgrade Node.js for frontend and extension scaffolds",
+ detail: `Current Node.js ${environment.nodeVersion} can run DevForge itself, but frontend and browser-extension scaffolds need ${minimumSupportedFrontendNodeVersionHint()}.`,
+ command: platformScopedNodeSetupCommand(environment, "22.12.0"),
+ });
+ }
+
+ const installedPackageManagers = sortInstalledPackageManagers(environment);
+ healthy.push({
+ title: "Detected package managers",
+ detail:
+ installedPackageManagers.length > 0
+ ? installedPackageManagers.join(", ")
+ : "No supported package managers were detected.",
+ });
+
+ if (hasSystemTool(environment, "corepack")) {
+ healthy.push({
+ title: "Corepack is available",
+ detail: "pnpm and Yarn scaffolds can be bootstrapped through Corepack on this machine.",
+ });
+ } else {
+ recommended.push({
+ title: "Enable Corepack for pnpm and Yarn flows",
+ detail: "Corepack lets DevForge activate the selected package manager version without requiring a global pnpm or Yarn install.",
+ command: "corepack enable",
+ });
+ }
+
+ if (environment.packageManagers.bun.installed) {
+ healthy.push({
+ title: "Bun is installed",
+ detail: `Detected Bun ${environment.packageManagers.bun.version ?? ""}`.trim(),
+ });
+ } else {
+ recommended.push({
+ title: "Install Bun for Bun-based scaffolds",
+ detail: "DevForge can generate Bun projects, but Bun is not currently available in your shell.",
+ command: platformScopedPackageManagerInstallCommand("bun", environment),
+ });
+ }
+
+ if (hasSystemTool(environment, "git")) {
+ healthy.push({
+ title: "Git is available",
+ detail: "Git initialization, remotes, and hook tooling can be used immediately.",
+ });
+ } else {
+ recommended.push({
+ title: "Install Git",
+ detail: "Many generated projects initialize a repository or assume Git is available for day-one workflows.",
+ command: platformScopedGitInstallCommand(environment.platform),
+ });
+ }
+
+ if (hasSystemTool(environment, "docker")) {
+ healthy.push({
+ title: "Docker is available",
+ detail: "Container-ready scaffolds can run without extra system setup.",
+ });
+ } else {
+ recommended.push({
+ title: "Install Docker for container workflows",
+ detail: "Docker is optional in DevForge, but required if you choose containerized scaffolds or generated Docker assets.",
+ command: platformScopedDockerInstallCommand(environment.platform),
+ });
+ }
+
+ if (hasSystemTool(environment, "ssh")) {
+ healthy.push({
+ title: "SSH client is available",
+ detail: "SSH-based Git remotes can be used on this machine.",
+ });
+ } else {
+ recommended.push({
+ title: "Install an SSH client",
+ detail: "SSH is needed if you plan to use SSH remotes or generate SSH setup guidance.",
+ command: platformScopedSshInstallCommand(environment.platform),
+ });
+ }
+
+ if (sshKeyReady(options)) {
+ healthy.push({
+ title: "An SSH public key is present",
+ detail: "You can attach the existing SSH key to GitHub or another Git provider if needed.",
+ });
+ } else {
+ recommended.push({
+ title: "Generate an SSH key before using SSH remotes",
+ detail: "No local SSH public key was found under `~/.ssh`, so SSH-based Git remotes will need one-time setup first.",
+ command: platformScopedSshKeygenCommand(),
+ });
+ }
+
+ if (hasInstalledPlaywrightBrowsers(environment.platform, options?.processEnv, options?.homeDir)) {
+ healthy.push({
+ title: "Playwright browsers are installed",
+ detail: "Browser E2E scaffolds can run Playwright tests without the extra browser-download step.",
+ });
+ } else {
+ recommended.push({
+ title: "Install Playwright browsers before browser E2E runs",
+ detail: "The Playwright package can be installed through npm, pnpm, yarn, or bun, but browser binaries still need a one-time machine-level install.",
+ command: platformScopedPlaywrightInstallCommand(environment.platform),
+ });
+ }
+
+ return {
+ title: "Doctor checks",
+ healthy,
+ requiredBeforeRun,
+ recommended,
+ nextCommands: [],
+ hasBlockingIssues: false,
+ };
+}
+
+export function buildPlanPreflightReport(
+ plan: ProjectPlan,
+ environment: EnvironmentInfo,
+ options?: BuildReportOptions,
+): PreflightReport {
+ const healthy: AdvisoryItem[] = [];
+ const requiredBeforeRun: AdvisoryItem[] = [];
+ const recommended: AdvisoryItem[] = [];
+ let hasBlockingIssues = false;
+
+ const currentNodeSupported = isNodeVersionSupportedForPlan(plan, environment.nodeVersion);
+ if (currentNodeSupported) {
+ healthy.push({
+ title: "Current Node.js version is compatible",
+ detail: `${environment.nodeVersion} satisfies this scaffold's runtime floor.`,
+ });
+ } else {
+ hasBlockingIssues = true;
+ requiredBeforeRun.push({
+ title: "Use a compatible Node.js version",
+ detail: `Your shell is currently using ${environment.nodeVersion}, but this scaffold requires Node.js ${minimumSupportedNodeVersionHint(plan)} before dependency installation or run commands will work reliably.`,
+ command: platformScopedNodeSetupCommand(environment, preferredNodeVersionForPlan(plan)),
+ });
+ }
+
+ if (
+ plan.nodeStrategy === "custom" &&
+ plan.customNodeVersion &&
+ currentNodeSupported &&
+ !environment.nodeVersion.toLowerCase().includes(plan.customNodeVersion.toLowerCase())
+ ) {
+ requiredBeforeRun.push({
+ title: "Switch to the selected custom Node.js version",
+ detail: `This scaffold was configured for Node.js ${plan.customNodeVersion}, but your shell is currently using ${environment.nodeVersion}.`,
+ command: platformScopedNodeSetupCommand(environment, plan.customNodeVersion),
+ });
+ }
+
+ if (environment.packageManagers[plan.packageManager].installed) {
+ healthy.push({
+ title: `${plan.packageManager} is available`,
+ detail: `DevForge can install and run the generated ${plan.packageManager} scripts on this machine.`,
+ });
+ } else {
+ hasBlockingIssues = true;
+ requiredBeforeRun.push({
+ title: `Install or enable ${plan.packageManager}`,
+ detail: `${plan.packageManager} was selected for this scaffold, but it is not currently available in your shell.`,
+ command: platformScopedPackageManagerInstallCommand(plan.packageManager, environment),
+ });
+ }
+
+ if (plan.testing.enabled && plan.testing.runner === "playwright") {
+ if (hasInstalledPlaywrightBrowsers(environment.platform, options?.processEnv, options?.homeDir)) {
+ healthy.push({
+ title: "Playwright browsers are already installed",
+ detail: "Browser E2E tests can run without extra machine-level setup.",
+ });
+ } else {
+ recommended.push({
+ title: "Install Playwright browsers",
+ detail: "This scaffold includes Playwright, and browser binaries still need a one-time install on each machine that runs the E2E tests.",
+ command: platformScopedPlaywrightInstallCommand(environment.platform),
+ });
+ }
+ }
+
+ if (plan.git.initialize || plan.git.addRemote || plan.tooling.husky) {
+ if (hasSystemTool(environment, "git")) {
+ healthy.push({
+ title: "Git is available for the requested repository tooling",
+ detail: "Repository initialization, hooks, and remote commands can run locally.",
+ });
+ } else {
+ recommended.push({
+ title: "Install Git",
+ detail: "This scaffold enables Git-oriented workflows, but Git is not currently available in your shell.",
+ command: platformScopedGitInstallCommand(environment.platform),
+ });
+ }
+ }
+
+ if (plan.tooling.docker) {
+ if (hasSystemTool(environment, "docker")) {
+ healthy.push({
+ title: "Docker is available for the generated container assets",
+ detail: "Container workflows can run locally without extra setup.",
+ });
+ } else {
+ recommended.push({
+ title: "Install Docker",
+ detail: "Docker support was selected for this scaffold, but Docker is not currently available in your shell.",
+ command: platformScopedDockerInstallCommand(environment.platform),
+ });
+ }
+ }
+
+ if (plan.git.setupSsh) {
+ if (hasSystemTool(environment, "ssh")) {
+ healthy.push({
+ title: "SSH client is available",
+ detail: "SSH-based remote setup can be completed locally.",
+ });
+ } else {
+ recommended.push({
+ title: "Install an SSH client",
+ detail: "SSH setup guidance was requested for this scaffold, but `ssh` is not currently available in your shell.",
+ command: platformScopedSshInstallCommand(environment.platform),
+ });
+ }
+
+ if (sshKeyReady(options)) {
+ healthy.push({
+ title: "A local SSH public key is present",
+ detail: "You can attach the existing key to your Git provider when connecting the generated repository.",
+ });
+ } else {
+ recommended.push({
+ title: "Generate an SSH key",
+ detail: "No local SSH public key was found under `~/.ssh`, so SSH remote setup will need a one-time key generation step.",
+ command: platformScopedSshKeygenCommand(),
+ });
+ }
+ }
+
+ return {
+ title: "Preflight checks",
+ healthy,
+ requiredBeforeRun,
+ recommended,
+ nextCommands: [],
+ hasBlockingIssues,
+ };
+}
+
+export function hasVisiblePreflightOutput(
+ report: PreflightReport,
+ options?: { showHealthy?: boolean },
+): boolean {
+ return (
+ report.requiredBeforeRun.length > 0 ||
+ report.recommended.length > 0 ||
+ report.nextCommands.length > 0 ||
+ Boolean(options?.showHealthy && report.healthy.length > 0)
+ );
+}
+
+export function printPreflightReport(
+ report: PreflightReport,
+ options?: { showHealthy?: boolean },
+): void {
+ if (!hasVisiblePreflightOutput(report, options)) {
+ return;
+ }
+
+ info("");
+ step(report.title);
+ printAdvisorySection("Required before run", report.requiredBeforeRun, {
+ warnItems: true,
+ });
+ printAdvisorySection("Recommended", report.recommended);
+
+ if (options?.showHealthy) {
+ printAdvisorySection("Healthy", report.healthy);
+ }
+
+ printNextCommands(report.nextCommands);
+}
diff --git a/src/remediation.ts b/src/remediation.ts
new file mode 100644
index 0000000..e76f8d3
--- /dev/null
+++ b/src/remediation.ts
@@ -0,0 +1,164 @@
+import { existsSync, readdirSync } from "node:fs";
+import os from "node:os";
+import { join } from "node:path";
+import type { EnvironmentInfo, PackageManager, ProjectPlan } from "./types.js";
+
+export function hasFrontendLikeSurface(plan: ProjectPlan): boolean {
+ return Boolean(plan.frontend) || plan.intent === "chrome-extension";
+}
+
+export function platformScopedPlaywrightInstallCommand(platform: NodeJS.Platform): string {
+ return platform === "linux"
+ ? "npx playwright install --with-deps"
+ : "npx playwright install";
+}
+
+export function preferredNodeVersionForPlan(plan: ProjectPlan): string | undefined {
+ if (plan.nodeStrategy === "custom" && plan.customNodeVersion) {
+ return plan.customNodeVersion;
+ }
+
+ return hasFrontendLikeSurface(plan) ? "22.12.0" : "20.0.0";
+}
+
+export function hasSystemTool(
+ environment: EnvironmentInfo,
+ tool: keyof NonNullable,
+): boolean {
+ return environment.systemTools?.[tool]?.installed ?? false;
+}
+
+export function platformScopedNodeSetupCommand(
+ environment: EnvironmentInfo,
+ targetNodeVersion: string | undefined,
+): string | undefined {
+ if (!targetNodeVersion) {
+ return undefined;
+ }
+
+ if (hasSystemTool(environment, "fnm")) {
+ return `fnm install ${targetNodeVersion} && fnm use ${targetNodeVersion}`;
+ }
+
+ if (environment.platform === "win32") {
+ return `nvm install ${targetNodeVersion} && nvm use ${targetNodeVersion}`;
+ }
+
+ return `nvm install ${targetNodeVersion} && nvm use ${targetNodeVersion}`;
+}
+
+export function platformScopedGitInstallCommand(platform: NodeJS.Platform): string {
+ switch (platform) {
+ case "darwin":
+ return "xcode-select --install";
+ case "win32":
+ return "winget install Git.Git";
+ case "linux":
+ default:
+ return "sudo apt-get update && sudo apt-get install -y git";
+ }
+}
+
+export function platformScopedDockerInstallCommand(platform: NodeJS.Platform): string {
+ switch (platform) {
+ case "darwin":
+ return "brew install --cask docker";
+ case "win32":
+ return "winget install Docker.DockerDesktop";
+ case "linux":
+ default:
+ return "curl -fsSL https://get.docker.com | sh";
+ }
+}
+
+export function platformScopedSshInstallCommand(platform: NodeJS.Platform): string {
+ switch (platform) {
+ case "darwin":
+ return "xcode-select --install";
+ case "win32":
+ return 'powershell -Command "Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0"';
+ case "linux":
+ default:
+ return "sudo apt-get update && sudo apt-get install -y openssh-client";
+ }
+}
+
+export function platformScopedSshKeygenCommand(email = "your-email@example.com"): string {
+ return `ssh-keygen -t ed25519 -C "${email}"`;
+}
+
+export function platformScopedPackageManagerInstallCommand(
+ packageManager: PackageManager,
+ environment: EnvironmentInfo,
+): string | undefined {
+ switch (packageManager) {
+ case "pnpm":
+ case "yarn":
+ return hasSystemTool(environment, "corepack")
+ ? `corepack enable && corepack prepare ${packageManager}@latest --activate`
+ : `npm install -g ${packageManager}`;
+ case "bun":
+ return environment.platform === "win32"
+ ? 'powershell -c "irm bun.sh/install.ps1 | iex"'
+ : "curl -fsSL https://bun.sh/install | bash";
+ case "npm":
+ switch (environment.platform) {
+ case "darwin":
+ return "brew install node";
+ case "win32":
+ return "winget install OpenJS.NodeJS.LTS";
+ case "linux":
+ default:
+ return "nvm install --lts";
+ }
+ default:
+ return undefined;
+ }
+}
+
+function defaultPlaywrightCacheDir(platform: NodeJS.Platform, homeDir: string): string {
+ switch (platform) {
+ case "darwin":
+ return join(homeDir, "Library", "Caches", "ms-playwright");
+ case "win32":
+ return join(homeDir, "AppData", "Local", "ms-playwright");
+ case "linux":
+ default:
+ return join(homeDir, ".cache", "ms-playwright");
+ }
+}
+
+export function hasInstalledPlaywrightBrowsers(
+ platform: NodeJS.Platform,
+ env: NodeJS.ProcessEnv = process.env,
+ homeDir = os.homedir(),
+): boolean {
+ const configuredPath = env.PLAYWRIGHT_BROWSERS_PATH;
+ const cacheDir =
+ configuredPath && configuredPath !== "0"
+ ? configuredPath
+ : defaultPlaywrightCacheDir(platform, homeDir);
+
+ if (!cacheDir || !existsSync(cacheDir)) {
+ return false;
+ }
+
+ try {
+ return readdirSync(cacheDir).some((entry) => !entry.startsWith("."));
+ } catch {
+ return false;
+ }
+}
+
+export function hasLocalSshPublicKey(homeDir = os.homedir()): boolean {
+ const sshDir = join(homeDir, ".ssh");
+ if (!existsSync(sshDir)) {
+ return false;
+ }
+
+ try {
+ return readdirSync(sshDir).some((entry) => entry.endsWith(".pub"));
+ } catch {
+ return false;
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index 7ce92b8..fc8f4a6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -89,9 +89,10 @@ export type LicenseChoice =
| "Proprietary";
export interface CliOptions {
- command: "init" | "help" | "version";
+ command: "init" | "help" | "version" | "doctor";
resume: boolean;
skipInstall: boolean;
+ preflightOnly?: boolean;
yes: boolean;
outputDir?: string;
projectName?: string;
@@ -114,6 +115,7 @@ export interface EnvironmentInfo {
docker?: BinaryStatus;
corepack?: BinaryStatus;
fnm?: BinaryStatus;
+ ssh?: BinaryStatus;
};
}
@@ -264,3 +266,12 @@ export interface PostCreateGuidance {
recommended: AdvisoryItem[];
stackNotes: string[];
}
+
+export interface PreflightReport {
+ title: string;
+ healthy: AdvisoryItem[];
+ requiredBeforeRun: AdvisoryItem[];
+ recommended: AdvisoryItem[];
+ nextCommands: string[];
+ hasBlockingIssues: boolean;
+}
diff --git a/src/utils/node-compat.ts b/src/utils/node-compat.ts
index f0ce39d..fed33e9 100644
--- a/src/utils/node-compat.ts
+++ b/src/utils/node-compat.ts
@@ -47,16 +47,11 @@ function requiresElevatedFrontendNode(plan: ProjectPlan): boolean {
return Boolean(plan.frontend) || plan.intent === "chrome-extension";
}
-export function generatedProjectNodeEngine(plan: ProjectPlan): string {
- return requiresElevatedFrontendNode(plan) ? ">=20.19.0 || >=22.12.0" : ">=20.0.0";
-}
-
-export function minimumSupportedNodeVersionHint(plan: ProjectPlan): string {
- return requiresElevatedFrontendNode(plan) ? "20.19.0 or 22.12.0+" : "20.0.0";
+export function minimumSupportedFrontendNodeVersionHint(): string {
+ return "20.19.0 or 22.12.0+";
}
-export function isNodeVersionSupportedForPlan(
- plan: ProjectPlan,
+export function isNodeVersionSupportedForFrontendToolchains(
value: string | undefined,
): boolean {
const parsed = parseNodeVersion(value);
@@ -64,10 +59,6 @@ export function isNodeVersionSupportedForPlan(
return true;
}
- if (!requiresElevatedFrontendNode(plan)) {
- return parsed.major > 20 || (parsed.major === 20 && parsed.minor >= 0);
- }
-
const minimumNode20: ParsedNodeVersion = { major: 20, minor: 19, patch: 0 };
const minimumNode22: ParsedNodeVersion = { major: 22, minor: 12, patch: 0 };
@@ -81,3 +72,29 @@ export function isNodeVersionSupportedForPlan(
return parsed.major > 22;
}
+
+export function generatedProjectNodeEngine(plan: ProjectPlan): string {
+ return requiresElevatedFrontendNode(plan) ? ">=20.19.0 || >=22.12.0" : ">=20.0.0";
+}
+
+export function minimumSupportedNodeVersionHint(plan: ProjectPlan): string {
+ return requiresElevatedFrontendNode(plan)
+ ? minimumSupportedFrontendNodeVersionHint()
+ : "20.0.0";
+}
+
+export function isNodeVersionSupportedForPlan(
+ plan: ProjectPlan,
+ value: string | undefined,
+): boolean {
+ const parsed = parseNodeVersion(value);
+ if (!parsed) {
+ return true;
+ }
+
+ if (!requiresElevatedFrontendNode(plan)) {
+ return parsed.major > 20 || (parsed.major === 20 && parsed.minor >= 0);
+ }
+
+ return isNodeVersionSupportedForFrontendToolchains(value);
+}
diff --git a/test/cli.test.ts b/test/cli.test.ts
new file mode 100644
index 0000000..e0b9d7e
--- /dev/null
+++ b/test/cli.test.ts
@@ -0,0 +1,25 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { parseArgs } from "../src/cli.js";
+
+test("cli parses the doctor command", () => {
+ const options = parseArgs(["doctor"]);
+
+ assert.equal(options.command, "doctor");
+ assert.equal(options.preflightOnly, false);
+});
+
+test("cli parses init preflight mode", () => {
+ const options = parseArgs(["init", "--preflight-only", "--yes"]);
+
+ assert.equal(options.command, "init");
+ assert.equal(options.preflightOnly, true);
+ assert.equal(options.yes, true);
+});
+
+test("cli rejects init-only flags on doctor", () => {
+ assert.throws(
+ () => parseArgs(["doctor", "--skip-install"]),
+ /--skip-install can only be used with `devforge init`/i,
+ );
+});
diff --git a/test/preflight.test.ts b/test/preflight.test.ts
new file mode 100644
index 0000000..ac05b7c
--- /dev/null
+++ b/test/preflight.test.ts
@@ -0,0 +1,132 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildDefaultPlan } from "../src/engines/prompts.js";
+import {
+ buildDoctorPreflightReport,
+ buildPlanPreflightReport,
+} from "../src/preflight.js";
+import type { CliOptions, EnvironmentInfo } from "../src/types.js";
+
+const cliOptions: CliOptions = {
+ command: "init",
+ resume: false,
+ skipInstall: true,
+ yes: true,
+ outputDir: "/tmp/devforge-preflight-test",
+ projectName: "devforge-preflight-test",
+};
+
+function createEnvironment(platform: NodeJS.Platform = "darwin"): EnvironmentInfo {
+ return {
+ platform,
+ arch: platform === "win32" ? "x64" : "arm64",
+ nodeVersion: "v22.0.0",
+ recommendedPackageManager: "pnpm",
+ packageManagers: {
+ npm: { installed: true, version: "10.5.1", path: "/usr/bin/npm" },
+ pnpm: { installed: true, version: "9.0.0", path: "/usr/local/bin/pnpm" },
+ yarn: { installed: true, version: "4.1.0", path: "/usr/local/bin/yarn" },
+ bun: { installed: true, version: "1.3.11", path: "/usr/local/bin/bun" },
+ },
+ systemTools: {
+ git: { installed: true, version: "2.45.1", path: "/usr/bin/git" },
+ docker: { installed: true, version: "27.0.0", path: "/usr/local/bin/docker" },
+ corepack: { installed: true, version: "0.29.3", path: "/usr/local/bin/corepack" },
+ fnm: { installed: true, version: "1.37.1", path: "/usr/local/bin/fnm" },
+ ssh: { installed: true, version: "OpenSSH_9.8", path: "/usr/bin/ssh" },
+ },
+ };
+}
+
+test("plan preflight flags unsupported Node versions and missing package managers as blockers", () => {
+ const environment = createEnvironment("darwin");
+ environment.packageManagers.bun = { installed: false };
+ const plan = buildDefaultPlan(environment, cliOptions);
+ plan.packageManager = "bun";
+
+ const report = buildPlanPreflightReport(plan, environment);
+
+ assert.equal(report.hasBlockingIssues, true);
+ assert.match(
+ report.requiredBeforeRun.map((item) => `${item.title} ${item.command ?? ""}`).join("\n"),
+ /Use a compatible Node\.js version[\s\S]*fnm install 22\.12\.0 && fnm use 22\.12\.0/i,
+ );
+ assert.match(
+ report.requiredBeforeRun.map((item) => `${item.title} ${item.command ?? ""}`).join("\n"),
+ /Install or enable bun[\s\S]*curl -fsSL https:\/\/bun\.sh\/install \| bash/i,
+ );
+});
+
+test("plan preflight recommends Playwright, Docker, and SSH follow-up steps when selected", async () => {
+ const environment = createEnvironment("linux");
+ environment.nodeVersion = "v22.12.0";
+ environment.systemTools = {
+ git: { installed: false },
+ docker: { installed: false },
+ corepack: { installed: true, version: "0.29.3", path: "/usr/local/bin/corepack" },
+ fnm: { installed: true, version: "1.37.1", path: "/usr/local/bin/fnm" },
+ ssh: { installed: false },
+ };
+
+ const homeDir = await mkdtemp(join(tmpdir(), "devforge-preflight-home-"));
+ const plan = buildDefaultPlan(environment, cliOptions);
+ plan.testing = {
+ enabled: true,
+ runner: "playwright",
+ environment: "browser-e2e",
+ includeExampleTests: true,
+ };
+ plan.tooling.docker = true;
+ plan.git.setupSsh = true;
+ plan.git.initialize = true;
+
+ const report = buildPlanPreflightReport(plan, environment, {
+ homeDir,
+ processEnv: { PLAYWRIGHT_BROWSERS_PATH: join(homeDir, "pw") },
+ });
+
+ const recommended = report.recommended.map((item) => `${item.title} ${item.command ?? ""}`).join("\n");
+
+ assert.match(recommended, /Install Playwright browsers[\s\S]*npx playwright install --with-deps/i);
+ assert.match(recommended, /Install Git[\s\S]*sudo apt-get update && sudo apt-get install -y git/i);
+ assert.match(recommended, /Install Docker[\s\S]*curl -fsSL https:\/\/get\.docker\.com \| sh/i);
+ assert.match(recommended, /Install an SSH client[\s\S]*openssh-client/i);
+ assert.match(recommended, /Generate an SSH key[\s\S]*ssh-keygen -t ed25519/i);
+});
+
+test("doctor report surfaces missing machine prerequisites and detects ready ones", async () => {
+ const environment = createEnvironment("darwin");
+ environment.nodeVersion = "v22.12.0";
+ environment.packageManagers.bun = { installed: false };
+ environment.systemTools = {
+ git: { installed: true, version: "2.45.1", path: "/usr/bin/git" },
+ docker: { installed: false },
+ corepack: { installed: false },
+ fnm: { installed: true, version: "1.37.1", path: "/usr/local/bin/fnm" },
+ ssh: { installed: false },
+ };
+
+ const homeDir = await mkdtemp(join(tmpdir(), "devforge-doctor-home-"));
+ const sshDir = join(homeDir, ".ssh");
+ const browsersDir = join(homeDir, "Library", "Caches", "ms-playwright");
+ await mkdir(sshDir, { recursive: true });
+ await mkdir(browsersDir, { recursive: true });
+ await writeFile(join(sshDir, "id_ed25519.pub"), "ssh-ed25519 AAAA test@example.com\n");
+ await writeFile(join(browsersDir, "chromium-1208"), "");
+
+ const report = buildDoctorPreflightReport(environment, { homeDir });
+ const healthy = report.healthy.map((item) => item.title).join("\n");
+ const recommended = report.recommended.map((item) => `${item.title} ${item.command ?? ""}`).join("\n");
+
+ assert.equal(report.hasBlockingIssues, false);
+ assert.match(healthy, /Node\.js is ready for frontend and extension scaffolds/i);
+ assert.match(healthy, /An SSH public key is present/i);
+ assert.match(healthy, /Playwright browsers are installed/i);
+ assert.match(recommended, /Enable Corepack for pnpm and Yarn flows[\s\S]*corepack enable/i);
+ assert.match(recommended, /Install Bun for Bun-based scaffolds[\s\S]*bun\.sh\/install/i);
+ assert.match(recommended, /Install Docker for container workflows[\s\S]*brew install --cask docker/i);
+ assert.match(recommended, /Install an SSH client[\s\S]*xcode-select --install/i);
+});