From 062bef3c2893cab59d360984aa15e30e861cb58e Mon Sep 17 00:00:00 2001 From: Ali-Dev11 Date: Tue, 31 Mar 2026 15:27:14 +0500 Subject: [PATCH] Release 0.4.4: Introduced `devforge doctor` for machine readiness checks, added `--preflight-only` option for project initialization, and implemented a packed-tarball smoke script for validating npm artifacts. Centralized remediation commands and improved documentation for the new preflight workflow. --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 13 ++ README.md | 15 ++ docs/changelog.md | 13 ++ docs/development.md | 6 + docs/index.md | 3 + package-lock.json | 4 +- package.json | 5 +- scripts/smoke-packed.mjs | 81 +++++++ src/cli.ts | 33 ++- src/commands/doctor.ts | 23 ++ src/commands/init.ts | 28 +++ src/engines/environment.ts | 1 + src/generated/package-metadata.ts | 2 +- src/guidance.ts | 107 +-------- src/preflight.ts | 374 ++++++++++++++++++++++++++++++ src/remediation.ts | 164 +++++++++++++ src/types.ts | 13 +- src/utils/node-compat.ts | 41 +++- test/cli.test.ts | 25 ++ test/preflight.test.ts | 132 +++++++++++ 21 files changed, 968 insertions(+), 116 deletions(-) create mode 100644 scripts/smoke-packed.mjs create mode 100644 src/commands/doctor.ts create mode 100644 src/preflight.ts create mode 100644 src/remediation.ts create mode 100644 test/cli.test.ts create mode 100644 test/preflight.test.ts 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); +});