From b12030146659f6a06e91ba692295e175cd686b3a Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 11:11:09 +0800 Subject: [PATCH 1/6] chore(dx): add one-command local dev bootstrap Add dev doctor and setup commands with fail-fast checks, document the contributor workflow, and wire a CI sanity check for doctor:dev. Co-authored-by: Codex --- .github/pull_request_template.md | 4 +- .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 19 +-- README.md | 14 +++ docs/development/TESTING.md | 16 ++- package.json | 2 + scripts/doctor-dev.js | 204 +++++++++++++++++++++++++++++++ scripts/setup-dev.js | 99 +++++++++++++++ 8 files changed, 346 insertions(+), 15 deletions(-) create mode 100644 scripts/doctor-dev.js create mode 100644 scripts/setup-dev.js diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2f0e3effa..01ad1026e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,10 +8,12 @@ ## Validation +- [ ] `npm run doctor:dev` +- [ ] `npm run setup:dev` +- [ ] `npm test -- test/documentation.test.ts` - [ ] `npm run lint` - [ ] `npm run typecheck` - [ ] `npm test` -- [ ] `npm test -- test/documentation.test.ts` - [ ] `npm run build` ## Docs and Governance Checklist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c4f0b99..f5e705a4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Dev doctor sanity check + run: npm run doctor:dev + - name: Run ESLint run: npm run lint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc5ebbcd7..eb8a065c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,11 +22,8 @@ If a proposal conflicts with OpenAI policy boundaries, it will be declined. ## Local Setup ```bash -npm ci -npm run typecheck -npm run lint -npm test -npm run build +npm run doctor:dev +npm run setup:dev ``` Node requirement: `>=18`. @@ -56,13 +53,17 @@ Documentation requirements for behavior changes: 1. Create a focused branch from `main`. 2. Keep commits atomic and reviewable. 3. Run full local gate: + - `npm run doctor:dev` + - `npm run setup:dev` + - `npm run test -- test/documentation.test.ts` +4. If triaging failures, run component gates directly: - `npm run typecheck` - `npm run lint` - `npm test` - `npm run build` -4. Include command output evidence in the PR description. -5. Document behavior changes and migration notes when needed. -6. Ensure no secrets or local runtime data are committed. +5. Include command output evidence in the PR description. +6. Document behavior changes and migration notes when needed. +7. Ensure no secrets or local runtime data are committed. Use `.github/pull_request_template.md` when opening the PR. @@ -116,4 +117,4 @@ Unacceptable behavior: ## License -By contributing, you agree contributions are licensed under the project license in [LICENSE](LICENSE). \ No newline at end of file +By contributing, you agree contributions are licensed under the project license in [LICENSE](LICENSE). diff --git a/README.md b/README.md index e254c6a65..4e3b5b6a9 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,20 @@ codex auth check --- +## Local Development (Contributors) + +From repo root: + +```bash +npm run doctor:dev +npm run setup:dev +``` + +`doctor:dev` validates local prerequisites and required project files. +`setup:dev` runs install plus the local quality gate (`lint`, `typecheck`, `build`, `test`). + +--- + ## Quick Start ```bash diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 9292b9065..cc251eb2a 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -19,6 +19,13 @@ Coverage thresholds in `vitest.config.ts`: statements/branches/functions/lines > ## Core Commands +```bash +npm run doctor:dev +npm run setup:dev +``` + +Component commands: + ```bash npm run typecheck npm run lint @@ -39,11 +46,10 @@ npm run bench:edit-formats:smoke ## Recommended Local Gate Before PR -1. `npm run typecheck` -2. `npm run lint` -3. `npm test` -4. `npm run build` -5. run docs command checks for newly documented command paths +1. `npm run doctor:dev` +2. `npm run setup:dev` +3. `npm run test -- test/documentation.test.ts` +4. run docs command checks for newly documented command paths * * * diff --git a/package.json b/package.json index 6f848975f..60c8d035f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ }, "scripts": { "build": "tsc && node scripts/copy-oauth-success.js", + "doctor:dev": "node scripts/doctor-dev.js", + "setup:dev": "node scripts/setup-dev.js", "typecheck": "tsc --noEmit", "lint": "npm run lint:ts && npm run lint:scripts", "lint:ts": "eslint . --ext .ts", diff --git a/scripts/doctor-dev.js b/scripts/doctor-dev.js new file mode 100644 index 000000000..ac91222db --- /dev/null +++ b/scripts/doctor-dev.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const REQUIRED_NODE_MAJOR = 18; + +function runCommand(command, args = []) { + try { + return spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return { + status: 1, + stdout: "", + stderr: "", + }; + } +} + +function splitPathEntries(pathValue) { + if (typeof pathValue !== "string" || pathValue.trim().length === 0) { + return []; + } + const delimiter = process.platform === "win32" ? ";" : ":"; + return pathValue + .split(delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function commandExistsAtPath(commandPath) { + try { + return existsSync(commandPath); + } catch { + return false; + } +} + +function findCommandInPath(commandName) { + const pathEntries = splitPathEntries(process.env.PATH ?? ""); + const hasExtension = /\.[A-Za-z0-9]+$/.test(commandName); + const windowsExtensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1") + .split(";") + .map((ext) => ext.trim().toLowerCase()) + .filter((ext) => ext.length > 0); + + for (const entry of pathEntries) { + if (process.platform !== "win32") { + const candidate = join(entry, commandName); + if (commandExistsAtPath(candidate)) { + return candidate; + } + continue; + } + + const candidates = []; + if (hasExtension) { + candidates.push(join(entry, commandName)); + } else { + candidates.push(join(entry, commandName)); + for (const extension of windowsExtensions) { + candidates.push(join(entry, `${commandName}${extension.toLowerCase()}`)); + } + } + + for (const candidate of candidates) { + if (commandExistsAtPath(candidate)) { + return candidate; + } + } + } + + return null; +} + +function getNodeMajor(versionText) { + const clean = versionText.trim().replace(/^v/, ""); + const major = Number.parseInt(clean.split(".")[0] ?? "", 10); + return Number.isFinite(major) ? major : null; +} + +function readPackageScripts(repoRoot) { + const packageJsonPath = join(repoRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + return null; + } + + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (typeof packageJson !== "object" || packageJson === null) { + return null; + } + const scripts = packageJson.scripts; + if (typeof scripts !== "object" || scripts === null) { + return null; + } + return scripts; + } catch { + return null; + } +} + +export function runDevDoctor(options = {}) { + const cwd = resolve(options.cwd ?? process.cwd()); + const failures = []; + const warnings = []; + const details = []; + + const nodeVersion = process.version; + const nodeMajor = getNodeMajor(nodeVersion); + if (nodeMajor === null || nodeMajor < REQUIRED_NODE_MAJOR) { + failures.push( + `Node.js ${REQUIRED_NODE_MAJOR}+ is required. Found ${nodeVersion}.`, + ); + } else { + details.push(`Node.js ${nodeVersion} OK`); + } + + const npmExecPathFromEnv = (process.env.npm_execpath ?? "").trim(); + const npmPath = npmExecPathFromEnv.length > 0 ? npmExecPathFromEnv : findCommandInPath("npm"); + if (!npmPath) { + failures.push("npm is required but was not found in PATH."); + } else { + details.push(`npm entrypoint detected at ${npmPath}`); + } + + const gitPath = findCommandInPath("git"); + if (!gitPath) { + failures.push("git is required but was not found in PATH."); + } else { + const gitCheck = runCommand(gitPath, ["--version"]); + if (gitCheck.status === 0 && gitCheck.stdout.trim().length > 0) { + details.push(`${gitCheck.stdout.trim()} OK`); + } else { + details.push(`git entrypoint detected at ${gitPath}`); + } + } + + if (!existsSync(join(cwd, ".git"))) { + failures.push(`No .git entry found in ${cwd}. Run this command from repo root.`); + } else { + details.push("Git worktree root detected"); + } + + if (!existsSync(join(cwd, "package.json"))) { + failures.push("package.json is missing in the current directory."); + } else { + details.push("package.json detected"); + } + + if (!existsSync(join(cwd, "package-lock.json"))) { + failures.push("package-lock.json is missing. This repo expects npm lockfile-based installs."); + } else { + details.push("package-lock.json detected"); + } + + const scripts = readPackageScripts(cwd); + const requiredScripts = ["typecheck", "lint", "test", "build"]; + for (const scriptName of requiredScripts) { + if (!scripts || typeof scripts[scriptName] !== "string") { + failures.push(`Missing required npm script: ${scriptName}`); + } + } + + if (!existsSync(join(cwd, "node_modules"))) { + warnings.push("node_modules is missing. Run npm ci before running local validation."); + } + + for (const detail of details) { + console.log(`OK: ${detail}`); + } + for (const warning of warnings) { + console.warn(`WARN: ${warning}`); + } + for (const failure of failures) { + console.error(`ERROR: ${failure}`); + } + + if (failures.length > 0) { + console.error("Dev doctor failed. Fix errors above and re-run."); + return 1; + } + + console.log("Dev doctor passed."); + return 0; +} + +const isDirectRun = (() => { + try { + return resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isDirectRun) { + process.exitCode = runDevDoctor(); +} diff --git a/scripts/setup-dev.js b/scripts/setup-dev.js new file mode 100644 index 000000000..633addabc --- /dev/null +++ b/scripts/setup-dev.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { runDevDoctor } from "./doctor-dev.js"; + +function resolveNpmInvocation() { + const npmExecPath = (process.env.npm_execpath ?? "").trim(); + if (npmExecPath.length > 0) { + return { + command: process.execPath, + prefixArgs: [npmExecPath], + }; + } + + return { + command: process.platform === "win32" ? "npm.cmd" : "npm", + prefixArgs: [], + }; +} + +function runCommand(command, args = [], cwd = process.cwd()) { + return new Promise((resolveExitCode) => { + const child = spawn(command, args, { + cwd, + stdio: "inherit", + env: process.env, + }); + + child.once("error", (error) => { + console.error(`Failed to run command: ${command} ${args.join(" ")}`); + console.error(String(error)); + resolveExitCode(1); + }); + + child.once("exit", (code, signal) => { + if (signal) { + resolveExitCode(signal === "SIGINT" ? 130 : 1); + return; + } + resolveExitCode(typeof code === "number" ? code : 1); + }); + }); +} + +export async function runSetupDev(options = {}) { + const cwd = resolve(options.cwd ?? process.cwd()); + + console.log("Running dev environment checks..."); + const doctorExitCode = runDevDoctor({ cwd }); + if (doctorExitCode !== 0) { + return doctorExitCode; + } + + const npmInvocation = resolveNpmInvocation(); + const runNpm = (args) => + runCommand(npmInvocation.command, [...npmInvocation.prefixArgs, ...args], cwd); + + console.log("Installing dependencies with npm ci..."); + const installExitCode = await runNpm(["ci"]); + if (installExitCode !== 0) { + console.error("setup:dev failed during npm ci."); + return installExitCode; + } + + console.log("Running local validation gate..."); + const gateCommands = [ + ["run", "lint"], + ["run", "typecheck"], + ["run", "build"], + ["test"], + ]; + + for (const gateCommand of gateCommands) { + const gateExitCode = await runNpm(gateCommand); + if (gateExitCode !== 0) { + console.error(`setup:dev failed on: npm ${gateCommand.join(" ")}`); + return gateExitCode; + } + } + + console.log("setup:dev completed successfully."); + return 0; +} + +const isDirectRun = (() => { + try { + return resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isDirectRun) { + const exitCode = await runSetupDev(); + process.exitCode = exitCode; +} From 5d0da221038c84e39aea001620c97b4a6820a079 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 11:50:25 +0800 Subject: [PATCH 2/6] chore(dx): unify verify gates and add release/local runbooks Add a canonical verify workflow shared by local and CI, integrate scoped Biome formatting checks, and document contributor/release runbooks to reduce setup and release friction. Co-authored-by: Codex --- .github/pull_request_template.md | 6 +- .github/workflows/ci.yml | 28 +-- CONTRIBUTING.md | 11 +- README.md | 14 +- biome.jsonc | 11 +- docs/DOCUMENTATION.md | 2 + docs/README.md | 2 + docs/development/LOCAL_DEV.md | 80 ++++++++ docs/development/RELEASE_RUNBOOK.md | 57 ++++++ docs/development/TESTING.md | 17 +- package-lock.json | 164 ++++++++++++++++ package.json | 277 +++++++++++++++------------- scripts/setup-dev.js | 6 +- 13 files changed, 507 insertions(+), 168 deletions(-) create mode 100644 docs/development/LOCAL_DEV.md create mode 100644 docs/development/RELEASE_RUNBOOK.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 01ad1026e..bcebbad21 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,13 +8,15 @@ ## Validation -- [ ] `npm run doctor:dev` -- [ ] `npm run setup:dev` +- [ ] `npm run verify` +- [ ] `npm run verify:ci` - [ ] `npm test -- test/documentation.test.ts` - [ ] `npm run lint` - [ ] `npm run typecheck` - [ ] `npm test` - [ ] `npm run build` +- [ ] `npm run doctor:dev` (when troubleshooting setup/environment issues) +- [ ] `npm run setup:dev` (for first-clone reproducibility checks) ## Docs and Governance Checklist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5e705a4e..2591bf6ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,15 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: name: Test on Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest + timeout-minutes: 30 strategy: matrix: @@ -28,32 +33,18 @@ jobs: - name: Install dependencies run: npm ci - - name: Repository hygiene check - run: npm run clean:repo:check - - - name: Security audit (CI policy) - run: npm run audit:ci - - - name: Lockfile floor guard - run: npm run test -- test/lockfile-version-floor.test.ts + - name: Run CI verify pipeline + run: npm run verify:ci - name: Security audit (full dependency tree, non-blocking) continue-on-error: true run: npm run audit:all - - name: Run type check - run: npm run typecheck - - - name: Run tests with coverage - run: npm run coverage - - - name: Build - run: npm run build - lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code @@ -71,12 +62,13 @@ jobs: - name: Dev doctor sanity check run: npm run doctor:dev - - name: Run ESLint + - name: Run lint and format checks run: npm run lint codex-compat: name: Codex Compatibility Smoke runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb8a065c3..fa38123c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,12 +22,17 @@ If a proposal conflicts with OpenAI policy boundaries, it will be declined. ## Local Setup ```bash -npm run doctor:dev npm run setup:dev +npm run verify ``` Node requirement: `>=18`. +Supporting commands: + +- `npm run doctor:dev` for prerequisite and repo-shape checks +- `npm run format` to apply Biome formatting for repo config files + --- ## Development Standards @@ -53,10 +58,10 @@ Documentation requirements for behavior changes: 1. Create a focused branch from `main`. 2. Keep commits atomic and reviewable. 3. Run full local gate: - - `npm run doctor:dev` - - `npm run setup:dev` + - `npm run verify` - `npm run test -- test/documentation.test.ts` 4. If triaging failures, run component gates directly: + - `npm run doctor:dev` - `npm run typecheck` - `npm run lint` - `npm test` diff --git a/README.md b/README.md index 4e3b5b6a9..1c90f836b 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,20 @@ codex auth check From repo root: ```bash -npm run doctor:dev npm run setup:dev ``` -`doctor:dev` validates local prerequisites and required project files. -`setup:dev` runs install plus the local quality gate (`lint`, `typecheck`, `build`, `test`). +Daily validation: + +```bash +npm run doctor:dev +npm run verify +``` + +- `doctor:dev` validates local prerequisites and required project files. +- `setup:dev` runs install plus the local validation gate. +- `verify` is the canonical local and CI gate. +- `format` applies Biome formatting for repo config files (JSON/JSONC/YAML). --- diff --git a/biome.jsonc b/biome.jsonc index 71ef6c65e..f670f6576 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -6,7 +6,16 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist"] + "includes": [ + "package.json", + "biome.jsonc", + ".github/**/*.yml", + ".github/**/*.yaml", + "!!dist/**", + "!!coverage/**", + "!!node_modules/**", + "!!vendor/**" + ] }, "formatter": { "enabled": true, diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 8e8b0edcb..8da83a02e 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -39,6 +39,8 @@ Canonical governance for repository documentation quality and consistency. | IA/findability audit (2026-03-01) | `docs/development/IA_FINDABILITY_AUDIT_2026-03-01.md` | | Config fields internals | `docs/development/CONFIG_FIELDS.md` | | Config flow internals | `docs/development/CONFIG_FLOW.md` | +| Local development runbook | `docs/development/LOCAL_DEV.md` | +| Release runbook | `docs/development/RELEASE_RUNBOOK.md` | | Repository ownership map | `docs/development/REPOSITORY_SCOPE.md` | | Testing and release gates | `docs/development/TESTING.md` | | TUI parity checklist | `docs/development/TUI_PARITY_CHECKLIST.md` | diff --git a/docs/README.md b/docs/README.md index 2accdd99f..5d0cde6d4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,8 @@ Canonical documentation map for `codex-multi-auth`. | [development/IA_FINDABILITY_AUDIT_2026-03-01.md](development/IA_FINDABILITY_AUDIT_2026-03-01.md) | IA/findability baseline, mismatches, and migration plan | | [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md) | Complete field and env inventory | | [development/CONFIG_FLOW.md](development/CONFIG_FLOW.md) | Configuration resolution flow | +| [development/LOCAL_DEV.md](development/LOCAL_DEV.md) | Local contributor setup and validation runbook | +| [development/RELEASE_RUNBOOK.md](development/RELEASE_RUNBOOK.md) | Maintainer release gate and checklist | | [development/REPOSITORY_SCOPE.md](development/REPOSITORY_SCOPE.md) | Ownership map by repository path | | [development/TESTING.md](development/TESTING.md) | Validation gates and test matrix | | [development/TUI_PARITY_CHECKLIST.md](development/TUI_PARITY_CHECKLIST.md) | Dashboard UX parity checklist | diff --git a/docs/development/LOCAL_DEV.md b/docs/development/LOCAL_DEV.md new file mode 100644 index 000000000..8851c4291 --- /dev/null +++ b/docs/development/LOCAL_DEV.md @@ -0,0 +1,80 @@ +# Local Development Runbook + +Canonical contributor workflow for setting up and validating this repository. + +--- + +## Prerequisites + +- Node.js `>=18` +- npm available in `PATH` +- git available in `PATH` + +Verify environment: + +```bash +npm run doctor:dev +``` + +--- + +## First Clone + +From repo root: + +```bash +npm run setup:dev +``` + +`setup:dev` runs: + +1. environment checks (`doctor:dev`) +2. dependency install (`npm ci`) +3. validation gate (`npm run verify`) +4. docs integrity smoke (`npm test -- test/documentation.test.ts`) + +--- + +## Daily Development + +```bash +npm run verify +``` + +Use component commands when debugging failures: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +Format repo config files (JSON/JSONC/YAML): + +```bash +npm run format +``` + +--- + +## Common Failure Modes + +- `doctor:dev` fails on missing npm/git: + - ensure shell `PATH` includes Node.js and git executables +- `verify` fails on audit policy: + - run `npm run audit:ci` to inspect blocking advisory output +- `test/documentation.test.ts` fails with missing `dist/lib/*.js`: + - run `npm run build` and re-run the docs test + +--- + +## CI Parity + +CI uses `npm run verify:ci` for the matrix test gate. + +Local equivalent: + +```bash +npm run verify:ci +``` diff --git a/docs/development/RELEASE_RUNBOOK.md b/docs/development/RELEASE_RUNBOOK.md new file mode 100644 index 000000000..30a512b99 --- /dev/null +++ b/docs/development/RELEASE_RUNBOOK.md @@ -0,0 +1,57 @@ +# Release Runbook + +Maintainer checklist for preparing a reliable release from `main`. + +--- + +## Preconditions + +1. Release PR merged to `main` +2. CI checks green on latest `main` +3. Working tree clean + +--- + +## Validation Gate + +Run from repository root: + +```bash +npm run release:check +``` + +This command runs: + +1. `npm run verify` +2. `npm run test -- test/documentation.test.ts` +3. `npm pack --dry-run` + +--- + +## Documentation Gate + +Before publishing/tagging: + +1. update `CHANGELOG.md` +2. add or update matching release note in `docs/releases/` +3. verify docs links in `README.md` and `docs/README.md` point to the latest stable release note + +--- + +## Publish/Tag Flow + +1. bump version in `package.json` and lockfile as needed +2. commit release metadata +3. create signed/annotated git tag +4. push commit and tag +5. verify package metadata and release notes in GitHub + +--- + +## Rollback + +If release validation fails after version bump: + +1. revert release commit on branch +2. re-run `npm run release:check` +3. open a corrective PR with failure evidence diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index cc251eb2a..f4466d374 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -20,8 +20,9 @@ Coverage thresholds in `vitest.config.ts`: statements/branches/functions/lines > ## Core Commands ```bash -npm run doctor:dev npm run setup:dev +npm run verify +npm run verify:ci ``` Component commands: @@ -46,11 +47,19 @@ npm run bench:edit-formats:smoke ## Recommended Local Gate Before PR -1. `npm run doctor:dev` -2. `npm run setup:dev` -3. `npm run test -- test/documentation.test.ts` +1. `npm run verify` +2. `npm run test -- test/documentation.test.ts` +3. for first clone setup: `npm run setup:dev` 4. run docs command checks for newly documented command paths +## Release Gate + +Run before version bump, tag, or publish workflow: + +1. `npm run release:check` +2. verify changelog and release notes alignment +3. confirm PR checks are green on the release branch + * * * ## Auth/Account Change Test Matrix diff --git a/package-lock.json b/package-lock.json index 93ee2ca5e..60b8c679a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "codex-multi-auth": "scripts/codex-multi-auth.js" }, "devDependencies": { + "@biomejs/biome": "^2.4.2", "@codex-ai/sdk": "file:vendor/codex-ai-sdk", "@fast-check/vitest": "^0.2.4", "@types/node": "^25.3.0", @@ -104,6 +105,169 @@ "node": ">=18" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.5.tgz", + "integrity": "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.5", + "@biomejs/cli-darwin-x64": "2.4.5", + "@biomejs/cli-linux-arm64": "2.4.5", + "@biomejs/cli-linux-arm64-musl": "2.4.5", + "@biomejs/cli-linux-x64": "2.4.5", + "@biomejs/cli-linux-x64-musl": "2.4.5", + "@biomejs/cli-win32-arm64": "2.4.5", + "@biomejs/cli-win32-x64": "2.4.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.5.tgz", + "integrity": "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.5.tgz", + "integrity": "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.5.tgz", + "integrity": "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.5.tgz", + "integrity": "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.5.tgz", + "integrity": "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.5.tgz", + "integrity": "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.5.tgz", + "integrity": "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.5.tgz", + "integrity": "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@codex-ai/plugin": { "resolved": "vendor/codex-ai-plugin", "link": true diff --git a/package.json b/package.json index 60c8d035f..e4b73144f 100644 --- a/package.json +++ b/package.json @@ -1,135 +1,146 @@ { - "name": "codex-multi-auth", - "version": "0.1.7", - "description": "OpenAI Codex CLI multi-account OAuth manager with resilient routing and quota-aware diagnostics", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "type": "module", - "license": "MIT", - "author": "Numman Ali", - "repository": { - "type": "git", - "url": "git+https://github.com/ndycode/codex-multi-auth.git" - }, - "keywords": [ - "openai", - "codex", - "codex-cli", - "chatgpt", - "oauth2", - "pkce", - "multi-account", - "account-rotation", - "token-refresh", - "session-recovery", - "rate-limit-handling", - "resilient-systems", - "fault-tolerance", - "terminal-ui", - "cli-tool", - "typescript", - "nodejs", - "developer-tools", - "authentication", - "productivity-tools" - ], - "homepage": "https://github.com/ndycode/codex-multi-auth#readme", - "bugs": { - "url": "https://github.com/ndycode/codex-multi-auth/issues" - }, - "scripts": { - "build": "tsc && node scripts/copy-oauth-success.js", - "doctor:dev": "node scripts/doctor-dev.js", - "setup:dev": "node scripts/setup-dev.js", - "typecheck": "tsc --noEmit", - "lint": "npm run lint:ts && npm run lint:scripts", - "lint:ts": "eslint . --ext .ts", - "lint:scripts": "eslint scripts --ext .js", - "lint:fix": "npm run lint:ts:fix && npm run lint:scripts:fix", - "lint:ts:fix": "eslint . --ext .ts --fix", - "lint:scripts:fix": "eslint scripts --ext .js --fix", - "test": "vitest run", - "test:watch": "vitest", - "test:ui": "vitest --ui", - "test:model-matrix": "node scripts/test-model-matrix.js", - "test:model-matrix:smoke": "node scripts/test-model-matrix.js --smoke", - "test:model-matrix:report": "node scripts/test-model-matrix.js --smoke --report-json=.tmp/model-matrix-report.json", - "clean:repo": "node scripts/repo-hygiene.js clean --mode aggressive", - "clean:repo:check": "node scripts/repo-hygiene.js check", - "bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core", - "bench:edit-formats:smoke": "node scripts/benchmark-edit-formats.mjs --smoke --preset=codex-core", - "bench:edit-formats:render": "node scripts/benchmark-render-dashboard.mjs", - "bench:runtime-path": "npm run build && node scripts/benchmark-runtime-path.mjs", - "bench:runtime-path:quick": "node scripts/benchmark-runtime-path.mjs", - "test:coverage": "vitest run --coverage", - "coverage": "npm run build && vitest run --coverage", - "audit:prod": "npm audit --omit=dev --audit-level=high", - "audit:all": "npm audit --audit-level=high", - "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", - "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", - "prepublishOnly": "npm run build", - "prepare": "husky" - }, - "bin": { - "codex": "scripts/codex.js", - "codex-multi-auth": "scripts/codex-multi-auth.js" - }, - "files": [ - "dist/", - "assets/", - "config/", - "scripts/", - "vendor/codex-ai-plugin/", - "vendor/codex-ai-sdk/", - "README.md", - "LICENSE" - ], - "bundleDependencies": [ - "@codex-ai/plugin" - ], - "lint-staged": { - "*.ts": [ - "eslint --max-warnings=0 --fix --no-warn-ignored" - ], - "scripts/**/*.js": [ - "eslint --max-warnings=0 --fix --no-warn-ignored" - ] - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "typescript": "^5" - }, - "devDependencies": { - "@fast-check/vitest": "^0.2.4", - "@codex-ai/sdk": "file:vendor/codex-ai-sdk", - "@types/node": "^25.3.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", - "@typescript-eslint/parser": "^8.56.0", - "@vitest/coverage-v8": "^4.0.18", - "@vitest/ui": "^4.0.18", - "eslint": "^10.0.0", - "fast-check": "^4.5.3", - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "typescript": "^5.9.3", - "typescript-language-server": "^5.1.3", - "vitest": "^4.0.18" - }, - "dependencies": { - "@openauthjs/openauth": "^0.4.3", - "@codex-ai/plugin": "file:vendor/codex-ai-plugin", - "hono": "4.12.3", - "zod": "^4.3.6" - }, - "overrides": { - "hono": "4.12.3", - "minimatch": "10.2.4", - "rollup": "4.59.0", - "vite": "^7.3.1", - "@typescript-eslint/typescript-estree": { - "minimatch": "9.0.9" - } - } + "name": "codex-multi-auth", + "version": "0.1.7", + "description": "OpenAI Codex CLI multi-account OAuth manager with resilient routing and quota-aware diagnostics", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "license": "MIT", + "author": "Numman Ali", + "repository": { + "type": "git", + "url": "git+https://github.com/ndycode/codex-multi-auth.git" + }, + "keywords": [ + "openai", + "codex", + "codex-cli", + "chatgpt", + "oauth2", + "pkce", + "multi-account", + "account-rotation", + "token-refresh", + "session-recovery", + "rate-limit-handling", + "resilient-systems", + "fault-tolerance", + "terminal-ui", + "cli-tool", + "typescript", + "nodejs", + "developer-tools", + "authentication", + "productivity-tools" + ], + "homepage": "https://github.com/ndycode/codex-multi-auth#readme", + "bugs": { + "url": "https://github.com/ndycode/codex-multi-auth/issues" + }, + "scripts": { + "build": "tsc && node scripts/copy-oauth-success.js", + "doctor:dev": "node scripts/doctor-dev.js", + "setup:dev": "node scripts/setup-dev.js", + "typecheck": "tsc --noEmit", + "lint": "npm run lint:ts && npm run lint:scripts && npm run format:check", + "lint:ts": "eslint . --ext .ts", + "lint:scripts": "eslint scripts --ext .js", + "lint:fix": "npm run lint:ts:fix && npm run lint:scripts:fix", + "lint:ts:fix": "eslint . --ext .ts --fix", + "lint:scripts:fix": "eslint scripts --ext .js --fix", + "format": "biome format . --write", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:model-matrix": "node scripts/test-model-matrix.js", + "test:model-matrix:smoke": "node scripts/test-model-matrix.js --smoke", + "test:model-matrix:report": "node scripts/test-model-matrix.js --smoke --report-json=.tmp/model-matrix-report.json", + "clean:repo": "node scripts/repo-hygiene.js clean --mode aggressive", + "clean:repo:check": "node scripts/repo-hygiene.js check", + "bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core", + "bench:edit-formats:smoke": "node scripts/benchmark-edit-formats.mjs --smoke --preset=codex-core", + "bench:edit-formats:render": "node scripts/benchmark-render-dashboard.mjs", + "bench:runtime-path": "npm run build && node scripts/benchmark-runtime-path.mjs", + "bench:runtime-path:quick": "node scripts/benchmark-runtime-path.mjs", + "test:coverage": "vitest run --coverage", + "coverage": "npm run build && vitest run --coverage", + "verify:repo": "npm run clean:repo:check && npm run audit:ci && npm run test -- test/lockfile-version-floor.test.ts", + "verify:quality": "npm run lint && npm run typecheck && npm run coverage", + "verify": "npm run verify:repo && npm run verify:quality", + "verify:ci": "npm run verify:repo && npm run verify:quality", + "audit:prod": "npm audit --omit=dev --audit-level=high", + "audit:all": "npm audit --audit-level=high", + "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", + "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", + "release:check": "npm run verify && npm run test -- test/documentation.test.ts && npm pack --dry-run", + "prepublishOnly": "npm run build", + "prepare": "husky" + }, + "bin": { + "codex": "scripts/codex.js", + "codex-multi-auth": "scripts/codex-multi-auth.js" + }, + "files": [ + "dist/", + "assets/", + "config/", + "scripts/", + "vendor/codex-ai-plugin/", + "vendor/codex-ai-sdk/", + "README.md", + "LICENSE" + ], + "bundleDependencies": [ + "@codex-ai/plugin" + ], + "lint-staged": { + "*.ts": [ + "eslint --max-warnings=0 --fix --no-warn-ignored" + ], + "scripts/**/*.js": [ + "eslint --max-warnings=0 --fix --no-warn-ignored" + ], + "*.{json,jsonc,yml,yaml}": [ + "biome format --write --files-ignore-unknown=true" + ] + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2", + "@fast-check/vitest": "^0.2.4", + "@codex-ai/sdk": "file:vendor/codex-ai-sdk", + "@types/node": "^25.3.0", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "eslint": "^10.0.0", + "fast-check": "^4.5.3", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "typescript": "^5.9.3", + "typescript-language-server": "^5.1.3", + "vitest": "^4.0.18" + }, + "dependencies": { + "@openauthjs/openauth": "^0.4.3", + "@codex-ai/plugin": "file:vendor/codex-ai-plugin", + "hono": "4.12.3", + "zod": "^4.3.6" + }, + "overrides": { + "hono": "4.12.3", + "minimatch": "10.2.4", + "rollup": "4.59.0", + "vite": "^7.3.1", + "@typescript-eslint/typescript-estree": { + "minimatch": "9.0.9" + } + } } diff --git a/scripts/setup-dev.js b/scripts/setup-dev.js index 633addabc..cd3f76907 100644 --- a/scripts/setup-dev.js +++ b/scripts/setup-dev.js @@ -67,10 +67,8 @@ export async function runSetupDev(options = {}) { console.log("Running local validation gate..."); const gateCommands = [ - ["run", "lint"], - ["run", "typecheck"], - ["run", "build"], - ["test"], + ["run", "verify"], + ["test", "--", "test/documentation.test.ts"], ]; for (const gateCommand of gateCommands) { From ccf3e66165048dae400de92e36d79eaebae98fd3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 12:35:54 +0800 Subject: [PATCH 3/6] test(dx): cover bootstrap scripts and harden Windows setup retries Add regression tests for doctor/setup scripts, make setup command execution deterministic under event-order races, add bounded Windows npm ci retries, and document contributor upgrade + Windows lock remediation guidance. Co-authored-by: Codex --- docs/development/LOCAL_DEV.md | 5 + docs/development/TESTING.md | 1 + docs/upgrade.md | 12 +++ scripts/doctor-dev.js | 170 ++++++++++++++++++++++++-------- scripts/setup-dev.js | 88 ++++++++++++++--- test/scripts/doctor-dev.test.ts | 131 ++++++++++++++++++++++++ test/scripts/setup-dev.test.ts | 164 ++++++++++++++++++++++++++++++ 7 files changed, 514 insertions(+), 57 deletions(-) create mode 100644 test/scripts/doctor-dev.test.ts create mode 100644 test/scripts/setup-dev.test.ts diff --git a/docs/development/LOCAL_DEV.md b/docs/development/LOCAL_DEV.md index 8851c4291..47603d909 100644 --- a/docs/development/LOCAL_DEV.md +++ b/docs/development/LOCAL_DEV.md @@ -64,6 +64,11 @@ npm run format - ensure shell `PATH` includes Node.js and git executables - `verify` fails on audit policy: - run `npm run audit:ci` to inspect blocking advisory output +- `setup:dev`/`doctor:dev` on Windows fails with transient `EBUSY`/`EPERM` lock errors: + - retry `npm ci` first (transient antivirus/file contention is common) + - if it persists, pause antivirus or exclude the repository, then re-run in an elevated PowerShell/CMD session + - if state looks corrupted, run `git clean -fdx` then run `npm ci` again + - if lock contention still stalls installs, try `npm ci --no-audit` or run setup from WSL2 - `test/documentation.test.ts` fails with missing `dist/lib/*.js`: - run `npm run build` and re-run the docs test diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index f4466d374..3ddabe718 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -21,6 +21,7 @@ Coverage thresholds in `vitest.config.ts`: statements/branches/functions/lines > ```bash npm run setup:dev +npm run doctor:dev npm run verify npm run verify:ci ``` diff --git a/docs/upgrade.md b/docs/upgrade.md index e34ecb2d4..90f45c7f8 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -49,6 +49,18 @@ codex auth forecast --live --model gpt-5-codex --- +## Contributor Workflow Upgrade (`doctor:dev` / `setup:dev`) + +Repository contributors should adopt the bootstrap workflow documented in `README.md`: + +- `npm run doctor:dev`: validates local prerequisites and repository shape before running gates. +- `npm run setup:dev`: runs `doctor:dev`, installs with `npm ci`, then executes the local validation gate. +- `npm run verify`: canonical local parity gate used by CI (`verify:ci`). + +After pulling this change set, run `npm run setup:dev` once per clone/worktree to align local tooling expectations. + +--- + ## Configuration Upgrade Notes During upgrades, runtime config source precedence is: diff --git a/scripts/doctor-dev.js b/scripts/doctor-dev.js index ac91222db..fa7fe7b5c 100644 --- a/scripts/doctor-dev.js +++ b/scripts/doctor-dev.js @@ -7,10 +7,39 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; const REQUIRED_NODE_MAJOR = 18; +const WINDOWS_LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); +const PACKAGE_READ_RETRY_ATTEMPTS = 4; +const PACKAGE_READ_BASE_DELAY_MS = 40; -function runCommand(command, args = []) { +function sleepSync(milliseconds) { + if (milliseconds <= 0) { + return; + } + + try { + const sleepBuffer = new SharedArrayBuffer(4); + const sleepArray = new Int32Array(sleepBuffer); + Atomics.wait(sleepArray, 0, 0, milliseconds); + } catch { + const end = Date.now() + milliseconds; + while (Date.now() < end) { + // Synchronous fallback only used for tiny retry delays. + } + } +} + +function isWindowsLockError(error) { + if (typeof error !== "object" || error === null) { + return false; + } + + const { code } = error; + return typeof code === "string" && WINDOWS_LOCK_CODES.has(code.toUpperCase()); +} + +function runCommand(command, args = [], spawnFn = spawnSync) { try { - return spawnSync(command, args, { + return spawnFn(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); @@ -23,37 +52,41 @@ function runCommand(command, args = []) { } } -function splitPathEntries(pathValue) { +export function splitPathEntries(pathValue, platform = process.platform) { if (typeof pathValue !== "string" || pathValue.trim().length === 0) { return []; } - const delimiter = process.platform === "win32" ? ";" : ":"; + + const delimiter = platform === "win32" ? ";" : ":"; return pathValue .split(delimiter) .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); } -function commandExistsAtPath(commandPath) { +function commandExistsAtPath(commandPath, pathExists = existsSync) { try { - return existsSync(commandPath); + return pathExists(commandPath); } catch { return false; } } -function findCommandInPath(commandName) { - const pathEntries = splitPathEntries(process.env.PATH ?? ""); +export function findCommandInPath(commandName, options = {}) { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const pathExists = options.pathExists ?? existsSync; + const pathEntries = splitPathEntries(env.PATH ?? "", platform); const hasExtension = /\.[A-Za-z0-9]+$/.test(commandName); - const windowsExtensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1") + const windowsExtensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1") .split(";") .map((ext) => ext.trim().toLowerCase()) .filter((ext) => ext.length > 0); for (const entry of pathEntries) { - if (process.platform !== "win32") { + if (platform !== "win32") { const candidate = join(entry, commandName); - if (commandExistsAtPath(candidate)) { + if (commandExistsAtPath(candidate, pathExists)) { return candidate; } continue; @@ -70,7 +103,7 @@ function findCommandInPath(commandName) { } for (const candidate of candidates) { - if (commandExistsAtPath(candidate)) { + if (commandExistsAtPath(candidate, pathExists)) { return candidate; } } @@ -79,40 +112,82 @@ function findCommandInPath(commandName) { return null; } -function getNodeMajor(versionText) { +export function getNodeMajor(versionText) { const clean = versionText.trim().replace(/^v/, ""); const major = Number.parseInt(clean.split(".")[0] ?? "", 10); return Number.isFinite(major) ? major : null; } -function readPackageScripts(repoRoot) { +function parsePackageScripts(packageJsonText) { + const packageJson = JSON.parse(packageJsonText); + if (typeof packageJson !== "object" || packageJson === null) { + return null; + } + + const scripts = packageJson.scripts; + if (typeof scripts !== "object" || scripts === null) { + return null; + } + + return scripts; +} + +export function readPackageScripts(repoRoot, options = {}) { + const platform = options.platform ?? process.platform; + const pathExists = options.pathExists ?? existsSync; + const readFile = options.readFile ?? readFileSync; + const maxAttempts = + typeof options.maxAttempts === "number" + ? options.maxAttempts + : platform === "win32" + ? PACKAGE_READ_RETRY_ATTEMPTS + : 1; + const baseDelayMs = + typeof options.baseDelayMs === "number" + ? options.baseDelayMs + : PACKAGE_READ_BASE_DELAY_MS; + const packageJsonPath = join(repoRoot, "package.json"); - if (!existsSync(packageJsonPath)) { + if (!pathExists(packageJsonPath)) { return null; } - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); - if (typeof packageJson !== "object" || packageJson === null) { - return null; - } - const scripts = packageJson.scripts; - if (typeof scripts !== "object" || scripts === null) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const packageJsonText = readFile(packageJsonPath, "utf8"); + return parsePackageScripts(packageJsonText); + } catch (error) { + const shouldRetry = + platform === "win32" && + isWindowsLockError(error) && + attempt < maxAttempts; + if (shouldRetry) { + const delayMs = baseDelayMs * 2 ** (attempt - 1); + sleepSync(delayMs); + continue; + } return null; } - return scripts; - } catch { - return null; } + + return null; } export function runDevDoctor(options = {}) { const cwd = resolve(options.cwd ?? process.cwd()); + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const nodeVersion = options.nodeVersion ?? process.version; + const pathExists = options.pathExists ?? existsSync; + const readFile = options.readFile ?? readFileSync; + const spawnFn = options.spawnSync ?? spawnSync; + const log = options.log ?? console.log; + const warn = options.warn ?? console.warn; + const error = options.error ?? console.error; const failures = []; const warnings = []; const details = []; - const nodeVersion = process.version; const nodeMajor = getNodeMajor(nodeVersion); if (nodeMajor === null || nodeMajor < REQUIRED_NODE_MAJOR) { failures.push( @@ -122,19 +197,22 @@ export function runDevDoctor(options = {}) { details.push(`Node.js ${nodeVersion} OK`); } - const npmExecPathFromEnv = (process.env.npm_execpath ?? "").trim(); - const npmPath = npmExecPathFromEnv.length > 0 ? npmExecPathFromEnv : findCommandInPath("npm"); + const npmExecPathFromEnv = (env.npm_execpath ?? "").trim(); + const npmPath = + npmExecPathFromEnv.length > 0 + ? npmExecPathFromEnv + : findCommandInPath("npm", { env, platform, pathExists }); if (!npmPath) { failures.push("npm is required but was not found in PATH."); } else { details.push(`npm entrypoint detected at ${npmPath}`); } - const gitPath = findCommandInPath("git"); + const gitPath = findCommandInPath("git", { env, platform, pathExists }); if (!gitPath) { failures.push("git is required but was not found in PATH."); } else { - const gitCheck = runCommand(gitPath, ["--version"]); + const gitCheck = runCommand(gitPath, ["--version"], spawnFn); if (gitCheck.status === 0 && gitCheck.stdout.trim().length > 0) { details.push(`${gitCheck.stdout.trim()} OK`); } else { @@ -142,52 +220,58 @@ export function runDevDoctor(options = {}) { } } - if (!existsSync(join(cwd, ".git"))) { + if (!pathExists(join(cwd, ".git"))) { failures.push(`No .git entry found in ${cwd}. Run this command from repo root.`); } else { details.push("Git worktree root detected"); } - if (!existsSync(join(cwd, "package.json"))) { + if (!pathExists(join(cwd, "package.json"))) { failures.push("package.json is missing in the current directory."); } else { details.push("package.json detected"); } - if (!existsSync(join(cwd, "package-lock.json"))) { + if (!pathExists(join(cwd, "package-lock.json"))) { failures.push("package-lock.json is missing. This repo expects npm lockfile-based installs."); } else { details.push("package-lock.json detected"); } - const scripts = readPackageScripts(cwd); + const scripts = readPackageScripts(cwd, { platform, pathExists, readFile }); const requiredScripts = ["typecheck", "lint", "test", "build"]; - for (const scriptName of requiredScripts) { - if (!scripts || typeof scripts[scriptName] !== "string") { - failures.push(`Missing required npm script: ${scriptName}`); + if (!scripts) { + failures.push( + "Unable to read package.json scripts. Re-run doctor:dev if Windows EBUSY/EPERM file locks are transient.", + ); + } else { + for (const scriptName of requiredScripts) { + if (typeof scripts[scriptName] !== "string") { + failures.push(`Missing required npm script: ${scriptName}`); + } } } - if (!existsSync(join(cwd, "node_modules"))) { + if (!pathExists(join(cwd, "node_modules"))) { warnings.push("node_modules is missing. Run npm ci before running local validation."); } for (const detail of details) { - console.log(`OK: ${detail}`); + log(`OK: ${detail}`); } for (const warning of warnings) { - console.warn(`WARN: ${warning}`); + warn(`WARN: ${warning}`); } for (const failure of failures) { - console.error(`ERROR: ${failure}`); + error(`ERROR: ${failure}`); } if (failures.length > 0) { - console.error("Dev doctor failed. Fix errors above and re-run."); + error("Dev doctor failed. Fix errors above and re-run."); return 1; } - console.log("Dev doctor passed."); + log("Dev doctor passed."); return 0; } diff --git a/scripts/setup-dev.js b/scripts/setup-dev.js index cd3f76907..d7c795114 100644 --- a/scripts/setup-dev.js +++ b/scripts/setup-dev.js @@ -6,60 +6,120 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { runDevDoctor } from "./doctor-dev.js"; -function resolveNpmInvocation() { - const npmExecPath = (process.env.npm_execpath ?? "").trim(); +const WINDOWS_INSTALL_RETRY_ATTEMPTS = 3; +const WINDOWS_INSTALL_RETRY_BASE_DELAY_MS = 300; + +async function waitForMilliseconds(milliseconds, waitFn) { + if (typeof waitFn === "function") { + await waitFn(milliseconds); + return; + } + await new Promise((resolveDelay) => { + setTimeout(resolveDelay, milliseconds); + }); +} + +export function resolveNpmInvocation(options = {}) { + const platform = options.platform ?? process.platform; + const npmExecPath = (options.npmExecPath ?? process.env.npm_execpath ?? "").trim(); + const execPath = options.execPath ?? process.execPath; + if (npmExecPath.length > 0) { return { - command: process.execPath, + command: execPath, prefixArgs: [npmExecPath], }; } return { - command: process.platform === "win32" ? "npm.cmd" : "npm", + command: platform === "win32" ? "npm.cmd" : "npm", prefixArgs: [], }; } -function runCommand(command, args = [], cwd = process.cwd()) { +export function runCommand(command, args = [], cwd = process.cwd(), options = {}) { + const spawnFactory = options.spawnFactory ?? spawn; + const env = options.env ?? process.env; + return new Promise((resolveExitCode) => { - const child = spawn(command, args, { + let settled = false; + const settle = (exitCode) => { + if (settled) { + return; + } + settled = true; + resolveExitCode(exitCode); + }; + + const child = spawnFactory(command, args, { cwd, stdio: "inherit", - env: process.env, + env, }); child.once("error", (error) => { console.error(`Failed to run command: ${command} ${args.join(" ")}`); console.error(String(error)); - resolveExitCode(1); + settle(1); }); child.once("exit", (code, signal) => { if (signal) { - resolveExitCode(signal === "SIGINT" ? 130 : 1); + settle(signal === "SIGINT" ? 130 : 1); return; } - resolveExitCode(typeof code === "number" ? code : 1); + settle(typeof code === "number" ? code : 1); }); }); } export async function runSetupDev(options = {}) { const cwd = resolve(options.cwd ?? process.cwd()); + const platform = options.platform ?? process.platform; + const runDoctor = options.runDevDoctorFn ?? runDevDoctor; + const runCommandFn = options.runCommandFn ?? ((command, args, commandCwd) => runCommand(command, args, commandCwd)); + const installRetryAttempts = + typeof options.installRetryAttempts === "number" + ? options.installRetryAttempts + : platform === "win32" + ? WINDOWS_INSTALL_RETRY_ATTEMPTS + : 1; + const installRetryBaseDelayMs = + typeof options.installRetryBaseDelayMs === "number" + ? options.installRetryBaseDelayMs + : WINDOWS_INSTALL_RETRY_BASE_DELAY_MS; + const npmInvocation = + options.npmInvocation ?? + resolveNpmInvocation({ + platform, + npmExecPath: options.npmExecPath, + execPath: options.execPath, + }); console.log("Running dev environment checks..."); - const doctorExitCode = runDevDoctor({ cwd }); + const doctorExitCode = runDoctor({ cwd }); if (doctorExitCode !== 0) { return doctorExitCode; } - const npmInvocation = resolveNpmInvocation(); const runNpm = (args) => - runCommand(npmInvocation.command, [...npmInvocation.prefixArgs, ...args], cwd); + runCommandFn(npmInvocation.command, [...npmInvocation.prefixArgs, ...args], cwd); console.log("Installing dependencies with npm ci..."); - const installExitCode = await runNpm(["ci"]); + let installExitCode = 1; + for (let attempt = 1; attempt <= installRetryAttempts; attempt += 1) { + installExitCode = await runNpm(["ci"]); + if (installExitCode === 0) { + break; + } + if (attempt < installRetryAttempts) { + const delayMs = installRetryBaseDelayMs * 2 ** (attempt - 1); + console.warn( + `npm ci failed (attempt ${attempt}/${installRetryAttempts}). Retrying in ${delayMs}ms to tolerate transient EBUSY/EPERM Windows file locks...`, + ); + await waitForMilliseconds(delayMs, options.waitFn); + } + } if (installExitCode !== 0) { console.error("setup:dev failed during npm ci."); return installExitCode; diff --git a/test/scripts/doctor-dev.test.ts b/test/scripts/doctor-dev.test.ts new file mode 100644 index 000000000..579276e7e --- /dev/null +++ b/test/scripts/doctor-dev.test.ts @@ -0,0 +1,131 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + readPackageScripts, + runDevDoctor, +} from "../../scripts/doctor-dev.js"; + +function createDoctorFixture(scripts = { build: "echo build", lint: "echo lint", test: "echo test", typecheck: "echo typecheck" }) { + const root = mkdtempSync(join(tmpdir(), "codex-doctor-dev-")); + const binDir = join(root, "bin"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(join(root, "node_modules"), { recursive: true }); + writeFileSync(join(root, ".git"), ""); + writeFileSync(join(root, "package-lock.json"), "{}\n"); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify({ name: "fixture", version: "1.0.0", scripts }, null, 2)}\n`, + ); + writeFileSync(join(binDir, "npm"), ""); + writeFileSync(join(binDir, "git"), ""); + writeFileSync(join(binDir, "npm.cmd"), ""); + writeFileSync(join(binDir, "git.cmd"), ""); + return { root, binDir }; +} + +describe("doctor-dev script", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("passes on win32 when commands are found via PATHEXT lookup", () => { + const fixture = createDoctorFixture(); + tempDirs.push(fixture.root); + + const logs: string[] = []; + const warnings: string[] = []; + const errors: string[] = []; + const code = runDevDoctor({ + cwd: fixture.root, + platform: "win32", + env: { + PATH: fixture.binDir, + PATHEXT: ".EXE;.CMD", + npm_execpath: "", + }, + nodeVersion: "v20.9.0", + log: (message) => logs.push(String(message)), + warn: (message) => warnings.push(String(message)), + error: (message) => errors.push(String(message)), + }); + + expect(code).toBe(0); + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(logs.some((line) => line.includes("npm entrypoint detected"))).toBe(true); + expect(logs.some((line) => line.includes("Dev doctor passed."))).toBe(true); + }); + + it("fails when required package scripts are missing", () => { + const fixture = createDoctorFixture({ + lint: "echo lint", + test: "echo test", + typecheck: "echo typecheck", + }); + tempDirs.push(fixture.root); + + const errors: string[] = []; + const code = runDevDoctor({ + cwd: fixture.root, + platform: "linux", + env: { + PATH: fixture.binDir, + npm_execpath: "/tmp/npm-cli.js", + }, + error: (message) => errors.push(String(message)), + warn: () => {}, + log: () => {}, + }); + + expect(code).toBe(1); + expect(errors.some((line) => line.includes("Missing required npm script: build"))).toBe(true); + }); + + it("fails fast on unsupported Node major version", () => { + const fixture = createDoctorFixture(); + tempDirs.push(fixture.root); + + const errors: string[] = []; + const code = runDevDoctor({ + cwd: fixture.root, + platform: "linux", + env: { + PATH: fixture.binDir, + npm_execpath: "/tmp/npm-cli.js", + }, + nodeVersion: "v16.20.0", + error: (message) => errors.push(String(message)), + warn: () => {}, + log: () => {}, + }); + + expect(code).toBe(1); + expect(errors.some((line) => line.includes("Node.js 18+ is required"))).toBe(true); + }); + + it("retries transient Windows lock errors when reading package.json", () => { + let attempts = 0; + const scripts = readPackageScripts("C:/repo", { + platform: "win32", + pathExists: () => true, + readFile: () => { + attempts += 1; + if (attempts === 1) { + throw Object.assign(new Error("busy"), { code: "EBUSY" }); + } + return '{"scripts":{"build":"ok"}}'; + }, + baseDelayMs: 0, + maxAttempts: 2, + }); + + expect(scripts).toEqual({ build: "ok" }); + expect(attempts).toBe(2); + }); +}); diff --git a/test/scripts/setup-dev.test.ts b/test/scripts/setup-dev.test.ts new file mode 100644 index 000000000..395b2b2b0 --- /dev/null +++ b/test/scripts/setup-dev.test.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { + resolveNpmInvocation, + runCommand, + runSetupDev, +} from "../../scripts/setup-dev.js"; + +describe("setup-dev script", () => { + it("uses process.execPath + npm_execpath when npm_execpath is set", () => { + expect( + resolveNpmInvocation({ + npmExecPath: "/tmp/npm-cli.js", + execPath: "/opt/node/bin/node", + platform: "linux", + }), + ).toEqual({ + command: "/opt/node/bin/node", + prefixArgs: ["/tmp/npm-cli.js"], + }); + }); + + it("falls back to npm.cmd on win32 when npm_execpath is absent", () => { + expect( + resolveNpmInvocation({ + npmExecPath: "", + platform: "win32", + }), + ).toEqual({ + command: "npm.cmd", + prefixArgs: [], + }); + }); + + it("runCommand resolves to 1 when error fires before exit", async () => { + const spawnFactory = vi.fn(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit("error", new Error("boom")); + child.emit("exit", 0, null); + }); + return child; + }); + + const exitCode = await runCommand("npm", ["ci"], process.cwd(), { spawnFactory, env: {} }); + expect(exitCode).toBe(1); + }); + + it("runCommand keeps the first result when exit fires before error", async () => { + const spawnFactory = vi.fn(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit("exit", 0, null); + child.emit("error", new Error("late error")); + }); + return child; + }); + + const exitCode = await runCommand("npm", ["ci"], process.cwd(), { spawnFactory, env: {} }); + expect(exitCode).toBe(0); + }); + + it("runCommand maps SIGINT to exit code 130", async () => { + const spawnFactory = vi.fn(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit("exit", null, "SIGINT"); + }); + return child; + }); + + const exitCode = await runCommand("npm", ["ci"], process.cwd(), { spawnFactory, env: {} }); + expect(exitCode).toBe(130); + }); + + it("runSetupDev executes doctor, install, and validation gates in order", async () => { + const runDevDoctorFn = vi.fn().mockReturnValue(0); + const runCommandFn = vi.fn().mockResolvedValue(0); + const exitCode = await runSetupDev({ + cwd: process.cwd(), + platform: "linux", + runDevDoctorFn, + runCommandFn, + npmInvocation: { command: "npm", prefixArgs: [] }, + }); + + expect(exitCode).toBe(0); + expect(runDevDoctorFn).toHaveBeenCalledTimes(1); + expect(runCommandFn).toHaveBeenNthCalledWith(1, "npm", ["ci"], expect.any(String)); + expect(runCommandFn).toHaveBeenNthCalledWith(2, "npm", ["run", "verify"], expect.any(String)); + expect(runCommandFn).toHaveBeenNthCalledWith( + 3, + "npm", + ["test", "--", "test/documentation.test.ts"], + expect.any(String), + ); + }); + + it("runSetupDev short-circuits when doctor fails", async () => { + const runDevDoctorFn = vi.fn().mockReturnValue(2); + const runCommandFn = vi.fn(); + const exitCode = await runSetupDev({ + cwd: process.cwd(), + runDevDoctorFn, + runCommandFn, + npmInvocation: { command: "npm", prefixArgs: [] }, + }); + + expect(exitCode).toBe(2); + expect(runCommandFn).not.toHaveBeenCalled(); + }); + + it("retries npm ci on win32 before running gates", async () => { + const runCommandFn = vi + .fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0); + const waitFn = vi.fn().mockResolvedValue(undefined); + const exitCode = await runSetupDev({ + cwd: process.cwd(), + platform: "win32", + runDevDoctorFn: () => 0, + runCommandFn, + waitFn, + npmInvocation: { command: "npm.cmd", prefixArgs: [] }, + installRetryAttempts: 3, + installRetryBaseDelayMs: 10, + }); + + expect(exitCode).toBe(0); + expect(runCommandFn).toHaveBeenNthCalledWith(1, "npm.cmd", ["ci"], expect.any(String)); + expect(runCommandFn).toHaveBeenNthCalledWith(2, "npm.cmd", ["ci"], expect.any(String)); + expect(runCommandFn).toHaveBeenNthCalledWith(3, "npm.cmd", ["ci"], expect.any(String)); + expect(waitFn).toHaveBeenCalledTimes(2); + expect(waitFn).toHaveBeenNthCalledWith(1, 10); + expect(waitFn).toHaveBeenNthCalledWith(2, 20); + }); + + it("fails setup when npm ci keeps failing after retries", async () => { + const runCommandFn = vi + .fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1); + const waitFn = vi.fn().mockResolvedValue(undefined); + const exitCode = await runSetupDev({ + cwd: process.cwd(), + platform: "win32", + runDevDoctorFn: () => 0, + runCommandFn, + waitFn, + npmInvocation: { command: "npm.cmd", prefixArgs: [] }, + installRetryAttempts: 3, + installRetryBaseDelayMs: 10, + }); + + expect(exitCode).toBe(1); + expect(runCommandFn).toHaveBeenCalledTimes(3); + expect(waitFn).toHaveBeenCalledTimes(2); + }); +}); From 19758dcb717fcc03870da273fcc60a7545b7a605 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 13:14:05 +0800 Subject: [PATCH 4/6] fix(dx): harden dev doctor checks and align onboarding docs - verify npm/git executability in doctor:dev - require verify script in doctor preflight - add regression coverage for missing verify script - reorder first-clone testing gate flow - keep setup/doctor scripts importable in vitest by removing shebangs Co-authored-by: Codex --- docs/development/TESTING.md | 6 ++--- scripts/doctor-dev.js | 18 ++++++++++---- scripts/setup-dev.js | 2 -- test/scripts/doctor-dev.test.ts | 42 +++++++++++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 3ddabe718..832781477 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -48,9 +48,9 @@ npm run bench:edit-formats:smoke ## Recommended Local Gate Before PR -1. `npm run verify` -2. `npm run test -- test/documentation.test.ts` -3. for first clone setup: `npm run setup:dev` +1. for first clone setup: `npm run setup:dev` +2. `npm run verify` +3. `npm run test -- test/documentation.test.ts` 4. run docs command checks for newly documented command paths ## Release Gate diff --git a/scripts/doctor-dev.js b/scripts/doctor-dev.js index fa7fe7b5c..bc8ab3935 100644 --- a/scripts/doctor-dev.js +++ b/scripts/doctor-dev.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -177,6 +175,7 @@ export function runDevDoctor(options = {}) { const cwd = resolve(options.cwd ?? process.cwd()); const platform = options.platform ?? process.platform; const env = options.env ?? process.env; + const execPath = options.execPath ?? process.execPath; const nodeVersion = options.nodeVersion ?? process.version; const pathExists = options.pathExists ?? existsSync; const readFile = options.readFile ?? readFileSync; @@ -205,7 +204,16 @@ export function runDevDoctor(options = {}) { if (!npmPath) { failures.push("npm is required but was not found in PATH."); } else { - details.push(`npm entrypoint detected at ${npmPath}`); + const npmCheck = + npmExecPathFromEnv.length > 0 + ? runCommand(execPath, [npmExecPathFromEnv, "--version"], spawnFn) + : runCommand(npmPath, ["--version"], spawnFn); + const npmOutput = `${npmCheck.stdout ?? ""}`.trim(); + if (npmCheck.status === 0 && npmOutput.length > 0) { + details.push(`npm ${npmOutput} OK`); + } else { + failures.push(`npm entrypoint detected at ${npmPath} but could not be executed.`); + } } const gitPath = findCommandInPath("git", { env, platform, pathExists }); @@ -216,7 +224,7 @@ export function runDevDoctor(options = {}) { if (gitCheck.status === 0 && gitCheck.stdout.trim().length > 0) { details.push(`${gitCheck.stdout.trim()} OK`); } else { - details.push(`git entrypoint detected at ${gitPath}`); + failures.push(`git entrypoint detected at ${gitPath} but could not be executed.`); } } @@ -239,7 +247,7 @@ export function runDevDoctor(options = {}) { } const scripts = readPackageScripts(cwd, { platform, pathExists, readFile }); - const requiredScripts = ["typecheck", "lint", "test", "build"]; + const requiredScripts = ["typecheck", "lint", "test", "build", "verify"]; if (!scripts) { failures.push( "Unable to read package.json scripts. Re-run doctor:dev if Windows EBUSY/EPERM file locks are transient.", diff --git a/scripts/setup-dev.js b/scripts/setup-dev.js index d7c795114..4b6d83e02 100644 --- a/scripts/setup-dev.js +++ b/scripts/setup-dev.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { spawn } from "node:child_process"; import { resolve } from "node:path"; import process from "node:process"; diff --git a/test/scripts/doctor-dev.test.ts b/test/scripts/doctor-dev.test.ts index 579276e7e..35736184e 100644 --- a/test/scripts/doctor-dev.test.ts +++ b/test/scripts/doctor-dev.test.ts @@ -7,7 +7,13 @@ import { runDevDoctor, } from "../../scripts/doctor-dev.js"; -function createDoctorFixture(scripts = { build: "echo build", lint: "echo lint", test: "echo test", typecheck: "echo typecheck" }) { +function createDoctorFixture(scripts: Record = { + build: "echo build", + lint: "echo lint", + test: "echo test", + typecheck: "echo typecheck", + verify: "echo verify", +}) { const root = mkdtempSync(join(tmpdir(), "codex-doctor-dev-")); const binDir = join(root, "bin"); mkdirSync(binDir, { recursive: true }); @@ -50,6 +56,12 @@ describe("doctor-dev script", () => { npm_execpath: "", }, nodeVersion: "v20.9.0", + spawnSync: (command) => { + if (String(command).toLowerCase().includes("git")) { + return { status: 0, stdout: "git version 2.40.0\n", stderr: "" }; + } + return { status: 0, stdout: "10.9.0\n", stderr: "" }; + }, log: (message) => logs.push(String(message)), warn: (message) => warnings.push(String(message)), error: (message) => errors.push(String(message)), @@ -58,7 +70,7 @@ describe("doctor-dev script", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(warnings).toEqual([]); - expect(logs.some((line) => line.includes("npm entrypoint detected"))).toBe(true); + expect(logs.some((line) => line.includes("npm 10.9.0 OK"))).toBe(true); expect(logs.some((line) => line.includes("Dev doctor passed."))).toBe(true); }); @@ -128,4 +140,30 @@ describe("doctor-dev script", () => { expect(scripts).toEqual({ build: "ok" }); expect(attempts).toBe(2); }); + + it("fails when verify script is missing from package.json", () => { + const fixture = createDoctorFixture({ + build: "echo build", + lint: "echo lint", + test: "echo test", + typecheck: "echo typecheck", + }); + tempDirs.push(fixture.root); + + const errors: string[] = []; + const code = runDevDoctor({ + cwd: fixture.root, + platform: "linux", + env: { + PATH: fixture.binDir, + npm_execpath: "/tmp/npm-cli.js", + }, + error: (message) => errors.push(String(message)), + warn: () => {}, + log: () => {}, + }); + + expect(code).toBe(1); + expect(errors.some((line) => line.includes("Missing required npm script: verify"))).toBe(true); + }); }); From c57681357fdb2ae3da83f728089b840b77a8ed65 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 16:35:16 +0800 Subject: [PATCH 5/6] fix(dx): address remaining CodeRabbit bootstrap comments - await injected async doctor hook in setup flow - tighten PATHEXT fixture to exercise .cmd fallback path - normalize git version output handling pattern in doctor - add regression test for async doctor injection path Co-authored-by: Codex --- scripts/doctor-dev.js | 5 +++-- scripts/setup-dev.js | 2 +- test/scripts/doctor-dev.test.ts | 2 -- test/scripts/setup-dev.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/doctor-dev.js b/scripts/doctor-dev.js index bc8ab3935..46d1a632c 100644 --- a/scripts/doctor-dev.js +++ b/scripts/doctor-dev.js @@ -221,8 +221,9 @@ export function runDevDoctor(options = {}) { failures.push("git is required but was not found in PATH."); } else { const gitCheck = runCommand(gitPath, ["--version"], spawnFn); - if (gitCheck.status === 0 && gitCheck.stdout.trim().length > 0) { - details.push(`${gitCheck.stdout.trim()} OK`); + const gitOutput = `${gitCheck.stdout ?? ""}`.trim(); + if (gitCheck.status === 0 && gitOutput.length > 0) { + details.push(`${gitOutput} OK`); } else { failures.push(`git entrypoint detected at ${gitPath} but could not be executed.`); } diff --git a/scripts/setup-dev.js b/scripts/setup-dev.js index 4b6d83e02..4b98e638b 100644 --- a/scripts/setup-dev.js +++ b/scripts/setup-dev.js @@ -95,7 +95,7 @@ export async function runSetupDev(options = {}) { }); console.log("Running dev environment checks..."); - const doctorExitCode = runDoctor({ cwd }); + const doctorExitCode = await runDoctor({ cwd }); if (doctorExitCode !== 0) { return doctorExitCode; } diff --git a/test/scripts/doctor-dev.test.ts b/test/scripts/doctor-dev.test.ts index 35736184e..174d02717 100644 --- a/test/scripts/doctor-dev.test.ts +++ b/test/scripts/doctor-dev.test.ts @@ -24,8 +24,6 @@ function createDoctorFixture(scripts: Record = { join(root, "package.json"), `${JSON.stringify({ name: "fixture", version: "1.0.0", scripts }, null, 2)}\n`, ); - writeFileSync(join(binDir, "npm"), ""); - writeFileSync(join(binDir, "git"), ""); writeFileSync(join(binDir, "npm.cmd"), ""); writeFileSync(join(binDir, "git.cmd"), ""); return { root, binDir }; diff --git a/test/scripts/setup-dev.test.ts b/test/scripts/setup-dev.test.ts index 395b2b2b0..37c18004d 100644 --- a/test/scripts/setup-dev.test.ts +++ b/test/scripts/setup-dev.test.ts @@ -96,6 +96,29 @@ describe("setup-dev script", () => { ); }); + it("awaits async doctor hook before running install and validation gates", async () => { + const runDevDoctorFn = vi.fn(async () => 0); + const runCommandFn = vi.fn().mockResolvedValue(0); + const exitCode = await runSetupDev({ + cwd: process.cwd(), + platform: "linux", + runDevDoctorFn, + runCommandFn, + npmInvocation: { command: "npm", prefixArgs: [] }, + }); + + expect(exitCode).toBe(0); + expect(runDevDoctorFn).toHaveBeenCalledTimes(1); + expect(runCommandFn).toHaveBeenNthCalledWith(1, "npm", ["ci"], expect.any(String)); + expect(runCommandFn).toHaveBeenNthCalledWith(2, "npm", ["run", "verify"], expect.any(String)); + expect(runCommandFn).toHaveBeenNthCalledWith( + 3, + "npm", + ["test", "--", "test/documentation.test.ts"], + expect.any(String), + ); + }); + it("runSetupDev short-circuits when doctor fails", async () => { const runDevDoctorFn = vi.fn().mockReturnValue(2); const runCommandFn = vi.fn(); From 8d61b3125f4ec1860456979535a9deba51026220 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 16:42:25 +0800 Subject: [PATCH 6/6] test(dx): stub doctor version checks in linux negative-path tests - add shared spawnSync stub for npm/git version probes - use stub in missing-script, old-node, and missing-verify tests - keep doctor tests deterministic across CI host environments Co-authored-by: Codex --- test/scripts/doctor-dev.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/scripts/doctor-dev.test.ts b/test/scripts/doctor-dev.test.ts index 174d02717..7785b6d45 100644 --- a/test/scripts/doctor-dev.test.ts +++ b/test/scripts/doctor-dev.test.ts @@ -29,6 +29,13 @@ function createDoctorFixture(scripts: Record = { return { root, binDir }; } +const spawnSyncVersionStub = (command: unknown) => { + if (String(command).toLowerCase().includes("git")) { + return { status: 0, stdout: "git version 2.40.0\n", stderr: "" }; + } + return { status: 0, stdout: "10.9.0\n", stderr: "" }; +}; + describe("doctor-dev script", () => { const tempDirs: string[] = []; @@ -88,6 +95,7 @@ describe("doctor-dev script", () => { PATH: fixture.binDir, npm_execpath: "/tmp/npm-cli.js", }, + spawnSync: spawnSyncVersionStub, error: (message) => errors.push(String(message)), warn: () => {}, log: () => {}, @@ -110,6 +118,7 @@ describe("doctor-dev script", () => { npm_execpath: "/tmp/npm-cli.js", }, nodeVersion: "v16.20.0", + spawnSync: spawnSyncVersionStub, error: (message) => errors.push(String(message)), warn: () => {}, log: () => {}, @@ -156,6 +165,7 @@ describe("doctor-dev script", () => { PATH: fixture.binDir, npm_execpath: "/tmp/npm-cli.js", }, + spawnSync: spawnSyncVersionStub, error: (message) => errors.push(String(message)), warn: () => {}, log: () => {},